From 799d7c463448b741d973dc426ee468c3c5de7bb9 Mon Sep 17 00:00:00 2001 From: Sean Date: Fri, 24 Jan 2025 09:36:38 -0800 Subject: [PATCH] initial commit to baseless --- .credo.exs | 137 + .dockerignore | 73 + .formatter.exs | 3 + .gitattributes | 10 + .gitignore | 59 + .gitlab-ci.yml | 161 + .gitlab/issue_templates/Bug.md | 18 + .gitlab/merge_request_templates/Default.md | 10 + .gitlab/merge_request_templates/Release.md | 8 + .mailmap | 3 + .tool-versions | 2 + AGPL-3 | 661 ++ CC-BY-4.0 | 395 ++ CC-BY-SA-4.0 | 427 ++ CHANGELOG.md | 1383 ++++ CHANGELOG_soapbox.md | 66 + COPYING | 56 + Dockerfile | 61 + Procfile | 2 + README.md | 33 + SECURITY.md | 16 + app.json | 13 + benchmarks/load_testing/activities.ex | 595 ++ benchmarks/load_testing/fetcher.ex | 624 ++ benchmarks/load_testing/helper.ex | 14 + benchmarks/load_testing/users.ex | 189 + benchmarks/mix/tasks/pleroma/benchmark.ex | 125 + .../mix/tasks/pleroma/benchmarks/tags.ex | 114 + .../mix/tasks/pleroma/benchmarks/timelines.ex | 70 + benchmarks/mix/tasks/pleroma/load_testing.ex | 67 + changelog.d/2.6.0-mergeback.skip | 0 changelog.d/3895.add | 1 + changelog.d/3896.add | 1 + changelog.d/3900.change | 1 + .../akkoma-xml-remote-entities.security | 1 + changelog.d/authorize-interaction.add | 1 + changelog.d/bare_uri_test.skip | 0 changelog.d/benchee.skip | 0 .../check-attachment-attribution.security | 1 + changelog.d/digest_emails.fix | 1 + changelog.d/docs-max-elixir-erlang.change | 1 + changelog.d/emoji-pack-sanitization.security | 1 + changelog.d/federation_status-access.change | 1 + changelog.d/healthcheck-disabled-error.fix | 1 + changelog.d/meilisearch.add | 1 + changelog.d/nginx-media-redirect-fix.fix | 1 + changelog.d/otp_perms.security | 1 + changelog.d/quotes-count.skip | 0 changelog.d/scrobble-url.add | 1 + changelog.d/system-cflags.fix | 1 + ci/Dockerfile | 8 + ci/build_and_push.sh | 1 + config/benchmark.exs | 88 + config/config.exs | 958 +++ config/description.exs | 3744 ++++++++++ config/dev.exs | 78 + config/docker.exs | 80 + config/dokku.exs | 25 + config/emoji.txt | 3 + config/prod.exs | 97 + config/soapbox.exs | 58 + config/test.exs | 164 + coveralls.json | 7 + docker-entrypoint.sh | 16 + docs/README.md | 4 + docs/SUMMARY.md | 16 + docs/administration/CLI_tasks/config.md | 157 + docs/administration/CLI_tasks/database.md | 161 + docs/administration/CLI_tasks/digest.md | 33 + docs/administration/CLI_tasks/email.md | 45 + docs/administration/CLI_tasks/emoji.md | 61 + docs/administration/CLI_tasks/frontend.md | 113 + .../CLI_tasks/general_cli_task_info.include | 5 + docs/administration/CLI_tasks/instance.md | 45 + docs/administration/CLI_tasks/oauth_app.md | 20 + docs/administration/CLI_tasks/relay.md | 45 + docs/administration/CLI_tasks/robots_txt.md | 21 + docs/administration/CLI_tasks/uploads.md | 21 + docs/administration/CLI_tasks/user.md | 302 + docs/administration/backup.md | 41 + docs/administration/updating.md | 27 + docs/clients.md | 125 + docs/configuration/auth.md | 1 + docs/configuration/cheatsheet.md | 1198 ++++ docs/configuration/custom_emoji.md | 69 + docs/configuration/hardening.md | 117 + ...w_to_serve_another_domain_for_webfinger.md | 62 + docs/configuration/howto_database_config.md | 155 + docs/configuration/howto_ejabberd.md | 136 + docs/configuration/howto_mediaproxy.md | 34 + docs/configuration/howto_mongooseim.md | 10 + docs/configuration/howto_proxy.md | 12 + docs/configuration/howto_search_cjk.md | 42 + ..._set_richmedia_cache_ttl_based_on_image.md | 33 + .../howto_theming_your_instance.md | 74 + docs/configuration/i2p.md | 196 + docs/configuration/mrf.md | 158 + docs/configuration/onion_federation.md | 159 + docs/configuration/optimizing_beam.md | 66 + docs/configuration/postgresql.md | 48 + docs/configuration/search.md | 123 + docs/configuration/static_dir.md | 95 + docs/configuration/storing_remote_media.md | 38 + docs/development/API/admin_api.md | 1753 +++++ docs/development/API/chats.md | 263 + .../API/differences_in_mastoapi_responses.md | 505 ++ docs/development/API/nodeinfo.md | 345 + docs/development/API/pleroma_api.md | 727 ++ docs/development/API/prometheus.md | 44 + docs/development/ap_extensions.md | 65 + .../authentication_authorization.md | 21 + docs/development/index.md | 1 + .../development/setting_up_a_gitlab_runner.md | 9 + docs/development/setting_up_pleroma_dev.md | 70 + docs/index.md | 19 + docs/installation/alpine_linux_en.md | 231 + docs/installation/arch_linux_en.md | 226 + docs/installation/debian_based_en.md | 189 + docs/installation/debian_based_jp.md | 198 + docs/installation/freebsd_en.md | 222 + docs/installation/further_reading.include | 5 + .../installation/generic_dependencies.include | 16 + docs/installation/gentoo_en.md | 308 + docs/installation/gentoo_otp_en.md | 207 + .../migrating_from_source_otp_en.md | 143 + docs/installation/netbsd_en.md | 215 + docs/installation/nixos_en.md | 15 + docs/installation/openbsd_en.md | 263 + docs/installation/openbsd_fi.md | 123 + .../optional/media_graphics_packages.md | 37 + docs/installation/otp_en.md | 312 + docs/installation/otp_vs_from_source.include | 8 + .../otp_vs_from_source_source.include | 3 + docs/installation/yunohost_en.md | 9 + elixir_buildpack.config | 2 + installation/apache-cache-purge.sh.example | 36 + installation/caddyfile-pleroma.example | 17 + installation/freebsd/rc.d/pleroma | 27 + installation/init.d/pleroma | 46 + installation/netbsd/rc.d/pleroma | 57 + installation/nginx-cache-purge.sh.example | 40 + installation/nsfw-api.service | 15 + installation/openbsd/httpd.conf | 36 + installation/openbsd/rc.d/pleromad | 34 + installation/openbsd/relayd.conf | 44 + installation/pleroma-apache.conf | 84 + installation/pleroma-mediaproxy.nginx | 97 + installation/pleroma-mongooseim.cfg | 936 +++ installation/pleroma.nginx | 97 + installation/pleroma.service | 37 + installation/pleroma.supervisord | 21 + installation/pleroma.vcl | 127 + lib/mix/pleroma.ex | 139 + lib/mix/tasks/pleroma/app.ex | 49 + lib/mix/tasks/pleroma/config.ex | 402 ++ lib/mix/tasks/pleroma/count_statuses.ex | 26 + lib/mix/tasks/pleroma/database.ex | 260 + lib/mix/tasks/pleroma/digest.ex | 36 + lib/mix/tasks/pleroma/docs.ex | 46 + lib/mix/tasks/pleroma/ecto.ex | 50 + lib/mix/tasks/pleroma/ecto/migrate.ex | 67 + lib/mix/tasks/pleroma/ecto/rollback.ex | 72 + lib/mix/tasks/pleroma/email.ex | 44 + lib/mix/tasks/pleroma/emoji.ex | 275 + lib/mix/tasks/pleroma/frontend.ex | 108 + lib/mix/tasks/pleroma/instance.ex | 300 + .../tasks/pleroma/notification_settings.ex | 87 + lib/mix/tasks/pleroma/openapi_spec.ex | 75 + .../tasks/pleroma/refresh_counter_cache.ex | 69 + lib/mix/tasks/pleroma/relay.ex | 59 + lib/mix/tasks/pleroma/robots_txt.ex | 33 + lib/mix/tasks/pleroma/search/meilisearch.ex | 145 + lib/mix/tasks/pleroma/uploads.ex | 100 + lib/mix/tasks/pleroma/user.ex | 490 ++ lib/phoenix/transports/web_socket/raw.ex | 93 + lib/pleroma/activity.ex | 411 ++ lib/pleroma/activity/html.ex | 81 + lib/pleroma/activity/ir/topics.ex | 99 + lib/pleroma/activity/queries.ex | 108 + lib/pleroma/announcement.ex | 160 + lib/pleroma/announcement_read_relationship.ex | 55 + lib/pleroma/application.ex | 336 + lib/pleroma/application_requirements.ex | 272 + lib/pleroma/bookmark.ex | 65 + lib/pleroma/caching.ex | 20 + lib/pleroma/captcha.ex | 104 + lib/pleroma/captcha/kocaptcha.ex | 38 + lib/pleroma/captcha/native.ex | 35 + lib/pleroma/captcha/service.ex | 37 + lib/pleroma/chat.ex | 97 + lib/pleroma/chat/message_reference.ex | 117 + lib/pleroma/clippy.ex | 156 + lib/pleroma/config.ex | 106 + lib/pleroma/config/deprecation_warnings.ex | 394 ++ lib/pleroma/config/getting.ex | 15 + lib/pleroma/config/helpers.ex | 17 + lib/pleroma/config/holder.ex | 42 + lib/pleroma/config/loader.ex | 50 + lib/pleroma/config/oban.ex | 36 + .../config/release_runtime_provider.ex | 70 + lib/pleroma/config/transfer_task.ex | 195 + lib/pleroma/config_db.ex | 391 ++ lib/pleroma/constants.ex | 116 + lib/pleroma/conversation.ex | 107 + lib/pleroma/conversation/participation.ex | 227 + .../participation/recipient_ship.ex | 34 + lib/pleroma/counter_cache.ex | 79 + lib/pleroma/data_migration.ex | 46 + lib/pleroma/delivery.ex | 49 + lib/pleroma/docs/generator.ex | 136 + lib/pleroma/docs/json.ex | 38 + lib/pleroma/docs/markdown.ex | 97 + lib/pleroma/docs/translator.ex | 10 + lib/pleroma/docs/translator/compiler.ex | 119 + lib/pleroma/ecto_enums.ex | 37 + .../object_validators/bare_uri.ex | 23 + .../object_validators/date_time.ex | 38 + .../activity_pub/object_validators/emoji.ex | 34 + .../activity_pub/object_validators/mime.ex | 25 + .../object_validators/object_id.ex | 27 + .../object_validators/recipients.ex | 52 + .../object_validators/safe_text.ex | 25 + .../activity_pub/object_validators/uri.ex | 24 + lib/pleroma/ecto_type/config/atom.ex | 26 + lib/pleroma/ecto_type/config/binary_value.ex | 27 + lib/pleroma/ecto_type/string_id.ex | 23 + lib/pleroma/emails/admin_email.ex | 99 + lib/pleroma/emails/mailer.ex | 71 + lib/pleroma/emails/new_users_digest_email.ex | 34 + lib/pleroma/emails/user_email.ex | 393 ++ lib/pleroma/emoji-test.txt | 5024 ++++++++++++++ lib/pleroma/emoji.ex | 207 + lib/pleroma/emoji/combinations.ex | 45 + lib/pleroma/emoji/formatter.ex | 52 + lib/pleroma/emoji/loader.ex | 233 + lib/pleroma/emoji/pack.ex | 669 ++ lib/pleroma/filter.ex | 226 + lib/pleroma/following_relationship.ex | 300 + lib/pleroma/formatter.ex | 161 + lib/pleroma/frontend.ex | 185 + lib/pleroma/gopher/server.ex | 162 + lib/pleroma/gun.ex | 29 + lib/pleroma/gun/api.ex | 46 + lib/pleroma/gun/conn.ex | 131 + lib/pleroma/gun/connection_pool.ex | 86 + lib/pleroma/gun/connection_pool/reclaimer.ex | 89 + lib/pleroma/gun/connection_pool/worker.ex | 153 + .../gun/connection_pool/worker_supervisor.ex | 49 + lib/pleroma/hashtag.ex | 106 + lib/pleroma/healthcheck.ex | 73 + lib/pleroma/helpers/auth_helper.ex | 46 + lib/pleroma/helpers/inet_helper.ex | 19 + lib/pleroma/helpers/media_helper.ex | 162 + lib/pleroma/helpers/qt_fast_start.ex | 131 + lib/pleroma/helpers/uri_helper.ex | 26 + lib/pleroma/html.ex | 93 + lib/pleroma/http.ex | 117 + lib/pleroma/http/adapter_helper.ex | 121 + lib/pleroma/http/adapter_helper/default.ex | 18 + lib/pleroma/http/adapter_helper/gun.ex | 82 + lib/pleroma/http/adapter_helper/hackney.ex | 36 + lib/pleroma/http/ex_aws.ex | 24 + lib/pleroma/http/request.ex | 23 + lib/pleroma/http/request_builder.ex | 95 + lib/pleroma/http/tzdata.ex | 29 + lib/pleroma/http/web_push.ex | 12 + lib/pleroma/instances.ex | 46 + lib/pleroma/instances/instance.ex | 318 + lib/pleroma/job_queue_monitor.ex | 84 + lib/pleroma/jwt.ex | 13 + lib/pleroma/keys.ex | 46 + lib/pleroma/language/language_detector.ex | 44 + .../language/language_detector/fasttext.ex | 47 + .../language/language_detector/provider.ex | 11 + lib/pleroma/language/translation.ex | 115 + lib/pleroma/language/translation/deepl.ex | 123 + .../language/translation/libretranslate.ex | 93 + lib/pleroma/language/translation/provider.ex | 40 + .../language/translation/translate_locally.ex | 129 + lib/pleroma/list.ex | 152 + lib/pleroma/logging.ex | 7 + lib/pleroma/maintenance.ex | 37 + lib/pleroma/maps.ex | 21 + lib/pleroma/marker.ex | 117 + lib/pleroma/mfa.ex | 155 + lib/pleroma/mfa/backup_codes.ex | 31 + lib/pleroma/mfa/changeset.ex | 64 + lib/pleroma/mfa/settings.ex | 24 + lib/pleroma/mfa/token.ex | 105 + lib/pleroma/mfa/totp.ex | 86 + .../migration_helper/notification_backfill.ex | 92 + .../context_objects_deletion_migrator.ex | 139 + .../migrators/hashtags_table_migrator.ex | 208 + .../migrators/support/base_migrator.ex | 210 + .../migrators/support/base_migrator_state.ex | 117 + lib/pleroma/moderation_log.ex | 646 ++ lib/pleroma/notification.ex | 888 +++ lib/pleroma/object.ex | 500 ++ lib/pleroma/object/containment.ex | 88 + lib/pleroma/object/fetcher.ex | 243 + lib/pleroma/object/updater.ex | 293 + lib/pleroma/object_tombstone.ex | 8 + lib/pleroma/otp_version.ex | 28 + lib/pleroma/pagination.ex | 165 + lib/pleroma/password/pbkdf2.ex | 55 + lib/pleroma/password_reset_token.ex | 62 + lib/pleroma/prom_ex.ex | 90 + lib/pleroma/registration.ex | 57 + lib/pleroma/release_tasks.ex | 66 + lib/pleroma/repo.ex | 97 + lib/pleroma/report_note.ex | 48 + lib/pleroma/reverse_proxy.ex | 426 ++ lib/pleroma/reverse_proxy/client.ex | 20 + lib/pleroma/reverse_proxy/client/hackney.ex | 24 + lib/pleroma/reverse_proxy/client/tesla.ex | 86 + lib/pleroma/reverse_proxy/client/wrapper.ex | 30 + lib/pleroma/rule.ex | 64 + lib/pleroma/scheduled_activity.ex | 210 + lib/pleroma/search.ex | 17 + lib/pleroma/search/database_search.ex | 172 + lib/pleroma/search/meilisearch.ex | 181 + lib/pleroma/search/search_backend.ex | 24 + lib/pleroma/signature.ex | 80 + lib/pleroma/stats.ex | 137 + lib/pleroma/telemetry/logger.ex | 90 + .../tesla/middleware/connection_pool.ex | 50 + lib/pleroma/tests/auth_test_controller.ex | 85 + lib/pleroma/thread_mute.ex | 75 + lib/pleroma/upload.ex | 282 + lib/pleroma/upload/filter.ex | 46 + lib/pleroma/upload/filter/analyze_metadata.ex | 83 + .../upload/filter/anonymize_filename.ex | 38 + lib/pleroma/upload/filter/dedupe.ex | 24 + .../filter/exiftool/read_description.ex | 52 + .../upload/filter/exiftool/strip_location.ex | 32 + lib/pleroma/upload/filter/mogrifun.ex | 53 + lib/pleroma/upload/filter/mogrify.ex | 48 + lib/pleroma/upload/filter/only_media.ex | 20 + lib/pleroma/upload/filter/set_meta.ex | 9 + lib/pleroma/uploaders/local.ex | 49 + lib/pleroma/uploaders/s3.ex | 85 + lib/pleroma/uploaders/uploader.ex | 84 + lib/pleroma/user.ex | 2833 ++++++++ lib/pleroma/user/backup.ex | 367 + lib/pleroma/user/email_list.ex | 70 + lib/pleroma/user/import.ex | 85 + lib/pleroma/user/notification_setting.ex | 34 + lib/pleroma/user/query.ex | 314 + lib/pleroma/user/search.ex | 266 + lib/pleroma/user/welcome_chat_message.ex | 45 + lib/pleroma/user/welcome_email.ex | 62 + lib/pleroma/user/welcome_message.ex | 47 + lib/pleroma/user_invite_token.ex | 124 + lib/pleroma/user_note.ex | 52 + lib/pleroma/user_relationship.ex | 242 + lib/pleroma/utils.ex | 85 + lib/pleroma/web.ex | 242 + lib/pleroma/web/activity_pub/activity_pub.ex | 1934 ++++++ .../activity_pub/activity_pub/persisting.ex | 7 + .../activity_pub/activity_pub/streaming.ex | 8 + .../activity_pub/activity_pub_controller.ex | 550 ++ lib/pleroma/web/activity_pub/builder.ex | 480 ++ .../web/activity_pub/internal_fetch_actor.ex | 34 + lib/pleroma/web/activity_pub/mrf.ex | 231 + .../mrf/activity_expiration_policy.ex | 61 + .../mrf/anti_duplication_policy.ex | 38 + .../activity_pub/mrf/anti_followbot_policy.ex | 85 + .../activity_pub/mrf/anti_link_spam_policy.ex | 60 + .../mrf/anti_mention_spam_policy.ex | 85 + .../mrf/block_notification_policy.ex | 95 + .../web/activity_pub/mrf/drop_policy.ex | 18 + .../web/activity_pub/mrf/emoji_policy.ex | 281 + .../activity_pub/mrf/ensure_re_prepended.ex | 47 + .../web/activity_pub/mrf/follow_bot_policy.ex | 63 + .../mrf/force_bot_unlisted_policy.ex | 56 + .../mrf/force_mentions_in_content.ex | 137 + .../web/activity_pub/mrf/hashtag_policy.ex | 143 + .../web/activity_pub/mrf/hellthread_policy.ex | 127 + .../activity_pub/mrf/inline_quote_policy.ex | 78 + .../web/activity_pub/mrf/keyword_policy.ex | 196 + .../mrf/media_proxy_warming_policy.ex | 72 + .../web/activity_pub/mrf/mention_policy.ex | 46 + .../web/activity_pub/mrf/no_empty_policy.ex | 70 + .../web/activity_pub/mrf/no_op_policy.ex | 16 + .../mrf/no_placeholder_text_policy.ex | 28 + .../web/activity_pub/mrf/normalize_markup.ex | 49 + .../web/activity_pub/mrf/nsfw_api_policy.ex | 265 + .../web/activity_pub/mrf/object_age_policy.ex | 141 + .../activity_pub/mrf/pipeline_filtering.ex | 7 + lib/pleroma/web/activity_pub/mrf/policy.ex | 17 + .../mrf/quote_to_link_tag_policy.ex | 49 + .../web/activity_pub/mrf/reject_non_public.ex | 74 + .../activity_pub/mrf/remote_report_policy.ex | 118 + .../web/activity_pub/mrf/simple_policy.ex | 371 + .../activity_pub/mrf/steal_emoji_policy.ex | 154 + .../web/activity_pub/mrf/subchain_policy.ex | 64 + .../web/activity_pub/mrf/tag_policy.ex | 163 + .../mrf/user_allow_list_policy.ex | 65 + lib/pleroma/web/activity_pub/mrf/utils.ex | 15 + .../web/activity_pub/mrf/vocabulary_policy.ex | 69 + .../web/activity_pub/object_validator.ex | 336 + .../object_validator/validating.ex | 7 + .../accept_reject_validator.ex | 66 + .../object_validators/add_remove_validator.ex | 79 + .../object_validators/announce_validator.ex | 123 + .../object_validators/answer_validator.ex | 70 + .../article_note_page_validator.ex | 133 + .../object_validators/attachment_validator.ex | 94 + .../audio_image_video_validator.ex | 126 + .../object_validators/block_validator.ex | 43 + .../chat_message_validator.ex | 129 + .../object_validators/common_fields.ex | 86 + .../object_validators/common_fixes.ex | 125 + .../object_validators/common_validations.ex | 150 + .../create_chat_message_validator.ex | 96 + .../create_generic_validator.ex | 161 + .../object_validators/delete_validator.ex | 87 + .../emoji_react_validator.ex | 130 + .../object_validators/event_validator.ex | 74 + .../object_validators/follow_validator.ex | 44 + .../object_validators/join_validator.ex | 61 + .../object_validators/leave_validator.ex | 56 + .../object_validators/like_validator.ex | 84 + .../object_validators/place_validator.ex | 54 + .../question_options_validator.ex | 37 + .../object_validators/question_validator.ex | 93 + .../object_validators/tag_validator.ex | 93 + .../object_validators/undo_validator.ex | 72 + .../object_validators/update_validator.ex | 64 + lib/pleroma/web/activity_pub/pipeline.ex | 82 + lib/pleroma/web/activity_pub/publisher.ex | 279 + lib/pleroma/web/activity_pub/relay.ex | 108 + lib/pleroma/web/activity_pub/side_effects.ex | 708 ++ .../web/activity_pub/side_effects/handling.ex | 8 + .../web/activity_pub/transmogrifier.ex | 1065 +++ lib/pleroma/web/activity_pub/utils.ex | 1038 +++ .../web/activity_pub/views/object_view.ex | 40 + .../web/activity_pub/views/user_view.ex | 317 + lib/pleroma/web/activity_pub/visibility.ex | 154 + .../controllers/admin_api_controller.ex | 445 ++ .../controllers/announcement_controller.ex | 83 + .../admin_api/controllers/chat_controller.ex | 78 + .../controllers/config_controller.ex | 198 + .../controllers/email_list_controller.ex | 35 + .../controllers/fallback_controller.ex | 43 + .../controllers/frontend_controller.ex | 71 + .../controllers/instance_controller.ex | 63 + .../instance_document_controller.ex | 41 + .../controllers/invite_controller.ex | 78 + .../media_proxy_cache_controller.ex | 76 + .../controllers/o_auth_app_controller.ex | 76 + .../admin_api/controllers/relay_controller.ex | 59 + .../controllers/report_controller.ex | 161 + .../admin_api/controllers/rule_controller.ex | 62 + .../controllers/status_controller.ex | 71 + .../admin_api/controllers/user_controller.ex | 324 + .../controllers/webhook_controller.ex | 95 + lib/pleroma/web/admin_api/report.ex | 71 + lib/pleroma/web/admin_api/search.ex | 30 + .../web/admin_api/views/account_view.ex | 161 + .../web/admin_api/views/announcement_view.ex | 15 + lib/pleroma/web/admin_api/views/chat_view.ex | 30 + .../web/admin_api/views/config_view.ex | 30 + .../web/admin_api/views/frontend_view.ex | 22 + .../web/admin_api/views/invite_view.ex | 25 + .../admin_api/views/media_proxy_cache_view.ex | 15 + .../admin_api/views/moderation_log_view.ex | 30 + .../web/admin_api/views/o_auth_app_view.ex | 10 + .../web/admin_api/views/report_view.ex | 99 + lib/pleroma/web/admin_api/views/rule_view.ex | 21 + .../web/admin_api/views/status_view.ex | 30 + lib/pleroma/web/admin_api/views/user_view.ex | 10 + .../web/admin_api/views/webhook_view.ex | 34 + lib/pleroma/web/api_spec.ex | 170 + lib/pleroma/web/api_spec/cast_and_validate.ex | 138 + lib/pleroma/web/api_spec/helpers.ex | 85 + .../api_spec/operations/account_operation.ex | 1040 +++ .../admin/announcement_operation.ex | 165 + .../operations/admin/chat_operation.ex | 96 + .../operations/admin/config_operation.ex | 145 + .../operations/admin/frontend_operation.ex | 86 + .../admin/instance_document_operation.ex | 115 + .../operations/admin/invite_operation.ex | 152 + .../admin/media_proxy_cache_operation.ex | 121 + .../operations/admin/o_auth_app_operation.ex | 217 + .../operations/admin/relay_operation.ex | 104 + .../operations/admin/report_operation.ex | 303 + .../operations/admin/rule_operation.ex | 113 + .../operations/admin/status_operation.ex | 167 + .../operations/admin/user_operation.ex | 453 ++ .../operations/admin/webhook_operation.ex | 201 + .../operations/announcement_operation.ex | 57 + .../web/api_spec/operations/app_operation.ex | 123 + .../web/api_spec/operations/chat_operation.ex | 407 ++ .../operations/conversation_operation.ex | 76 + .../operations/custom_emoji_operation.ex | 88 + .../operations/directory_operation.ex | 41 + .../operations/domain_block_operation.ex | 85 + .../operations/emoji_reaction_operation.ex | 110 + .../api_spec/operations/filter_operation.ex | 237 + .../operations/follow_request_operation.ex | 65 + .../api_spec/operations/instance_operation.ex | 482 ++ .../web/api_spec/operations/list_operation.ex | 195 + .../api_spec/operations/marker_operation.ex | 142 + .../mastodon_admin/account_operation.ex | 247 + .../mastodon_admin/report_operation.ex | 172 + .../api_spec/operations/media_operation.ex | 135 + .../operations/notification_operation.ex | 244 + .../operations/pleroma_account_operation.ex | 150 + .../operations/pleroma_app_operation.ex | 31 + .../operations/pleroma_backup_operation.ex | 87 + .../pleroma_conversation_operation.ex | 107 + .../pleroma_emoji_file_operation.ex | 144 + .../pleroma_emoji_pack_operation.ex | 349 + .../operations/pleroma_event_operation.ex | 346 + .../operations/pleroma_instances_operation.ex | 40 + .../operations/pleroma_mascot_operation.ex | 79 + .../pleroma_notification_operation.ex | 49 + .../operations/pleroma_report_operation.ex | 97 + .../operations/pleroma_scrobble_operation.ex | 106 + .../operations/pleroma_search_operation.ex | 56 + .../operations/pleroma_status_operation.ex | 45 + .../web/api_spec/operations/poll_operation.ex | 76 + .../api_spec/operations/report_operation.ex | 89 + .../scheduled_activity_operation.ex | 96 + .../api_spec/operations/search_operation.ex | 208 + .../api_spec/operations/status_operation.ex | 863 +++ .../operations/streaming_operation.ex | 464 ++ .../operations/subscription_operation.ex | 247 + .../api_spec/operations/timeline_operation.ex | 230 + .../operations/twitter_util_operation.ex | 443 ++ .../operations/user_import_operation.ex | 81 + lib/pleroma/web/api_spec/render_error.ex | 234 + lib/pleroma/web/api_spec/schemas/account.ex | 226 + .../web/api_spec/schemas/account_field.ex | 26 + .../api_spec/schemas/account_relationship.ex | 48 + .../web/api_spec/schemas/actor_type.ex | 13 + .../web/api_spec/schemas/announcement.ex | 45 + lib/pleroma/web/api_spec/schemas/api_error.ex | 19 + lib/pleroma/web/api_spec/schemas/app.ex | 33 + .../web/api_spec/schemas/attachment.ex | 68 + .../web/api_spec/schemas/boolean_like.ex | 40 + lib/pleroma/web/api_spec/schemas/chat.ex | 75 + .../web/api_spec/schemas/chat_message.ex | 77 + .../web/api_spec/schemas/conversation.ex | 41 + lib/pleroma/web/api_spec/schemas/emoji.ex | 29 + lib/pleroma/web/api_spec/schemas/event.ex | 112 + lib/pleroma/web/api_spec/schemas/flake_id.ex | 14 + lib/pleroma/web/api_spec/schemas/list.ex | 23 + .../web/api_spec/schemas/location_result.ex | 80 + .../api_spec/schemas/participation_request.ex | 30 + lib/pleroma/web/api_spec/schemas/poll.ex | 94 + .../web/api_spec/schemas/push_subscription.ex | 66 + .../web/api_spec/schemas/scheduled_status.ex | 56 + lib/pleroma/web/api_spec/schemas/status.ex | 401 ++ lib/pleroma/web/api_spec/schemas/tag.ex | 27 + .../web/api_spec/schemas/visibility_scope.ex | 14 + lib/pleroma/web/api_spec/scopes/compiler.ex | 82 + lib/pleroma/web/api_spec/scopes/translator.ex | 10 + lib/pleroma/web/auth/authenticator.ex | 13 + lib/pleroma/web/auth/helpers.ex | 33 + lib/pleroma/web/auth/ldap_authenticator.ex | 129 + lib/pleroma/web/auth/pleroma_authenticator.ex | 104 + lib/pleroma/web/auth/totp_authenticator.ex | 45 + lib/pleroma/web/auth/wrapper_authenticator.ex | 42 + lib/pleroma/web/common_api.ex | 813 +++ lib/pleroma/web/common_api/activity_draft.ex | 455 ++ lib/pleroma/web/common_api/utils.ex | 533 ++ lib/pleroma/web/controller_helper.ex | 118 + lib/pleroma/web/embed_controller.ex | 42 + lib/pleroma/web/endpoint.ex | 203 + .../legacy_pleroma_api_rerouter_plug.ex | 26 + .../web/fallback/redirect_controller.ex | 114 + lib/pleroma/web/federator.ex | 112 + lib/pleroma/web/federator/publisher.ex | 109 + lib/pleroma/web/federator/publishing.ex | 7 + lib/pleroma/web/feed/feed_view.ex | 190 + lib/pleroma/web/feed/tag_controller.ex | 48 + lib/pleroma/web/feed/user_controller.ex | 83 + lib/pleroma/web/gettext.ex | 220 + lib/pleroma/web/instance_document.ex | 62 + .../web/mailer/subscription_controller.ex | 24 + lib/pleroma/web/manifest_controller.ex | 14 + .../admin/controllers/account_controller.ex | 220 + .../admin/controllers/report_controller.ex | 146 + .../mastodon_api/admin/views/account_view.ex | 65 + .../mastodon_api/admin/views/report_view.ex | 74 + .../controllers/account_controller.ex | 627 ++ .../controllers/announcement_controller.ex | 60 + .../controllers/app_controller.ex | 57 + .../controllers/auth_controller.ex | 24 + .../controllers/conversation_controller.ex | 48 + .../controllers/custom_emoji_controller.ex | 17 + .../controllers/directory_controller.ex | 82 + .../controllers/domain_block_controller.ex | 50 + .../controllers/fallback_controller.ex | 44 + .../controllers/filter_controller.ex | 86 + .../controllers/follow_request_controller.ex | 59 + .../controllers/instance_controller.ex | 43 + .../controllers/list_controller.ex | 99 + .../controllers/marker_controller.ex | 38 + .../controllers/mastodon_api_controller.ex | 31 + .../controllers/media_controller.ex | 80 + .../controllers/notification_controller.ex | 117 + .../controllers/poll_controller.ex | 67 + .../controllers/report_controller.ex | 21 + .../scheduled_activity_controller.ex | 61 + .../controllers/search_controller.ex | 193 + .../controllers/status_controller.ex | 540 ++ .../controllers/subscription_controller.ex | 77 + .../controllers/suggestion_controller.ex | 120 + .../controllers/timeline_controller.ex | 210 + lib/pleroma/web/mastodon_api/mastodon_api.ex | 121 + .../web/mastodon_api/views/account_view.ex | 498 ++ .../mastodon_api/views/announcement_view.ex | 15 + .../web/mastodon_api/views/app_view.ex | 50 + .../mastodon_api/views/conversation_view.ex | 58 + .../mastodon_api/views/custom_emoji_view.ex | 28 + .../web/mastodon_api/views/filter_view.ex | 31 + .../mastodon_api/views/follow_request_view.ex | 10 + .../web/mastodon_api/views/instance_view.ex | 364 + .../web/mastodon_api/views/list_view.ex | 19 + .../web/mastodon_api/views/marker_view.ex | 21 + .../web/mastodon_api/views/media_view.ex | 10 + .../mastodon_api/views/notification_view.ex | 195 + .../web/mastodon_api/views/poll_view.ex | 109 + .../web/mastodon_api/views/report_view.ex | 14 + .../views/scheduled_activity_view.ex | 45 + .../web/mastodon_api/views/status_view.ex | 915 +++ .../mastodon_api/views/subscription_view.ex | 19 + .../web/mastodon_api/views/suggestion_view.ex | 28 + .../web/mastodon_api/views/timeline_view.ex | 10 + .../web/mastodon_api/websocket_handler.ex | 257 + lib/pleroma/web/media_proxy.ex | 186 + lib/pleroma/web/media_proxy/invalidation.ex | 40 + .../web/media_proxy/invalidation/http.ex | 40 + .../web/media_proxy/invalidation/script.ex | 62 + .../web/media_proxy/media_proxy_controller.ex | 212 + lib/pleroma/web/metadata.ex | 54 + lib/pleroma/web/metadata/player_view.ex | 25 + lib/pleroma/web/metadata/providers/feed.ex | 23 + .../web/metadata/providers/open_graph.ex | 148 + .../web/metadata/providers/provider.ex | 7 + lib/pleroma/web/metadata/providers/rel_me.ex | 27 + .../metadata/providers/restrict_indexing.ex | 24 + .../web/metadata/providers/twitter_card.ex | 142 + lib/pleroma/web/metadata/utils.ex | 67 + .../web/mongoose_im/mongoose_im_controller.ex | 60 + lib/pleroma/web/multipart.ex | 22 + lib/pleroma/web/nodeinfo/nodeinfo.ex | 97 + .../web/nodeinfo/nodeinfo_controller.ex | 44 + lib/pleroma/web/o_auth.ex | 6 + lib/pleroma/web/o_auth/app.ex | 158 + lib/pleroma/web/o_auth/authorization.ex | 97 + lib/pleroma/web/o_auth/fallback_controller.ex | 32 + lib/pleroma/web/o_auth/mfa_controller.ex | 97 + lib/pleroma/web/o_auth/mfa_view.ex | 18 + lib/pleroma/web/o_auth/o_auth_controller.ex | 633 ++ lib/pleroma/web/o_auth/o_auth_view.ex | 31 + lib/pleroma/web/o_auth/scopes.ex | 76 + lib/pleroma/web/o_auth/token.ex | 145 + lib/pleroma/web/o_auth/token/query.ex | 49 + .../o_auth/token/strategy/refresh_token.ex | 58 + .../web/o_auth/token/strategy/revoke.ex | 38 + lib/pleroma/web/o_auth/token/utils.ex | 72 + .../web/o_status/o_status_controller.ex | 140 + .../controllers/account_controller.ex | 159 + .../pleroma_api/controllers/app_controller.ex | 23 + .../controllers/backup_controller.ex | 28 + .../controllers/chat_controller.ex | 202 + .../controllers/conversation_controller.ex | 93 + .../controllers/emoji_file_controller.ex | 154 + .../controllers/emoji_pack_controller.ex | 230 + .../controllers/emoji_reaction_controller.ex | 102 + .../controllers/event_controller.ex | 311 + .../controllers/instances_controller.ex | 21 + .../controllers/mascot_controller.ex | 42 + .../controllers/notification_controller.ex | 38 + .../controllers/report_controller.ex | 46 + .../controllers/scrobble_controller.ex | 52 + .../controllers/search_controller.ex | 23 + .../controllers/status_controller.ex | 66 + .../two_factor_authentication_controller.ex | 133 + .../controllers/user_import_controller.ex | 61 + .../web/pleroma_api/views/account_view.ex | 10 + lib/pleroma/web/pleroma_api/views/app_view.ex | 11 + .../web/pleroma_api/views/backup_view.ex | 39 + .../views/chat/message_reference_view.ex | 63 + .../web/pleroma_api/views/chat_view.ex | 44 + .../pleroma_api/views/conversation_view.ex | 10 + .../pleroma_api/views/emoji_reaction_view.ex | 46 + .../web/pleroma_api/views/event_view.ex | 86 + .../pleroma_api/views/notification_view.ex | 10 + .../web/pleroma_api/views/report_view.ex | 55 + .../web/pleroma_api/views/scrobble_view.ex | 38 + .../web/pleroma_api/views/search_view.ex | 36 + lib/pleroma/web/plug.ex | 8 + .../plugs/admin_secret_authentication_plug.ex | 60 + lib/pleroma/web/plugs/authentication_plug.ex | 64 + .../web/plugs/basic_auth_decoder_plug.ex | 32 + lib/pleroma/web/plugs/cache.ex | 146 + lib/pleroma/web/plugs/digest_plug.ex | 28 + .../web/plugs/ensure_authenticated_plug.ex | 45 + .../web/plugs/ensure_privileged_plug.ex | 44 + .../ensure_public_or_authenticated_plug.ex | 40 + .../plugs/ensure_user_token_assigns_plug.ex | 41 + .../plugs/expect_authenticated_check_plug.ex | 20 + ...pect_public_or_authenticated_check_plug.ex | 21 + lib/pleroma/web/plugs/federating_plug.ex | 32 + lib/pleroma/web/plugs/frontend_static.ex | 67 + lib/pleroma/web/plugs/http_security_plug.ex | 280 + lib/pleroma/web/plugs/http_signature_plug.ex | 102 + lib/pleroma/web/plugs/idempotency_plug.ex | 86 + lib/pleroma/web/plugs/instance_static.ex | 53 + .../mapped_signature_to_identity_plug.ex | 76 + lib/pleroma/web/plugs/metrics_predicate.ex | 29 + lib/pleroma/web/plugs/o_auth_plug.ex | 109 + lib/pleroma/web/plugs/o_auth_scopes_plug.ex | 58 + lib/pleroma/web/plugs/plug_helper.ex | 40 + lib/pleroma/web/plugs/rate_limiter.ex | 269 + .../plugs/rate_limiter/limiter_supervisor.ex | 54 + .../web/plugs/rate_limiter/supervisor.ex | 20 + lib/pleroma/web/plugs/remote_ip.ex | 48 + lib/pleroma/web/plugs/set_application_plug.ex | 28 + lib/pleroma/web/plugs/set_format_plug.ex | 24 + lib/pleroma/web/plugs/set_locale_plug.ex | 91 + .../web/plugs/set_user_session_id_plug.ex | 18 + lib/pleroma/web/plugs/static_fe_plug.ex | 26 + lib/pleroma/web/plugs/trailing_format_plug.ex | 42 + lib/pleroma/web/plugs/uploaded_media.ex | 131 + lib/pleroma/web/plugs/user_enabled_plug.ex | 24 + lib/pleroma/web/plugs/user_fetcher_plug.ex | 27 + lib/pleroma/web/plugs/user_is_admin_plug.ex | 24 + lib/pleroma/web/plugs/user_is_staff_plug.ex | 23 + lib/pleroma/web/plugs/user_tracking_plug.ex | 30 + lib/pleroma/web/preload.ex | 35 + lib/pleroma/web/preload/providers/instance.ex | 59 + lib/pleroma/web/preload/providers/provider.ex | 7 + .../web/preload/providers/timelines.ex | 39 + lib/pleroma/web/preload/providers/user.ex | 26 + lib/pleroma/web/push.ex | 37 + lib/pleroma/web/push/impl.ex | 206 + lib/pleroma/web/push/subscription.ex | 102 + lib/pleroma/web/rel_me.ex | 49 + lib/pleroma/web/rich_media/helpers.ex | 148 + lib/pleroma/web/rich_media/parser.ex | 173 + lib/pleroma/web/rich_media/parser/card.ex | 148 + lib/pleroma/web/rich_media/parser/embed.ex | 10 + .../web/rich_media/parser/meta_tags.ex | 39 + lib/pleroma/web/rich_media/parser/ttl.ex | 7 + .../rich_media/parser/ttl/aws_signed_url.ex | 50 + lib/pleroma/web/rich_media/parsers/o_embed.ex | 34 + lib/pleroma/web/rich_media/parsers/ogp.ex | 8 + .../web/rich_media/parsers/twitter_card.ex | 24 + lib/pleroma/web/router.ex | 1187 ++++ .../web/static_fe/static_fe_controller.ex | 197 + lib/pleroma/web/static_fe/static_fe_view.ex | 38 + lib/pleroma/web/streamer.ex | 431 ++ .../web/templates/email/digest.html.eex | 568 ++ .../templates/email/new_users_digest.html.eex | 158 + .../web/templates/embed/_attachment.html.eex | 8 + lib/pleroma/web/templates/embed/show.html.eex | 76 + .../templates/feed/feed/_activity.atom.eex | 50 + .../web/templates/feed/feed/_activity.rss.eex | 45 + .../web/templates/feed/feed/_author.atom.eex | 14 + .../web/templates/feed/feed/_author.rss.eex | 10 + .../feed/feed/_tag_activity.atom.eex | 49 + .../templates/feed/feed/_tag_activity.xml.eex | 14 + .../templates/feed/feed/_tag_author.atom.eex | 14 + .../web/templates/feed/feed/tag.atom.eex | 20 + .../web/templates/feed/feed/tag.rss.eex | 16 + .../web/templates/feed/feed/user.atom.eex | 25 + .../web/templates/feed/feed/user.rss.eex | 30 + lib/pleroma/web/templates/layout/app.html.eex | 34 + .../web/templates/layout/email.html.eex | 10 + .../templates/layout/email_styled.html.eex | 193 + .../web/templates/layout/embed.html.eex | 15 + .../templates/layout/metadata_player.html.eex | 16 + .../web/templates/layout/static_fe.html.eex | 15 + .../subscription/unsubscribe_failure.html.eex | 1 + .../subscription/unsubscribe_success.html.eex | 1 + .../templates/o_auth/mfa/recovery.html.eex | 24 + .../web/templates/o_auth/mfa/totp.html.eex | 24 + .../templates/o_auth/o_auth/_scopes.html.eex | 21 + .../templates/o_auth/o_auth/consumer.html.eex | 15 + .../o_auth/oob_authorization_created.html.eex | 2 + .../o_auth/o_auth/oob_token_exists.html.eex | 2 + .../templates/o_auth/o_auth/register.html.eex | 42 + .../web/templates/o_auth/o_auth/show.html.eex | 67 + .../static_fe/static_fe/_attachment.html.eex | 8 + .../static_fe/static_fe/_notice.html.eex | 41 + .../static_fe/static_fe/_user_card.html.eex | 11 + .../static_fe/static_fe/conversation.html.eex | 11 + .../static_fe/static_fe/error.html.eex | 7 + .../static_fe/static_fe/profile.html.eex | 31 + .../password/invalid_token.html.eex | 1 + .../twitter_api/password/reset.html.eex | 13 + .../password/reset_failed.html.eex | 6 + .../password/reset_success.html.eex | 2 + .../twitter_api/remote_follow/follow.html.eex | 11 + .../remote_follow/follow_login.html.eex | 14 + .../remote_follow/follow_mfa.html.eex | 13 + .../remote_follow/followed.html.eex | 5 + .../twitter_api/util/status_interact.html.eex | 10 + .../twitter_api/util/subscribe.html.eex | 10 + lib/pleroma/web/translation_helpers.ex | 29 + lib/pleroma/web/twitter_api/controller.ex | 69 + .../controllers/password_controller.ex | 52 + .../controllers/remote_follow_controller.ex | 165 + .../controllers/util_controller.ex | 387 ++ lib/pleroma/web/twitter_api/twitter_api.ex | 136 + .../web/twitter_api/views/password_view.ex | 9 + .../twitter_api/views/remote_follow_view.ex | 15 + .../web/twitter_api/views/token_view.ex | 21 + .../web/twitter_api/views/util_view.ex | 31 + lib/pleroma/web/uploader_controller.ex | 25 + lib/pleroma/web/utils/colors.ex | 110 + lib/pleroma/web/utils/colors/rgb.ex | 13 + lib/pleroma/web/utils/guards.ex | 13 + lib/pleroma/web/utils/params.ex | 16 + lib/pleroma/web/views/email_view.ex | 24 + lib/pleroma/web/views/embed_view.ex | 71 + lib/pleroma/web/views/error_helpers.ex | 33 + lib/pleroma/web/views/error_view.ex | 28 + lib/pleroma/web/views/layout_view.ex | 7 + .../web/views/mailer/subscription_view.ex | 8 + lib/pleroma/web/views/manifest_view.ex | 69 + lib/pleroma/web/views/streamer_view.ex | 197 + lib/pleroma/web/web_finger.ex | 257 + .../web/web_finger/web_finger_controller.ex | 45 + lib/pleroma/web/xml.ex | 41 + lib/pleroma/webhook.ex | 100 + lib/pleroma/webhook/notify.ex | 72 + .../workers/attachments_cleanup_worker.ex | 112 + lib/pleroma/workers/background_worker.ex | 54 + lib/pleroma/workers/backup_worker.ex | 74 + .../workers/cron/digest_emails_worker.ex | 61 + .../workers/cron/new_users_digest_worker.ex | 63 + lib/pleroma/workers/event_reminder_worker.ex | 74 + lib/pleroma/workers/mailer_worker.ex | 18 + lib/pleroma/workers/mute_expire_worker.ex | 23 + lib/pleroma/workers/notification_worker.ex | 27 + lib/pleroma/workers/poll_worker.ex | 48 + lib/pleroma/workers/publisher_worker.ex | 28 + lib/pleroma/workers/purge_expired_activity.ex | 75 + lib/pleroma/workers/purge_expired_filter.ex | 46 + lib/pleroma/workers/purge_expired_token.ex | 32 + lib/pleroma/workers/receiver_worker.ex | 26 + lib/pleroma/workers/remote_fetcher_worker.ex | 17 + .../workers/scheduled_activity_worker.ex | 61 + lib/pleroma/workers/search_indexing_worker.ex | 23 + lib/pleroma/workers/web_pusher_worker.ex | 23 + lib/pleroma/workers/worker_helper.ex | 48 + lib/pleroma/xml_builder.ex | 49 + lib/soapbox.ex | 5 + mix.exs | 365 + mix.lock | 161 + priv/gettext/config_descriptions.pot | 5975 ++++++++++++++++ priv/gettext/default.pot | 185 + priv/gettext/en/LC_MESSAGES/oauth_scopes.po | 264 + priv/gettext/en/LC_MESSAGES/posix_errors.po | 141 + priv/gettext/en_test/LC_MESSAGES/default.po | 185 + priv/gettext/en_test/LC_MESSAGES/errors.po | 556 ++ .../en_test/LC_MESSAGES/posix_errors.po | 152 + .../en_test/LC_MESSAGES/static_pages.po | 525 ++ priv/gettext/errors.pot | 609 ++ priv/gettext/es/LC_MESSAGES/errors.po | 586 ++ .../fr/LC_MESSAGES/config_descriptions.po | 6011 ++++++++++++++++ priv/gettext/fr/LC_MESSAGES/default.po | 212 + priv/gettext/fr/LC_MESSAGES/errors.po | 467 ++ priv/gettext/fr/LC_MESSAGES/posix_errors.po | 165 + priv/gettext/fr/LC_MESSAGES/static_pages.po | 564 ++ priv/gettext/he/LC_MESSAGES/errors.po | 599 ++ priv/gettext/id/LC_MESSAGES/errors.po | 580 ++ priv/gettext/it/LC_MESSAGES/errors.po | 583 ++ priv/gettext/ja/LC_MESSAGES/errors.po | 580 ++ priv/gettext/ko/LC_MESSAGES/default.po | 197 + priv/gettext/nl/LC_MESSAGES/errors.po | 590 ++ priv/gettext/nl/LC_MESSAGES/posix_errors.po | 165 + priv/gettext/nl/LC_MESSAGES/static_pages.po | 567 ++ priv/gettext/oauth_scopes.pot | 221 + priv/gettext/pl/LC_MESSAGES/errors.po | 590 ++ priv/gettext/posix_errors.pot | 149 + priv/gettext/pt_PT/LC_MESSAGES/errors.po | 594 ++ priv/gettext/ru/LC_MESSAGES/errors.po | 462 ++ priv/gettext/static_pages.pot | 562 ++ .../uk/LC_MESSAGES/config_descriptions.po | 6071 +++++++++++++++++ priv/gettext/uk/LC_MESSAGES/default.po | 197 + priv/gettext/uk/LC_MESSAGES/errors.po | 599 ++ priv/gettext/vi/LC_MESSAGES/errors.po | 580 ++ .../LC_MESSAGES/config_descriptions.po | 6034 ++++++++++++++++ priv/gettext/zh_Hans/LC_MESSAGES/default.po | 193 + priv/gettext/zh_Hans/LC_MESSAGES/errors.po | 617 ++ .../zh_Hans/LC_MESSAGES/posix_errors.po | 160 + .../zh_Hans/LC_MESSAGES/static_pages.po | 548 ++ priv/gettext/zh_Hant/LC_MESSAGES/errors.po | 580 ++ .../migrations/20170320193800_create_user.exs | 19 + .../20170321074828_create_activity.exs | 17 + .../20170321074832_create_object.exs | 15 + ...0321133335_add_following_list_to_users.exs | 13 + .../20170321143152_add_ap_id_to_users.exs | 13 + .../20170330153447_add_index_to_objects.exs | 11 + ...add_unique_index_to_email_and_nickname.exs | 12 + ...70416122418_add_avatar_object_to_users.exs | 13 + ...143_create_webssub_server_subscription.exs | 19 + .../20170423154511_add_fields_to_users.exs | 14 + ...4155_create_websub_client_subscription.exs | 19 + .../20170427054757_add_user_and_hub.exs | 14 + ...d_contraints_to_activities_and_objects.exs | 13 + ...nts_to_activities_and_objects_part_two.exs | 22 + ...02083023_add_local_field_to_activities.exs | 15 + ...0170506222027_add_unique_index_to_apid.exs | 11 + .../20170522160642_case_insensivtivity.exs | 35 + .../migrations/20170529093232_longer_bios.exs | 19 + ...20170620095947_remove_activities_index.exs | 11 + ...170620133028_add_object_activity_index.exs | 12 + ...420_add_object_activity_index_part_two.exs | 21 + ...0701142005_add_actor_index_to_activity.exs | 13 + ...719152213_add_follower_address_to_user.exs | 19 + .../20170906120646_add_mastodon_apps.exs | 20 + ...906143140_create_o_auth_authorizations.exs | 19 + .../20170906152508_create_o_auth_token.exs | 19 + .../20170911123607_create_notifications.exs | 19 + .../20170912114248_add_context_index.exs | 17 + ...0916090107_add_fts_index_to_activities.exs | 18 + .../20170917120416_add_tag_index.exs | 19 + ...019141706_create_password_reset_tokens.exs | 17 + ...035_add_second_object_index_to_activty.exs | 24 + .../20171024090137_drop_object_index.exs | 11 + .../20171024121413_add_object_actor_index.exs | 18 + .../20171109091239_add_actor_to_activity.exs | 25 + .../20171109114020_fill_actor_field.exs | 31 + ...109141309_add_sort_index_to_activities.exs | 12 + ...20171130135819_add_local_index_to_user.exs | 11 + ...212163643_add_recipients_to_activities.exs | 15 + ...12164525_fill_recipients_in_activities.exs | 29 + ...21210540_make_following_postgres_array.exs | 25 + ...51_add_follower_address_index_to_users.exs | 13 + ...7174350_drop_local_index_on_activities.exs | 11 + ...180327175831_actually_drop_local_index.exs | 12 + .../20180429094642_create_lists.exs | 19 + .../20180513104714_modify_activity_index.exs | 13 + .../20180516144508_add_trigram_extension.exs | 26 + ...180516154905_create_user_trigram_index.exs | 13 + .../20180530123448_add_list_follow_index.exs | 11 + ...3637_create_apid_host_extraction_index.exs | 17 + ...180612110515_create_user_invite_tokens.exs | 16 + ...40_create_activities_in_reply_to_index.exs | 17 + .../20180813003722_create_filters.exs | 27 + ...ipients_to_and_cc_fields_to_activities.exs | 17 + ...829182612_activities_add_to_cc_indices.exs | 17 + ...ients_to_and_cc_fields_from_activities.exs | 21 + ...903114437_users_add_is_moderator_index.exs | 13 + ...180918182427_create_push_subscriptions.exs | 22 + ...0919060348_users_add_last_refreshed_at.exs | 13 + .../20181201104428_add_uuid_extension.exs | 13 + ...20181201105617_add_uui_ds_to_user_info.exs | 13 + .../20181206125616_add_tags_to_users.exs | 15 + .../20181214121049_add_bookmarks_to_users.exs | 13 + ...18172826_users_and_activities_flake_id.exs | 142 + ...20190109152453_add_visibility_function.exs | 54 + .../20190115085500_create_user_fts_index.exs | 23 + .../20190118074940_fix_user_trigram_index.exs | 26 + ...90122153157_update_activity_visibility.exs | 38 + ...0190123092341_users_add_is_admin_index.exs | 13 + .../20190123125546_create_instances.exs | 19 + .../20190123125839_fix_info_ids.exs | 15 + ...31141_update_activity_visibility_again.exs | 38 + ...0540_change_push_subscriptions_varchar.exs | 19 + ...90127151220_add_activities_likes_index.exs | 18 + .../20190203185340_split_hide_network.exs | 24 + .../20190204200237_add_correct_dm_index.exs | 34 + .../20190205114625_create_thread_mutes.exs | 16 + ...08131753_add_scopes_to_o_auth_entities.exs | 15 + ...03_change_apps_scopes_to_varchar_array.exs | 27 + ..._data_migration_populate_o_auth_scopes.exs | 15 + ...104808_data_migration_normalize_scopes.exs | 15 + ...0190301101154_add_default_tags_to_user.exs | 13 + ...190303120636_update_user_note_counters.exs | 45 + .../20190315101315_create_registrations.exs | 22 + ...325185009_create_notification_id_index.exs | 11 + ...0328053912_create_scheduled_activities.exs | 20 + ...20190403131720_add_oauth_token_indexes.exs | 13 + ...50946_add_fields_to_user_invite_tokens.exs | 16 + ...0190405160700_add_index_on_subscribers.exs | 18 + .../20190408123347_create_conversations.exs | 26 + ...859_add_participation_updated_at_index.exs | 11 + ...120_add_index_on_user_info_deactivated.exs | 13 + .../20190412052952_add_user_info_fields.exs | 24 + .../20190413082658_create_bookmarks.exs | 18 + ...d_signin_and_last_digest_dates_to_user.exs | 13 + .../20190414125034_migrate_old_bookmarks.exs | 42 + ...0190501125843_add_fts_index_to_objects.exs | 23 + ...33552_add_refresh_token_index_to_token.exs | 11 + ...factor_authentication_settings_to_user.exs | 13 + .../20190508193213_create_mfa_tokens.exs | 20 + ...511191044_set_default_state_to_reports.exs | 23 + ...809_change_hide_column_in_filter_table.exs | 19 + ...5222404_add_thread_visibility_function.exs | 77 + .../20190516112144_add_ap_id_to_lists.exs | 30 + .../20190518032627_create_config.exs | 17 + ...lowers_fields_to_notification_settings.exs | 16 + ...03115238_add_index_on_activities_local.exs | 11 + ...603162018_add_object_in_reply_to_index.exs | 11 + ...0190603173419_add_tag_index_to_objects.exs | 15 + ...20190622151019_add_group_key_to_config.exs | 16 + ...10115833_add_following_address_to_user.exs | 13 + ...51_add_following_address_index_to_user.exs | 12 + ...add_following_address_from_source_data.exs | 32 + .../20190711042021_create_safe_jsonb_set.exs | 25 + ...2024_copy_muted_to_muted_notifications.exs | 15 + .../20190716100804_add_expirations_table.exs | 14 + .../20190730055101_add_oban_jobs_table.exs | 13 + ...ersation_participation_recipient_ships.exs | 17 + .../20190818124341_create_moderation_log.exs | 15 + ...90823000549_add_likes_index_to_objects.exs | 11 + .../20190912065617_create_deliveries.exs | 17 + .../migrations/20190917100019_update_oban.exs | 15 + ...0929201536_drop_subscription_if_exists.exs | 20 + ...unread_conversation_count_to_user_info.exs | 15 + .../20191006123824_add_keys_column.exs | 13 + ...006135457_move_keys_to_separate_column.exs | 14 + ...7073319_create_following_relationships.exs | 153 + ...132217_migrate_following_relationships.exs | 93 + .../20191008132427_drop_users_following.exs | 20 + .../20191009154606_add_user_info_columns.exs | 57 + ...154608_copy_users_info_fields_to_users.exs | 149 + .../20191014181019_create_markers.exs | 19 + .../20191017225002_drop_websub_tables.exs | 14 + ...191025081729_add_move_support_to_users.exs | 14 + .../20191025143434_add_defaults_to_tables.exs | 72 + ...1026190317_set_not_null_for_activities.exs | 21 + ..._set_not_null_for_activity_expirations.exs | 19 + .../20191026190500_set_not_null_for_apps.exs | 21 + ...91026190533_set_not_null_for_bookmarks.exs | 21 + ...20191026190622_set_not_null_for_config.exs | 21 + ...ersation_participation_recipient_ships.exs | 21 + ...t_null_for_conversation_participations.exs | 23 + ...0191026190841_set_not_null_for_filters.exs | 23 + ...91026191023_set_not_null_for_instances.exs | 19 + .../20191026191100_set_not_null_for_lists.exs | 19 + ...0191026191134_set_not_null_for_markers.exs | 19 + ...191218_set_not_null_for_moderation_log.exs | 19 + ...6191249_set_not_null_for_notifications.exs | 21 + ..._set_not_null_for_oauth_authorizations.exs | 23 + ...26191401_set_not_null_for_oauth_tokens.exs | 19 + ...0191026191442_set_not_null_for_objects.exs | 19 + ...set_not_null_for_password_reset_tokens.exs | 23 + ...03_set_not_null_for_push_subscriptions.exs | 29 + ...6191635_set_not_null_for_registrations.exs | 23 + ..._set_not_null_for_scheduled_activities.exs | 19 + ...26191753_set_not_null_for_thread_mutes.exs | 21 + ...26_set_not_null_for_user_invite_tokens.exs | 23 + .../20191026191910_set_not_null_for_users.exs | 48 + ...101340_migrate_missing_follow_requests.exs | 39 + .../20191029172832_fix_blocked_follows.exs | 116 + ...91104133100_set_visible_service_actors.exs | 26 + ...191118084425_create_user_relationships.exs | 21 + ..._migration_populate_user_relationships.exs | 72 + ...91123030554_add_activitypub_actor_type.exs | 13 + .../20191123103423_remove_info_from_users.exs | 13 + ...1128153944_fix_missing_following_count.exs | 57 + .../20191203043610_create_report_notes.exs | 17 + ...add_scopes_to_pleroma_feo_auth_records.exs | 21 + ...20200109123126_add_counter_cache_table.exs | 59 + .../20200227122417_add_trusted_to_apps.exs | 13 + ...0307103755_fix_moderation_log_subjects.exs | 13 + .../20200309123730_create_chats.exs | 20 + ...3607_config_remove_fetch_initial_posts.exs | 14 + ...125756_delete_fetch_initial_posts_jobs.exs | 14 + .../20200322174133_user_raw_bio.exs | 13 + ...rf_config_move_from_instance_namespace.exs | 43 + ...llowing_relationships_state_to_integer.exs | 33 + ...owing_relationships_following_id_index.exs | 15 + .../20200328193433_populate_user_raw_bio.exs | 29 + .../20200401030751_users_add_public_key.exs | 21 + .../20200401072456_users_add_inboxes.exs | 24 + .../20200402063221_update_oban_jobs_table.exs | 15 + .../20200406100225_users_add_emoji.exs | 42 + ...0200406105422_users_remove_source_data.exs | 19 + .../20200415181818_update_markers.exs | 48 + ...338_insert_skeletons_for_deleted_users.exs | 47 + .../20200505072231_remove_magic_key_field.exs | 13 + ...00508092434_update_counter_cache_table.exs | 147 + ...ients_contain_blocked_domains_function.exs | 37 + .../20200526144426_add_apps_indexes.exs | 11 + ...7104138_change_notification_user_index.exs | 12 + ...ete_notifications_from_invisible_users.exs | 22 + ...200602094828_add_type_to_notifications.exs | 13 + ...0602125218_backfill_notification_types.exs | 14 + ...02150528_create_chat_message_reference.exs | 20 + ...nique_index_to_chat_message_references.exs | 11 + ...0200603120448_remove_unread_from_chats.exs | 13 + ..._seen_index_to_chat_message_references.exs | 16 + ...n_to_unread_in_chat_message_references.exs | 34 + ..._change_type_to_enum_for_notifications.exs | 40 + ...20200607112923_change_chat_id_to_flake.exs | 27 + ...359_rename_notification_privacy_option.exs | 23 + ...0200630162024_rename_user_settings_col.exs | 15 + ...703101031_add_chat_acceptance_to_users.exs | 21 + ...0200706060258_remove_tesla_from_config.exs | 14 + .../20200707112859_instances_add_favicon.exs | 14 + ...20200708193702_drop_user_trigram_index.exs | 22 + ...712234852_add_approval_fields_to_users.exs | 14 + ...20200714081657_oban_2_0_config_changes.exs | 31 + .../20200716195806_autolinker_to_linkify.exs | 40 + ...2185515_fix_malformed_formatter_config.exs | 30 + .../20200724133313_move_welcome_settings.exs | 98 + .../20200802170532_fix_legacy_tags.exs | 44 + ...0804180322_remove_nonlocal_expirations.exs | 23 + ...3107_add_unique_index_to_app_client_id.exs | 11 + .../20200806175913_rename_instance_chat.exs | 12 + .../20200808173046_only_expire_creates.exs | 23 + ..._set_defaults_to_user_approval_pending.exs | 19 + .../20200811143147_ap_id_not_null.exs | 23 + ...817120935_add_invisible_index_to_users.exs | 11 + ...541_rename_activity_expiration_setting.exs | 17 + ...1316_move_activity_expirations_to_oban.exs | 34 + ...093037_drop_activity_expirations_table.exs | 11 + ...ve_unread_conversation_count_from_user.exs | 42 + ...ad_index_to_conversation_participation.exs | 16 + .../20200831142509_chat_constraints.exs | 26 + ...eroma_report_to_enum_for_notifications.exs | 52 + .../20200831192323_create_backups.exs | 21 + .../20200901061256_ensure_bio_is_string.exs | 11 + .../20200901061637_bio_set_not_null.exs | 14 + ...e_await_up_timeout_in_connections_pool.exs | 17 + ...20200905091427_rename_timeout_in_pools.exs | 23 + ...ove_cron_stats_worker_from_oban_config.exs | 23 + ...ar_oauth_token_worker_from_oban_config.exs | 23 + ...92050_move_tokens_expiration_into_oban.exs | 44 + ...10113106_remove_managed_config_from_db.exs | 31 + .../20200911055909_remove_cron_jobs.exs | 24 + ...8_delete_notification_without_activity.exs | 34 + ...914105800_add_notification_constraints.exs | 27 + .../20200915095704_remove_background_jobs.exs | 26 + .../20200919182636_remoteip_plug_rename.exs | 23 + .../20200925065249_make_user_ids_ci.exs | 13 + .../20200928145912_revert_citext_change.exs | 15 + ...0930082320_user_ur_is_index_part_three.exs | 12 + ...05123100_simple_policy_string_to_tuple.exs | 44 + ...00_quarantained_policy_string_to_tuple.exs | 65 + ...ransparency_exclusions_string_to_tuple.exs | 65 + ...173004_refactor_deactivated_user_field.exs | 22 + ...01013141127_refactor_locked_user_field.exs | 15 + ...44052_refactor_discoverable_user_field.exs | 15 + ...factor_confirmation_pending_user_field.exs | 20 + ...0_refactor_approval_pending_user_field.exs | 20 + ...pired_activity_worker_from_oban_config.exs | 23 + ...tion_prolong_o_auth_tokens_valid_until.exs | 17 + .../20201221202251_create_hashtags.exs | 17 + ...201221202252_remove_data_from_hashtags.exs | 19 + ...20201221203824_create_hashtags_objects.exs | 17 + ...20201231185546_confirm_logged_in_users.exs | 22 + .../20210105195018_create_data_migrations.exs | 21 + ...gration_create_populate_hashtags_table.exs | 20 + ...72254_create_data_migration_failed_ids.exs | 18 + ...210113225652_deprecate_public_endpoint.exs | 57 + ...20210115205649_upgrade_oban_jobs_to_v9.exs | 15 + ...1080964_add_default_text_search_config.exs | 15 + ...0122151424_add_last_active_at_to_users.exs | 15 + ...licates_from_activity_expiration_queue.exs | 33 + ...0202110641_add_pinned_objects_to_users.exs | 13 + ...03141144_add_featured_address_to_users.exs | 27 + ..._pinned_activities_into_pinned_objects.exs | 32 + ...21_remove_pinned_activities_from_users.exs | 19 + ...218223811_add_disclose_client_to_users.exs | 13 + ...emove_hashtags_objects_duplicate_index.exs | 15 + ...222184616_change_hashtags_name_to_text.exs | 19 + ...1143153_user_notification_settings_fix.exs | 21 + ...708_remove_mastofe_settings_from_users.exs | 13 + ...204354_delete_hashtags_objects_cascade.exs | 23 + ...12185407_add_email_list_field_to_users.exs | 9 + ...7000000_add_poll_to_notifications_enum.exs | 53 + .../20210818023112_add_user_id_to_apps.exs | 15 + .../20211121000000_create_user_notes.exs | 19 + ...25110126_force_pinned_objects_to_exist.exs | 15 + .../20211126191138_add_suggestions.exs | 15 + ...1222165256_add_last_status_at_to_users.exs | 15 + ...802_add_is_discoverable_index_to_users.exs | 11 + ...hips_target_id_relationship_type_index.exs | 11 + .../20220116183110_add_birthday_to_users.exs | 16 + ..._add_birthday_month_day_index_to_users.exs | 15 + .../20220203224011_create_rules.exs | 12 + ...er_exiftool_to_exiftool_strip_location.exs | 37 + ...00_add_activity_assigned_account_index.exs | 9 + .../20220302013920_add_language_to_users.exs | 9 + .../20220308012601_create_announcements.exs | 26 + .../20220314220000_add_location_to_users.exs | 9 + ...00000_add_status_to_notifications_enum.exs | 50 + .../20220506175506_add_index_hotspots.exs | 17 + ...read_visibility_to_be_local_only_aware.exs | 153 + ...7134341_add_quote_url_index_to_objects.exs | 11 + ...33_change_report_notes_content_to_text.exs | 16 + ...85734_add_update_to_notifications_enum.exs | 52 + ...3_add_expires_at_to_user_relationships.exs | 13 + .../20220624104914_create_webhooks.exs | 20 + ...2322_add_associated_object_id_function.exs | 37 + ...0_switch_to_associated_object_id_index.exs | 37 + ..._data_migration_delete_context_objects.exs | 18 + ...ipation_accepted_to_notifications_enum.exs | 66 + ..._visibility_to_use_new_object_id_index.exs | 156 + ...0220905011454_generate_unset_user_keys.exs | 28 + ...220927220033_add_last_move_at_to_users.exs | 13 + ...0221029171353_add_internal_to_webhooks.exs | 13 + .../20221103014611_create_oban_peers.exs | 7 + ...221103014728_swap_primary_oban_indexes.exs | 26 + .../20221111164213_deprecate_quack.exs | 24 + .../20221207235104_add_events_index.exs | 16 + .../20221216052127_add_state_to_backups.exs | 21 + .../20230306112859_instances_add_metadata.exs | 14 + .../20230422154018_drop_unused_indexes.exs | 73 + .../20230504173400_remove_user_ap_enabled.exs | 13 + ...0231107200724_consolidate_email_queues.exs | 9 + ...510135645_add_fts_index_to_objects_two.exs | 55 + priv/repo/seeds.exs | 15 + priv/scrubbers/default.ex | 113 + priv/scrubbers/links_only.ex | 31 + priv/scrubbers/media_proxy.ex | 36 + priv/scrubbers/o_embed.ex | 18 + priv/scrubbers/search_indexing.ex | 24 + priv/scrubbers/twitter_text.ex | 62 + .../READ_THIS_BEFORE_TOUCHING_FILES_HERE | 1 + priv/static/adminfe/app.147d87e8.css | 1 + priv/static/adminfe/chunk-0c3d.79348811.css | 1 + priv/static/adminfe/chunk-0c60.ee394b7b.css | 1 + priv/static/adminfe/chunk-0fa6.d224ff3a.css | 1 + priv/static/adminfe/chunk-1a25.098b6a73.css | 1 + priv/static/adminfe/chunk-2be3.846d0d5a.css | 1 + priv/static/adminfe/chunk-2cfa.c090392a.css | 1 + priv/static/adminfe/chunk-305b.64cc20ab.css | 1 + priv/static/adminfe/chunk-31b9.abc32430.css | 1 + priv/static/adminfe/chunk-33c9.3c0c7538.css | 1 + priv/static/adminfe/chunk-42d5.9ade3c1d.css | 1 + priv/static/adminfe/chunk-45ed.fc62bc5c.css | 1 + priv/static/adminfe/chunk-4995.ba9a98d5.css | 1 + priv/static/adminfe/chunk-5669.9e91529b.css | 1 + priv/static/adminfe/chunk-59e6.07d1d2f0.css | 1 + priv/static/adminfe/chunk-5cf2.5bf52c8f.css | 1 + priv/static/adminfe/chunk-68b6.d7a7a7ea.css | 1 + priv/static/adminfe/chunk-7c11.5c2bbb9c.css | 1 + .../adminfe/chunk-elementUI.852ab1db.css | 1 + priv/static/adminfe/chunk-libs.0b4a26df.css | 1 + priv/static/adminfe/favicon.ico | Bin 0 -> 67646 bytes priv/static/adminfe/index.html | 1 + .../static/fonts/element-icons.535877f.woff | Bin 0 -> 28200 bytes .../static/fonts/element-icons.732389d.ttf | Bin 0 -> 55956 bytes .../static/adminfe/static/js/7zzA.e1ae1c94.js | 2 + .../adminfe/static/js/7zzA.e1ae1c94.js.map | 1 + .../static/adminfe/static/js/JEtC.f9ba4594.js | 2 + .../adminfe/static/js/JEtC.f9ba4594.js.map | 1 + priv/static/adminfe/static/js/app.c3e187df.js | 2 + .../adminfe/static/js/app.c3e187df.js.map | 1 + .../adminfe/static/js/chunk-0c3d.00a00ec3.js | 2 + .../static/js/chunk-0c3d.00a00ec3.js.map | 1 + .../adminfe/static/js/chunk-0c60.e0f08810.js | 2 + .../static/js/chunk-0c60.e0f08810.js.map | 1 + .../adminfe/static/js/chunk-0e2d.04760e2c.js | 25 + .../static/js/chunk-0e2d.04760e2c.js.map | 1 + .../adminfe/static/js/chunk-0fa6.ddd4199e.js | 2 + .../static/js/chunk-0fa6.ddd4199e.js.map | 1 + .../adminfe/static/js/chunk-16d0.7d343bb9.js | 2 + .../static/js/chunk-16d0.7d343bb9.js.map | 1 + .../adminfe/static/js/chunk-1a25.107c34e7.js | 2 + .../static/js/chunk-1a25.107c34e7.js.map | 1 + .../adminfe/static/js/chunk-2be3.7b7c045e.js | 2 + .../static/js/chunk-2be3.7b7c045e.js.map | 1 + .../adminfe/static/js/chunk-2cfa.608c3714.js | 2 + .../static/js/chunk-2cfa.608c3714.js.map | 1 + .../adminfe/static/js/chunk-305b.3a7fb27b.js | 2 + .../static/js/chunk-305b.3a7fb27b.js.map | 1 + .../adminfe/static/js/chunk-319f.8919d1e7.js | 274 + .../static/js/chunk-319f.8919d1e7.js.map | 1 + .../adminfe/static/js/chunk-31b9.7b89f7b5.js | 2 + .../static/js/chunk-31b9.7b89f7b5.js.map | 1 + .../adminfe/static/js/chunk-33c9.cf3bdd1b.js | 2 + .../static/js/chunk-33c9.cf3bdd1b.js.map | 1 + .../adminfe/static/js/chunk-42d5.f83ab775.js | 2 + .../static/js/chunk-42d5.f83ab775.js.map | 1 + .../adminfe/static/js/chunk-45ed.10b6b7a7.js | 2 + .../static/js/chunk-45ed.10b6b7a7.js.map | 1 + .../adminfe/static/js/chunk-4995.94e052fc.js | 2 + .../static/js/chunk-4995.94e052fc.js.map | 1 + .../adminfe/static/js/chunk-5669.88725eaa.js | 2 + .../static/js/chunk-5669.88725eaa.js.map | 1 + .../adminfe/static/js/chunk-59e6.01e205d8.js | 2 + .../static/js/chunk-59e6.01e205d8.js.map | 1 + .../adminfe/static/js/chunk-5cf2.a8562f1f.js | 2 + .../static/js/chunk-5cf2.a8562f1f.js.map | 1 + .../adminfe/static/js/chunk-68b6.4347fd87.js | 2 + .../static/js/chunk-68b6.4347fd87.js.map | 1 + .../adminfe/static/js/chunk-7c11.b858969e.js | 2 + .../static/js/chunk-7c11.b858969e.js.map | 1 + .../static/js/chunk-elementUI.bec6fa77.js | 2 + .../static/js/chunk-elementUI.bec6fa77.js.map | 1 + .../adminfe/static/js/chunk-libs.eb232bda.js | 74 + .../static/js/chunk-libs.eb232bda.js.map | 1 + .../static/adminfe/static/js/oAJy.25bb7cc7.js | 2 + .../adminfe/static/js/oAJy.25bb7cc7.js.map | 1 + .../adminfe/static/js/runtime.f1a41c33.js | 2 + .../adminfe/static/js/runtime.f1a41c33.js.map | 1 + priv/static/embed.css | 115 + priv/static/embed.js | 43 + priv/static/favicon.png | Bin 0 -> 981 bytes priv/static/images/avi.png | Bin 0 -> 4138 bytes priv/static/images/banner.png | Bin 0 -> 81 bytes priv/static/images/logo.png | Bin 0 -> 8841 bytes priv/static/images/rebased-wide.svg | 45 + priv/static/images/shortcuts/chats.png | Bin 0 -> 2478 bytes .../static/images/shortcuts/notifications.png | Bin 0 -> 2611 bytes priv/static/images/shortcuts/search.png | Bin 0 -> 2860 bytes priv/static/index.html | 74 + priv/static/instance/panel.html | 5 + priv/static/instance/static.css | 398 ++ priv/static/instance/thumbnail.png | Bin 0 -> 9861 bytes priv/static/robots.txt | 2 + priv/static/schemas/litepub-0.1.jsonld | 71 + priv/static/static-fe/static-fe.css | 184 + .../static/css/7586.0d43f70bc6240422f179.css | 2 + .../css/7586.0d43f70bc6240422f179.css.map | 1 + .../static/css/7962.76663e78ad5ea0bb0b90.css | 11 + .../css/7962.76663e78ad5ea0bb0b90.css.map | 1 + .../static/css/9801.cfe503d4c949ae0c3813.css | 2 + .../css/9801.cfe503d4c949ae0c3813.css.map | 1 + .../static/css/app.c18a2c80794a1b699a61.css | 2 + .../css/app.c18a2c80794a1b699a61.css.map | 1 + .../static/js/159.903e90c9de8ef6c67077.js | 2 + .../static/js/159.903e90c9de8ef6c67077.js.map | 1 + .../static/js/3733.7060d1e6bca813125a0c.js | 3 + .../3733.7060d1e6bca813125a0c.js.LICENSE.txt | 38 + .../js/3733.7060d1e6bca813125a0c.js.map | 1 + .../static/js/48.b5ecdbc517423af07ca4.js | 3 + .../js/48.b5ecdbc517423af07ca4.js.LICENSE.txt | 11 + .../static/js/48.b5ecdbc517423af07ca4.js.map | 1 + .../static/js/6464.eb9c90a1c948cde554e9.js | 2 + .../js/6464.eb9c90a1c948cde554e9.js.map | 1 + .../static/js/7586.981b2305a0019f6042a5.js | 2 + .../js/7586.981b2305a0019f6042a5.js.map | 1 + .../static/js/7962.e25d40b042f8ee7389c3.js | 2 + .../js/7962.e25d40b042f8ee7389c3.js.map | 1 + .../static/js/9060.24271e167e0471a1a732.js | 2 + .../js/9060.24271e167e0471a1a732.js.map | 1 + .../static/js/9801.99ace6b5dc657bf1a65b.js | 2 + .../js/9801.99ace6b5dc657bf1a65b.js.map | 1 + .../static/js/app.7c4b412b26221a7c8572.js | 2 + .../static/js/app.7c4b412b26221a7c8572.js.map | 1 + .../js/i18n/ar-json.4916f840147303aa65fe.js | 2 + .../i18n/ar-json.4916f840147303aa65fe.js.map | 1 + .../js/i18n/eo-json.6c62eef99e850912498b.js | 2 + .../i18n/eo-json.6c62eef99e850912498b.js.map | 1 + .../js/i18n/id-json.e5c9ee768155f88128b9.js | 2 + .../i18n/id-json.e5c9ee768155f88128b9.js.map | 1 + .../js/i18n/ko-json.9029d09084bb22d8b705.js | 2 + .../i18n/ko-json.9029d09084bb22d8b705.js.map | 1 + .../i18n/nan-TW-json.7f2789d8a461e86d1734.js | 2 + .../nan-TW-json.7f2789d8a461e86d1734.js.map | 1 + .../js/i18n/zh-json.5831b903c3e6d281f122.js | 2 + .../i18n/zh-json.5831b903c3e6d281f122.js.map | 1 + .../i18n/zh_Hant-json.f7e1d0f4b873c60d6396.js | 2 + .../zh_Hant-json.f7e1d0f4b873c60d6396.js.map | 1 + priv/static/static/terms-of-service.html | 9 + priv/static/sw-pleroma.js | 5 + priv/static/sw-pleroma.js.map | 1 + priv/templates/robots_txt.eex | 2 + priv/templates/sample_config.eex | 79 + priv/templates/sample_psql.eex | 12 + rebased.png | Bin 0 -> 11039 bytes rel/env.sh.eex | 12 + rel/files/bin/pleroma_ctl | 161 + rel/files/installation/init.d/pleroma | 22 + rel/files/installation/pleroma.service | 36 + rel/vm.args.eex | 11 + restarter/lib/pleroma.ex | 106 + restarter/lib/restarter.ex | 8 + restarter/mix.exs | 22 + test/config/emoji.txt | 1 + test/credo/check/consistency/file_location.ex | 166 + test/fixtures/DSCN0010.jpg | Bin 0 -> 161713 bytes .../activitypub-client-post-activity.json | 10 + test/fixtures/avatar_data_uri | 1 + test/fixtures/birthdays/misskey-user.json | 1 + test/fixtures/bogus-mastodon-announce.json | 43 + test/fixtures/bridgy/actor.json | 80 + .../config/temp.exported_from_db.secret.exs | 9 + test/fixtures/config/temp.secret.exs | 15 + test/fixtures/create-chat-message.json | 31 + ...reate-pleroma-reply-to-misskey-thread.json | 61 + test/fixtures/custom-emoji-reaction.json | 28 + test/fixtures/custom_instance_panel.html | 1 + test/fixtures/emoji-reaction-no-emoji.json | 30 + test/fixtures/emoji-reaction-too-long.json | 30 + test/fixtures/emoji-reaction.json | 30 + test/fixtures/emoji/packs/blank.png.zip | Bin 0 -> 284 bytes .../emoji/packs/default-manifest.json | 10 + test/fixtures/emoji/packs/finmoji.json | 3 + test/fixtures/emoji/packs/manifest.json | 10 + test/fixtures/emojis.zip | Bin 0 -> 1446 bytes test/fixtures/empty.zip | Bin 0 -> 22 bytes test/fixtures/fep-e232.json | 31 + .../fetch_mocks/104410921027210069.json | 72 + .../fetch_mocks/9wTkLEnuq47B25EehM.json | 59 + test/fixtures/fetch_mocks/eal.json | 43 + test/fixtures/fetch_mocks/tuxcrafting.json | 59 + test/fixtures/friendica_salmon.xml | 7 + test/fixtures/guppe-actor.json | 26 + .../host-meta-zetsubou.xn--q9jyb4c.xml | 5 + test/fixtures/hubzilla-actor.json | 1 + test/fixtures/hubzilla-create-image.json | 1 + test/fixtures/hubzilla-follow-activity.json | 31 + test/fixtures/image.gif | Bin 0 -> 1001718 bytes test/fixtures/image.jpg | Bin 0 -> 284468 bytes test/fixtures/image.png | Bin 0 -> 104426 bytes test/fixtures/image_with_caption-abstract.jpg | Bin 0 -> 697 bytes ..._imagedescription_and_caption-abstract.jpg | Bin 0 -> 823 bytes ..._caption-abstract_and_stray_data_after.png | Bin 0 -> 820 bytes ...ption_and_caption-abstract_whitespaces.jpg | Bin 0 -> 785 bytes test/fixtures/image_with_no_description.jpg | Bin 0 -> 631 bytes test/fixtures/image_with_stray_data_after.png | Bin 0 -> 104435 bytes .../kroeg-announce-with-inline-actor.json | 88 + test/fixtures/kroeg-array-less-emoji.json | 1 + test/fixtures/kroeg-array-less-hashtag.json | 1 + test/fixtures/kroeg-post-activity.json | 50 + test/fixtures/lain.xml | 12 + test/fixtures/lambadalambda.json | 64 + .../margaret-corbin-grave-west-point.html | 2895 ++++++++ test/fixtures/mastodon-accept-activity.json | 34 + test/fixtures/mastodon-announce-private.json | 35 + test/fixtures/mastodon-announce.json | 37 + test/fixtures/mastodon-block-activity.json | 29 + .../mastodon-create-with-attachment.json | 63 + test/fixtures/mastodon-delete-user.json | 24 + test/fixtures/mastodon-delete.json | 30 + test/fixtures/mastodon-follow-activity.json | 29 + test/fixtures/mastodon-like.json | 29 + test/fixtures/mastodon-nodeinfo20.json | 1 + test/fixtures/mastodon-note-object.json | 45 + .../mastodon-post-activity-contentmap.json | 67 + .../mastodon-post-activity-hashtag.json | 70 + .../fixtures/mastodon-post-activity-nsfw.json | 68 + test/fixtures/mastodon-post-activity.json | 77 + test/fixtures/mastodon-question-activity.json | 98 + test/fixtures/mastodon-reject-activity.json | 34 + test/fixtures/mastodon-unblock-activity.json | 34 + test/fixtures/mastodon-undo-announce.json | 47 + .../mastodon-undo-like-compact-object.json | 29 + test/fixtures/mastodon-undo-like.json | 34 + test/fixtures/mastodon-unfollow-activity.json | 34 + test/fixtures/mastodon-update.json | 63 + test/fixtures/mastodon-vote.json | 16 + .../mastodon-well-known-nodeinfo.json | 1 + test/fixtures/mastodon/application_actor.json | 67 + .../collections/external_featured.json | 14 + .../mastodon/collections/featured.json | 39 + test/fixtures/mewmew_no_name.json | 46 + test/fixtures/misskey-like.json | 14 + test/fixtures/modules/good_mrf.ex | 23 + test/fixtures/modules/runtime_module.ex | 9 + ...facial-recognition-children-teenagers.html | 227 + ...acial-recognition-children-teenagers2.html | 226 + ...acial-recognition-children-teenagers3.html | 227 + ...acial-recognition-children-teenagers4.html | 228 + test/fixtures/osada-follow-activity.json | 52 + .../owncast-note-with-attachment.json | 31 + test/fixtures/peertube/actor-person.json | 121 + .../peertube/video-object-mpegURL-only.json | 413 ++ .../preload_static/instance/panel.html | 1 + test/fixtures/prismo-url-map.json | 65 + test/fixtures/private_key.pem | 27 + .../quote_post/fedibird_quote_mismatched.json | 54 + .../quote_post/fedibird_quote_post.json | 52 + .../quote_post/fedibird_quote_uri.json | 54 + .../quote_post/fep-e232-tag-example.json | 17 + .../quote_post/misskey_quote_post.json | 46 + test/fixtures/rel_me_anchor.html | 14 + test/fixtures/rel_me_anchor_nofollow.html | 14 + test/fixtures/rel_me_link.html | 14 + test/fixtures/rel_me_null.html | 14 + test/fixtures/relay/accept-follow.json | 15 + test/fixtures/relay/relay.json | 20 + test/fixtures/rich_media/amz.html | 5 + test/fixtures/rich_media/malformed-data.html | 4874 +++++++++++++ test/fixtures/rich_media/non_ogp_embed.html | 1479 ++++ test/fixtures/rich_media/oembed.html | 3 + test/fixtures/rich_media/oembed.json | 1 + .../fixtures/rich_media/ogp-missing-data.html | 8 + .../rich_media/ogp-missing-title.html | 12 + test/fixtures/rich_media/ogp.html | 10 + test/fixtures/rich_media/twitter_card.html | 5 + test/fixtures/rich_media/wordpress.html | 584 ++ test/fixtures/rich_media/wordpress_embed.json | 1 + test/fixtures/roadhouse-create-activity.json | 109 + test/fixtures/rsa_keys/key_1.pem | 27 + test/fixtures/rsa_keys/key_2.pem | 27 + test/fixtures/rsa_keys/key_3.pem | 27 + test/fixtures/rsa_keys/key_4.pem | 27 + test/fixtures/rsa_keys/key_5.pem | 27 + test/fixtures/salmon.xml | 2 + test/fixtures/salmon2.xml | 2 + test/fixtures/snow.js | 32 + .../soapbox_no_mentions_in_content.json | 79 + test/fixtures/sound.mp3 | Bin 0 -> 521 bytes test/fixtures/spoofed-object.json | 26 + test/fixtures/statuses/masto-note.json | 47 + test/fixtures/statuses/note.json | 27 + test/fixtures/tesla_mock/7369654.html | 665 ++ test/fixtures/tesla_mock/7even.json | 1 + .../tesla_mock/admin@mastdon.example.org.json | 70 + test/fixtures/tesla_mock/aimu@misskey.io.json | 64 + .../tesla_mock/atarifrosch_webfinger.xml | 18 + .../baptiste.gelex.xyz-article.json | 227 + .../tesla_mock/baptiste.gelex.xyz-user.json | 1 + test/fixtures/tesla_mock/craigmaloney.json | 112 + .../tesla_mock/deepl-languages-list.json | 1 + .../tesla_mock/deepl-translation.json | 1 + test/fixtures/tesla_mock/dist/test.txt | 1 + test/fixtures/tesla_mock/eal_sakamoto.xml | 1 + test/fixtures/tesla_mock/emelie.json | 1 + .../fixtures/tesla_mock/emoji-in-summary.json | 49 + .../tesla_mock/framasoft@framatube.org.json | 1 + .../tesla_mock/framatube.org-video.json | 1 + .../tesla_mock/framatube.org_host_meta | 2 + test/fixtures/tesla_mock/frontend.zip | Bin 0 -> 186 bytes test/fixtures/tesla_mock/frontend_dist.zip | Bin 0 -> 334 bytes test/fixtures/tesla_mock/funkwhale_audio.json | 44 + .../tesla_mock/funkwhale_channel.json | 44 + .../tesla_mock/funkwhale_create_audio.json | 58 + .../fixtures/tesla_mock/gerzilla.de_host_meta | 10 + .../gleasonator-AG3RzWfwEKKrY63qj2.json | 35 + .../tesla_mock/gleasonator.com_host_meta | 4 + .../tesla_mock/gnusocial.de_host_meta | 2 + .../tesla_mock/gs.example.org_host_meta | 9 + .../tesla_mock/helene@p.helene.moe.json | 50 + test/fixtures/tesla_mock/hellpie.json | 1 + ...__gs.example.org_4040_index.php_user_1.xml | 19 + ...n.example.org_users_admin_status_1234.json | 55 + ...ps___framatube.org_accounts_framasoft.json | 1 + .../https___info.pleroma.site_actor.json | 17 + .../https___lm.kazv.moe_users_mewmew.xml | 1 + ...___mastodon.social_users_lambadalambda.xml | 11 + .../https___osada.macgirvin.com.html | 301 + ...ps___osada.macgirvin.com_channel_mike.json | 55 + .../https___pawoo.net_users_aqidaqidaqid.xml | 11 + .../https___pawoo.net_users_pekorino.xml | 11 + .../https___pleroma.soykaf.com_users_lain.xml | 1 + .../tesla_mock/https___prismo.news__mxb.json | 1 + ...ttps___shitposter.club_notice_2827873.html | 653 ++ .../https___shitposter.club_user_1.xml | 20 + .../https___social.heldscal.la_user_23211.xml | 20 + .../https___social.heldscal.la_user_29191.xml | 20 + .../https__info.pleroma.site_activity.json | 14 + .../https__info.pleroma.site_activity2.json | 14 + .../https__info.pleroma.site_activity3.json | 13 + .../https__info.pleroma.site_activity4.json | 13 + .../tesla_mock/kaniini@gerzilla.de.json | 1 + .../kaniini@hubzilla.example.org.json | 1 + test/fixtures/tesla_mock/kpherox@mstdn.jp.xml | 10 + .../tesla_mock/lain_squeet.me_webfinger.xml | 42 + test/fixtures/tesla_mock/lemmy-page.json | 17 + test/fixtures/tesla_mock/lemmy-user.json | 27 + .../fixtures/tesla_mock/lm.kazv.moe_host_meta | 1 + test/fixtures/tesla_mock/lucifermysticus.json | 1 + .../tesla_mock/macgirvin.com_host_meta | 11 + .../tesla_mock/macgirvin@gleasonator.com.json | 41 + .../mametsuko@mk.absturztau.be.json | 65 + test/fixtures/tesla_mock/mamot.fr_host_meta | 4 + .../tesla_mock/mastodon.social_host_meta | 4 + .../tesla_mock/mastodon.xyz_host_meta | 4 + test/fixtures/tesla_mock/mayumayu.json | 1 + test/fixtures/tesla_mock/mayumayupost.json | 1 + .../tesla_mock/mewmew@lm.kazv.moe.json | 1 + .../tesla_mock/mike@osada.macgirvin.com.json | 53 + .../tesla_mock/misskey.io_8vs6wxufd0.json | 44 + .../tesla_mock/misskey_poll_no_end_date.json | 1 + ..._01830912-1357-d4c5-e4a2-76eab347e749.json | 14 + .../mk.absturztau.be-93e7nm8wqg-activity.json | 1 + .../mk.absturztau.be-93e7nm8wqg.json | 44 + .../mobilizon-event-join-accept.json | 80 + .../tesla_mock/mobilizon-event-join.json | 74 + .../tesla_mock/mobilizon.org-event.json | 1 + .../tesla_mock/mobilizon.org-user.json | 1 + .../tesla_mock/moonman@shitposter.club.json | 1 + test/fixtures/tesla_mock/mstdn.jp_host_meta | 4 + .../tesla_mock/nominatim_search_results.json | 1 + .../tesla_mock/nominatim_single_result.json | 1 + .../nonexistant@social.heldscal.la.xml | 90 + .../fixtures/tesla_mock/osada-user-indio.json | 1 + .../p.helene.moe-AM7S6vZQmL6pI9TgPY.json | 36 + test/fixtures/tesla_mock/pawoo.net_host_meta | 4 + test/fixtures/tesla_mock/peertube-social.json | 234 + .../fixtures/tesla_mock/peertube.moe-vid.json | 187 + .../pekorino@pawoo.net_host_meta.json | 12 + .../tesla_mock/pleroma.soykaf.com_host_meta | 1 + test/fixtures/tesla_mock/poll_attachment.json | 99 + test/fixtures/tesla_mock/poll_modified.json | 1 + test/fixtures/tesla_mock/poll_original.json | 1 + test/fixtures/tesla_mock/puckipedia.com.json | 1 + .../tesla_mock/relay@mastdon.example.org.json | 55 + test/fixtures/tesla_mock/rin.json | 1 + test/fixtures/tesla_mock/rinpatch.json | 64 + test/fixtures/tesla_mock/rye.json | 1 + test/fixtures/tesla_mock/sdf.org_host_meta | 4 + .../tesla_mock/shitposter.club_host_meta | 9 + .../shp@pleroma.soykaf.com.webfigner | 1 + .../tesla_mock/shp@social.heldscal.la.xml | 20 + test/fixtures/tesla_mock/sjw.json | 1 + test/fixtures/tesla_mock/skruyb@mamot.fr.atom | 11 + .../smithereen_non_anonymous_poll.json | 1 + test/fixtures/tesla_mock/smithereen_user.json | 1 + .../snowdusk@sdf.org_host_meta.json | 12 + .../tesla_mock/social.heldscal.la_host_meta | 9 + .../tesla_mock/social.sakamoto.gq_host_meta | 1 + ...ial.stopwatchingus-heidelberg.de_host_meta | 9 + .../tesla_mock/social.wxcafe.net_host_meta | 4 + test/fixtures/tesla_mock/soykaf.com_host_meta | 4 + test/fixtures/tesla_mock/spc_5381_xrd.xml | 20 + test/fixtures/tesla_mock/squeet.me_host_meta | 16 + .../tesla_mock/status.alpicola.com_host_meta | 2 + test/fixtures/tesla_mock/status.emelie.json | 64 + .../stopwatchingus-heidelberg.de_host_meta | 31 + .../fixtures/tesla_mock/webfinger_emelie.json | 36 + test/fixtures/tesla_mock/webfinger_spoof.json | 28 + .../tesla_mock/wedistribute-article.json | 18 + .../wedistribute-create-article.json | 1 + .../tesla_mock/wedistribute-user.json | 31 + .../tesla_mock/winterdienst_webfinger.json | 1 + test/fixtures/test.txt | 1 + test/fixtures/user_full.xml | 10 + test/fixtures/user_name_only.xml | 5 + .../users_mock/friendica_followers.json | 19 + .../users_mock/friendica_following.json | 19 + .../users_mock/masto_closed_followers.json | 7 + .../masto_closed_followers_page.json | 1 + .../users_mock/masto_closed_following.json | 7 + .../masto_closed_following_page.json | 1 + test/fixtures/users_mock/masto_featured.json | 18 + .../users_mock/pleroma_followers.json | 20 + .../users_mock/pleroma_following.json | 20 + test/fixtures/users_mock/user.json | 42 + test/fixtures/video.mp4 | Bin 0 -> 522216 bytes test/fixtures/warnings/otp_version/21.1 | 1 + test/fixtures/warnings/otp_version/22.1 | 1 + test/fixtures/warnings/otp_version/22.4 | 1 + test/fixtures/warnings/otp_version/23.0 | 1 + test/fixtures/webfinger.xml | 20 + test/fixtures/webfinger/masto-host-meta.xml | 4 + test/fixtures/webfinger/masto-user.json | 92 + test/fixtures/webfinger/masto-webfinger.json | 23 + test/fixtures/webfinger/pleroma-host-meta.xml | 1 + test/fixtures/webfinger/pleroma-user.json | 58 + .../fixtures/webfinger/pleroma-webfinger.json | 27 + test/fixtures/wildebeest-nodeinfo21.json | 1 + .../wildebeest-well-known-nodeinfo.json | 1 + test/fixtures/xml_billion_laughs.xml | 15 + test/fixtures/xml_external_entities.xml | 3 + test/instance_static/add/shortcode.png | Bin 0 -> 95 bytes test/instance_static/emoji/blobs.gg/blank.png | Bin 0 -> 95 bytes test/instance_static/emoji/blobs.gg/pack.json | 11 + .../emoji/pack_bad_sha/blank.png | Bin 0 -> 95 bytes .../emoji/pack_bad_sha/pack.json | 13 + .../emoji/pack_bad_sha/pack_bad_sha.zip | Bin 0 -> 256 bytes .../instance_static/emoji/test_pack/blank.png | Bin 0 -> 95 bytes .../emoji/test_pack/blank2.png | Bin 0 -> 95 bytes .../instance_static/emoji/test_pack/pack.json | 12 + .../emoji/test_pack_for_import/blank.png | Bin 0 -> 95 bytes .../emoji/test_pack_nonshared/nonshared.zip | Bin 0 -> 548 bytes .../emoji/test_pack_nonshared/pack.json | 13 + test/instance_static/local_pack/files.json | 3 + test/instance_static/local_pack/manifest.json | 10 + test/mix/pleroma_test.exs | 50 + test/mix/tasks/pleroma/app_test.exs | 65 + test/mix/tasks/pleroma/config_test.exs | 335 + .../mix/tasks/pleroma/count_statuses_test.exs | 40 + test/mix/tasks/pleroma/database_test.exs | 176 + test/mix/tasks/pleroma/digest_test.exs | 73 + test/mix/tasks/pleroma/ecto/migrate_test.exs | 20 + test/mix/tasks/pleroma/ecto/rollback_test.exs | 20 + test/mix/tasks/pleroma/ecto_test.exs | 15 + test/mix/tasks/pleroma/email_test.exs | 127 + test/mix/tasks/pleroma/emoji_test.exs | 243 + test/mix/tasks/pleroma/frontend_test.exs | 135 + test/mix/tasks/pleroma/instance_test.exs | 103 + test/mix/tasks/pleroma/openapi_spec_test.exs | 62 + .../pleroma/refresh_counter_cache_test.exs | 44 + test/mix/tasks/pleroma/relay_test.exs | 180 + test/mix/tasks/pleroma/robots_txt_test.exs | 45 + test/mix/tasks/pleroma/uploads_test.exs | 56 + test/mix/tasks/pleroma/user_test.exs | 625 ++ test/pleroma/activity/ir/topics_test.exs | 266 + test/pleroma/activity_test.exs | 356 + .../announcement_read_relationship_test.exs | 40 + test/pleroma/announcement_test.exs | 98 + .../pleroma/application_requirements_test.exs | 166 + test/pleroma/bookmark_test.exs | 56 + test/pleroma/captcha_test.exs | 117 + test/pleroma/chat/message_reference_test.exs | 29 + test/pleroma/chat_test.exs | 84 + .../config/deprecation_warnings_test.exs | 382 ++ test/pleroma/config/holder_test.exs | 31 + test/pleroma/config/loader_test.exs | 28 + .../config/release_runtime_provider_test.exs | 56 + test/pleroma/config/transfer_task_test.exs | 161 + test/pleroma/config_db_test.exs | 543 ++ test/pleroma/config_test.exs | 139 + .../conversation/participation_test.exs | 374 + test/pleroma/conversation_test.exs | 204 + test/pleroma/docs/generator_test.exs | 226 + .../pleroma/docs/translator/compiler_test.exs | 90 + .../object_validators/bare_uri_test.exs | 25 + .../object_validators/date_time_test.exs | 36 + .../object_validators/object_id_test.exs | 41 + .../object_validators/recipients_test.exs | 31 + .../object_validators/safe_text_test.exs | 30 + test/pleroma/emails/admin_email_test.exs | 65 + test/pleroma/emails/mailer_test.exs | 55 + test/pleroma/emails/user_email_test.exs | 71 + test/pleroma/emoji/formatter_test.exs | 49 + test/pleroma/emoji/loader_test.exs | 83 + test/pleroma/emoji/pack_test.exs | 97 + test/pleroma/emoji_test.exs | 58 + test/pleroma/filter_test.exs | 193 + test/pleroma/following_relationship_test.exs | 47 + test/pleroma/formatter_test.exs | 375 + test/pleroma/frontend_test.exs | 162 + test/pleroma/gun/connection_pool_test.exs | 100 + test/pleroma/hashtag_test.exs | 17 + test/pleroma/healthcheck_test.exs | 33 + test/pleroma/html_test.exs | 281 + test/pleroma/http/adapter_helper/gun_test.exs | 77 + .../http/adapter_helper/hackney_test.exs | 35 + test/pleroma/http/adapter_helper_test.exs | 28 + test/pleroma/http/ex_aws_test.exs | 54 + test/pleroma/http/request_builder_test.exs | 93 + test/pleroma/http/tzdata_test.exs | 35 + test/pleroma/http_test.exs | 70 + test/pleroma/instances/instance_test.exs | 252 + test/pleroma/instances_test.exs | 124 + test/pleroma/integration/federation_test.exs | 47 + .../integration/mastodon_websocket_test.exs | 504 ++ test/pleroma/job_queue_monitor_test.exs | 70 + test/pleroma/keys_test.exs | 24 + .../language/language_detector_test.exs | 31 + .../language/translation/deepl_test.exs | 37 + .../translation/translate_locally_test.exs | 59 + test/pleroma/language/translation_test.exs | 32 + test/pleroma/list_test.exs | 149 + test/pleroma/marker_test.exs | 78 + test/pleroma/mfa/backup_codes_test.exs | 15 + test/pleroma/mfa/totp_test.exs | 21 + test/pleroma/mfa_test.exs | 52 + .../notification_backfill_test.exs | 63 + test/pleroma/moderation_log_test.exs | 311 + test/pleroma/notification_test.exs | 1456 ++++ test/pleroma/object/containment_test.exs | 125 + test/pleroma/object/fetcher_test.exs | 615 ++ test/pleroma/object/updater_test.exs | 76 + test/pleroma/object_test.exs | 485 ++ test/pleroma/otp_version_test.exs | 42 + test/pleroma/pagination_test.exs | 92 + test/pleroma/password/pbkdf2_test.exs | 35 + test/pleroma/registration_test.exs | 59 + .../migrations/autolinker_to_linkify_test.exs | 72 + .../confirm_logged_in_users_test.exs | 40 + .../deprecate_public_endpoint_test.exs | 60 + .../repo/migrations/fix_legacy_tags_test.exs | 28 + .../fix_malformed_formatter_config_test.exs | 70 + .../migrations/move_welcome_settings_test.exs | 144 + test/pleroma/repo_test.exs | 80 + test/pleroma/report_note_test.exs | 39 + test/pleroma/resilience_test.exs | 103 + test/pleroma/reverse_proxy_test.exs | 331 + test/pleroma/rule_test.exs | 57 + test/pleroma/runtime_test.exs | 12 + test/pleroma/safe_jsonb_set_test.exs | 16 + test/pleroma/scheduled_activity_test.exs | 104 + test/pleroma/search/database_search_test.exs | 62 + test/pleroma/search/meilisearch_test.exs | 160 + test/pleroma/signature_test.exs | 139 + test/pleroma/stats_test.exs | 122 + .../upload/filter/analyze_metadata_test.exs | 33 + .../upload/filter/anonymize_filename_test.exs | 41 + test/pleroma/upload/filter/dedupe_test.exs | 32 + .../filter/exiftool/read_description_test.exs | 144 + .../filter/exiftool/strip_location_test.exs | 49 + test/pleroma/upload/filter/mogrifun_test.exs | 44 + test/pleroma/upload/filter/mogrify_test.exs | 41 + .../pleroma/upload/filter/only_media_test.exs | 32 + test/pleroma/upload/filter_test.exs | 32 + test/pleroma/upload_test.exs | 257 + test/pleroma/uploaders/local_test.exs | 55 + test/pleroma/uploaders/s3_test.exs | 89 + test/pleroma/user/backup_test.exs | 365 + test/pleroma/user/email_list_test.exs | 66 + test/pleroma/user/import_test.exs | 75 + .../user/notification_setting_test.exs | 21 + test/pleroma/user/query_test.exs | 106 + .../user/welcome_chat_message_test.exs | 36 + test/pleroma/user/welcome_email_test.exs | 61 + test/pleroma/user/welcome_message_test.exs | 35 + test/pleroma/user_invite_token_test.exs | 96 + test/pleroma/user_relationship_test.exs | 136 + test/pleroma/user_search_test.exs | 370 + test/pleroma/user_test.exs | 2915 ++++++++ test/pleroma/utils_test.exs | 15 + .../activity_pub_controller_test.exs | 2079 ++++++ .../web/activity_pub/activity_pub_test.exs | 2737 ++++++++ .../pleroma/web/activity_pub/builder_test.exs | 77 + .../mrf/activity_expiration_policy_test.exs | 84 + .../mrf/anti_duplication_policy_test.exs | 31 + .../mrf/anti_followbot_policy_test.exs | 107 + .../mrf/anti_link_spam_policy_test.exs | 191 + .../mrf/anti_mention_spam_policy_test.exs | 65 + .../mrf/block_notification_policy_test.exs | 95 + .../activity_pub/mrf/emoji_policy_test.exs | 425 ++ .../mrf/ensure_re_prepended_test.exs | 141 + .../mrf/follow_bot_policy_test.exs | 126 + .../mrf/force_bot_unlisted_policy_test.exs | 60 + .../mrf/force_mentions_in_content_test.exs | 310 + .../activity_pub/mrf/hashtag_policy_test.exs | 101 + .../mrf/hellthread_policy_test.exs | 92 + .../mrf/inline_quote_policy_test.exs | 112 + .../activity_pub/mrf/keyword_policy_test.exs | 356 + .../mrf/media_proxy_warming_policy_test.exs | 97 + .../activity_pub/mrf/mention_policy_test.exs | 96 + .../activity_pub/mrf/no_empty_policy_test.exs | 177 + .../mrf/no_placeholder_text_policy_test.exs | 78 + .../mrf/normalize_markup_test.exs | 81 + .../activity_pub/mrf/nsfw_api_policy_test.exs | 267 + .../mrf/object_age_policy_test.exs | 148 + .../mrf/quote_to_link_tag_policy_test.exs | 73 + .../mrf/reject_non_public_test.exs | 100 + .../mrf/remote_report_policy_test.exs | 142 + .../activity_pub/mrf/simple_policy_test.exs | 579 ++ .../mrf/steal_emoji_policy_test.exs | 122 + .../activity_pub/mrf/subchain_policy_test.exs | 33 + .../web/activity_pub/mrf/tag_policy_test.exs | 159 + .../mrf/user_allow_list_policy_test.exs | 31 + .../web/activity_pub/mrf/utils_test.exs | 19 + .../mrf/vocabulary_policy_test.exs | 106 + test/pleroma/web/activity_pub/mrf_test.exs | 167 + .../accept_validation_test.exs | 56 + .../announce_validation_test.exs | 108 + .../article_note_page_validator_test.exs | 168 + .../attachment_validator_test.exs | 194 + .../block_validation_test.exs | 39 + .../chat_validation_test.exs | 212 + .../create_generic_validator_test.exs | 62 + .../delete_validation_test.exs | 115 + .../emoji_react_handling_test.exs | 107 + .../follow_validation_test.exs | 26 + .../like_validation_test.exs | 116 + .../reject_validation_test.exs | 56 + .../object_validators/undo_handling_test.exs | 53 + .../update_handling_test.exs | 168 + .../web/activity_pub/pipeline_test.exs | 103 + .../web/activity_pub/publisher_test.exs | 433 ++ test/pleroma/web/activity_pub/relay_test.exs | 168 + .../activity_pub/side_effects/delete_test.exs | 147 + .../web/activity_pub/side_effects_test.exs | 945 +++ .../transmogrifier/accept_handling_test.exs | 118 + .../add_remove_handling_test.exs | 182 + .../transmogrifier/announce_handling_test.exs | 153 + .../transmogrifier/answer_handling_test.exs | 79 + .../transmogrifier/article_handling_test.exs | 82 + .../transmogrifier/audio_handling_test.exs | 87 + .../transmogrifier/block_handling_test.exs | 63 + .../transmogrifier/chat_message_test.exs | 171 + .../transmogrifier/delete_handling_test.exs | 115 + .../emoji_react_handling_test.exs | 141 + .../transmogrifier/event_handling_test.exs | 44 + .../transmogrifier/follow_handling_test.exs | 208 + .../transmogrifier/image_handling_test.exs | 50 + .../transmogrifier/join_handling_test.exs | 76 + .../transmogrifier/like_handling_test.exs | 78 + .../transmogrifier/note_handling_test.exs | 861 +++ .../transmogrifier/page_handling_test.exs | 36 + .../transmogrifier/question_handling_test.exs | 192 + .../transmogrifier/reject_handling_test.exs | 67 + .../transmogrifier/undo_handling_test.exs | 185 + .../user_update_handling_test.exs | 159 + .../transmogrifier/video_handling_test.exs | 122 + .../web/activity_pub/transmogrifier_test.exs | 615 ++ test/pleroma/web/activity_pub/utils_test.exs | 699 ++ .../activity_pub/views/object_view_test.exs | 98 + .../web/activity_pub/views/user_view_test.exs | 179 + .../web/activity_pub/visibility_test.exs | 297 + .../controllers/admin_api_controller_test.exs | 1179 ++++ .../announcement_controller_test.exs | 375 + .../controllers/chat_controller_test.exs | 252 + .../controllers/config_controller_test.exs | 1526 +++++ .../email_list_controller_test.exs | 102 + .../controllers/frontend_controller_test.exs | 156 + .../controllers/instance_controller_test.exs | 92 + .../instance_document_controller_test.exs | 105 + .../controllers/invite_controller_test.exs | 342 + .../media_proxy_cache_controller_test.exs | 167 + .../o_auth_app_controller_test.exs | 219 + .../controllers/relay_controller_test.exs | 98 + .../controllers/report_controller_test.exs | 617 ++ .../controllers/rule_controller_test.exs | 78 + .../controllers/status_controller_test.exs | 243 + .../controllers/user_controller_test.exs | 1117 +++ .../controllers/webhook_controller_test.exs | 98 + test/pleroma/web/admin_api/search_test.exs | 216 + .../web/admin_api/views/account_view_test.exs | 16 + .../views/moderation_log_view_test.exs | 103 + .../web/admin_api/views/report_view_test.exs | 192 + .../web/api_spec/schema_examples_test.exs | 43 + .../web/api_spec/scopes/compiler_test.exs | 56 + .../pleroma/web/auth/auth_controller_test.exs | 242 + test/pleroma/web/auth/authenticator_test.exs | 42 + test/pleroma/web/auth/basic_auth_test.exs | 47 + .../web/auth/pleroma_authenticator_test.exs | 54 + .../web/auth/totp_authenticator_test.exs | 51 + .../web/common_api/activity_draft_test.exs | 33 + test/pleroma/web/common_api/utils_test.exs | 691 ++ test/pleroma/web/common_api_test.exs | 1905 ++++++ .../web/endpoint/metrics_exporter_test.exs | 69 + test/pleroma/web/fallback_test.exs | 98 + test/pleroma/web/federator_test.exs | 173 + test/pleroma/web/feed/tag_controller_test.exs | 194 + .../pleroma/web/feed/user_controller_test.exs | 302 + test/pleroma/web/gettext_test.exs | 173 + test/pleroma/web/manifest_controller_test.exs | 17 + .../controllers/account_controller_test.exs | 175 + .../controllers/report_controller_test.exs | 152 + .../controllers/account_controller_test.exs | 2325 +++++++ .../announcement_controller_test.exs | 169 + .../controllers/app_controller_test.exs | 92 + .../conversation_controller_test.exs | 258 + .../custom_emoji_controller_test.exs | 23 + .../controllers/directory_controller_test.exs | 50 + .../domain_block_controller_test.exs | 80 + .../controllers/filter_controller_test.exs | 410 ++ .../follow_request_controller_test.exs | 74 + .../controllers/instance_controller_test.exs | 217 + .../controllers/list_controller_test.exs | 185 + .../controllers/marker_controller_test.exs | 131 + .../controllers/media_controller_test.exs | 218 + .../notification_controller_test.exs | 733 ++ .../controllers/poll_controller_test.exs | 242 + .../controllers/report_controller_test.exs | 152 + .../scheduled_activity_controller_test.exs | 139 + .../controllers/search_controller_test.exs | 460 ++ .../controllers/status_controller_test.exs | 2606 +++++++ .../subscription_controller_test.exs | 263 + .../suggestion_controller_test.exs | 83 + .../controllers/timeline_controller_test.exs | 1070 +++ .../mastodon_api_controller_test.exs | 34 + .../web/mastodon_api/mastodon_api_test.exs | 103 + .../mastodon_api/update_credentials_test.exs | 753 ++ .../mastodon_api/views/account_view_test.exs | 780 +++ .../views/conversation_view_test.exs | 46 + .../web/mastodon_api/views/list_view_test.exs | 32 + .../mastodon_api/views/marker_view_test.exs | 29 + .../views/notification_view_test.exs | 335 + .../web/mastodon_api/views/poll_view_test.exs | 190 + .../views/scheduled_activity_view_test.exs | 69 + .../mastodon_api/views/status_view_test.exs | 915 +++ .../views/subscription_view_test.exs | 23 + .../views/suggestion_view_test.exs | 34 + .../media_proxy/invalidation/http_test.exs | 39 + .../media_proxy/invalidation/script_test.exs | 55 + .../web/media_proxy/invalidation_test.exs | 62 + .../media_proxy_controller_test.exs | 352 + test/pleroma/web/media_proxy_test.exs | 233 + .../pleroma/web/metadata/player_view_test.exs | 33 + .../web/metadata/providers/feed_test.exs | 18 + .../metadata/providers/open_graph_test.exs | 190 + .../web/metadata/providers/rel_me_test.exs | 34 + .../providers/restrict_indexing_test.exs | 27 + .../metadata/providers/twitter_card_test.exs | 205 + test/pleroma/web/metadata/utils_test.exs | 80 + .../web/mongoose_im_controller_test.exs | 83 + test/pleroma/web/node_info_test.exs | 295 + test/pleroma/web/o_auth/app_test.exs | 56 + .../pleroma/web/o_auth/authorization_test.exs | 77 + .../web/o_auth/ldap_authorization_test.exs | 135 + .../web/o_auth/mfa_controller_test.exs | 304 + .../web/o_auth/o_auth_controller_test.exs | 1307 ++++ test/pleroma/web/o_auth/token/utils_test.exs | 53 + test/pleroma/web/o_auth/token_test.exs | 72 + .../web/o_status/o_status_controller_test.exs | 396 ++ .../controllers/account_controller_test.exs | 362 + .../controllers/app_controller_test.exs | 53 + .../controllers/backup_controller_test.exs | 105 + .../controllers/chat_controller_test.exs | 486 ++ .../conversation_controller_test.exs | 136 + .../emoji_file_controller_test.exs | 409 ++ .../emoji_pack_controller_test.exs | 722 ++ .../emoji_reaction_controller_test.exs | 375 + .../controllers/event_controller_test.exs | 393 ++ .../controllers/instances_controller_test.exs | 40 + .../controllers/mascot_controller_test.exs | 73 + .../notification_controller_test.exs | 74 + .../controllers/report_controller_test.exs | 80 + .../controllers/scrobble_controller_test.exs | 64 + .../controllers/search_controller_test.exs | 38 + .../controllers/status_controller_test.exs | 54 + ..._factor_authentication_controller_test.exs | 265 + .../user_import_controller_test.exs | 235 + .../web/pleroma_api/views/app_view_test.exs | 21 + .../pleroma_api/views/backup_view_test.exs | 57 + .../chat_message_reference_view_test.exs | 75 + .../web/pleroma_api/views/chat_view_test.exs | 49 + .../pleroma_api/views/scrobble_view_test.exs | 20 + .../admin_secret_authentication_plug_test.exs | 77 + .../web/plugs/authentication_plug_test.exs | 98 + .../plugs/basic_auth_decoder_plug_test.exs | 50 + test/pleroma/web/plugs/cache_control_test.exs | 20 + test/pleroma/web/plugs/cache_test.exs | 200 + test/pleroma/web/plugs/digest_plug_test.exs | 52 + .../plugs/ensure_authenticated_plug_test.exs | 96 + .../web/plugs/ensure_privileged_plug_test.exs | 96 + ...sure_public_or_authenticated_plug_test.exs | 47 + .../ensure_user_token_assigns_plug_test.exs | 69 + .../web/plugs/federating_plug_test.exs | 31 + .../web/plugs/frontend_static_plug_test.exs | 111 + .../web/plugs/http_security_plug_test.exs | 154 + .../web/plugs/http_signature_plug_test.exs | 85 + .../web/plugs/idempotency_plug_test.exs | 111 + .../web/plugs/instance_static_test.exs | 65 + ...mapped_signature_to_identity_plug_test.exs | 59 + test/pleroma/web/plugs/o_auth_plug_test.exs | 128 + .../web/plugs/o_auth_scopes_plug_test.exs | 172 + test/pleroma/web/plugs/plug_helper_test.exs | 91 + test/pleroma/web/plugs/rate_limiter_test.exs | 278 + test/pleroma/web/plugs/remote_ip_test.exs | 108 + .../web/plugs/set_format_plug_test.exs | 38 + .../web/plugs/set_locale_plug_test.exs | 164 + .../plugs/set_user_session_id_plug_test.exs | 43 + .../web/plugs/uploaded_media_plug_test.exs | 69 + .../web/plugs/user_enabled_plug_test.exs | 59 + .../web/plugs/user_fetcher_plug_test.exs | 41 + .../web/plugs/user_is_admin_plug_test.exs | 37 + .../web/plugs/user_is_staff_plug_test.exs | 47 + .../web/plugs/user_tracking_plug_test.exs | 58 + .../web/preload/providers/instance_test.exs | 56 + .../web/preload/providers/timeline_test.exs | 56 + .../web/preload/providers/user_test.exs | 33 + test/pleroma/web/push/impl_test.exs | 382 ++ test/pleroma/web/rel_me_test.exs | 48 + test/pleroma/web/rich_media/helpers_test.exs | 84 + .../web/rich_media/parser/card_test.exs | 129 + .../web/rich_media/parser/meta_tags_test.exs | 81 + .../parser/ttl/aws_signed_url_test.exs | 81 + test/pleroma/web/rich_media/parser_test.exs | 216 + .../rich_media/parsers/twitter_card_test.exs | 126 + .../static_fe/static_fe_controller_test.exs | 232 + test/pleroma/web/streamer_test.exs | 1091 +++ .../web/twitter_api/controller_test.exs | 102 + .../twitter_api/password_controller_test.exs | 213 + .../remote_follow_controller_test.exs | 492 ++ .../web/twitter_api/twitter_api_test.exs | 438 ++ .../web/twitter_api/util_controller_test.exs | 1028 +++ test/pleroma/web/uploader_controller_test.exs | 43 + test/pleroma/web/utils/colors_test.exs | 39 + test/pleroma/web/views/error_view_test.exs | 36 + .../web_finger/web_finger_controller_test.exs | 137 + test/pleroma/web/web_finger_test.exs | 215 + test/pleroma/web/xml_test.exs | 15 + test/pleroma/webhook/notify_test.exs | 36 + test/pleroma/webhook_test.exs | 56 + .../cron/digest_emails_worker_test.exs | 60 + .../cron/new_users_digest_worker_test.exs | 50 + .../pleroma/workers/publisher_worker_test.exs | 40 + .../workers/purge_expired_activity_test.exs | 59 + .../workers/purge_expired_filter_test.exs | 34 + .../workers/purge_expired_token_test.exs | 51 + test/pleroma/workers/receiver_worker_test.exs | 52 + .../scheduled_activity_worker_test.exs | 52 + test/pleroma/xml_builder_test.exs | 65 + test/support/api_spec_helpers.ex | 55 + test/support/builders/activity_builder.ex | 61 + test/support/builders/user_builder.ex | 30 + test/support/cachex_proxy.ex | 43 + test/support/captcha/mock.ex | 28 + test/support/channel_case.ex | 34 + test/support/cluster.ex | 222 + test/support/conn_case.ex | 125 + test/support/data_case.ex | 144 + test/support/factory.ex | 716 ++ test/support/helpers.ex | 138 + test/support/http_request_mock.ex | 1742 +++++ test/support/language_detector_mock.ex | 18 + test/support/mocks.ex | 31 + test/support/mrf_module_mock.ex | 13 + test/support/null_cache.ex | 52 + test/support/oban_helpers.ex | 49 + test/support/translation_mock.ex | 43 + test/support/websocket_client.ex | 66 + test/test_helper.exs | 20 + tools/check-changelog | 18 + tools/collect-changelog | 27 + uploads/.gitignore | 3 + 2094 files changed, 261103 insertions(+) create mode 100644 .credo.exs create mode 100644 .dockerignore create mode 100644 .formatter.exs create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .gitlab/issue_templates/Bug.md create mode 100644 .gitlab/merge_request_templates/Default.md create mode 100644 .gitlab/merge_request_templates/Release.md create mode 100644 .mailmap create mode 100644 .tool-versions create mode 100644 AGPL-3 create mode 100644 CC-BY-4.0 create mode 100644 CC-BY-SA-4.0 create mode 100644 CHANGELOG.md create mode 100644 CHANGELOG_soapbox.md create mode 100644 COPYING create mode 100644 Dockerfile create mode 100644 Procfile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 app.json create mode 100644 benchmarks/load_testing/activities.ex create mode 100644 benchmarks/load_testing/fetcher.ex create mode 100644 benchmarks/load_testing/helper.ex create mode 100644 benchmarks/load_testing/users.ex create mode 100644 benchmarks/mix/tasks/pleroma/benchmark.ex create mode 100644 benchmarks/mix/tasks/pleroma/benchmarks/tags.ex create mode 100644 benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex create mode 100644 benchmarks/mix/tasks/pleroma/load_testing.ex create mode 100644 changelog.d/2.6.0-mergeback.skip create mode 100644 changelog.d/3895.add create mode 100644 changelog.d/3896.add create mode 100644 changelog.d/3900.change create mode 100644 changelog.d/akkoma-xml-remote-entities.security create mode 100644 changelog.d/authorize-interaction.add create mode 100644 changelog.d/bare_uri_test.skip create mode 100644 changelog.d/benchee.skip create mode 100644 changelog.d/check-attachment-attribution.security create mode 100644 changelog.d/digest_emails.fix create mode 100644 changelog.d/docs-max-elixir-erlang.change create mode 100644 changelog.d/emoji-pack-sanitization.security create mode 100644 changelog.d/federation_status-access.change create mode 100644 changelog.d/healthcheck-disabled-error.fix create mode 100644 changelog.d/meilisearch.add create mode 100644 changelog.d/nginx-media-redirect-fix.fix create mode 100644 changelog.d/otp_perms.security create mode 100644 changelog.d/quotes-count.skip create mode 100644 changelog.d/scrobble-url.add create mode 100644 changelog.d/system-cflags.fix create mode 100644 ci/Dockerfile create mode 100755 ci/build_and_push.sh create mode 100644 config/benchmark.exs create mode 100644 config/config.exs create mode 100644 config/description.exs create mode 100644 config/dev.exs create mode 100644 config/docker.exs create mode 100644 config/dokku.exs create mode 100644 config/emoji.txt create mode 100644 config/prod.exs create mode 100644 config/soapbox.exs create mode 100644 config/test.exs create mode 100644 coveralls.json create mode 100755 docker-entrypoint.sh create mode 100644 docs/README.md create mode 100644 docs/SUMMARY.md create mode 100644 docs/administration/CLI_tasks/config.md create mode 100644 docs/administration/CLI_tasks/database.md create mode 100644 docs/administration/CLI_tasks/digest.md create mode 100644 docs/administration/CLI_tasks/email.md create mode 100644 docs/administration/CLI_tasks/emoji.md create mode 100644 docs/administration/CLI_tasks/frontend.md create mode 100644 docs/administration/CLI_tasks/general_cli_task_info.include create mode 100644 docs/administration/CLI_tasks/instance.md create mode 100644 docs/administration/CLI_tasks/oauth_app.md create mode 100644 docs/administration/CLI_tasks/relay.md create mode 100644 docs/administration/CLI_tasks/robots_txt.md create mode 100644 docs/administration/CLI_tasks/uploads.md create mode 100644 docs/administration/CLI_tasks/user.md create mode 100644 docs/administration/backup.md create mode 100644 docs/administration/updating.md create mode 100644 docs/clients.md create mode 100644 docs/configuration/auth.md create mode 100644 docs/configuration/cheatsheet.md create mode 100644 docs/configuration/custom_emoji.md create mode 100644 docs/configuration/hardening.md create mode 100644 docs/configuration/how_to_serve_another_domain_for_webfinger.md create mode 100644 docs/configuration/howto_database_config.md create mode 100644 docs/configuration/howto_ejabberd.md create mode 100644 docs/configuration/howto_mediaproxy.md create mode 100644 docs/configuration/howto_mongooseim.md create mode 100644 docs/configuration/howto_proxy.md create mode 100644 docs/configuration/howto_search_cjk.md create mode 100644 docs/configuration/howto_set_richmedia_cache_ttl_based_on_image.md create mode 100644 docs/configuration/howto_theming_your_instance.md create mode 100644 docs/configuration/i2p.md create mode 100644 docs/configuration/mrf.md create mode 100644 docs/configuration/onion_federation.md create mode 100644 docs/configuration/optimizing_beam.md create mode 100644 docs/configuration/postgresql.md create mode 100644 docs/configuration/search.md create mode 100644 docs/configuration/static_dir.md create mode 100644 docs/configuration/storing_remote_media.md create mode 100644 docs/development/API/admin_api.md create mode 100644 docs/development/API/chats.md create mode 100644 docs/development/API/differences_in_mastoapi_responses.md create mode 100644 docs/development/API/nodeinfo.md create mode 100644 docs/development/API/pleroma_api.md create mode 100644 docs/development/API/prometheus.md create mode 100644 docs/development/ap_extensions.md create mode 100644 docs/development/authentication_authorization.md create mode 100644 docs/development/index.md create mode 100644 docs/development/setting_up_a_gitlab_runner.md create mode 100644 docs/development/setting_up_pleroma_dev.md create mode 100644 docs/index.md create mode 100644 docs/installation/alpine_linux_en.md create mode 100644 docs/installation/arch_linux_en.md create mode 100644 docs/installation/debian_based_en.md create mode 100644 docs/installation/debian_based_jp.md create mode 100644 docs/installation/freebsd_en.md create mode 100644 docs/installation/further_reading.include create mode 100644 docs/installation/generic_dependencies.include create mode 100644 docs/installation/gentoo_en.md create mode 100644 docs/installation/gentoo_otp_en.md create mode 100644 docs/installation/migrating_from_source_otp_en.md create mode 100644 docs/installation/netbsd_en.md create mode 100644 docs/installation/nixos_en.md create mode 100644 docs/installation/openbsd_en.md create mode 100644 docs/installation/openbsd_fi.md create mode 100644 docs/installation/optional/media_graphics_packages.md create mode 100644 docs/installation/otp_en.md create mode 100644 docs/installation/otp_vs_from_source.include create mode 100644 docs/installation/otp_vs_from_source_source.include create mode 100644 docs/installation/yunohost_en.md create mode 100644 elixir_buildpack.config create mode 100755 installation/apache-cache-purge.sh.example create mode 100644 installation/caddyfile-pleroma.example create mode 100755 installation/freebsd/rc.d/pleroma create mode 100755 installation/init.d/pleroma create mode 100755 installation/netbsd/rc.d/pleroma create mode 100755 installation/nginx-cache-purge.sh.example create mode 100644 installation/nsfw-api.service create mode 100644 installation/openbsd/httpd.conf create mode 100755 installation/openbsd/rc.d/pleromad create mode 100644 installation/openbsd/relayd.conf create mode 100644 installation/pleroma-apache.conf create mode 100644 installation/pleroma-mediaproxy.nginx create mode 100755 installation/pleroma-mongooseim.cfg create mode 100644 installation/pleroma.nginx create mode 100644 installation/pleroma.service create mode 100644 installation/pleroma.supervisord create mode 100644 installation/pleroma.vcl create mode 100644 lib/mix/pleroma.ex create mode 100644 lib/mix/tasks/pleroma/app.ex create mode 100644 lib/mix/tasks/pleroma/config.ex create mode 100644 lib/mix/tasks/pleroma/count_statuses.ex create mode 100644 lib/mix/tasks/pleroma/database.ex create mode 100644 lib/mix/tasks/pleroma/digest.ex create mode 100644 lib/mix/tasks/pleroma/docs.ex create mode 100644 lib/mix/tasks/pleroma/ecto.ex create mode 100644 lib/mix/tasks/pleroma/ecto/migrate.ex create mode 100644 lib/mix/tasks/pleroma/ecto/rollback.ex create mode 100644 lib/mix/tasks/pleroma/email.ex create mode 100644 lib/mix/tasks/pleroma/emoji.ex create mode 100644 lib/mix/tasks/pleroma/frontend.ex create mode 100644 lib/mix/tasks/pleroma/instance.ex create mode 100644 lib/mix/tasks/pleroma/notification_settings.ex create mode 100644 lib/mix/tasks/pleroma/openapi_spec.ex create mode 100644 lib/mix/tasks/pleroma/refresh_counter_cache.ex create mode 100644 lib/mix/tasks/pleroma/relay.ex create mode 100644 lib/mix/tasks/pleroma/robots_txt.ex create mode 100644 lib/mix/tasks/pleroma/search/meilisearch.ex create mode 100644 lib/mix/tasks/pleroma/uploads.ex create mode 100644 lib/mix/tasks/pleroma/user.ex create mode 100644 lib/phoenix/transports/web_socket/raw.ex create mode 100644 lib/pleroma/activity.ex create mode 100644 lib/pleroma/activity/html.ex create mode 100644 lib/pleroma/activity/ir/topics.ex create mode 100644 lib/pleroma/activity/queries.ex create mode 100644 lib/pleroma/announcement.ex create mode 100644 lib/pleroma/announcement_read_relationship.ex create mode 100644 lib/pleroma/application.ex create mode 100644 lib/pleroma/application_requirements.ex create mode 100644 lib/pleroma/bookmark.ex create mode 100644 lib/pleroma/caching.ex create mode 100644 lib/pleroma/captcha.ex create mode 100644 lib/pleroma/captcha/kocaptcha.ex create mode 100644 lib/pleroma/captcha/native.ex create mode 100644 lib/pleroma/captcha/service.ex create mode 100644 lib/pleroma/chat.ex create mode 100644 lib/pleroma/chat/message_reference.ex create mode 100644 lib/pleroma/clippy.ex create mode 100644 lib/pleroma/config.ex create mode 100644 lib/pleroma/config/deprecation_warnings.ex create mode 100644 lib/pleroma/config/getting.ex create mode 100644 lib/pleroma/config/helpers.ex create mode 100644 lib/pleroma/config/holder.ex create mode 100644 lib/pleroma/config/loader.ex create mode 100644 lib/pleroma/config/oban.ex create mode 100644 lib/pleroma/config/release_runtime_provider.ex create mode 100644 lib/pleroma/config/transfer_task.ex create mode 100644 lib/pleroma/config_db.ex create mode 100644 lib/pleroma/constants.ex create mode 100644 lib/pleroma/conversation.ex create mode 100644 lib/pleroma/conversation/participation.ex create mode 100644 lib/pleroma/conversation/participation/recipient_ship.ex create mode 100644 lib/pleroma/counter_cache.ex create mode 100644 lib/pleroma/data_migration.ex create mode 100644 lib/pleroma/delivery.ex create mode 100644 lib/pleroma/docs/generator.ex create mode 100644 lib/pleroma/docs/json.ex create mode 100644 lib/pleroma/docs/markdown.ex create mode 100644 lib/pleroma/docs/translator.ex create mode 100644 lib/pleroma/docs/translator/compiler.ex create mode 100644 lib/pleroma/ecto_enums.ex create mode 100644 lib/pleroma/ecto_type/activity_pub/object_validators/bare_uri.ex create mode 100644 lib/pleroma/ecto_type/activity_pub/object_validators/date_time.ex create mode 100644 lib/pleroma/ecto_type/activity_pub/object_validators/emoji.ex create mode 100644 lib/pleroma/ecto_type/activity_pub/object_validators/mime.ex create mode 100644 lib/pleroma/ecto_type/activity_pub/object_validators/object_id.ex create mode 100644 lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex create mode 100644 lib/pleroma/ecto_type/activity_pub/object_validators/safe_text.ex create mode 100644 lib/pleroma/ecto_type/activity_pub/object_validators/uri.ex create mode 100644 lib/pleroma/ecto_type/config/atom.ex create mode 100644 lib/pleroma/ecto_type/config/binary_value.ex create mode 100644 lib/pleroma/ecto_type/string_id.ex create mode 100644 lib/pleroma/emails/admin_email.ex create mode 100644 lib/pleroma/emails/mailer.ex create mode 100644 lib/pleroma/emails/new_users_digest_email.ex create mode 100644 lib/pleroma/emails/user_email.ex create mode 100644 lib/pleroma/emoji-test.txt create mode 100644 lib/pleroma/emoji.ex create mode 100644 lib/pleroma/emoji/combinations.ex create mode 100644 lib/pleroma/emoji/formatter.ex create mode 100644 lib/pleroma/emoji/loader.ex create mode 100644 lib/pleroma/emoji/pack.ex create mode 100644 lib/pleroma/filter.ex create mode 100644 lib/pleroma/following_relationship.ex create mode 100644 lib/pleroma/formatter.ex create mode 100644 lib/pleroma/frontend.ex create mode 100644 lib/pleroma/gopher/server.ex create mode 100644 lib/pleroma/gun.ex create mode 100644 lib/pleroma/gun/api.ex create mode 100644 lib/pleroma/gun/conn.ex create mode 100644 lib/pleroma/gun/connection_pool.ex create mode 100644 lib/pleroma/gun/connection_pool/reclaimer.ex create mode 100644 lib/pleroma/gun/connection_pool/worker.ex create mode 100644 lib/pleroma/gun/connection_pool/worker_supervisor.ex create mode 100644 lib/pleroma/hashtag.ex create mode 100644 lib/pleroma/healthcheck.ex create mode 100644 lib/pleroma/helpers/auth_helper.ex create mode 100644 lib/pleroma/helpers/inet_helper.ex create mode 100644 lib/pleroma/helpers/media_helper.ex create mode 100644 lib/pleroma/helpers/qt_fast_start.ex create mode 100644 lib/pleroma/helpers/uri_helper.ex create mode 100644 lib/pleroma/html.ex create mode 100644 lib/pleroma/http.ex create mode 100644 lib/pleroma/http/adapter_helper.ex create mode 100644 lib/pleroma/http/adapter_helper/default.ex create mode 100644 lib/pleroma/http/adapter_helper/gun.ex create mode 100644 lib/pleroma/http/adapter_helper/hackney.ex create mode 100644 lib/pleroma/http/ex_aws.ex create mode 100644 lib/pleroma/http/request.ex create mode 100644 lib/pleroma/http/request_builder.ex create mode 100644 lib/pleroma/http/tzdata.ex create mode 100644 lib/pleroma/http/web_push.ex create mode 100644 lib/pleroma/instances.ex create mode 100644 lib/pleroma/instances/instance.ex create mode 100644 lib/pleroma/job_queue_monitor.ex create mode 100644 lib/pleroma/jwt.ex create mode 100644 lib/pleroma/keys.ex create mode 100644 lib/pleroma/language/language_detector.ex create mode 100644 lib/pleroma/language/language_detector/fasttext.ex create mode 100644 lib/pleroma/language/language_detector/provider.ex create mode 100644 lib/pleroma/language/translation.ex create mode 100644 lib/pleroma/language/translation/deepl.ex create mode 100644 lib/pleroma/language/translation/libretranslate.ex create mode 100644 lib/pleroma/language/translation/provider.ex create mode 100644 lib/pleroma/language/translation/translate_locally.ex create mode 100644 lib/pleroma/list.ex create mode 100644 lib/pleroma/logging.ex create mode 100644 lib/pleroma/maintenance.ex create mode 100644 lib/pleroma/maps.ex create mode 100644 lib/pleroma/marker.ex create mode 100644 lib/pleroma/mfa.ex create mode 100644 lib/pleroma/mfa/backup_codes.ex create mode 100644 lib/pleroma/mfa/changeset.ex create mode 100644 lib/pleroma/mfa/settings.ex create mode 100644 lib/pleroma/mfa/token.ex create mode 100644 lib/pleroma/mfa/totp.ex create mode 100644 lib/pleroma/migration_helper/notification_backfill.ex create mode 100644 lib/pleroma/migrators/context_objects_deletion_migrator.ex create mode 100644 lib/pleroma/migrators/hashtags_table_migrator.ex create mode 100644 lib/pleroma/migrators/support/base_migrator.ex create mode 100644 lib/pleroma/migrators/support/base_migrator_state.ex create mode 100644 lib/pleroma/moderation_log.ex create mode 100644 lib/pleroma/notification.ex create mode 100644 lib/pleroma/object.ex create mode 100644 lib/pleroma/object/containment.ex create mode 100644 lib/pleroma/object/fetcher.ex create mode 100644 lib/pleroma/object/updater.ex create mode 100644 lib/pleroma/object_tombstone.ex create mode 100644 lib/pleroma/otp_version.ex create mode 100644 lib/pleroma/pagination.ex create mode 100644 lib/pleroma/password/pbkdf2.ex create mode 100644 lib/pleroma/password_reset_token.ex create mode 100644 lib/pleroma/prom_ex.ex create mode 100644 lib/pleroma/registration.ex create mode 100644 lib/pleroma/release_tasks.ex create mode 100644 lib/pleroma/repo.ex create mode 100644 lib/pleroma/report_note.ex create mode 100644 lib/pleroma/reverse_proxy.ex create mode 100644 lib/pleroma/reverse_proxy/client.ex create mode 100644 lib/pleroma/reverse_proxy/client/hackney.ex create mode 100644 lib/pleroma/reverse_proxy/client/tesla.ex create mode 100644 lib/pleroma/reverse_proxy/client/wrapper.ex create mode 100644 lib/pleroma/rule.ex create mode 100644 lib/pleroma/scheduled_activity.ex create mode 100644 lib/pleroma/search.ex create mode 100644 lib/pleroma/search/database_search.ex create mode 100644 lib/pleroma/search/meilisearch.ex create mode 100644 lib/pleroma/search/search_backend.ex create mode 100644 lib/pleroma/signature.ex create mode 100644 lib/pleroma/stats.ex create mode 100644 lib/pleroma/telemetry/logger.ex create mode 100644 lib/pleroma/tesla/middleware/connection_pool.ex create mode 100644 lib/pleroma/tests/auth_test_controller.ex create mode 100644 lib/pleroma/thread_mute.ex create mode 100644 lib/pleroma/upload.ex create mode 100644 lib/pleroma/upload/filter.ex create mode 100644 lib/pleroma/upload/filter/analyze_metadata.ex create mode 100644 lib/pleroma/upload/filter/anonymize_filename.ex create mode 100644 lib/pleroma/upload/filter/dedupe.ex create mode 100644 lib/pleroma/upload/filter/exiftool/read_description.ex create mode 100644 lib/pleroma/upload/filter/exiftool/strip_location.ex create mode 100644 lib/pleroma/upload/filter/mogrifun.ex create mode 100644 lib/pleroma/upload/filter/mogrify.ex create mode 100644 lib/pleroma/upload/filter/only_media.ex create mode 100644 lib/pleroma/upload/filter/set_meta.ex create mode 100644 lib/pleroma/uploaders/local.ex create mode 100644 lib/pleroma/uploaders/s3.ex create mode 100644 lib/pleroma/uploaders/uploader.ex create mode 100644 lib/pleroma/user.ex create mode 100644 lib/pleroma/user/backup.ex create mode 100644 lib/pleroma/user/email_list.ex create mode 100644 lib/pleroma/user/import.ex create mode 100644 lib/pleroma/user/notification_setting.ex create mode 100644 lib/pleroma/user/query.ex create mode 100644 lib/pleroma/user/search.ex create mode 100644 lib/pleroma/user/welcome_chat_message.ex create mode 100644 lib/pleroma/user/welcome_email.ex create mode 100644 lib/pleroma/user/welcome_message.ex create mode 100644 lib/pleroma/user_invite_token.ex create mode 100644 lib/pleroma/user_note.ex create mode 100644 lib/pleroma/user_relationship.ex create mode 100644 lib/pleroma/utils.ex create mode 100644 lib/pleroma/web.ex create mode 100644 lib/pleroma/web/activity_pub/activity_pub.ex create mode 100644 lib/pleroma/web/activity_pub/activity_pub/persisting.ex create mode 100644 lib/pleroma/web/activity_pub/activity_pub/streaming.ex create mode 100644 lib/pleroma/web/activity_pub/activity_pub_controller.ex create mode 100644 lib/pleroma/web/activity_pub/builder.ex create mode 100644 lib/pleroma/web/activity_pub/internal_fetch_actor.ex create mode 100644 lib/pleroma/web/activity_pub/mrf.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/anti_duplication_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/anti_mention_spam_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/block_notification_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/drop_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/emoji_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/keyword_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/mention_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/no_op_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/normalize_markup.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/nsfw_api_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/object_age_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/pipeline_filtering.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/reject_non_public.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/simple_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/subchain_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/tag_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/utils.ex create mode 100644 lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex create mode 100644 lib/pleroma/web/activity_pub/object_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validator/validating.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/announce_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/answer_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/audio_image_video_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/block_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/common_fields.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/common_fixes.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/common_validations.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/delete_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/event_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/follow_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/join_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/leave_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/like_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/place_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/question_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/tag_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/undo_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/update_validator.ex create mode 100644 lib/pleroma/web/activity_pub/pipeline.ex create mode 100644 lib/pleroma/web/activity_pub/publisher.ex create mode 100644 lib/pleroma/web/activity_pub/relay.ex create mode 100644 lib/pleroma/web/activity_pub/side_effects.ex create mode 100644 lib/pleroma/web/activity_pub/side_effects/handling.ex create mode 100644 lib/pleroma/web/activity_pub/transmogrifier.ex create mode 100644 lib/pleroma/web/activity_pub/utils.ex create mode 100644 lib/pleroma/web/activity_pub/views/object_view.ex create mode 100644 lib/pleroma/web/activity_pub/views/user_view.ex create mode 100644 lib/pleroma/web/activity_pub/visibility.ex create mode 100644 lib/pleroma/web/admin_api/controllers/admin_api_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/announcement_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/chat_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/config_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/email_list_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/fallback_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/frontend_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/instance_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/instance_document_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/invite_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/o_auth_app_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/relay_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/report_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/rule_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/status_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/user_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/webhook_controller.ex create mode 100644 lib/pleroma/web/admin_api/report.ex create mode 100644 lib/pleroma/web/admin_api/search.ex create mode 100644 lib/pleroma/web/admin_api/views/account_view.ex create mode 100644 lib/pleroma/web/admin_api/views/announcement_view.ex create mode 100644 lib/pleroma/web/admin_api/views/chat_view.ex create mode 100644 lib/pleroma/web/admin_api/views/config_view.ex create mode 100644 lib/pleroma/web/admin_api/views/frontend_view.ex create mode 100644 lib/pleroma/web/admin_api/views/invite_view.ex create mode 100644 lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex create mode 100644 lib/pleroma/web/admin_api/views/moderation_log_view.ex create mode 100644 lib/pleroma/web/admin_api/views/o_auth_app_view.ex create mode 100644 lib/pleroma/web/admin_api/views/report_view.ex create mode 100644 lib/pleroma/web/admin_api/views/rule_view.ex create mode 100644 lib/pleroma/web/admin_api/views/status_view.ex create mode 100644 lib/pleroma/web/admin_api/views/user_view.ex create mode 100644 lib/pleroma/web/admin_api/views/webhook_view.ex create mode 100644 lib/pleroma/web/api_spec.ex create mode 100644 lib/pleroma/web/api_spec/cast_and_validate.ex create mode 100644 lib/pleroma/web/api_spec/helpers.ex create mode 100644 lib/pleroma/web/api_spec/operations/account_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/chat_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/config_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/instance_document_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/invite_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/relay_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/report_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/rule_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/status_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/user_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/webhook_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/announcement_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/app_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/chat_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/conversation_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/directory_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/domain_block_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/filter_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/follow_request_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/instance_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/list_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/marker_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/media_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/notification_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_app_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_conversation_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_emoji_file_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_event_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_instances_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_mascot_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_report_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_search_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/poll_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/report_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/search_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/status_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/streaming_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/subscription_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/timeline_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/twitter_util_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/user_import_operation.ex create mode 100644 lib/pleroma/web/api_spec/render_error.ex create mode 100644 lib/pleroma/web/api_spec/schemas/account.ex create mode 100644 lib/pleroma/web/api_spec/schemas/account_field.ex create mode 100644 lib/pleroma/web/api_spec/schemas/account_relationship.ex create mode 100644 lib/pleroma/web/api_spec/schemas/actor_type.ex create mode 100644 lib/pleroma/web/api_spec/schemas/announcement.ex create mode 100644 lib/pleroma/web/api_spec/schemas/api_error.ex create mode 100644 lib/pleroma/web/api_spec/schemas/app.ex create mode 100644 lib/pleroma/web/api_spec/schemas/attachment.ex create mode 100644 lib/pleroma/web/api_spec/schemas/boolean_like.ex create mode 100644 lib/pleroma/web/api_spec/schemas/chat.ex create mode 100644 lib/pleroma/web/api_spec/schemas/chat_message.ex create mode 100644 lib/pleroma/web/api_spec/schemas/conversation.ex create mode 100644 lib/pleroma/web/api_spec/schemas/emoji.ex create mode 100644 lib/pleroma/web/api_spec/schemas/event.ex create mode 100644 lib/pleroma/web/api_spec/schemas/flake_id.ex create mode 100644 lib/pleroma/web/api_spec/schemas/list.ex create mode 100644 lib/pleroma/web/api_spec/schemas/location_result.ex create mode 100644 lib/pleroma/web/api_spec/schemas/participation_request.ex create mode 100644 lib/pleroma/web/api_spec/schemas/poll.ex create mode 100644 lib/pleroma/web/api_spec/schemas/push_subscription.ex create mode 100644 lib/pleroma/web/api_spec/schemas/scheduled_status.ex create mode 100644 lib/pleroma/web/api_spec/schemas/status.ex create mode 100644 lib/pleroma/web/api_spec/schemas/tag.ex create mode 100644 lib/pleroma/web/api_spec/schemas/visibility_scope.ex create mode 100644 lib/pleroma/web/api_spec/scopes/compiler.ex create mode 100644 lib/pleroma/web/api_spec/scopes/translator.ex create mode 100644 lib/pleroma/web/auth/authenticator.ex create mode 100644 lib/pleroma/web/auth/helpers.ex create mode 100644 lib/pleroma/web/auth/ldap_authenticator.ex create mode 100644 lib/pleroma/web/auth/pleroma_authenticator.ex create mode 100644 lib/pleroma/web/auth/totp_authenticator.ex create mode 100644 lib/pleroma/web/auth/wrapper_authenticator.ex create mode 100644 lib/pleroma/web/common_api.ex create mode 100644 lib/pleroma/web/common_api/activity_draft.ex create mode 100644 lib/pleroma/web/common_api/utils.ex create mode 100644 lib/pleroma/web/controller_helper.ex create mode 100644 lib/pleroma/web/embed_controller.ex create mode 100644 lib/pleroma/web/endpoint.ex create mode 100644 lib/pleroma/web/fallback/legacy_pleroma_api_rerouter_plug.ex create mode 100644 lib/pleroma/web/fallback/redirect_controller.ex create mode 100644 lib/pleroma/web/federator.ex create mode 100644 lib/pleroma/web/federator/publisher.ex create mode 100644 lib/pleroma/web/federator/publishing.ex create mode 100644 lib/pleroma/web/feed/feed_view.ex create mode 100644 lib/pleroma/web/feed/tag_controller.ex create mode 100644 lib/pleroma/web/feed/user_controller.ex create mode 100644 lib/pleroma/web/gettext.ex create mode 100644 lib/pleroma/web/instance_document.ex create mode 100644 lib/pleroma/web/mailer/subscription_controller.ex create mode 100644 lib/pleroma/web/manifest_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/admin/views/account_view.ex create mode 100644 lib/pleroma/web/mastodon_api/admin/views/report_view.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/account_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/app_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/auth_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/directory_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/filter_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/instance_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/list_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/marker_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/media_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/notification_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/report_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/search_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/status_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/mastodon_api.ex create mode 100644 lib/pleroma/web/mastodon_api/views/account_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/announcement_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/app_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/conversation_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/filter_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/follow_request_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/instance_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/list_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/marker_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/media_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/notification_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/poll_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/report_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/status_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/subscription_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/suggestion_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/timeline_view.ex create mode 100644 lib/pleroma/web/mastodon_api/websocket_handler.ex create mode 100644 lib/pleroma/web/media_proxy.ex create mode 100644 lib/pleroma/web/media_proxy/invalidation.ex create mode 100644 lib/pleroma/web/media_proxy/invalidation/http.ex create mode 100644 lib/pleroma/web/media_proxy/invalidation/script.ex create mode 100644 lib/pleroma/web/media_proxy/media_proxy_controller.ex create mode 100644 lib/pleroma/web/metadata.ex create mode 100644 lib/pleroma/web/metadata/player_view.ex create mode 100644 lib/pleroma/web/metadata/providers/feed.ex create mode 100644 lib/pleroma/web/metadata/providers/open_graph.ex create mode 100644 lib/pleroma/web/metadata/providers/provider.ex create mode 100644 lib/pleroma/web/metadata/providers/rel_me.ex create mode 100644 lib/pleroma/web/metadata/providers/restrict_indexing.ex create mode 100644 lib/pleroma/web/metadata/providers/twitter_card.ex create mode 100644 lib/pleroma/web/metadata/utils.ex create mode 100644 lib/pleroma/web/mongoose_im/mongoose_im_controller.ex create mode 100644 lib/pleroma/web/multipart.ex create mode 100644 lib/pleroma/web/nodeinfo/nodeinfo.ex create mode 100644 lib/pleroma/web/nodeinfo/nodeinfo_controller.ex create mode 100644 lib/pleroma/web/o_auth.ex create mode 100644 lib/pleroma/web/o_auth/app.ex create mode 100644 lib/pleroma/web/o_auth/authorization.ex create mode 100644 lib/pleroma/web/o_auth/fallback_controller.ex create mode 100644 lib/pleroma/web/o_auth/mfa_controller.ex create mode 100644 lib/pleroma/web/o_auth/mfa_view.ex create mode 100644 lib/pleroma/web/o_auth/o_auth_controller.ex create mode 100644 lib/pleroma/web/o_auth/o_auth_view.ex create mode 100644 lib/pleroma/web/o_auth/scopes.ex create mode 100644 lib/pleroma/web/o_auth/token.ex create mode 100644 lib/pleroma/web/o_auth/token/query.ex create mode 100644 lib/pleroma/web/o_auth/token/strategy/refresh_token.ex create mode 100644 lib/pleroma/web/o_auth/token/strategy/revoke.ex create mode 100644 lib/pleroma/web/o_auth/token/utils.ex create mode 100644 lib/pleroma/web/o_status/o_status_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/account_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/app_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/backup_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/chat_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/emoji_file_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/event_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/instances_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/notification_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/report_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/search_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/status_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/user_import_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/views/account_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/app_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/backup_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/chat_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/conversation_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/event_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/notification_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/report_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/scrobble_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/search_view.ex create mode 100644 lib/pleroma/web/plug.ex create mode 100644 lib/pleroma/web/plugs/admin_secret_authentication_plug.ex create mode 100644 lib/pleroma/web/plugs/authentication_plug.ex create mode 100644 lib/pleroma/web/plugs/basic_auth_decoder_plug.ex create mode 100644 lib/pleroma/web/plugs/cache.ex create mode 100644 lib/pleroma/web/plugs/digest_plug.ex create mode 100644 lib/pleroma/web/plugs/ensure_authenticated_plug.ex create mode 100644 lib/pleroma/web/plugs/ensure_privileged_plug.ex create mode 100644 lib/pleroma/web/plugs/ensure_public_or_authenticated_plug.ex create mode 100644 lib/pleroma/web/plugs/ensure_user_token_assigns_plug.ex create mode 100644 lib/pleroma/web/plugs/expect_authenticated_check_plug.ex create mode 100644 lib/pleroma/web/plugs/expect_public_or_authenticated_check_plug.ex create mode 100644 lib/pleroma/web/plugs/federating_plug.ex create mode 100644 lib/pleroma/web/plugs/frontend_static.ex create mode 100644 lib/pleroma/web/plugs/http_security_plug.ex create mode 100644 lib/pleroma/web/plugs/http_signature_plug.ex create mode 100644 lib/pleroma/web/plugs/idempotency_plug.ex create mode 100644 lib/pleroma/web/plugs/instance_static.ex create mode 100644 lib/pleroma/web/plugs/mapped_signature_to_identity_plug.ex create mode 100644 lib/pleroma/web/plugs/metrics_predicate.ex create mode 100644 lib/pleroma/web/plugs/o_auth_plug.ex create mode 100644 lib/pleroma/web/plugs/o_auth_scopes_plug.ex create mode 100644 lib/pleroma/web/plugs/plug_helper.ex create mode 100644 lib/pleroma/web/plugs/rate_limiter.ex create mode 100644 lib/pleroma/web/plugs/rate_limiter/limiter_supervisor.ex create mode 100644 lib/pleroma/web/plugs/rate_limiter/supervisor.ex create mode 100644 lib/pleroma/web/plugs/remote_ip.ex create mode 100644 lib/pleroma/web/plugs/set_application_plug.ex create mode 100644 lib/pleroma/web/plugs/set_format_plug.ex create mode 100644 lib/pleroma/web/plugs/set_locale_plug.ex create mode 100644 lib/pleroma/web/plugs/set_user_session_id_plug.ex create mode 100644 lib/pleroma/web/plugs/static_fe_plug.ex create mode 100644 lib/pleroma/web/plugs/trailing_format_plug.ex create mode 100644 lib/pleroma/web/plugs/uploaded_media.ex create mode 100644 lib/pleroma/web/plugs/user_enabled_plug.ex create mode 100644 lib/pleroma/web/plugs/user_fetcher_plug.ex create mode 100644 lib/pleroma/web/plugs/user_is_admin_plug.ex create mode 100644 lib/pleroma/web/plugs/user_is_staff_plug.ex create mode 100644 lib/pleroma/web/plugs/user_tracking_plug.ex create mode 100644 lib/pleroma/web/preload.ex create mode 100644 lib/pleroma/web/preload/providers/instance.ex create mode 100644 lib/pleroma/web/preload/providers/provider.ex create mode 100644 lib/pleroma/web/preload/providers/timelines.ex create mode 100644 lib/pleroma/web/preload/providers/user.ex create mode 100644 lib/pleroma/web/push.ex create mode 100644 lib/pleroma/web/push/impl.ex create mode 100644 lib/pleroma/web/push/subscription.ex create mode 100644 lib/pleroma/web/rel_me.ex create mode 100644 lib/pleroma/web/rich_media/helpers.ex create mode 100644 lib/pleroma/web/rich_media/parser.ex create mode 100644 lib/pleroma/web/rich_media/parser/card.ex create mode 100644 lib/pleroma/web/rich_media/parser/embed.ex create mode 100644 lib/pleroma/web/rich_media/parser/meta_tags.ex create mode 100644 lib/pleroma/web/rich_media/parser/ttl.ex create mode 100644 lib/pleroma/web/rich_media/parser/ttl/aws_signed_url.ex create mode 100644 lib/pleroma/web/rich_media/parsers/o_embed.ex create mode 100644 lib/pleroma/web/rich_media/parsers/ogp.ex create mode 100644 lib/pleroma/web/rich_media/parsers/twitter_card.ex create mode 100644 lib/pleroma/web/router.ex create mode 100644 lib/pleroma/web/static_fe/static_fe_controller.ex create mode 100644 lib/pleroma/web/static_fe/static_fe_view.ex create mode 100644 lib/pleroma/web/streamer.ex create mode 100644 lib/pleroma/web/templates/email/digest.html.eex create mode 100644 lib/pleroma/web/templates/email/new_users_digest.html.eex create mode 100644 lib/pleroma/web/templates/embed/_attachment.html.eex create mode 100644 lib/pleroma/web/templates/embed/show.html.eex create mode 100644 lib/pleroma/web/templates/feed/feed/_activity.atom.eex create mode 100644 lib/pleroma/web/templates/feed/feed/_activity.rss.eex create mode 100644 lib/pleroma/web/templates/feed/feed/_author.atom.eex create mode 100644 lib/pleroma/web/templates/feed/feed/_author.rss.eex create mode 100644 lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex create mode 100644 lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex create mode 100644 lib/pleroma/web/templates/feed/feed/_tag_author.atom.eex create mode 100644 lib/pleroma/web/templates/feed/feed/tag.atom.eex create mode 100644 lib/pleroma/web/templates/feed/feed/tag.rss.eex create mode 100644 lib/pleroma/web/templates/feed/feed/user.atom.eex create mode 100644 lib/pleroma/web/templates/feed/feed/user.rss.eex create mode 100644 lib/pleroma/web/templates/layout/app.html.eex create mode 100644 lib/pleroma/web/templates/layout/email.html.eex create mode 100644 lib/pleroma/web/templates/layout/email_styled.html.eex create mode 100644 lib/pleroma/web/templates/layout/embed.html.eex create mode 100644 lib/pleroma/web/templates/layout/metadata_player.html.eex create mode 100644 lib/pleroma/web/templates/layout/static_fe.html.eex create mode 100644 lib/pleroma/web/templates/mailer/subscription/unsubscribe_failure.html.eex create mode 100644 lib/pleroma/web/templates/mailer/subscription/unsubscribe_success.html.eex create mode 100644 lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex create mode 100644 lib/pleroma/web/templates/o_auth/mfa/totp.html.eex create mode 100644 lib/pleroma/web/templates/o_auth/o_auth/_scopes.html.eex create mode 100644 lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex create mode 100644 lib/pleroma/web/templates/o_auth/o_auth/oob_authorization_created.html.eex create mode 100644 lib/pleroma/web/templates/o_auth/o_auth/oob_token_exists.html.eex create mode 100644 lib/pleroma/web/templates/o_auth/o_auth/register.html.eex create mode 100644 lib/pleroma/web/templates/o_auth/o_auth/show.html.eex create mode 100644 lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex create mode 100644 lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex create mode 100644 lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex create mode 100644 lib/pleroma/web/templates/static_fe/static_fe/conversation.html.eex create mode 100644 lib/pleroma/web/templates/static_fe/static_fe/error.html.eex create mode 100644 lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex create mode 100644 lib/pleroma/web/templates/twitter_api/password/invalid_token.html.eex create mode 100644 lib/pleroma/web/templates/twitter_api/password/reset.html.eex create mode 100644 lib/pleroma/web/templates/twitter_api/password/reset_failed.html.eex create mode 100644 lib/pleroma/web/templates/twitter_api/password/reset_success.html.eex create mode 100644 lib/pleroma/web/templates/twitter_api/remote_follow/follow.html.eex create mode 100644 lib/pleroma/web/templates/twitter_api/remote_follow/follow_login.html.eex create mode 100644 lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex create mode 100644 lib/pleroma/web/templates/twitter_api/remote_follow/followed.html.eex create mode 100644 lib/pleroma/web/templates/twitter_api/util/status_interact.html.eex create mode 100644 lib/pleroma/web/templates/twitter_api/util/subscribe.html.eex create mode 100644 lib/pleroma/web/translation_helpers.ex create mode 100644 lib/pleroma/web/twitter_api/controller.ex create mode 100644 lib/pleroma/web/twitter_api/controllers/password_controller.ex create mode 100644 lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex create mode 100644 lib/pleroma/web/twitter_api/controllers/util_controller.ex create mode 100644 lib/pleroma/web/twitter_api/twitter_api.ex create mode 100644 lib/pleroma/web/twitter_api/views/password_view.ex create mode 100644 lib/pleroma/web/twitter_api/views/remote_follow_view.ex create mode 100644 lib/pleroma/web/twitter_api/views/token_view.ex create mode 100644 lib/pleroma/web/twitter_api/views/util_view.ex create mode 100644 lib/pleroma/web/uploader_controller.ex create mode 100644 lib/pleroma/web/utils/colors.ex create mode 100644 lib/pleroma/web/utils/colors/rgb.ex create mode 100644 lib/pleroma/web/utils/guards.ex create mode 100644 lib/pleroma/web/utils/params.ex create mode 100644 lib/pleroma/web/views/email_view.ex create mode 100644 lib/pleroma/web/views/embed_view.ex create mode 100644 lib/pleroma/web/views/error_helpers.ex create mode 100644 lib/pleroma/web/views/error_view.ex create mode 100644 lib/pleroma/web/views/layout_view.ex create mode 100644 lib/pleroma/web/views/mailer/subscription_view.ex create mode 100644 lib/pleroma/web/views/manifest_view.ex create mode 100644 lib/pleroma/web/views/streamer_view.ex create mode 100644 lib/pleroma/web/web_finger.ex create mode 100644 lib/pleroma/web/web_finger/web_finger_controller.ex create mode 100644 lib/pleroma/web/xml.ex create mode 100644 lib/pleroma/webhook.ex create mode 100644 lib/pleroma/webhook/notify.ex create mode 100644 lib/pleroma/workers/attachments_cleanup_worker.ex create mode 100644 lib/pleroma/workers/background_worker.ex create mode 100644 lib/pleroma/workers/backup_worker.ex create mode 100644 lib/pleroma/workers/cron/digest_emails_worker.ex create mode 100644 lib/pleroma/workers/cron/new_users_digest_worker.ex create mode 100644 lib/pleroma/workers/event_reminder_worker.ex create mode 100644 lib/pleroma/workers/mailer_worker.ex create mode 100644 lib/pleroma/workers/mute_expire_worker.ex create mode 100644 lib/pleroma/workers/notification_worker.ex create mode 100644 lib/pleroma/workers/poll_worker.ex create mode 100644 lib/pleroma/workers/publisher_worker.ex create mode 100644 lib/pleroma/workers/purge_expired_activity.ex create mode 100644 lib/pleroma/workers/purge_expired_filter.ex create mode 100644 lib/pleroma/workers/purge_expired_token.ex create mode 100644 lib/pleroma/workers/receiver_worker.ex create mode 100644 lib/pleroma/workers/remote_fetcher_worker.ex create mode 100644 lib/pleroma/workers/scheduled_activity_worker.ex create mode 100644 lib/pleroma/workers/search_indexing_worker.ex create mode 100644 lib/pleroma/workers/web_pusher_worker.ex create mode 100644 lib/pleroma/workers/worker_helper.ex create mode 100644 lib/pleroma/xml_builder.ex create mode 100644 lib/soapbox.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 priv/gettext/config_descriptions.pot create mode 100644 priv/gettext/default.pot create mode 100644 priv/gettext/en/LC_MESSAGES/oauth_scopes.po create mode 100644 priv/gettext/en/LC_MESSAGES/posix_errors.po create mode 100644 priv/gettext/en_test/LC_MESSAGES/default.po create mode 100644 priv/gettext/en_test/LC_MESSAGES/errors.po create mode 100644 priv/gettext/en_test/LC_MESSAGES/posix_errors.po create mode 100644 priv/gettext/en_test/LC_MESSAGES/static_pages.po create mode 100644 priv/gettext/errors.pot create mode 100644 priv/gettext/es/LC_MESSAGES/errors.po create mode 100644 priv/gettext/fr/LC_MESSAGES/config_descriptions.po create mode 100644 priv/gettext/fr/LC_MESSAGES/default.po create mode 100644 priv/gettext/fr/LC_MESSAGES/errors.po create mode 100644 priv/gettext/fr/LC_MESSAGES/posix_errors.po create mode 100644 priv/gettext/fr/LC_MESSAGES/static_pages.po create mode 100644 priv/gettext/he/LC_MESSAGES/errors.po create mode 100644 priv/gettext/id/LC_MESSAGES/errors.po create mode 100644 priv/gettext/it/LC_MESSAGES/errors.po create mode 100644 priv/gettext/ja/LC_MESSAGES/errors.po create mode 100644 priv/gettext/ko/LC_MESSAGES/default.po create mode 100644 priv/gettext/nl/LC_MESSAGES/errors.po create mode 100644 priv/gettext/nl/LC_MESSAGES/posix_errors.po create mode 100644 priv/gettext/nl/LC_MESSAGES/static_pages.po create mode 100644 priv/gettext/oauth_scopes.pot create mode 100644 priv/gettext/pl/LC_MESSAGES/errors.po create mode 100644 priv/gettext/posix_errors.pot create mode 100644 priv/gettext/pt_PT/LC_MESSAGES/errors.po create mode 100644 priv/gettext/ru/LC_MESSAGES/errors.po create mode 100644 priv/gettext/static_pages.pot create mode 100644 priv/gettext/uk/LC_MESSAGES/config_descriptions.po create mode 100644 priv/gettext/uk/LC_MESSAGES/default.po create mode 100644 priv/gettext/uk/LC_MESSAGES/errors.po create mode 100644 priv/gettext/vi/LC_MESSAGES/errors.po create mode 100644 priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po create mode 100644 priv/gettext/zh_Hans/LC_MESSAGES/default.po create mode 100644 priv/gettext/zh_Hans/LC_MESSAGES/errors.po create mode 100644 priv/gettext/zh_Hans/LC_MESSAGES/posix_errors.po create mode 100644 priv/gettext/zh_Hans/LC_MESSAGES/static_pages.po create mode 100644 priv/gettext/zh_Hant/LC_MESSAGES/errors.po create mode 100644 priv/repo/migrations/20170320193800_create_user.exs create mode 100644 priv/repo/migrations/20170321074828_create_activity.exs create mode 100644 priv/repo/migrations/20170321074832_create_object.exs create mode 100644 priv/repo/migrations/20170321133335_add_following_list_to_users.exs create mode 100644 priv/repo/migrations/20170321143152_add_ap_id_to_users.exs create mode 100644 priv/repo/migrations/20170330153447_add_index_to_objects.exs create mode 100644 priv/repo/migrations/20170415141210_add_unique_index_to_email_and_nickname.exs create mode 100644 priv/repo/migrations/20170416122418_add_avatar_object_to_users.exs create mode 100644 priv/repo/migrations/20170418200143_create_webssub_server_subscription.exs create mode 100644 priv/repo/migrations/20170423154511_add_fields_to_users.exs create mode 100644 priv/repo/migrations/20170426154155_create_websub_client_subscription.exs create mode 100644 priv/repo/migrations/20170427054757_add_user_and_hub.exs create mode 100644 priv/repo/migrations/20170501124823_add_id_contraints_to_activities_and_objects.exs create mode 100644 priv/repo/migrations/20170501133231_add_id_contraints_to_activities_and_objects_part_two.exs create mode 100644 priv/repo/migrations/20170502083023_add_local_field_to_activities.exs create mode 100644 priv/repo/migrations/20170506222027_add_unique_index_to_apid.exs create mode 100644 priv/repo/migrations/20170522160642_case_insensivtivity.exs create mode 100644 priv/repo/migrations/20170529093232_longer_bios.exs create mode 100644 priv/repo/migrations/20170620095947_remove_activities_index.exs create mode 100644 priv/repo/migrations/20170620133028_add_object_activity_index.exs create mode 100644 priv/repo/migrations/20170620142420_add_object_activity_index_part_two.exs create mode 100644 priv/repo/migrations/20170701142005_add_actor_index_to_activity.exs create mode 100644 priv/repo/migrations/20170719152213_add_follower_address_to_user.exs create mode 100644 priv/repo/migrations/20170906120646_add_mastodon_apps.exs create mode 100644 priv/repo/migrations/20170906143140_create_o_auth_authorizations.exs create mode 100644 priv/repo/migrations/20170906152508_create_o_auth_token.exs create mode 100644 priv/repo/migrations/20170911123607_create_notifications.exs create mode 100644 priv/repo/migrations/20170912114248_add_context_index.exs create mode 100644 priv/repo/migrations/20170916090107_add_fts_index_to_activities.exs create mode 100644 priv/repo/migrations/20170917120416_add_tag_index.exs create mode 100644 priv/repo/migrations/20171019141706_create_password_reset_tokens.exs create mode 100644 priv/repo/migrations/20171023155035_add_second_object_index_to_activty.exs create mode 100644 priv/repo/migrations/20171024090137_drop_object_index.exs create mode 100644 priv/repo/migrations/20171024121413_add_object_actor_index.exs create mode 100644 priv/repo/migrations/20171109091239_add_actor_to_activity.exs create mode 100644 priv/repo/migrations/20171109114020_fill_actor_field.exs create mode 100644 priv/repo/migrations/20171109141309_add_sort_index_to_activities.exs create mode 100644 priv/repo/migrations/20171130135819_add_local_index_to_user.exs create mode 100644 priv/repo/migrations/20171212163643_add_recipients_to_activities.exs create mode 100644 priv/repo/migrations/20171212164525_fill_recipients_in_activities.exs create mode 100644 priv/repo/migrations/20180221210540_make_following_postgres_array.exs create mode 100644 priv/repo/migrations/20180325172351_add_follower_address_index_to_users.exs create mode 100644 priv/repo/migrations/20180327174350_drop_local_index_on_activities.exs create mode 100644 priv/repo/migrations/20180327175831_actually_drop_local_index.exs create mode 100644 priv/repo/migrations/20180429094642_create_lists.exs create mode 100644 priv/repo/migrations/20180513104714_modify_activity_index.exs create mode 100644 priv/repo/migrations/20180516144508_add_trigram_extension.exs create mode 100644 priv/repo/migrations/20180516154905_create_user_trigram_index.exs create mode 100644 priv/repo/migrations/20180530123448_add_list_follow_index.exs create mode 100644 priv/repo/migrations/20180606173637_create_apid_host_extraction_index.exs create mode 100644 priv/repo/migrations/20180612110515_create_user_invite_tokens.exs create mode 100644 priv/repo/migrations/20180617221540_create_activities_in_reply_to_index.exs create mode 100644 priv/repo/migrations/20180813003722_create_filters.exs create mode 100644 priv/repo/migrations/20180829082446_add_recipients_to_and_cc_fields_to_activities.exs create mode 100644 priv/repo/migrations/20180829182612_activities_add_to_cc_indices.exs create mode 100644 priv/repo/migrations/20180829183529_remove_recipients_to_and_cc_fields_from_activities.exs create mode 100644 priv/repo/migrations/20180903114437_users_add_is_moderator_index.exs create mode 100644 priv/repo/migrations/20180918182427_create_push_subscriptions.exs create mode 100644 priv/repo/migrations/20180919060348_users_add_last_refreshed_at.exs create mode 100644 priv/repo/migrations/20181201104428_add_uuid_extension.exs create mode 100644 priv/repo/migrations/20181201105617_add_uui_ds_to_user_info.exs create mode 100644 priv/repo/migrations/20181206125616_add_tags_to_users.exs create mode 100644 priv/repo/migrations/20181214121049_add_bookmarks_to_users.exs create mode 100644 priv/repo/migrations/20181218172826_users_and_activities_flake_id.exs create mode 100644 priv/repo/migrations/20190109152453_add_visibility_function.exs create mode 100644 priv/repo/migrations/20190115085500_create_user_fts_index.exs create mode 100644 priv/repo/migrations/20190118074940_fix_user_trigram_index.exs create mode 100644 priv/repo/migrations/20190122153157_update_activity_visibility.exs create mode 100644 priv/repo/migrations/20190123092341_users_add_is_admin_index.exs create mode 100644 priv/repo/migrations/20190123125546_create_instances.exs create mode 100644 priv/repo/migrations/20190123125839_fix_info_ids.exs create mode 100644 priv/repo/migrations/20190124131141_update_activity_visibility_again.exs create mode 100644 priv/repo/migrations/20190126160540_change_push_subscriptions_varchar.exs create mode 100644 priv/repo/migrations/20190127151220_add_activities_likes_index.exs create mode 100644 priv/repo/migrations/20190203185340_split_hide_network.exs create mode 100644 priv/repo/migrations/20190204200237_add_correct_dm_index.exs create mode 100644 priv/repo/migrations/20190205114625_create_thread_mutes.exs create mode 100644 priv/repo/migrations/20190208131753_add_scopes_to_o_auth_entities.exs create mode 100644 priv/repo/migrations/20190213185503_change_apps_scopes_to_varchar_array.exs create mode 100644 priv/repo/migrations/20190213185600_data_migration_populate_o_auth_scopes.exs create mode 100644 priv/repo/migrations/20190222104808_data_migration_normalize_scopes.exs create mode 100644 priv/repo/migrations/20190301101154_add_default_tags_to_user.exs create mode 100644 priv/repo/migrations/20190303120636_update_user_note_counters.exs create mode 100644 priv/repo/migrations/20190315101315_create_registrations.exs create mode 100644 priv/repo/migrations/20190325185009_create_notification_id_index.exs create mode 100644 priv/repo/migrations/20190328053912_create_scheduled_activities.exs create mode 100644 priv/repo/migrations/20190403131720_add_oauth_token_indexes.exs create mode 100644 priv/repo/migrations/20190404050946_add_fields_to_user_invite_tokens.exs create mode 100644 priv/repo/migrations/20190405160700_add_index_on_subscribers.exs create mode 100644 priv/repo/migrations/20190408123347_create_conversations.exs create mode 100644 priv/repo/migrations/20190410152859_add_participation_updated_at_index.exs create mode 100644 priv/repo/migrations/20190411094120_add_index_on_user_info_deactivated.exs create mode 100644 priv/repo/migrations/20190412052952_add_user_info_fields.exs create mode 100644 priv/repo/migrations/20190413082658_create_bookmarks.exs create mode 100644 priv/repo/migrations/20190413085040_add_signin_and_last_digest_dates_to_user.exs create mode 100644 priv/repo/migrations/20190414125034_migrate_old_bookmarks.exs create mode 100644 priv/repo/migrations/20190501125843_add_fts_index_to_objects.exs create mode 100644 priv/repo/migrations/20190501133552_add_refresh_token_index_to_token.exs create mode 100644 priv/repo/migrations/20190506054542_add_multi_factor_authentication_settings_to_user.exs create mode 100644 priv/repo/migrations/20190508193213_create_mfa_tokens.exs create mode 100644 priv/repo/migrations/20190511191044_set_default_state_to_reports.exs create mode 100644 priv/repo/migrations/20190513175809_change_hide_column_in_filter_table.exs create mode 100644 priv/repo/migrations/20190515222404_add_thread_visibility_function.exs create mode 100644 priv/repo/migrations/20190516112144_add_ap_id_to_lists.exs create mode 100644 priv/repo/migrations/20190518032627_create_config.exs create mode 100644 priv/repo/migrations/20190525071417_add_non_follows_and_non_followers_fields_to_notification_settings.exs create mode 100644 priv/repo/migrations/20190603115238_add_index_on_activities_local.exs create mode 100644 priv/repo/migrations/20190603162018_add_object_in_reply_to_index.exs create mode 100644 priv/repo/migrations/20190603173419_add_tag_index_to_objects.exs create mode 100644 priv/repo/migrations/20190622151019_add_group_key_to_config.exs create mode 100644 priv/repo/migrations/20190710115833_add_following_address_to_user.exs create mode 100644 priv/repo/migrations/20190710125051_add_following_address_index_to_user.exs create mode 100644 priv/repo/migrations/20190710125158_add_following_address_from_source_data.exs create mode 100644 priv/repo/migrations/20190711042021_create_safe_jsonb_set.exs create mode 100644 priv/repo/migrations/20190711042024_copy_muted_to_muted_notifications.exs create mode 100644 priv/repo/migrations/20190716100804_add_expirations_table.exs create mode 100644 priv/repo/migrations/20190730055101_add_oban_jobs_table.exs create mode 100644 priv/repo/migrations/20190801154554_create_conversation_participation_recipient_ships.exs create mode 100644 priv/repo/migrations/20190818124341_create_moderation_log.exs create mode 100644 priv/repo/migrations/20190823000549_add_likes_index_to_objects.exs create mode 100644 priv/repo/migrations/20190912065617_create_deliveries.exs create mode 100644 priv/repo/migrations/20190917100019_update_oban.exs create mode 100644 priv/repo/migrations/20190929201536_drop_subscription_if_exists.exs create mode 100644 priv/repo/migrations/20191005165212_add_unread_conversation_count_to_user_info.exs create mode 100644 priv/repo/migrations/20191006123824_add_keys_column.exs create mode 100644 priv/repo/migrations/20191006135457_move_keys_to_separate_column.exs create mode 100644 priv/repo/migrations/20191007073319_create_following_relationships.exs create mode 100644 priv/repo/migrations/20191008132217_migrate_following_relationships.exs create mode 100644 priv/repo/migrations/20191008132427_drop_users_following.exs create mode 100644 priv/repo/migrations/20191009154606_add_user_info_columns.exs create mode 100644 priv/repo/migrations/20191009154608_copy_users_info_fields_to_users.exs create mode 100644 priv/repo/migrations/20191014181019_create_markers.exs create mode 100644 priv/repo/migrations/20191017225002_drop_websub_tables.exs create mode 100644 priv/repo/migrations/20191025081729_add_move_support_to_users.exs create mode 100644 priv/repo/migrations/20191025143434_add_defaults_to_tables.exs create mode 100644 priv/repo/migrations/20191026190317_set_not_null_for_activities.exs create mode 100644 priv/repo/migrations/20191026190415_set_not_null_for_activity_expirations.exs create mode 100644 priv/repo/migrations/20191026190500_set_not_null_for_apps.exs create mode 100644 priv/repo/migrations/20191026190533_set_not_null_for_bookmarks.exs create mode 100644 priv/repo/migrations/20191026190622_set_not_null_for_config.exs create mode 100644 priv/repo/migrations/20191026190712_set_not_null_for_conversation_participation_recipient_ships.exs create mode 100644 priv/repo/migrations/20191026190759_set_not_null_for_conversation_participations.exs create mode 100644 priv/repo/migrations/20191026190841_set_not_null_for_filters.exs create mode 100644 priv/repo/migrations/20191026191023_set_not_null_for_instances.exs create mode 100644 priv/repo/migrations/20191026191100_set_not_null_for_lists.exs create mode 100644 priv/repo/migrations/20191026191134_set_not_null_for_markers.exs create mode 100644 priv/repo/migrations/20191026191218_set_not_null_for_moderation_log.exs create mode 100644 priv/repo/migrations/20191026191249_set_not_null_for_notifications.exs create mode 100644 priv/repo/migrations/20191026191328_set_not_null_for_oauth_authorizations.exs create mode 100644 priv/repo/migrations/20191026191401_set_not_null_for_oauth_tokens.exs create mode 100644 priv/repo/migrations/20191026191442_set_not_null_for_objects.exs create mode 100644 priv/repo/migrations/20191026191524_set_not_null_for_password_reset_tokens.exs create mode 100644 priv/repo/migrations/20191026191603_set_not_null_for_push_subscriptions.exs create mode 100644 priv/repo/migrations/20191026191635_set_not_null_for_registrations.exs create mode 100644 priv/repo/migrations/20191026191711_set_not_null_for_scheduled_activities.exs create mode 100644 priv/repo/migrations/20191026191753_set_not_null_for_thread_mutes.exs create mode 100644 priv/repo/migrations/20191026191826_set_not_null_for_user_invite_tokens.exs create mode 100644 priv/repo/migrations/20191026191910_set_not_null_for_users.exs create mode 100644 priv/repo/migrations/20191029101340_migrate_missing_follow_requests.exs create mode 100644 priv/repo/migrations/20191029172832_fix_blocked_follows.exs create mode 100644 priv/repo/migrations/20191104133100_set_visible_service_actors.exs create mode 100644 priv/repo/migrations/20191118084425_create_user_relationships.exs create mode 100644 priv/repo/migrations/20191118084500_data_migration_populate_user_relationships.exs create mode 100644 priv/repo/migrations/20191123030554_add_activitypub_actor_type.exs create mode 100644 priv/repo/migrations/20191123103423_remove_info_from_users.exs create mode 100644 priv/repo/migrations/20191128153944_fix_missing_following_count.exs create mode 100644 priv/repo/migrations/20191203043610_create_report_notes.exs create mode 100644 priv/repo/migrations/20191220174645_add_scopes_to_pleroma_feo_auth_records.exs create mode 100644 priv/repo/migrations/20200109123126_add_counter_cache_table.exs create mode 100644 priv/repo/migrations/20200227122417_add_trusted_to_apps.exs create mode 100644 priv/repo/migrations/20200307103755_fix_moderation_log_subjects.exs create mode 100644 priv/repo/migrations/20200309123730_create_chats.exs create mode 100644 priv/repo/migrations/20200314123607_config_remove_fetch_initial_posts.exs create mode 100644 priv/repo/migrations/20200315125756_delete_fetch_initial_posts_jobs.exs create mode 100644 priv/repo/migrations/20200322174133_user_raw_bio.exs create mode 100644 priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs create mode 100644 priv/repo/migrations/20200328124805_change_following_relationships_state_to_integer.exs create mode 100644 priv/repo/migrations/20200328130139_add_following_relationships_following_id_index.exs create mode 100644 priv/repo/migrations/20200328193433_populate_user_raw_bio.exs create mode 100644 priv/repo/migrations/20200401030751_users_add_public_key.exs create mode 100644 priv/repo/migrations/20200401072456_users_add_inboxes.exs create mode 100644 priv/repo/migrations/20200402063221_update_oban_jobs_table.exs create mode 100644 priv/repo/migrations/20200406100225_users_add_emoji.exs create mode 100644 priv/repo/migrations/20200406105422_users_remove_source_data.exs create mode 100644 priv/repo/migrations/20200415181818_update_markers.exs create mode 100644 priv/repo/migrations/20200428221338_insert_skeletons_for_deleted_users.exs create mode 100644 priv/repo/migrations/20200505072231_remove_magic_key_field.exs create mode 100644 priv/repo/migrations/20200508092434_update_counter_cache_table.exs create mode 100644 priv/repo/migrations/20200520155351_add_recipients_contain_blocked_domains_function.exs create mode 100644 priv/repo/migrations/20200526144426_add_apps_indexes.exs create mode 100644 priv/repo/migrations/20200527104138_change_notification_user_index.exs create mode 100644 priv/repo/migrations/20200527163635_delete_notifications_from_invisible_users.exs create mode 100644 priv/repo/migrations/20200602094828_add_type_to_notifications.exs create mode 100644 priv/repo/migrations/20200602125218_backfill_notification_types.exs create mode 100644 priv/repo/migrations/20200602150528_create_chat_message_reference.exs create mode 100644 priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs create mode 100644 priv/repo/migrations/20200603120448_remove_unread_from_chats.exs create mode 100644 priv/repo/migrations/20200603122732_add_seen_index_to_chat_message_references.exs create mode 100644 priv/repo/migrations/20200604150318_migrate_seen_to_unread_in_chat_message_references.exs create mode 100644 priv/repo/migrations/20200606105430_change_type_to_enum_for_notifications.exs create mode 100644 priv/repo/migrations/20200607112923_change_chat_id_to_flake.exs create mode 100644 priv/repo/migrations/20200626163359_rename_notification_privacy_option.exs create mode 100644 priv/repo/migrations/20200630162024_rename_user_settings_col.exs create mode 100644 priv/repo/migrations/20200703101031_add_chat_acceptance_to_users.exs create mode 100644 priv/repo/migrations/20200706060258_remove_tesla_from_config.exs create mode 100644 priv/repo/migrations/20200707112859_instances_add_favicon.exs create mode 100644 priv/repo/migrations/20200708193702_drop_user_trigram_index.exs create mode 100644 priv/repo/migrations/20200712234852_add_approval_fields_to_users.exs create mode 100644 priv/repo/migrations/20200714081657_oban_2_0_config_changes.exs create mode 100644 priv/repo/migrations/20200716195806_autolinker_to_linkify.exs create mode 100644 priv/repo/migrations/20200722185515_fix_malformed_formatter_config.exs create mode 100644 priv/repo/migrations/20200724133313_move_welcome_settings.exs create mode 100644 priv/repo/migrations/20200802170532_fix_legacy_tags.exs create mode 100644 priv/repo/migrations/20200804180322_remove_nonlocal_expirations.exs create mode 100644 priv/repo/migrations/20200804183107_add_unique_index_to_app_client_id.exs create mode 100644 priv/repo/migrations/20200806175913_rename_instance_chat.exs create mode 100644 priv/repo/migrations/20200808173046_only_expire_creates.exs create mode 100644 priv/repo/migrations/20200811125613_set_defaults_to_user_approval_pending.exs create mode 100644 priv/repo/migrations/20200811143147_ap_id_not_null.exs create mode 100644 priv/repo/migrations/20200817120935_add_invisible_index_to_users.exs create mode 100644 priv/repo/migrations/20200824115541_rename_activity_expiration_setting.exs create mode 100644 priv/repo/migrations/20200825061316_move_activity_expirations_to_oban.exs create mode 100644 priv/repo/migrations/20200825093037_drop_activity_expirations_table.exs create mode 100644 priv/repo/migrations/20200831114918_remove_unread_conversation_count_from_user.exs create mode 100644 priv/repo/migrations/20200831115854_add_unread_index_to_conversation_participation.exs create mode 100644 priv/repo/migrations/20200831142509_chat_constraints.exs create mode 100644 priv/repo/migrations/20200831152600_add_pleroma_report_to_enum_for_notifications.exs create mode 100644 priv/repo/migrations/20200831192323_create_backups.exs create mode 100644 priv/repo/migrations/20200901061256_ensure_bio_is_string.exs create mode 100644 priv/repo/migrations/20200901061637_bio_set_not_null.exs create mode 100644 priv/repo/migrations/20200905082737_rename_await_up_timeout_in_connections_pool.exs create mode 100644 priv/repo/migrations/20200905091427_rename_timeout_in_pools.exs create mode 100644 priv/repo/migrations/20200906072147_remove_cron_stats_worker_from_oban_config.exs create mode 100644 priv/repo/migrations/20200907084956_remove_cron_clear_oauth_token_worker_from_oban_config.exs create mode 100644 priv/repo/migrations/20200907092050_move_tokens_expiration_into_oban.exs create mode 100644 priv/repo/migrations/20200910113106_remove_managed_config_from_db.exs create mode 100644 priv/repo/migrations/20200911055909_remove_cron_jobs.exs create mode 100644 priv/repo/migrations/20200914105638_delete_notification_without_activity.exs create mode 100644 priv/repo/migrations/20200914105800_add_notification_constraints.exs create mode 100644 priv/repo/migrations/20200915095704_remove_background_jobs.exs create mode 100644 priv/repo/migrations/20200919182636_remoteip_plug_rename.exs create mode 100644 priv/repo/migrations/20200925065249_make_user_ids_ci.exs create mode 100644 priv/repo/migrations/20200928145912_revert_citext_change.exs create mode 100644 priv/repo/migrations/20200930082320_user_ur_is_index_part_three.exs create mode 100644 priv/repo/migrations/20201005123100_simple_policy_string_to_tuple.exs create mode 100644 priv/repo/migrations/20201005124600_quarantained_policy_string_to_tuple.exs create mode 100644 priv/repo/migrations/20201005132900_transparency_exclusions_string_to_tuple.exs create mode 100644 priv/repo/migrations/20201012173004_refactor_deactivated_user_field.exs create mode 100644 priv/repo/migrations/20201013141127_refactor_locked_user_field.exs create mode 100644 priv/repo/migrations/20201013144052_refactor_discoverable_user_field.exs create mode 100644 priv/repo/migrations/20201013184200_refactor_confirmation_pending_user_field.exs create mode 100644 priv/repo/migrations/20201016205220_refactor_approval_pending_user_field.exs create mode 100644 priv/repo/migrations/20201113060459_remove_purge_expired_activity_worker_from_oban_config.exs create mode 100644 priv/repo/migrations/20201217172858_data_migration_prolong_o_auth_tokens_valid_until.exs create mode 100644 priv/repo/migrations/20201221202251_create_hashtags.exs create mode 100644 priv/repo/migrations/20201221202252_remove_data_from_hashtags.exs create mode 100644 priv/repo/migrations/20201221203824_create_hashtags_objects.exs create mode 100644 priv/repo/migrations/20201231185546_confirm_logged_in_users.exs create mode 100644 priv/repo/migrations/20210105195018_create_data_migrations.exs create mode 100644 priv/repo/migrations/20210106183301_data_migration_create_populate_hashtags_table.exs create mode 100644 priv/repo/migrations/20210111172254_create_data_migration_failed_ids.exs create mode 100644 priv/repo/migrations/20210113225652_deprecate_public_endpoint.exs create mode 100644 priv/repo/migrations/20210115205649_upgrade_oban_jobs_to_v9.exs create mode 100644 priv/repo/migrations/20210121080964_add_default_text_search_config.exs create mode 100644 priv/repo/migrations/20210122151424_add_last_active_at_to_users.exs create mode 100644 priv/repo/migrations/20210128092834_remove_duplicates_from_activity_expiration_queue.exs create mode 100644 priv/repo/migrations/20210202110641_add_pinned_objects_to_users.exs create mode 100644 priv/repo/migrations/20210203141144_add_featured_address_to_users.exs create mode 100644 priv/repo/migrations/20210205145000_move_pinned_activities_into_pinned_objects.exs create mode 100644 priv/repo/migrations/20210206045221_remove_pinned_activities_from_users.exs create mode 100644 priv/repo/migrations/20210218223811_add_disclose_client_to_users.exs create mode 100644 priv/repo/migrations/20210222183840_remove_hashtags_objects_duplicate_index.exs create mode 100644 priv/repo/migrations/20210222184616_change_hashtags_name_to_text.exs create mode 100644 priv/repo/migrations/20210401143153_user_notification_settings_fix.exs create mode 100644 priv/repo/migrations/20210416051708_remove_mastofe_settings_from_users.exs create mode 100644 priv/repo/migrations/20210420204354_delete_hashtags_objects_cascade.exs create mode 100644 priv/repo/migrations/20210612185407_add_email_list_field_to_users.exs create mode 100644 priv/repo/migrations/20210717000000_add_poll_to_notifications_enum.exs create mode 100644 priv/repo/migrations/20210818023112_add_user_id_to_apps.exs create mode 100644 priv/repo/migrations/20211121000000_create_user_notes.exs create mode 100644 priv/repo/migrations/20211125110126_force_pinned_objects_to_exist.exs create mode 100644 priv/repo/migrations/20211126191138_add_suggestions.exs create mode 100644 priv/repo/migrations/20211222165256_add_last_status_at_to_users.exs create mode 100644 priv/repo/migrations/20211225154802_add_is_discoverable_index_to_users.exs create mode 100644 priv/repo/migrations/20211229075801_user_relationships_target_id_relationship_type_index.exs create mode 100644 priv/repo/migrations/20220116183110_add_birthday_to_users.exs create mode 100644 priv/repo/migrations/20220125104429_add_birthday_month_day_index_to_users.exs create mode 100644 priv/repo/migrations/20220203224011_create_rules.exs create mode 100644 priv/repo/migrations/20220220135625_upload_filter_exiftool_to_exiftool_strip_location.exs create mode 100644 priv/repo/migrations/20220225164000_add_activity_assigned_account_index.exs create mode 100644 priv/repo/migrations/20220302013920_add_language_to_users.exs create mode 100644 priv/repo/migrations/20220308012601_create_announcements.exs create mode 100644 priv/repo/migrations/20220314220000_add_location_to_users.exs create mode 100644 priv/repo/migrations/20220319000000_add_status_to_notifications_enum.exs create mode 100644 priv/repo/migrations/20220506175506_add_index_hotspots.exs create mode 100644 priv/repo/migrations/20220509180452_change_thread_visibility_to_be_local_only_aware.exs create mode 100644 priv/repo/migrations/20220527134341_add_quote_url_index_to_objects.exs create mode 100644 priv/repo/migrations/20220602052233_change_report_notes_content_to_text.exs create mode 100644 priv/repo/migrations/20220605185734_add_update_to_notifications_enum.exs create mode 100644 priv/repo/migrations/20220616163503_add_expires_at_to_user_relationships.exs create mode 100644 priv/repo/migrations/20220624104914_create_webhooks.exs create mode 100644 priv/repo/migrations/20220711182322_add_associated_object_id_function.exs create mode 100644 priv/repo/migrations/20220711192750_switch_to_associated_object_id_index.exs create mode 100644 priv/repo/migrations/20220807125023_data_migration_delete_context_objects.exs create mode 100644 priv/repo/migrations/20220819171321_add_pleroma_participation_accepted_to_notifications_enum.exs create mode 100644 priv/repo/migrations/20220821004840_change_thread_visibility_to_use_new_object_id_index.exs create mode 100644 priv/repo/migrations/20220905011454_generate_unset_user_keys.exs create mode 100644 priv/repo/migrations/20220927220033_add_last_move_at_to_users.exs create mode 100644 priv/repo/migrations/20221029171353_add_internal_to_webhooks.exs create mode 100644 priv/repo/migrations/20221103014611_create_oban_peers.exs create mode 100644 priv/repo/migrations/20221103014728_swap_primary_oban_indexes.exs create mode 100644 priv/repo/migrations/20221111164213_deprecate_quack.exs create mode 100644 priv/repo/migrations/20221207235104_add_events_index.exs create mode 100644 priv/repo/migrations/20221216052127_add_state_to_backups.exs create mode 100644 priv/repo/migrations/20230306112859_instances_add_metadata.exs create mode 100644 priv/repo/migrations/20230422154018_drop_unused_indexes.exs create mode 100644 priv/repo/migrations/20230504173400_remove_user_ap_enabled.exs create mode 100644 priv/repo/migrations/20231107200724_consolidate_email_queues.exs create mode 100644 priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs create mode 100644 priv/repo/seeds.exs create mode 100644 priv/scrubbers/default.ex create mode 100644 priv/scrubbers/links_only.ex create mode 100644 priv/scrubbers/media_proxy.ex create mode 100644 priv/scrubbers/o_embed.ex create mode 100644 priv/scrubbers/search_indexing.ex create mode 100644 priv/scrubbers/twitter_text.ex create mode 100644 priv/static/READ_THIS_BEFORE_TOUCHING_FILES_HERE create mode 100644 priv/static/adminfe/app.147d87e8.css create mode 100644 priv/static/adminfe/chunk-0c3d.79348811.css create mode 100644 priv/static/adminfe/chunk-0c60.ee394b7b.css create mode 100644 priv/static/adminfe/chunk-0fa6.d224ff3a.css create mode 100644 priv/static/adminfe/chunk-1a25.098b6a73.css create mode 100644 priv/static/adminfe/chunk-2be3.846d0d5a.css create mode 100644 priv/static/adminfe/chunk-2cfa.c090392a.css create mode 100644 priv/static/adminfe/chunk-305b.64cc20ab.css create mode 100644 priv/static/adminfe/chunk-31b9.abc32430.css create mode 100644 priv/static/adminfe/chunk-33c9.3c0c7538.css create mode 100644 priv/static/adminfe/chunk-42d5.9ade3c1d.css create mode 100644 priv/static/adminfe/chunk-45ed.fc62bc5c.css create mode 100644 priv/static/adminfe/chunk-4995.ba9a98d5.css create mode 100644 priv/static/adminfe/chunk-5669.9e91529b.css create mode 100644 priv/static/adminfe/chunk-59e6.07d1d2f0.css create mode 100644 priv/static/adminfe/chunk-5cf2.5bf52c8f.css create mode 100644 priv/static/adminfe/chunk-68b6.d7a7a7ea.css create mode 100644 priv/static/adminfe/chunk-7c11.5c2bbb9c.css create mode 100644 priv/static/adminfe/chunk-elementUI.852ab1db.css create mode 100644 priv/static/adminfe/chunk-libs.0b4a26df.css create mode 100644 priv/static/adminfe/favicon.ico create mode 100644 priv/static/adminfe/index.html create mode 100644 priv/static/adminfe/static/fonts/element-icons.535877f.woff create mode 100644 priv/static/adminfe/static/fonts/element-icons.732389d.ttf create mode 100644 priv/static/adminfe/static/js/7zzA.e1ae1c94.js create mode 100644 priv/static/adminfe/static/js/7zzA.e1ae1c94.js.map create mode 100644 priv/static/adminfe/static/js/JEtC.f9ba4594.js create mode 100644 priv/static/adminfe/static/js/JEtC.f9ba4594.js.map create mode 100644 priv/static/adminfe/static/js/app.c3e187df.js create mode 100644 priv/static/adminfe/static/js/app.c3e187df.js.map create mode 100644 priv/static/adminfe/static/js/chunk-0c3d.00a00ec3.js create mode 100644 priv/static/adminfe/static/js/chunk-0c3d.00a00ec3.js.map create mode 100644 priv/static/adminfe/static/js/chunk-0c60.e0f08810.js create mode 100644 priv/static/adminfe/static/js/chunk-0c60.e0f08810.js.map create mode 100644 priv/static/adminfe/static/js/chunk-0e2d.04760e2c.js create mode 100644 priv/static/adminfe/static/js/chunk-0e2d.04760e2c.js.map create mode 100644 priv/static/adminfe/static/js/chunk-0fa6.ddd4199e.js create mode 100644 priv/static/adminfe/static/js/chunk-0fa6.ddd4199e.js.map create mode 100644 priv/static/adminfe/static/js/chunk-16d0.7d343bb9.js create mode 100644 priv/static/adminfe/static/js/chunk-16d0.7d343bb9.js.map create mode 100644 priv/static/adminfe/static/js/chunk-1a25.107c34e7.js create mode 100644 priv/static/adminfe/static/js/chunk-1a25.107c34e7.js.map create mode 100644 priv/static/adminfe/static/js/chunk-2be3.7b7c045e.js create mode 100644 priv/static/adminfe/static/js/chunk-2be3.7b7c045e.js.map create mode 100644 priv/static/adminfe/static/js/chunk-2cfa.608c3714.js create mode 100644 priv/static/adminfe/static/js/chunk-2cfa.608c3714.js.map create mode 100644 priv/static/adminfe/static/js/chunk-305b.3a7fb27b.js create mode 100644 priv/static/adminfe/static/js/chunk-305b.3a7fb27b.js.map create mode 100644 priv/static/adminfe/static/js/chunk-319f.8919d1e7.js create mode 100644 priv/static/adminfe/static/js/chunk-319f.8919d1e7.js.map create mode 100644 priv/static/adminfe/static/js/chunk-31b9.7b89f7b5.js create mode 100644 priv/static/adminfe/static/js/chunk-31b9.7b89f7b5.js.map create mode 100644 priv/static/adminfe/static/js/chunk-33c9.cf3bdd1b.js create mode 100644 priv/static/adminfe/static/js/chunk-33c9.cf3bdd1b.js.map create mode 100644 priv/static/adminfe/static/js/chunk-42d5.f83ab775.js create mode 100644 priv/static/adminfe/static/js/chunk-42d5.f83ab775.js.map create mode 100644 priv/static/adminfe/static/js/chunk-45ed.10b6b7a7.js create mode 100644 priv/static/adminfe/static/js/chunk-45ed.10b6b7a7.js.map create mode 100644 priv/static/adminfe/static/js/chunk-4995.94e052fc.js create mode 100644 priv/static/adminfe/static/js/chunk-4995.94e052fc.js.map create mode 100644 priv/static/adminfe/static/js/chunk-5669.88725eaa.js create mode 100644 priv/static/adminfe/static/js/chunk-5669.88725eaa.js.map create mode 100644 priv/static/adminfe/static/js/chunk-59e6.01e205d8.js create mode 100644 priv/static/adminfe/static/js/chunk-59e6.01e205d8.js.map create mode 100644 priv/static/adminfe/static/js/chunk-5cf2.a8562f1f.js create mode 100644 priv/static/adminfe/static/js/chunk-5cf2.a8562f1f.js.map create mode 100644 priv/static/adminfe/static/js/chunk-68b6.4347fd87.js create mode 100644 priv/static/adminfe/static/js/chunk-68b6.4347fd87.js.map create mode 100644 priv/static/adminfe/static/js/chunk-7c11.b858969e.js create mode 100644 priv/static/adminfe/static/js/chunk-7c11.b858969e.js.map create mode 100644 priv/static/adminfe/static/js/chunk-elementUI.bec6fa77.js create mode 100644 priv/static/adminfe/static/js/chunk-elementUI.bec6fa77.js.map create mode 100644 priv/static/adminfe/static/js/chunk-libs.eb232bda.js create mode 100644 priv/static/adminfe/static/js/chunk-libs.eb232bda.js.map create mode 100644 priv/static/adminfe/static/js/oAJy.25bb7cc7.js create mode 100644 priv/static/adminfe/static/js/oAJy.25bb7cc7.js.map create mode 100644 priv/static/adminfe/static/js/runtime.f1a41c33.js create mode 100644 priv/static/adminfe/static/js/runtime.f1a41c33.js.map create mode 100644 priv/static/embed.css create mode 100644 priv/static/embed.js create mode 100644 priv/static/favicon.png create mode 100644 priv/static/images/avi.png create mode 100644 priv/static/images/banner.png create mode 100644 priv/static/images/logo.png create mode 100644 priv/static/images/rebased-wide.svg create mode 100644 priv/static/images/shortcuts/chats.png create mode 100644 priv/static/images/shortcuts/notifications.png create mode 100644 priv/static/images/shortcuts/search.png create mode 100644 priv/static/index.html create mode 100644 priv/static/instance/panel.html create mode 100644 priv/static/instance/static.css create mode 100644 priv/static/instance/thumbnail.png create mode 100644 priv/static/robots.txt create mode 100644 priv/static/schemas/litepub-0.1.jsonld create mode 100644 priv/static/static-fe/static-fe.css create mode 100644 priv/static/static/css/7586.0d43f70bc6240422f179.css create mode 100644 priv/static/static/css/7586.0d43f70bc6240422f179.css.map create mode 100644 priv/static/static/css/7962.76663e78ad5ea0bb0b90.css create mode 100644 priv/static/static/css/7962.76663e78ad5ea0bb0b90.css.map create mode 100644 priv/static/static/css/9801.cfe503d4c949ae0c3813.css create mode 100644 priv/static/static/css/9801.cfe503d4c949ae0c3813.css.map create mode 100644 priv/static/static/css/app.c18a2c80794a1b699a61.css create mode 100644 priv/static/static/css/app.c18a2c80794a1b699a61.css.map create mode 100644 priv/static/static/js/159.903e90c9de8ef6c67077.js create mode 100644 priv/static/static/js/159.903e90c9de8ef6c67077.js.map create mode 100644 priv/static/static/js/3733.7060d1e6bca813125a0c.js create mode 100644 priv/static/static/js/3733.7060d1e6bca813125a0c.js.LICENSE.txt create mode 100644 priv/static/static/js/3733.7060d1e6bca813125a0c.js.map create mode 100644 priv/static/static/js/48.b5ecdbc517423af07ca4.js create mode 100644 priv/static/static/js/48.b5ecdbc517423af07ca4.js.LICENSE.txt create mode 100644 priv/static/static/js/48.b5ecdbc517423af07ca4.js.map create mode 100644 priv/static/static/js/6464.eb9c90a1c948cde554e9.js create mode 100644 priv/static/static/js/6464.eb9c90a1c948cde554e9.js.map create mode 100644 priv/static/static/js/7586.981b2305a0019f6042a5.js create mode 100644 priv/static/static/js/7586.981b2305a0019f6042a5.js.map create mode 100644 priv/static/static/js/7962.e25d40b042f8ee7389c3.js create mode 100644 priv/static/static/js/7962.e25d40b042f8ee7389c3.js.map create mode 100644 priv/static/static/js/9060.24271e167e0471a1a732.js create mode 100644 priv/static/static/js/9060.24271e167e0471a1a732.js.map create mode 100644 priv/static/static/js/9801.99ace6b5dc657bf1a65b.js create mode 100644 priv/static/static/js/9801.99ace6b5dc657bf1a65b.js.map create mode 100644 priv/static/static/js/app.7c4b412b26221a7c8572.js create mode 100644 priv/static/static/js/app.7c4b412b26221a7c8572.js.map create mode 100644 priv/static/static/js/i18n/ar-json.4916f840147303aa65fe.js create mode 100644 priv/static/static/js/i18n/ar-json.4916f840147303aa65fe.js.map create mode 100644 priv/static/static/js/i18n/eo-json.6c62eef99e850912498b.js create mode 100644 priv/static/static/js/i18n/eo-json.6c62eef99e850912498b.js.map create mode 100644 priv/static/static/js/i18n/id-json.e5c9ee768155f88128b9.js create mode 100644 priv/static/static/js/i18n/id-json.e5c9ee768155f88128b9.js.map create mode 100644 priv/static/static/js/i18n/ko-json.9029d09084bb22d8b705.js create mode 100644 priv/static/static/js/i18n/ko-json.9029d09084bb22d8b705.js.map create mode 100644 priv/static/static/js/i18n/nan-TW-json.7f2789d8a461e86d1734.js create mode 100644 priv/static/static/js/i18n/nan-TW-json.7f2789d8a461e86d1734.js.map create mode 100644 priv/static/static/js/i18n/zh-json.5831b903c3e6d281f122.js create mode 100644 priv/static/static/js/i18n/zh-json.5831b903c3e6d281f122.js.map create mode 100644 priv/static/static/js/i18n/zh_Hant-json.f7e1d0f4b873c60d6396.js create mode 100644 priv/static/static/js/i18n/zh_Hant-json.f7e1d0f4b873c60d6396.js.map create mode 100644 priv/static/static/terms-of-service.html create mode 100644 priv/static/sw-pleroma.js create mode 100644 priv/static/sw-pleroma.js.map create mode 100644 priv/templates/robots_txt.eex create mode 100644 priv/templates/sample_config.eex create mode 100644 priv/templates/sample_psql.eex create mode 100644 rebased.png create mode 100644 rel/env.sh.eex create mode 100755 rel/files/bin/pleroma_ctl create mode 100755 rel/files/installation/init.d/pleroma create mode 100644 rel/files/installation/pleroma.service create mode 100644 rel/vm.args.eex create mode 100644 restarter/lib/pleroma.ex create mode 100644 restarter/lib/restarter.ex create mode 100644 restarter/mix.exs create mode 100644 test/config/emoji.txt create mode 100644 test/credo/check/consistency/file_location.ex create mode 100644 test/fixtures/DSCN0010.jpg create mode 100644 test/fixtures/activitypub-client-post-activity.json create mode 100644 test/fixtures/avatar_data_uri create mode 100644 test/fixtures/birthdays/misskey-user.json create mode 100644 test/fixtures/bogus-mastodon-announce.json create mode 100644 test/fixtures/bridgy/actor.json create mode 100644 test/fixtures/config/temp.exported_from_db.secret.exs create mode 100644 test/fixtures/config/temp.secret.exs create mode 100644 test/fixtures/create-chat-message.json create mode 100644 test/fixtures/create-pleroma-reply-to-misskey-thread.json create mode 100644 test/fixtures/custom-emoji-reaction.json create mode 100644 test/fixtures/custom_instance_panel.html create mode 100644 test/fixtures/emoji-reaction-no-emoji.json create mode 100644 test/fixtures/emoji-reaction-too-long.json create mode 100644 test/fixtures/emoji-reaction.json create mode 100644 test/fixtures/emoji/packs/blank.png.zip create mode 100644 test/fixtures/emoji/packs/default-manifest.json create mode 100644 test/fixtures/emoji/packs/finmoji.json create mode 100644 test/fixtures/emoji/packs/manifest.json create mode 100644 test/fixtures/emojis.zip create mode 100644 test/fixtures/empty.zip create mode 100644 test/fixtures/fep-e232.json create mode 100644 test/fixtures/fetch_mocks/104410921027210069.json create mode 100644 test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json create mode 100644 test/fixtures/fetch_mocks/eal.json create mode 100644 test/fixtures/fetch_mocks/tuxcrafting.json create mode 100644 test/fixtures/friendica_salmon.xml create mode 100644 test/fixtures/guppe-actor.json create mode 100644 test/fixtures/host-meta-zetsubou.xn--q9jyb4c.xml create mode 100644 test/fixtures/hubzilla-actor.json create mode 100644 test/fixtures/hubzilla-create-image.json create mode 100644 test/fixtures/hubzilla-follow-activity.json create mode 100755 test/fixtures/image.gif create mode 100644 test/fixtures/image.jpg create mode 100755 test/fixtures/image.png create mode 100644 test/fixtures/image_with_caption-abstract.jpg create mode 100644 test/fixtures/image_with_imagedescription_and_caption-abstract.jpg create mode 100644 test/fixtures/image_with_imagedescription_and_caption-abstract_and_stray_data_after.png create mode 100644 test/fixtures/image_with_imagedescription_and_caption-abstract_whitespaces.jpg create mode 100644 test/fixtures/image_with_no_description.jpg create mode 100755 test/fixtures/image_with_stray_data_after.png create mode 100644 test/fixtures/kroeg-announce-with-inline-actor.json create mode 100644 test/fixtures/kroeg-array-less-emoji.json create mode 100644 test/fixtures/kroeg-array-less-hashtag.json create mode 100644 test/fixtures/kroeg-post-activity.json create mode 100644 test/fixtures/lain.xml create mode 100644 test/fixtures/lambadalambda.json create mode 100644 test/fixtures/margaret-corbin-grave-west-point.html create mode 100644 test/fixtures/mastodon-accept-activity.json create mode 100644 test/fixtures/mastodon-announce-private.json create mode 100644 test/fixtures/mastodon-announce.json create mode 100644 test/fixtures/mastodon-block-activity.json create mode 100644 test/fixtures/mastodon-create-with-attachment.json create mode 100644 test/fixtures/mastodon-delete-user.json create mode 100644 test/fixtures/mastodon-delete.json create mode 100644 test/fixtures/mastodon-follow-activity.json create mode 100644 test/fixtures/mastodon-like.json create mode 100644 test/fixtures/mastodon-nodeinfo20.json create mode 100644 test/fixtures/mastodon-note-object.json create mode 100644 test/fixtures/mastodon-post-activity-contentmap.json create mode 100644 test/fixtures/mastodon-post-activity-hashtag.json create mode 100644 test/fixtures/mastodon-post-activity-nsfw.json create mode 100644 test/fixtures/mastodon-post-activity.json create mode 100644 test/fixtures/mastodon-question-activity.json create mode 100644 test/fixtures/mastodon-reject-activity.json create mode 100644 test/fixtures/mastodon-unblock-activity.json create mode 100644 test/fixtures/mastodon-undo-announce.json create mode 100644 test/fixtures/mastodon-undo-like-compact-object.json create mode 100644 test/fixtures/mastodon-undo-like.json create mode 100644 test/fixtures/mastodon-unfollow-activity.json create mode 100644 test/fixtures/mastodon-update.json create mode 100644 test/fixtures/mastodon-vote.json create mode 100644 test/fixtures/mastodon-well-known-nodeinfo.json create mode 100644 test/fixtures/mastodon/application_actor.json create mode 100644 test/fixtures/mastodon/collections/external_featured.json create mode 100644 test/fixtures/mastodon/collections/featured.json create mode 100644 test/fixtures/mewmew_no_name.json create mode 100644 test/fixtures/misskey-like.json create mode 100644 test/fixtures/modules/good_mrf.ex create mode 100644 test/fixtures/modules/runtime_module.ex create mode 100644 test/fixtures/nypd-facial-recognition-children-teenagers.html create mode 100644 test/fixtures/nypd-facial-recognition-children-teenagers2.html create mode 100644 test/fixtures/nypd-facial-recognition-children-teenagers3.html create mode 100644 test/fixtures/nypd-facial-recognition-children-teenagers4.html create mode 100644 test/fixtures/osada-follow-activity.json create mode 100644 test/fixtures/owncast-note-with-attachment.json create mode 100644 test/fixtures/peertube/actor-person.json create mode 100644 test/fixtures/peertube/video-object-mpegURL-only.json create mode 100644 test/fixtures/preload_static/instance/panel.html create mode 100644 test/fixtures/prismo-url-map.json create mode 100644 test/fixtures/private_key.pem create mode 100644 test/fixtures/quote_post/fedibird_quote_mismatched.json create mode 100644 test/fixtures/quote_post/fedibird_quote_post.json create mode 100644 test/fixtures/quote_post/fedibird_quote_uri.json create mode 100644 test/fixtures/quote_post/fep-e232-tag-example.json create mode 100644 test/fixtures/quote_post/misskey_quote_post.json create mode 100644 test/fixtures/rel_me_anchor.html create mode 100644 test/fixtures/rel_me_anchor_nofollow.html create mode 100644 test/fixtures/rel_me_link.html create mode 100644 test/fixtures/rel_me_null.html create mode 100644 test/fixtures/relay/accept-follow.json create mode 100644 test/fixtures/relay/relay.json create mode 100644 test/fixtures/rich_media/amz.html create mode 100644 test/fixtures/rich_media/malformed-data.html create mode 100644 test/fixtures/rich_media/non_ogp_embed.html create mode 100644 test/fixtures/rich_media/oembed.html create mode 100644 test/fixtures/rich_media/oembed.json create mode 100644 test/fixtures/rich_media/ogp-missing-data.html create mode 100644 test/fixtures/rich_media/ogp-missing-title.html create mode 100644 test/fixtures/rich_media/ogp.html create mode 100644 test/fixtures/rich_media/twitter_card.html create mode 100644 test/fixtures/rich_media/wordpress.html create mode 100644 test/fixtures/rich_media/wordpress_embed.json create mode 100644 test/fixtures/roadhouse-create-activity.json create mode 100644 test/fixtures/rsa_keys/key_1.pem create mode 100644 test/fixtures/rsa_keys/key_2.pem create mode 100644 test/fixtures/rsa_keys/key_3.pem create mode 100644 test/fixtures/rsa_keys/key_4.pem create mode 100644 test/fixtures/rsa_keys/key_5.pem create mode 100644 test/fixtures/salmon.xml create mode 100644 test/fixtures/salmon2.xml create mode 100644 test/fixtures/snow.js create mode 100644 test/fixtures/soapbox_no_mentions_in_content.json create mode 100644 test/fixtures/sound.mp3 create mode 100644 test/fixtures/spoofed-object.json create mode 100644 test/fixtures/statuses/masto-note.json create mode 100644 test/fixtures/statuses/note.json create mode 100644 test/fixtures/tesla_mock/7369654.html create mode 100644 test/fixtures/tesla_mock/7even.json create mode 100644 test/fixtures/tesla_mock/admin@mastdon.example.org.json create mode 100644 test/fixtures/tesla_mock/aimu@misskey.io.json create mode 100644 test/fixtures/tesla_mock/atarifrosch_webfinger.xml create mode 100644 test/fixtures/tesla_mock/baptiste.gelex.xyz-article.json create mode 100644 test/fixtures/tesla_mock/baptiste.gelex.xyz-user.json create mode 100644 test/fixtures/tesla_mock/craigmaloney.json create mode 100644 test/fixtures/tesla_mock/deepl-languages-list.json create mode 100644 test/fixtures/tesla_mock/deepl-translation.json create mode 100644 test/fixtures/tesla_mock/dist/test.txt create mode 100644 test/fixtures/tesla_mock/eal_sakamoto.xml create mode 100644 test/fixtures/tesla_mock/emelie.json create mode 100644 test/fixtures/tesla_mock/emoji-in-summary.json create mode 100644 test/fixtures/tesla_mock/framasoft@framatube.org.json create mode 100644 test/fixtures/tesla_mock/framatube.org-video.json create mode 100644 test/fixtures/tesla_mock/framatube.org_host_meta create mode 100644 test/fixtures/tesla_mock/frontend.zip create mode 100644 test/fixtures/tesla_mock/frontend_dist.zip create mode 100644 test/fixtures/tesla_mock/funkwhale_audio.json create mode 100644 test/fixtures/tesla_mock/funkwhale_channel.json create mode 100644 test/fixtures/tesla_mock/funkwhale_create_audio.json create mode 100644 test/fixtures/tesla_mock/gerzilla.de_host_meta create mode 100644 test/fixtures/tesla_mock/gleasonator-AG3RzWfwEKKrY63qj2.json create mode 100644 test/fixtures/tesla_mock/gleasonator.com_host_meta create mode 100644 test/fixtures/tesla_mock/gnusocial.de_host_meta create mode 100644 test/fixtures/tesla_mock/gs.example.org_host_meta create mode 100644 test/fixtures/tesla_mock/helene@p.helene.moe.json create mode 100644 test/fixtures/tesla_mock/hellpie.json create mode 100644 test/fixtures/tesla_mock/http___gs.example.org_4040_index.php_user_1.xml create mode 100644 test/fixtures/tesla_mock/http___mastodon.example.org_users_admin_status_1234.json create mode 100644 test/fixtures/tesla_mock/https___framatube.org_accounts_framasoft.json create mode 100644 test/fixtures/tesla_mock/https___info.pleroma.site_actor.json create mode 100644 test/fixtures/tesla_mock/https___lm.kazv.moe_users_mewmew.xml create mode 100644 test/fixtures/tesla_mock/https___mastodon.social_users_lambadalambda.xml create mode 100644 test/fixtures/tesla_mock/https___osada.macgirvin.com.html create mode 100644 test/fixtures/tesla_mock/https___osada.macgirvin.com_channel_mike.json create mode 100644 test/fixtures/tesla_mock/https___pawoo.net_users_aqidaqidaqid.xml create mode 100644 test/fixtures/tesla_mock/https___pawoo.net_users_pekorino.xml create mode 100644 test/fixtures/tesla_mock/https___pleroma.soykaf.com_users_lain.xml create mode 100644 test/fixtures/tesla_mock/https___prismo.news__mxb.json create mode 100644 test/fixtures/tesla_mock/https___shitposter.club_notice_2827873.html create mode 100644 test/fixtures/tesla_mock/https___shitposter.club_user_1.xml create mode 100644 test/fixtures/tesla_mock/https___social.heldscal.la_user_23211.xml create mode 100644 test/fixtures/tesla_mock/https___social.heldscal.la_user_29191.xml create mode 100644 test/fixtures/tesla_mock/https__info.pleroma.site_activity.json create mode 100644 test/fixtures/tesla_mock/https__info.pleroma.site_activity2.json create mode 100644 test/fixtures/tesla_mock/https__info.pleroma.site_activity3.json create mode 100644 test/fixtures/tesla_mock/https__info.pleroma.site_activity4.json create mode 100644 test/fixtures/tesla_mock/kaniini@gerzilla.de.json create mode 100644 test/fixtures/tesla_mock/kaniini@hubzilla.example.org.json create mode 100644 test/fixtures/tesla_mock/kpherox@mstdn.jp.xml create mode 100644 test/fixtures/tesla_mock/lain_squeet.me_webfinger.xml create mode 100644 test/fixtures/tesla_mock/lemmy-page.json create mode 100644 test/fixtures/tesla_mock/lemmy-user.json create mode 100644 test/fixtures/tesla_mock/lm.kazv.moe_host_meta create mode 100644 test/fixtures/tesla_mock/lucifermysticus.json create mode 100644 test/fixtures/tesla_mock/macgirvin.com_host_meta create mode 100644 test/fixtures/tesla_mock/macgirvin@gleasonator.com.json create mode 100644 test/fixtures/tesla_mock/mametsuko@mk.absturztau.be.json create mode 100644 test/fixtures/tesla_mock/mamot.fr_host_meta create mode 100644 test/fixtures/tesla_mock/mastodon.social_host_meta create mode 100644 test/fixtures/tesla_mock/mastodon.xyz_host_meta create mode 100644 test/fixtures/tesla_mock/mayumayu.json create mode 100644 test/fixtures/tesla_mock/mayumayupost.json create mode 100644 test/fixtures/tesla_mock/mewmew@lm.kazv.moe.json create mode 100644 test/fixtures/tesla_mock/mike@osada.macgirvin.com.json create mode 100644 test/fixtures/tesla_mock/misskey.io_8vs6wxufd0.json create mode 100644 test/fixtures/tesla_mock/misskey_poll_no_end_date.json create mode 100644 test/fixtures/tesla_mock/mitra.social_01830912-1357-d4c5-e4a2-76eab347e749.json create mode 100644 test/fixtures/tesla_mock/mk.absturztau.be-93e7nm8wqg-activity.json create mode 100644 test/fixtures/tesla_mock/mk.absturztau.be-93e7nm8wqg.json create mode 100644 test/fixtures/tesla_mock/mobilizon-event-join-accept.json create mode 100644 test/fixtures/tesla_mock/mobilizon-event-join.json create mode 100644 test/fixtures/tesla_mock/mobilizon.org-event.json create mode 100644 test/fixtures/tesla_mock/mobilizon.org-user.json create mode 100644 test/fixtures/tesla_mock/moonman@shitposter.club.json create mode 100644 test/fixtures/tesla_mock/mstdn.jp_host_meta create mode 100644 test/fixtures/tesla_mock/nominatim_search_results.json create mode 100644 test/fixtures/tesla_mock/nominatim_single_result.json create mode 100644 test/fixtures/tesla_mock/nonexistant@social.heldscal.la.xml create mode 100644 test/fixtures/tesla_mock/osada-user-indio.json create mode 100644 test/fixtures/tesla_mock/p.helene.moe-AM7S6vZQmL6pI9TgPY.json create mode 100644 test/fixtures/tesla_mock/pawoo.net_host_meta create mode 100644 test/fixtures/tesla_mock/peertube-social.json create mode 100644 test/fixtures/tesla_mock/peertube.moe-vid.json create mode 100644 test/fixtures/tesla_mock/pekorino@pawoo.net_host_meta.json create mode 100644 test/fixtures/tesla_mock/pleroma.soykaf.com_host_meta create mode 100644 test/fixtures/tesla_mock/poll_attachment.json create mode 100644 test/fixtures/tesla_mock/poll_modified.json create mode 100644 test/fixtures/tesla_mock/poll_original.json create mode 100644 test/fixtures/tesla_mock/puckipedia.com.json create mode 100644 test/fixtures/tesla_mock/relay@mastdon.example.org.json create mode 100644 test/fixtures/tesla_mock/rin.json create mode 100644 test/fixtures/tesla_mock/rinpatch.json create mode 100644 test/fixtures/tesla_mock/rye.json create mode 100644 test/fixtures/tesla_mock/sdf.org_host_meta create mode 100644 test/fixtures/tesla_mock/shitposter.club_host_meta create mode 100644 test/fixtures/tesla_mock/shp@pleroma.soykaf.com.webfigner create mode 100644 test/fixtures/tesla_mock/shp@social.heldscal.la.xml create mode 100644 test/fixtures/tesla_mock/sjw.json create mode 100644 test/fixtures/tesla_mock/skruyb@mamot.fr.atom create mode 100644 test/fixtures/tesla_mock/smithereen_non_anonymous_poll.json create mode 100644 test/fixtures/tesla_mock/smithereen_user.json create mode 100644 test/fixtures/tesla_mock/snowdusk@sdf.org_host_meta.json create mode 100644 test/fixtures/tesla_mock/social.heldscal.la_host_meta create mode 100644 test/fixtures/tesla_mock/social.sakamoto.gq_host_meta create mode 100644 test/fixtures/tesla_mock/social.stopwatchingus-heidelberg.de_host_meta create mode 100644 test/fixtures/tesla_mock/social.wxcafe.net_host_meta create mode 100644 test/fixtures/tesla_mock/soykaf.com_host_meta create mode 100644 test/fixtures/tesla_mock/spc_5381_xrd.xml create mode 100644 test/fixtures/tesla_mock/squeet.me_host_meta create mode 100644 test/fixtures/tesla_mock/status.alpicola.com_host_meta create mode 100644 test/fixtures/tesla_mock/status.emelie.json create mode 100644 test/fixtures/tesla_mock/stopwatchingus-heidelberg.de_host_meta create mode 100644 test/fixtures/tesla_mock/webfinger_emelie.json create mode 100644 test/fixtures/tesla_mock/webfinger_spoof.json create mode 100644 test/fixtures/tesla_mock/wedistribute-article.json create mode 100644 test/fixtures/tesla_mock/wedistribute-create-article.json create mode 100644 test/fixtures/tesla_mock/wedistribute-user.json create mode 100644 test/fixtures/tesla_mock/winterdienst_webfinger.json create mode 100644 test/fixtures/test.txt create mode 100644 test/fixtures/user_full.xml create mode 100644 test/fixtures/user_name_only.xml create mode 100644 test/fixtures/users_mock/friendica_followers.json create mode 100644 test/fixtures/users_mock/friendica_following.json create mode 100644 test/fixtures/users_mock/masto_closed_followers.json create mode 100644 test/fixtures/users_mock/masto_closed_followers_page.json create mode 100644 test/fixtures/users_mock/masto_closed_following.json create mode 100644 test/fixtures/users_mock/masto_closed_following_page.json create mode 100644 test/fixtures/users_mock/masto_featured.json create mode 100644 test/fixtures/users_mock/pleroma_followers.json create mode 100644 test/fixtures/users_mock/pleroma_following.json create mode 100644 test/fixtures/users_mock/user.json create mode 100644 test/fixtures/video.mp4 create mode 100644 test/fixtures/warnings/otp_version/21.1 create mode 100644 test/fixtures/warnings/otp_version/22.1 create mode 100644 test/fixtures/warnings/otp_version/22.4 create mode 100644 test/fixtures/warnings/otp_version/23.0 create mode 100644 test/fixtures/webfinger.xml create mode 100644 test/fixtures/webfinger/masto-host-meta.xml create mode 100644 test/fixtures/webfinger/masto-user.json create mode 100644 test/fixtures/webfinger/masto-webfinger.json create mode 100644 test/fixtures/webfinger/pleroma-host-meta.xml create mode 100644 test/fixtures/webfinger/pleroma-user.json create mode 100644 test/fixtures/webfinger/pleroma-webfinger.json create mode 100644 test/fixtures/wildebeest-nodeinfo21.json create mode 100644 test/fixtures/wildebeest-well-known-nodeinfo.json create mode 100644 test/fixtures/xml_billion_laughs.xml create mode 100644 test/fixtures/xml_external_entities.xml create mode 100644 test/instance_static/add/shortcode.png create mode 100644 test/instance_static/emoji/blobs.gg/blank.png create mode 100644 test/instance_static/emoji/blobs.gg/pack.json create mode 100644 test/instance_static/emoji/pack_bad_sha/blank.png create mode 100644 test/instance_static/emoji/pack_bad_sha/pack.json create mode 100644 test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip create mode 100644 test/instance_static/emoji/test_pack/blank.png create mode 100644 test/instance_static/emoji/test_pack/blank2.png create mode 100644 test/instance_static/emoji/test_pack/pack.json create mode 100644 test/instance_static/emoji/test_pack_for_import/blank.png create mode 100644 test/instance_static/emoji/test_pack_nonshared/nonshared.zip create mode 100644 test/instance_static/emoji/test_pack_nonshared/pack.json create mode 100644 test/instance_static/local_pack/files.json create mode 100644 test/instance_static/local_pack/manifest.json create mode 100644 test/mix/pleroma_test.exs create mode 100644 test/mix/tasks/pleroma/app_test.exs create mode 100644 test/mix/tasks/pleroma/config_test.exs create mode 100644 test/mix/tasks/pleroma/count_statuses_test.exs create mode 100644 test/mix/tasks/pleroma/database_test.exs create mode 100644 test/mix/tasks/pleroma/digest_test.exs create mode 100644 test/mix/tasks/pleroma/ecto/migrate_test.exs create mode 100644 test/mix/tasks/pleroma/ecto/rollback_test.exs create mode 100644 test/mix/tasks/pleroma/ecto_test.exs create mode 100644 test/mix/tasks/pleroma/email_test.exs create mode 100644 test/mix/tasks/pleroma/emoji_test.exs create mode 100644 test/mix/tasks/pleroma/frontend_test.exs create mode 100644 test/mix/tasks/pleroma/instance_test.exs create mode 100644 test/mix/tasks/pleroma/openapi_spec_test.exs create mode 100644 test/mix/tasks/pleroma/refresh_counter_cache_test.exs create mode 100644 test/mix/tasks/pleroma/relay_test.exs create mode 100644 test/mix/tasks/pleroma/robots_txt_test.exs create mode 100644 test/mix/tasks/pleroma/uploads_test.exs create mode 100644 test/mix/tasks/pleroma/user_test.exs create mode 100644 test/pleroma/activity/ir/topics_test.exs create mode 100644 test/pleroma/activity_test.exs create mode 100644 test/pleroma/announcement_read_relationship_test.exs create mode 100644 test/pleroma/announcement_test.exs create mode 100644 test/pleroma/application_requirements_test.exs create mode 100644 test/pleroma/bookmark_test.exs create mode 100644 test/pleroma/captcha_test.exs create mode 100644 test/pleroma/chat/message_reference_test.exs create mode 100644 test/pleroma/chat_test.exs create mode 100644 test/pleroma/config/deprecation_warnings_test.exs create mode 100644 test/pleroma/config/holder_test.exs create mode 100644 test/pleroma/config/loader_test.exs create mode 100644 test/pleroma/config/release_runtime_provider_test.exs create mode 100644 test/pleroma/config/transfer_task_test.exs create mode 100644 test/pleroma/config_db_test.exs create mode 100644 test/pleroma/config_test.exs create mode 100644 test/pleroma/conversation/participation_test.exs create mode 100644 test/pleroma/conversation_test.exs create mode 100644 test/pleroma/docs/generator_test.exs create mode 100644 test/pleroma/docs/translator/compiler_test.exs create mode 100644 test/pleroma/ecto_type/activity_pub/object_validators/bare_uri_test.exs create mode 100644 test/pleroma/ecto_type/activity_pub/object_validators/date_time_test.exs create mode 100644 test/pleroma/ecto_type/activity_pub/object_validators/object_id_test.exs create mode 100644 test/pleroma/ecto_type/activity_pub/object_validators/recipients_test.exs create mode 100644 test/pleroma/ecto_type/activity_pub/object_validators/safe_text_test.exs create mode 100644 test/pleroma/emails/admin_email_test.exs create mode 100644 test/pleroma/emails/mailer_test.exs create mode 100644 test/pleroma/emails/user_email_test.exs create mode 100644 test/pleroma/emoji/formatter_test.exs create mode 100644 test/pleroma/emoji/loader_test.exs create mode 100644 test/pleroma/emoji/pack_test.exs create mode 100644 test/pleroma/emoji_test.exs create mode 100644 test/pleroma/filter_test.exs create mode 100644 test/pleroma/following_relationship_test.exs create mode 100644 test/pleroma/formatter_test.exs create mode 100644 test/pleroma/frontend_test.exs create mode 100644 test/pleroma/gun/connection_pool_test.exs create mode 100644 test/pleroma/hashtag_test.exs create mode 100644 test/pleroma/healthcheck_test.exs create mode 100644 test/pleroma/html_test.exs create mode 100644 test/pleroma/http/adapter_helper/gun_test.exs create mode 100644 test/pleroma/http/adapter_helper/hackney_test.exs create mode 100644 test/pleroma/http/adapter_helper_test.exs create mode 100644 test/pleroma/http/ex_aws_test.exs create mode 100644 test/pleroma/http/request_builder_test.exs create mode 100644 test/pleroma/http/tzdata_test.exs create mode 100644 test/pleroma/http_test.exs create mode 100644 test/pleroma/instances/instance_test.exs create mode 100644 test/pleroma/instances_test.exs create mode 100644 test/pleroma/integration/federation_test.exs create mode 100644 test/pleroma/integration/mastodon_websocket_test.exs create mode 100644 test/pleroma/job_queue_monitor_test.exs create mode 100644 test/pleroma/keys_test.exs create mode 100644 test/pleroma/language/language_detector_test.exs create mode 100644 test/pleroma/language/translation/deepl_test.exs create mode 100644 test/pleroma/language/translation/translate_locally_test.exs create mode 100644 test/pleroma/language/translation_test.exs create mode 100644 test/pleroma/list_test.exs create mode 100644 test/pleroma/marker_test.exs create mode 100644 test/pleroma/mfa/backup_codes_test.exs create mode 100644 test/pleroma/mfa/totp_test.exs create mode 100644 test/pleroma/mfa_test.exs create mode 100644 test/pleroma/migration_helper/notification_backfill_test.exs create mode 100644 test/pleroma/moderation_log_test.exs create mode 100644 test/pleroma/notification_test.exs create mode 100644 test/pleroma/object/containment_test.exs create mode 100644 test/pleroma/object/fetcher_test.exs create mode 100644 test/pleroma/object/updater_test.exs create mode 100644 test/pleroma/object_test.exs create mode 100644 test/pleroma/otp_version_test.exs create mode 100644 test/pleroma/pagination_test.exs create mode 100644 test/pleroma/password/pbkdf2_test.exs create mode 100644 test/pleroma/registration_test.exs create mode 100644 test/pleroma/repo/migrations/autolinker_to_linkify_test.exs create mode 100644 test/pleroma/repo/migrations/confirm_logged_in_users_test.exs create mode 100644 test/pleroma/repo/migrations/deprecate_public_endpoint_test.exs create mode 100644 test/pleroma/repo/migrations/fix_legacy_tags_test.exs create mode 100644 test/pleroma/repo/migrations/fix_malformed_formatter_config_test.exs create mode 100644 test/pleroma/repo/migrations/move_welcome_settings_test.exs create mode 100644 test/pleroma/repo_test.exs create mode 100644 test/pleroma/report_note_test.exs create mode 100644 test/pleroma/resilience_test.exs create mode 100644 test/pleroma/reverse_proxy_test.exs create mode 100644 test/pleroma/rule_test.exs create mode 100644 test/pleroma/runtime_test.exs create mode 100644 test/pleroma/safe_jsonb_set_test.exs create mode 100644 test/pleroma/scheduled_activity_test.exs create mode 100644 test/pleroma/search/database_search_test.exs create mode 100644 test/pleroma/search/meilisearch_test.exs create mode 100644 test/pleroma/signature_test.exs create mode 100644 test/pleroma/stats_test.exs create mode 100644 test/pleroma/upload/filter/analyze_metadata_test.exs create mode 100644 test/pleroma/upload/filter/anonymize_filename_test.exs create mode 100644 test/pleroma/upload/filter/dedupe_test.exs create mode 100644 test/pleroma/upload/filter/exiftool/read_description_test.exs create mode 100644 test/pleroma/upload/filter/exiftool/strip_location_test.exs create mode 100644 test/pleroma/upload/filter/mogrifun_test.exs create mode 100644 test/pleroma/upload/filter/mogrify_test.exs create mode 100644 test/pleroma/upload/filter/only_media_test.exs create mode 100644 test/pleroma/upload/filter_test.exs create mode 100644 test/pleroma/upload_test.exs create mode 100644 test/pleroma/uploaders/local_test.exs create mode 100644 test/pleroma/uploaders/s3_test.exs create mode 100644 test/pleroma/user/backup_test.exs create mode 100644 test/pleroma/user/email_list_test.exs create mode 100644 test/pleroma/user/import_test.exs create mode 100644 test/pleroma/user/notification_setting_test.exs create mode 100644 test/pleroma/user/query_test.exs create mode 100644 test/pleroma/user/welcome_chat_message_test.exs create mode 100644 test/pleroma/user/welcome_email_test.exs create mode 100644 test/pleroma/user/welcome_message_test.exs create mode 100644 test/pleroma/user_invite_token_test.exs create mode 100644 test/pleroma/user_relationship_test.exs create mode 100644 test/pleroma/user_search_test.exs create mode 100644 test/pleroma/user_test.exs create mode 100644 test/pleroma/utils_test.exs create mode 100644 test/pleroma/web/activity_pub/activity_pub_controller_test.exs create mode 100644 test/pleroma/web/activity_pub/activity_pub_test.exs create mode 100644 test/pleroma/web/activity_pub/builder_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/activity_expiration_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/anti_duplication_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/anti_followbot_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/anti_link_spam_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/anti_mention_spam_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/block_notification_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/emoji_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/ensure_re_prepended_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/force_mentions_in_content_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/hellthread_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/keyword_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/mention_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/no_empty_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/no_placeholder_text_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/normalize_markup_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/nsfw_api_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/object_age_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/reject_non_public_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/simple_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/steal_emoji_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/subchain_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/tag_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/user_allow_list_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/utils_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf/vocabulary_policy_test.exs create mode 100644 test/pleroma/web/activity_pub/mrf_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/accept_validation_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/announce_validation_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/block_validation_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/chat_validation_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/create_generic_validator_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/delete_validation_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/emoji_react_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/follow_validation_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/like_validation_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/reject_validation_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/undo_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/object_validators/update_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/pipeline_test.exs create mode 100644 test/pleroma/web/activity_pub/publisher_test.exs create mode 100644 test/pleroma/web/activity_pub/relay_test.exs create mode 100644 test/pleroma/web/activity_pub/side_effects/delete_test.exs create mode 100644 test/pleroma/web/activity_pub/side_effects_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/accept_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/add_remove_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/announce_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/answer_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/article_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/audio_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/block_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/chat_message_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/delete_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/emoji_react_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/event_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/follow_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/image_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/join_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/like_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/page_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/question_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/reject_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/undo_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/user_update_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier/video_handling_test.exs create mode 100644 test/pleroma/web/activity_pub/transmogrifier_test.exs create mode 100644 test/pleroma/web/activity_pub/utils_test.exs create mode 100644 test/pleroma/web/activity_pub/views/object_view_test.exs create mode 100644 test/pleroma/web/activity_pub/views/user_view_test.exs create mode 100644 test/pleroma/web/activity_pub/visibility_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/admin_api_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/announcement_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/chat_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/config_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/email_list_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/frontend_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/instance_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/instance_document_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/invite_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/media_proxy_cache_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/o_auth_app_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/relay_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/report_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/rule_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/status_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/user_controller_test.exs create mode 100644 test/pleroma/web/admin_api/controllers/webhook_controller_test.exs create mode 100644 test/pleroma/web/admin_api/search_test.exs create mode 100644 test/pleroma/web/admin_api/views/account_view_test.exs create mode 100644 test/pleroma/web/admin_api/views/moderation_log_view_test.exs create mode 100644 test/pleroma/web/admin_api/views/report_view_test.exs create mode 100644 test/pleroma/web/api_spec/schema_examples_test.exs create mode 100644 test/pleroma/web/api_spec/scopes/compiler_test.exs create mode 100644 test/pleroma/web/auth/auth_controller_test.exs create mode 100644 test/pleroma/web/auth/authenticator_test.exs create mode 100644 test/pleroma/web/auth/basic_auth_test.exs create mode 100644 test/pleroma/web/auth/pleroma_authenticator_test.exs create mode 100644 test/pleroma/web/auth/totp_authenticator_test.exs create mode 100644 test/pleroma/web/common_api/activity_draft_test.exs create mode 100644 test/pleroma/web/common_api/utils_test.exs create mode 100644 test/pleroma/web/common_api_test.exs create mode 100644 test/pleroma/web/endpoint/metrics_exporter_test.exs create mode 100644 test/pleroma/web/fallback_test.exs create mode 100644 test/pleroma/web/federator_test.exs create mode 100644 test/pleroma/web/feed/tag_controller_test.exs create mode 100644 test/pleroma/web/feed/user_controller_test.exs create mode 100644 test/pleroma/web/gettext_test.exs create mode 100644 test/pleroma/web/manifest_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/admin/controllers/account_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/admin/controllers/report_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/account_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/announcement_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/app_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/conversation_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/custom_emoji_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/directory_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/domain_block_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/follow_request_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/list_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/marker_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/media_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/report_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/scheduled_activity_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/search_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/status_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/subscription_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/suggestion_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/mastodon_api_controller_test.exs create mode 100644 test/pleroma/web/mastodon_api/mastodon_api_test.exs create mode 100644 test/pleroma/web/mastodon_api/update_credentials_test.exs create mode 100644 test/pleroma/web/mastodon_api/views/account_view_test.exs create mode 100644 test/pleroma/web/mastodon_api/views/conversation_view_test.exs create mode 100644 test/pleroma/web/mastodon_api/views/list_view_test.exs create mode 100644 test/pleroma/web/mastodon_api/views/marker_view_test.exs create mode 100644 test/pleroma/web/mastodon_api/views/notification_view_test.exs create mode 100644 test/pleroma/web/mastodon_api/views/poll_view_test.exs create mode 100644 test/pleroma/web/mastodon_api/views/scheduled_activity_view_test.exs create mode 100644 test/pleroma/web/mastodon_api/views/status_view_test.exs create mode 100644 test/pleroma/web/mastodon_api/views/subscription_view_test.exs create mode 100644 test/pleroma/web/mastodon_api/views/suggestion_view_test.exs create mode 100644 test/pleroma/web/media_proxy/invalidation/http_test.exs create mode 100644 test/pleroma/web/media_proxy/invalidation/script_test.exs create mode 100644 test/pleroma/web/media_proxy/invalidation_test.exs create mode 100644 test/pleroma/web/media_proxy/media_proxy_controller_test.exs create mode 100644 test/pleroma/web/media_proxy_test.exs create mode 100644 test/pleroma/web/metadata/player_view_test.exs create mode 100644 test/pleroma/web/metadata/providers/feed_test.exs create mode 100644 test/pleroma/web/metadata/providers/open_graph_test.exs create mode 100644 test/pleroma/web/metadata/providers/rel_me_test.exs create mode 100644 test/pleroma/web/metadata/providers/restrict_indexing_test.exs create mode 100644 test/pleroma/web/metadata/providers/twitter_card_test.exs create mode 100644 test/pleroma/web/metadata/utils_test.exs create mode 100644 test/pleroma/web/mongoose_im_controller_test.exs create mode 100644 test/pleroma/web/node_info_test.exs create mode 100644 test/pleroma/web/o_auth/app_test.exs create mode 100644 test/pleroma/web/o_auth/authorization_test.exs create mode 100644 test/pleroma/web/o_auth/ldap_authorization_test.exs create mode 100644 test/pleroma/web/o_auth/mfa_controller_test.exs create mode 100644 test/pleroma/web/o_auth/o_auth_controller_test.exs create mode 100644 test/pleroma/web/o_auth/token/utils_test.exs create mode 100644 test/pleroma/web/o_auth/token_test.exs create mode 100644 test/pleroma/web/o_status/o_status_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/account_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/app_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/backup_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/conversation_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/emoji_file_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/emoji_pack_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/event_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/instances_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/mascot_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/notification_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/report_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/scrobble_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/search_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/status_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/controllers/user_import_controller_test.exs create mode 100644 test/pleroma/web/pleroma_api/views/app_view_test.exs create mode 100644 test/pleroma/web/pleroma_api/views/backup_view_test.exs create mode 100644 test/pleroma/web/pleroma_api/views/chat_message_reference_view_test.exs create mode 100644 test/pleroma/web/pleroma_api/views/chat_view_test.exs create mode 100644 test/pleroma/web/pleroma_api/views/scrobble_view_test.exs create mode 100644 test/pleroma/web/plugs/admin_secret_authentication_plug_test.exs create mode 100644 test/pleroma/web/plugs/authentication_plug_test.exs create mode 100644 test/pleroma/web/plugs/basic_auth_decoder_plug_test.exs create mode 100644 test/pleroma/web/plugs/cache_control_test.exs create mode 100644 test/pleroma/web/plugs/cache_test.exs create mode 100644 test/pleroma/web/plugs/digest_plug_test.exs create mode 100644 test/pleroma/web/plugs/ensure_authenticated_plug_test.exs create mode 100644 test/pleroma/web/plugs/ensure_privileged_plug_test.exs create mode 100644 test/pleroma/web/plugs/ensure_public_or_authenticated_plug_test.exs create mode 100644 test/pleroma/web/plugs/ensure_user_token_assigns_plug_test.exs create mode 100644 test/pleroma/web/plugs/federating_plug_test.exs create mode 100644 test/pleroma/web/plugs/frontend_static_plug_test.exs create mode 100644 test/pleroma/web/plugs/http_security_plug_test.exs create mode 100644 test/pleroma/web/plugs/http_signature_plug_test.exs create mode 100644 test/pleroma/web/plugs/idempotency_plug_test.exs create mode 100644 test/pleroma/web/plugs/instance_static_test.exs create mode 100644 test/pleroma/web/plugs/mapped_signature_to_identity_plug_test.exs create mode 100644 test/pleroma/web/plugs/o_auth_plug_test.exs create mode 100644 test/pleroma/web/plugs/o_auth_scopes_plug_test.exs create mode 100644 test/pleroma/web/plugs/plug_helper_test.exs create mode 100644 test/pleroma/web/plugs/rate_limiter_test.exs create mode 100644 test/pleroma/web/plugs/remote_ip_test.exs create mode 100644 test/pleroma/web/plugs/set_format_plug_test.exs create mode 100644 test/pleroma/web/plugs/set_locale_plug_test.exs create mode 100644 test/pleroma/web/plugs/set_user_session_id_plug_test.exs create mode 100644 test/pleroma/web/plugs/uploaded_media_plug_test.exs create mode 100644 test/pleroma/web/plugs/user_enabled_plug_test.exs create mode 100644 test/pleroma/web/plugs/user_fetcher_plug_test.exs create mode 100644 test/pleroma/web/plugs/user_is_admin_plug_test.exs create mode 100644 test/pleroma/web/plugs/user_is_staff_plug_test.exs create mode 100644 test/pleroma/web/plugs/user_tracking_plug_test.exs create mode 100644 test/pleroma/web/preload/providers/instance_test.exs create mode 100644 test/pleroma/web/preload/providers/timeline_test.exs create mode 100644 test/pleroma/web/preload/providers/user_test.exs create mode 100644 test/pleroma/web/push/impl_test.exs create mode 100644 test/pleroma/web/rel_me_test.exs create mode 100644 test/pleroma/web/rich_media/helpers_test.exs create mode 100644 test/pleroma/web/rich_media/parser/card_test.exs create mode 100644 test/pleroma/web/rich_media/parser/meta_tags_test.exs create mode 100644 test/pleroma/web/rich_media/parser/ttl/aws_signed_url_test.exs create mode 100644 test/pleroma/web/rich_media/parser_test.exs create mode 100644 test/pleroma/web/rich_media/parsers/twitter_card_test.exs create mode 100644 test/pleroma/web/static_fe/static_fe_controller_test.exs create mode 100644 test/pleroma/web/streamer_test.exs create mode 100644 test/pleroma/web/twitter_api/controller_test.exs create mode 100644 test/pleroma/web/twitter_api/password_controller_test.exs create mode 100644 test/pleroma/web/twitter_api/remote_follow_controller_test.exs create mode 100644 test/pleroma/web/twitter_api/twitter_api_test.exs create mode 100644 test/pleroma/web/twitter_api/util_controller_test.exs create mode 100644 test/pleroma/web/uploader_controller_test.exs create mode 100644 test/pleroma/web/utils/colors_test.exs create mode 100644 test/pleroma/web/views/error_view_test.exs create mode 100644 test/pleroma/web/web_finger/web_finger_controller_test.exs create mode 100644 test/pleroma/web/web_finger_test.exs create mode 100644 test/pleroma/web/xml_test.exs create mode 100644 test/pleroma/webhook/notify_test.exs create mode 100644 test/pleroma/webhook_test.exs create mode 100644 test/pleroma/workers/cron/digest_emails_worker_test.exs create mode 100644 test/pleroma/workers/cron/new_users_digest_worker_test.exs create mode 100644 test/pleroma/workers/publisher_worker_test.exs create mode 100644 test/pleroma/workers/purge_expired_activity_test.exs create mode 100644 test/pleroma/workers/purge_expired_filter_test.exs create mode 100644 test/pleroma/workers/purge_expired_token_test.exs create mode 100644 test/pleroma/workers/receiver_worker_test.exs create mode 100644 test/pleroma/workers/scheduled_activity_worker_test.exs create mode 100644 test/pleroma/xml_builder_test.exs create mode 100644 test/support/api_spec_helpers.ex create mode 100644 test/support/builders/activity_builder.ex create mode 100644 test/support/builders/user_builder.ex create mode 100644 test/support/cachex_proxy.ex create mode 100644 test/support/captcha/mock.ex create mode 100644 test/support/channel_case.ex create mode 100644 test/support/cluster.ex create mode 100644 test/support/conn_case.ex create mode 100644 test/support/data_case.ex create mode 100644 test/support/factory.ex create mode 100644 test/support/helpers.ex create mode 100644 test/support/http_request_mock.ex create mode 100644 test/support/language_detector_mock.ex create mode 100644 test/support/mocks.ex create mode 100644 test/support/mrf_module_mock.ex create mode 100644 test/support/null_cache.ex create mode 100644 test/support/oban_helpers.ex create mode 100644 test/support/translation_mock.ex create mode 100644 test/support/websocket_client.ex create mode 100644 test/test_helper.exs create mode 100644 tools/check-changelog create mode 100755 tools/collect-changelog create mode 100644 uploads/.gitignore diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..a467f29 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,137 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + included: ["lib/", "src/", "web/", "apps/", "test/"], + excluded: [~r"/_build/", ~r"/deps/"] + }, + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + requires: ["./test/credo/check/consistency/file_location.ex"], + # + # Credo automatically checks for updates, like e.g. Hex does. + # You can disable this behaviour below: + check_for_updates: true, + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + strict: false, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: [ + {Credo.Check.Consistency.ExceptionNames}, + {Credo.Check.Consistency.LineEndings}, + {Credo.Check.Consistency.MultiAliasImportRequireUse}, + {Credo.Check.Consistency.ParameterPatternMatching}, + {Credo.Check.Consistency.SpaceAroundOperators}, + {Credo.Check.Consistency.SpaceInParentheses}, + {Credo.Check.Consistency.TabsOrSpaces}, + + # For some checks, like AliasUsage, you can only customize the priority + # Priority values are: `low, normal, high, higher` + {Credo.Check.Design.AliasUsage, priority: :low, if_called_more_often_than: 3}, + + # For others you can set parameters + + # If you don't want the `setup` and `test` macro calls in ExUnit tests + # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just + # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. + {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, + + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + {Credo.Check.Design.TagTODO, exit_status: 0}, + {Credo.Check.Design.TagFIXME, exit_status: 0}, + {Credo.Check.Readability.FunctionNames}, + {Credo.Check.Readability.LargeNumbers}, + {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 100}, + {Credo.Check.Readability.ModuleAttributeNames}, + {Credo.Check.Readability.ModuleDoc, false}, + {Credo.Check.Readability.ModuleNames}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, + {Credo.Check.Readability.ParenthesesInCondition}, + {Credo.Check.Readability.PredicateFunctionNames}, + # lanodan: I think PreferImplicitTry should be consistency, and the behaviour seems + # inconsistent, see: https://github.com/rrrene/credo/issues/224 + {Credo.Check.Readability.PreferImplicitTry, false}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, exit_status: 0}, + {Credo.Check.Readability.RedundantBlankLines}, + {Credo.Check.Readability.StringSigils}, + {Credo.Check.Readability.TrailingBlankLine}, + {Credo.Check.Readability.TrailingWhiteSpace}, + {Credo.Check.Readability.VariableNames}, + {Credo.Check.Readability.Semicolons}, + {Credo.Check.Readability.SpaceAfterCommas}, + {Credo.Check.Readability.WithSingleClause, exit_status: 0}, + {Credo.Check.Refactor.DoubleBooleanNegation}, + {Credo.Check.Refactor.CondStatements}, + {Credo.Check.Refactor.CyclomaticComplexity}, + {Credo.Check.Refactor.FunctionArity}, + {Credo.Check.Refactor.MatchInCondition}, + {Credo.Check.Refactor.NegatedConditionsInUnless}, + {Credo.Check.Refactor.NegatedConditionsWithElse}, + {Credo.Check.Refactor.Nesting}, + {Credo.Check.Refactor.PipeChainStart}, + {Credo.Check.Refactor.UnlessWithElse}, + {Credo.Check.Warning.BoolOperationOnSameValues}, + {Credo.Check.Warning.IExPry}, + {Credo.Check.Warning.IoInspect}, + # Got too much of them, not sure if relevant + {Credo.Check.Warning.LazyLogging, false}, + {Credo.Check.Warning.OperationOnSameValues}, + {Credo.Check.Warning.OperationWithConstantResult}, + {Credo.Check.Warning.UnusedEnumOperation}, + {Credo.Check.Warning.UnusedFileOperation}, + {Credo.Check.Warning.UnusedKeywordOperation}, + {Credo.Check.Warning.UnusedListOperation}, + {Credo.Check.Warning.UnusedPathOperation}, + {Credo.Check.Warning.UnusedRegexOperation}, + {Credo.Check.Warning.UnusedStringOperation}, + {Credo.Check.Warning.UnusedTupleOperation}, + + # Controversial and experimental checks (opt-in, just remove `, false`) + # + {Credo.Check.Refactor.ABCSize, false}, + {Credo.Check.Refactor.AppendSingleItem, false}, + {Credo.Check.Refactor.VariableRebinding, false}, + {Credo.Check.Warning.MapGetUnsafePass, false}, + + # Deprecated checks (these will be deleted after a grace period) + {Credo.Check.Readability.Specs, false}, + + # Custom checks can be created using `mix credo.gen.check`. + # + {Credo.Check.Consistency.FileLocation} + ] + } + ] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cc92e70 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,73 @@ +.* +*.md +AGPL-3 +CC-BY-SA-4.0 +COPYING +*file +elixir_buildpack.config +test/ + +# Required to get version +!.git + +# App artifacts +/_build +/db +/deps +/*.ez +/test/instance +/test/uploads +/.elixir_ls +/test/fixtures/DSCN0010_tmp.jpg +/test/fixtures/test_tmp.txt +/test/fixtures/image_tmp.jpg +/test/tmp/ +/test/frontend_static_test/ +/doc +/instance +/priv/ssh_keys + +# Prevent committing custom emojis +/priv/static/emoji/custom/* + +# Generated on crash by the VM +erl_crash.dump + +# Files matching config/*.secret.exs pattern contain sensitive +# data and you should not commit them into version control. +# +# Alternatively, you may comment the line below and commit the +# secrets files as long as you replace their contents by environment +# variables. +/config/*.secret.exs +/config/generated_config.exs +/config/runtime.exs +/config/*.env + + +# Database setup file, some may forget to delete it +/config/setup_db*.psql + +# Whitelist Landing FE +!/instance/static/frontends/landing-fe/vendor/** + +.DS_Store +.env + +# Editor config +/.vscode/ + +# Prevent committing docs files +/priv/static/doc/* +docs/generated_config.md + +# Code test coverage +/cover +/Elixir.*.coverdata + +.idea +pleroma.iml + +# Editor temp files +/*~ +/*# diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..abd91db --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}", "priv/repo/migrations/*.exs", "priv/repo/optional_migrations/**/*.exs", "priv/scrubbers/*.ex"] +] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..eb0c947 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +*.ex diff=elixir +*.exs diff=elixir + +priv/static/instance/static.css diff=css + +# Most of js/css files included in the repo are minified bundles, +# and we don't want to search/diff those as text files. +*.js binary +*.js.map binary +*.css binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bae0abb --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# App artifacts +/_build +/db +/deps +/*.ez +/test/instance +/test/uploads +/.elixir_ls +/test/fixtures/DSCN0010_tmp.jpg +/test/fixtures/test_tmp.txt +/test/fixtures/image_tmp.jpg +/test/tmp/ +/test/frontend_static_test/ +/doc +/instance +/priv/ssh_keys + +# Prevent committing custom emojis +/priv/static/emoji/custom/* + +# Generated on crash by the VM +erl_crash.dump + +# Files matching config/*.secret.exs pattern contain sensitive +# data and you should not commit them into version control. +# +# Alternatively, you may comment the line below and commit the +# secrets files as long as you replace their contents by environment +# variables. +/config/*.secret.exs +/config/generated_config.exs +/config/runtime.exs +/config/*.env + + +# Database setup file, some may forget to delete it +/config/setup_db*.psql + +.DS_Store +.env + +# Editor config +/.vscode/ + +# Prevent committing docs files +/priv/static/doc/* +docs/generated_config.md + +# Code test coverage +/cover +/Elixir.*.coverdata +/coverage.xml + +.idea +pleroma.iml + +# Editor temp files +/*~ +/*# diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..6e22447 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,161 @@ +image: registry.gitlab.com/soapbox-pub/rebased/ci + +variables: &global_variables + POSTGRES_DB: pleroma_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + DB_HOST: postgres + DB_PORT: 5432 + MIX_ENV: test + # Needed for Dokku deployment. + # https://github.com/dokku/dokku/issues/2514#issuecomment-616775470 + GIT_DEPTH: 0 + +cache: &cache + key: + files: + - mix.lock + paths: + - deps + - _build + policy: pull + +stages: + - deps + - test + - deploy + +deps: + stage: deps + script: + - mix deps.get + - mix deps.compile + cache: + <<: *cache + policy: pull-push + only: + changes: + - mix.lock + +openapi: + stage: test + only: + changes: + - ".gitlab-ci.yml" + - "lib/pleroma/web/api_spec/**/*.ex" + - "lib/pleroma/web/api_spec.ex" + artifacts: + paths: + - spec.json + script: + - mix pleroma.openapi_spec spec.json + +test: + stage: test + only: + changes: &elixir-changes + - ".gitlab-ci.yml" + - "**/*.ex" + - "**/*.exs" + - "mix.lock" + services: &db-services + - name: postgres:13-alpine + alias: postgres + command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] + before_script: &db-setup + - mix ecto.create + - mix ecto.migrate + script: + - mix test --cover --preload-modules + coverage: '/^Line total: ([^ ]*%)$/' + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: coverage.xml + +test-erratic: + stage: test + allow_failure: true + only: + changes: *elixir-changes + services: *db-services + before_script: *db-setup + script: + - mix test --only=erratic + +lint: + stage: test + only: + changes: *elixir-changes + script: + - mix format --check-formatted + +# analysis: +# stage: test +# only: +# changes: *elixir-changes +# script: +# - mix credo --strict --only=warnings,todo,fixme,consistency,readability + +cycles: + stage: test + only: + changes: *elixir-changes + script: + - mix xref graph --format cycles --label compile | awk '{print $0} END{exit ($0 != "No cycles found")}' + +# Deploy with Dokku +# https://github.com/dokku/gitlab-ci +# https://github.com/dokku/ci-docker-image +review: + image: dokku/ci-docker-image + stage: test + environment: + name: review/$CI_COMMIT_REF_NAME + url: https://rebased-$CI_COMMIT_REF_SLUG.dokku.soapbox.pub + only: + - branches + except: + - main + variables: + GIT_REMOTE_URL: ssh://dokku@$DOKKU_HOST/rebased-$CI_COMMIT_REF_SLUG + GIT_PUSH_FLAGS: --force + script: dokku-deploy + allow_failure: true + +release: + stage: deploy + variables: + MIX_ENV: prod + PLEROMA_BUILD_BRANCH: $CI_COMMIT_REF_NAME + script: + - mix deps.get + - mkdir release + - mix release --path release + artifacts: + name: "rebased-$CI_COMMIT_REF_NAME-$CI_COMMIT_SHORT_SHA-$CI_JOB_NAME" + paths: + - release/* + cache: {} + only: + refs: + - main + changes: *elixir-changes + +docker: + stage: deploy + image: docker:20.10.17 + cache: {} + services: + - docker:20.10.17-dind + tags: + - dind + # https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df + script: + - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin + - docker build -t $CI_REGISTRY_IMAGE . + - docker push $CI_REGISTRY_IMAGE + only: + refs: + - main diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md new file mode 100644 index 0000000..dd0d6eb --- /dev/null +++ b/.gitlab/issue_templates/Bug.md @@ -0,0 +1,18 @@ + + +### Environment + +* Installation type (OTP or From Source): +* Pleroma version (could be found in the "Version" tab of settings in Pleroma-FE): +* Elixir version (`elixir -v` for from source installations, N/A for OTP): +* Operating system: +* PostgreSQL version (`psql -V`): + + +### Bug description diff --git a/.gitlab/merge_request_templates/Default.md b/.gitlab/merge_request_templates/Default.md new file mode 100644 index 0000000..641d9cf --- /dev/null +++ b/.gitlab/merge_request_templates/Default.md @@ -0,0 +1,10 @@ +### Checklist +- [ ] Adding a changelog: In the `changelog.d` directory, create a file named `.`. + + `` can be anything, but we recommend using a more or less unique identifier to avoid collisions, such as the branch name. + + `` can be `add`, `change`, `remove`, `fix`, `security` or `skip`. `skip` is only used if there is no user-visible change in the MR (for example, only editing comments in the code). Otherwise, choose a type that corresponds to your change. + + In the file, write the changelog entry. For example, if an MR adds group functionality, we can create a file named `group.add` and write `Add group functionality` in it. + + If one changelog entry is not enough, you may add more. But that might mean you can split it into two MRs. Only use more than one changelog entry if you really need to (for example, when one change in the code fix two different bugs, or when refactoring). diff --git a/.gitlab/merge_request_templates/Release.md b/.gitlab/merge_request_templates/Release.md new file mode 100644 index 0000000..e57556e --- /dev/null +++ b/.gitlab/merge_request_templates/Release.md @@ -0,0 +1,8 @@ +### Release checklist +* [ ] Bump version in `mix.exs` +* [ ] Compile a changelog with the `tools/collect-changelog` script +* [ ] Create an MR with an announcement to pleroma.social +#### post-merge +* [ ] Tag the release on the merge commit +* [ ] Make the tag into a Gitlab Release™ +* [ ] Merge `stable` into `develop` (in case the fixes are already in develop, use `git merge -s ours --no-commit` and manually merge the changelogs) diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..84fffae --- /dev/null +++ b/.mailmap @@ -0,0 +1,3 @@ +Ariadne Conill +Ariadne Conill +rinpatch diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..6518889 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.13 +erlang 24.3.4.2 diff --git a/AGPL-3 b/AGPL-3 new file mode 100644 index 0000000..0bf2395 --- /dev/null +++ b/AGPL-3 @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + Pleroma + Copyright (C) 2017 Roger Braun + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/CC-BY-4.0 b/CC-BY-4.0 new file mode 100644 index 0000000..4ea99c2 --- /dev/null +++ b/CC-BY-4.0 @@ -0,0 +1,395 @@ +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/CC-BY-SA-4.0 b/CC-BY-SA-4.0 new file mode 100644 index 0000000..4681ab8 --- /dev/null +++ b/CC-BY-SA-4.0 @@ -0,0 +1,427 @@ +Attribution-ShareAlike 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-ShareAlike 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-ShareAlike 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + l. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + m. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + b. ShareAlike. + + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. + + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + including for purposes of Section 3(b); and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..394eb51 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1383 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## 2.6.0 +### Security +- Preload: Make generated JSON html-safe. It already was html safe because it only consists of config data that is base64 encoded, but this will keep it safe it that ever changes. +- CommonAPI: Prevent users from accessing media of other users by creating a status with reused attachment ID +- Disable XML entity resolution completely to fix a dos vulnerability + +### Added +- Support for Image activities, namely from Hubzilla +- Add OAuth scope descriptions +- Allow lang attribute in status text +- OnlyMedia Upload Filter +- Implement MRF policy to reject or delist according to emojis +- (hardening) Add no_new_privs=yes to OpenRC service files +- Implement quotes +- Add unified streaming endpoint + +### Fixed +- rel="me" was missing its cache +- MediaProxy responses now return a sandbox CSP header +- Filter context activities using Visibility.visible_for_user? +- UploadedMedia: Add missing disposition_type to Content-Disposition +- fix not being able to fetch flash file from remote instance +- Fix abnormal behaviour when refetching a poll +- Allow non-HTTP(s) URIs in "url" fields for compatibility with "FEP-fffd: Proxy Objects" +- Fix opengraph and twitter card meta tags +- ForceMentionsInContent: fix double mentions for Mastodon/Misskey posts +- OEmbed HTML tags are now filtered +- Restrict attachments to only uploaded files only +- Fix error 404 when deleting status of a banned user +- Fix config ownership in dockerfile to pass restriction test +- Fix user fetch completely broken if featured collection is not in a supported form +- Correctly handle the situation when a poll has both "anyOf" and "oneOf" but one of them being empty +- Fix handling report from a deactivated user +- Prevent using the .json format to bypass authorized fetch mode +- Fix mentioning punycode domains when using Markdown +- Show more informative errors when profile exceeds char limits + +### Removed +- BREAKING: Support for passwords generated with `crypt(3)` (Gnu Social migration artifact) +- remove BBS/SSH feature, replaced by an external bridge. +- Remove a few unused indexes. +- Cleanup OStatus-era user upgrades and ap_enabled indicator +- Deprecate Pleroma's audio scrobbling + +## 2.5.4 + +## Security +- Fix XML External Entity (XXE) loading vulnerability allowing to fetch arbitary files from the server's filesystem + +## 2.5.3 + +### Security +- Emoji pack loader sanitizes pack names +- Reduced permissions of config files and directories, distros requiring greater permissions like group-read need to pre-create the directories + +## 2.5.5 + +## Security +- Prevent users from accessing media of other users by creating a status with reused attachment ID + +## 2.5.4 + +## Security +- Fix XML External Entity (XXE) loading vulnerability allowing to fetch arbitary files from the server's filesystem + +## 2.5.3 + +### Security +- Emoji pack loader sanitizes pack names +- Reduced permissions of config files and directories, distros requiring greater permissions like group-read need to pre-create the directories + +## 2.5.2 + +### Security +- `/proxy` endpoint now sets a Content-Security-Policy (sandbox) +- WebSocket endpoint now respects unauthenticated restrictions for streams of public posts +- OEmbed HTML tags are now filtered + +### Changed +- docs: Be more explicit about the level of compatibility of OTP releases +- Set default background worker timeout to 15 minutes + +### Fixed +- Atom/RSS formatting (HTML truncation, published, missing summary) +- Remove `static_fe` pipeline for `/users/:nickname/feed` +- Stop oban from retrying if validating errors occur when processing incoming data +- Make sure object refetching as used by already received polls follows MRF rules + +### Removed +- BREAKING: Support for passwords generated with `crypt(3)` (Gnu Social migration artifact) + +## 2.5.1 + +### Added +- Allow customizing instance languages + +### Fixed +- Security: uploading HTTP endpoint can no longer create directories in the upload dir (internal APIs, like backup, still can do it.) +- ~ character in urls in Markdown posts are handled properly +- Exiftool upload filter will now ignore SVG files +- Fix `block_from_stranger` setting +- Fix rel="me" +- Docker images will now run properly +- Fix inproper content being cached in report content +- Notification filter on object content will not operate on the ones that inherently have no content +- ZWNJ and double dots in links are parsed properly for Plain-text posts +- OTP releases will work on systems with a newer libcrypt +- Errors when running Exiftool.ReadDescription filter will not be filled into the image description + +## 2.5.0 - 2022-12-23 + +### Removed + +- MastoFE +- Quack, the logging backend that pushes to Slack channels + +### Changed +- **Breaking:** Elixir >=1.11 is now required (was >= 1.9) +- Allow users to remove their emails if instance does not need email to register +- Uploadfilter `Pleroma.Upload.Filter.Exiftool` has been renamed to `Pleroma.Upload.Filter.Exiftool.StripLocation` +- **Breaking**: `/api/v1/pleroma/backups` endpoints now requires `read:backups` scope instead of `read:accounts` +- Updated the recommended pleroma.vcl configuration for Varnish to target Varnish 7.0+ +- Set timeout values for Oban queues. The default is infinity and some operations may not time out on their own. +- Delete activities are federated at lowest priority +- CSP now includes wasm-unsafe-eval + +### Added +- `activeMonth` and `activeHalfyear` fields in NodeInfo usage.users object +- Experimental support for Finch. Put `config :tesla, :adapter, {Tesla.Adapter.Finch, name: MyFinch}` in your secrets file to use it. Reverse Proxy will still use Hackney. +- `ForceMentionsInPostContent` MRF policy +- PleromaAPI: Add remote follow API endpoint at `POST /api/v1/pleroma/remote_interaction` +- MastoAPI: Add `GET /api/v1/accounts/lookup` +- MastoAPI: Profile Directory support +- MastoAPI: Support v2 Suggestions (handpicked accounts only) +- Ability to log slow Ecto queries by configuring `:pleroma, :telemetry, :slow_queries_logging` +- Added Phoenix LiveDashboard at `/phoenix/live_dashboard` +- Added `/manifest.json` for progressive web apps. +- MastoAPI: Support for `birthday` and `show_birthday` field in `/api/v1/accounts/update_credentials`. +- Configuration: Add `birthday_required` and `birthday_min_age` settings to provide a way to require users to enter their birth date. +- PleromaAPI: Add `GET /api/v1/pleroma/birthdays` API endpoint +- Make backend-rendered pages translatable. This includes emails. Pages returned as a HTTP response are translated using the language specified in the `userLanguage` cookie, or the `Accept-Language` header. Emails are translated using the `language` field when registering. This language can be changed by `PATCH /api/v1/accounts/update_credentials` with the `language` field. +- Add fine grained options to provide privileges to moderators and admins (e.g. delete messages, manage reports...) +- Uploadfilter `Pleroma.Upload.Filter.Exiftool.ReadDescription` returns description values to the FE so they can pre fill the image description field +- Added move account API +- Enable remote users to interact with posts +- Possibility to discover users like `user@example.org`, while Pleroma is working on `pleroma.example.org`. Additional configuration required. + +### Fixed +- Subscription(Bell) Notifications: Don't create from Pipeline Ingested replies +- Handle Reject for already-accepted Follows properly +- Display OpenGraph data on alternative notice routes. +- Fix replies count for remote replies +- Fixed hashtags disappearing from the end of lines when Markdown is enabled +- ChatAPI: Add link headers +- Limited number of search results to 40 to prevent DoS attacks +- ActivityPub: fixed federation of attachment dimensions +- Fixed benchmarks +- Elixir 1.13 support +- Fixed crash when pinned_objects is nil +- Fixed slow timelines when there are a lot of deactivated users +- Fixed account deletion API +- Fixed lowercase HTTP HEAD method in the Media Proxy Preview code +- Removed useless notification call on Delete activities +- Improved performance for filtering out deactivated and invisible users +- RSS and Atom feeds for users work again +- TwitterCard meta tags conformance + +## 2.4.5 - 2022-11-27 + +## Fixed +- Image `class` attributes not being scrubbed, allowing to exploit frontend special classes [!3792](https://git.pleroma.social/pleroma/pleroma/-/merge_requests/3792) +- Delete report notifs when demoting from superuser [!3642](https://git.pleroma.social/pleroma/pleroma/-/merge_requests/3642) +- Validate `mediaType` only by it's format rather than using a list [!3597](https://git.pleroma.social/pleroma/pleroma/-/merge_requests/3597) +- Pagination: Make mutes and blocks lists behave the same as other lists [!3693](https://git.pleroma.social/pleroma/pleroma/-/merge_requests/3693) +- Compatibility with Elixir 1.14 [!3740](https://git.pleroma.social/pleroma/pleroma/-/merge_requests/3740) +- Frontend installer: FediFE build URL [!3736](https://git.pleroma.social/pleroma/pleroma/-/merge_requests/3736) +- Streaming: Don't stream ChatMessage into the home timeline [!3738](https://git.pleroma.social/pleroma/pleroma/-/merge_requests/3738) +- Streaming: Stream local-only posts in the local timeline [!3738](https://git.pleroma.social/pleroma/pleroma/-/merge_requests/3738) +- Signatures: Fix `keyId` lookup for GoToSocial [!3725](https://git.pleroma.social/pleroma/pleroma/-/merge_requests/3725) +- Validator: Fix `replies` handling for GoToSocial [!3725](https://git.pleroma.social/pleroma/pleroma/-/merge_requests/3725) + +## 2.4.4 - 2022-08-19 + +### Security +- Streaming API sessions will now properly disconnect if the corresponding token is revoked + +## 2.4.3 - 2022-05-06 + +### Security +- Private `/objects/` and `/activities/` leaking if cached by authenticated user +- SweetXML library DTD bomb + +## 2.4.2 - 2022-01-10 + +### Fixed +- Federation issues caused by HTTP pool checkout timeouts +- Compatibility with Elixir 1.13 + +### Upgrade notes + +1. Restart Pleroma + +## 2.4.1 - 2021-08-29 + +### Changed +- Make `mix pleroma.database set_text_search_config` run concurrently and indefinitely + +### Added +- AdminAPI: Missing configuration description for StealEmojiPolicy + +### Fixed +- MastodonAPI: Stream out Create activities +- MRF ObjectAgePolicy: Fix pattern matching on "published" +- TwitterAPI: Make `change_password` and `change_email` require params on body instead of query +- Subscription(Bell) Notifications: Don't create from Pipeline Ingested replies +- AdminAPI: Fix rendering reports containing a `nil` object +- Mastodon API: Activity Search fallbacks on status fetching after a DB Timeout/Error +- Mastodon API: Fix crash in Streamer related to reblogging +- AdminAPI: List available frontends when `static/frontends` folder is missing +- Make activity search properly use language-aware GIN indexes +- AdminAPI: Fix suggestions for MRF Policies + +## 2.4.0 - 2021-08-08 + +### Changed + +- **Breaking:** Configuration: `:chat, enabled` moved to `:shout, enabled` and `:instance, chat_limit` moved to `:shout, limit` +- **Breaking** Entries for simple_policy, transparency_exclusions and quarantined_instances now list both the instance and a reason. +- Support for Erlang/OTP 24 +- The `application` metadata returned with statuses is no longer hardcoded. Apps that want to display these details will now have valid data for new posts after this change. +- HTTPSecurityPlug now sends a response header to opt out of Google's FLoC (Federated Learning of Cohorts) targeted advertising. +- Email address is now returned if requesting user is the owner of the user account so it can be exposed in client and FE user settings UIs. +- Improved Twittercard and OpenGraph meta tag generation including thumbnails and image dimension metadata when available. +- AdminAPI: sort users so the newest are at the top. +- ActivityPub Client-to-Server(C2S): Limitation on the type of Activity/Object are lifted as they are now passed through ObjectValidators +- MRF (`AntiFollowbotPolicy`): Bot accounts are now also considered followbots. Users can still allow bots to follow them by first following the bot. + +### Added + +- MRF (`FollowBotPolicy`): New MRF Policy which makes a designated local Bot account attempt to follow all users in public Notes received by your instance. Users who require approving follower requests or have #nobot in their profile are excluded. +- Return OAuth token `id` (primary key) in POST `/oauth/token`. +- AdminAPI: return `created_at` date with users. +- AdminAPI: add DELETE `/api/v1/pleroma/admin/instances/:instance` to delete all content from a remote instance. +- `AnalyzeMetadata` upload filter for extracting image/video attachment dimensions and generating blurhashes for images. Blurhashes for videos are not generated at this time. +- Attachment dimensions and blurhashes are federated when available. +- Mastodon API: support `poll` notification. +- Pinned posts federation + +### Fixed +- Don't crash so hard when email settings are invalid. +- Checking activated Upload Filters for required commands. +- Remote users can no longer reappear after being deleted. +- Deactivated users may now be deleted. +- Deleting an activity with a lot of likes/boosts no longer causes a database timeout. +- Mix task `pleroma.database prune_objects` +- Fixed rendering of JSON errors on ActivityPub endpoints. +- Linkify: Parsing crash with URLs ending in unbalanced closed paren, no path separator, and no query parameters +- Try to save exported ConfigDB settings (migrate_from_db) in the system temp directory if default location is not writable. +- Uploading custom instance thumbnail via AdminAPI/AdminFE generated invalid URL to the image +- Applying ConcurrentLimiter settings via AdminAPI +- User login failures if their `notification_settings` were in a NULL state. +- Mix task `pleroma.user delete_activities` query transaction timeout is now :infinity +- MRF (`SimplePolicy`): Embedded objects are now checked. If any embedded object would be rejected, its parent is rejected. This fixes Announces leaking posts from blocked domains. +- Fixed some Markdown issues, including trailing slash in links. + +### Removed +- **Breaking**: Remove deprecated `/api/qvitter/statuses/notifications/read` (replaced by `/api/v1/pleroma/notifications/read`) + +## [2.3.0] - 2021-03-01 + +### Security + +- Fixed client user agent leaking through MediaProxy + +### Removed + +- `:auth, :enforce_oauth_admin_scope_usage` configuration option. + +### Changed + +- **Breaking**: Changed `mix pleroma.user toggle_confirmed` to `mix pleroma.user confirm` +- **Breaking**: Changed `mix pleroma.user toggle_activated` to `mix pleroma.user activate/deactivate` +- **Breaking:** NSFW hashtag is no longer added on sensitive posts +- Polls now always return a `voters_count`, even if they are single-choice. +- Admin Emails: The ap id is used as the user link in emails now. +- Improved registration workflow for email confirmation and account approval modes. +- Search: When using Postgres 11+, Pleroma will use the `websearch_to_tsvector` function to parse search queries. +- Emoji: Support the full Unicode 13.1 set of Emoji for reactions, plus regional indicators. +- Deprecated `Pleroma.Uploaders.S3, :public_endpoint`. Now `Pleroma.Upload, :base_url` is the standard configuration key for all uploaders. +- Improved Apache webserver support: updated sample configuration, MediaProxy cache invalidation verified with the included sample script +- Improve OAuth 2.0 provider support. A missing `fqn` field was added to the response, but does not expose the user's email address. +- Provide redirect of external posts from `/notice/:id` to their original URL +- Admins no longer receive notifications for reports if they are the actor making the report. +- Improved Mailer configuration setting descriptions for AdminFE. +- Updated default avatar to look nicer. + +
+ API Changes + +- **Breaking:** AdminAPI changed User field `confirmation_pending` to `is_confirmed` +- **Breaking:** AdminAPI changed User field `approval_pending` to `is_approved` +- **Breaking**: AdminAPI changed User field `deactivated` to `is_active` +- **Breaking:** AdminAPI `GET /api/pleroma/admin/users/:nickname_or_id/statuses` changed response format and added the number of total users posts. +- **Breaking:** AdminAPI `GET /api/pleroma/admin/instances/:instance/statuses` changed response format and added the number of total users posts. +- Admin API: Reports now ordered by newest +- Pleroma API: `GET /api/v1/pleroma/chats` is deprecated in favor of `GET /api/v2/pleroma/chats`. +- Pleroma API: Reroute `/api/pleroma/*` to `/api/v1/pleroma/*` + +
+- Improved hashtag timeline performance (requires a background migration). + +### Added + +- Reports now generate notifications for admins and mods. +- Support for local-only statuses. +- Support pagination of blocks and mutes. +- Account backup. +- Configuration: Add `:instance, autofollowing_nicknames` setting to provide a way to make accounts automatically follow new users that register on the local Pleroma instance. +- `[:activitypub, :blockers_visible]` config to control visibility of blockers. +- Ability to view remote timelines, with ex. `/api/v1/timelines/public?instance=lain.com` and streams `public:remote` and `public:remote:media`. +- The site title is now injected as a `title` tag like preloads or metadata. +- Password reset tokens now are not accepted after a certain age. +- Mix tasks to help with displaying and removing ConfigDB entries. See `mix pleroma.config`. +- OAuth form improvements: users are remembered by their cookie, the CSS is overridable by the admin, and the style has been improved. +- OAuth improvements and fixes: more secure session-based authentication (by token that could be revoked anytime), ability to revoke belonging OAuth token from any client etc. +- Ability to set ActivityPub aliases for follower migration. +- Configurable background job limits for RichMedia (link previews) and MediaProxyWarmingPolicy +- Ability to define custom HTTP headers per each frontend +- MRF (`NoEmptyPolicy`): New MRF Policy which will deny empty statuses or statuses of only mentions from being created by local users +- New users will receive a simple email confirming their registration if no other emails will be dispatched. (e.g., Welcome, Confirmation, or Approval Required) + +
+ API Changes +- Admin API: (`GET /api/pleroma/admin/users`) filter users by `unconfirmed` status and `actor_type`. +- Admin API: OpenAPI spec for the user-related operations +- Pleroma API: `GET /api/v2/pleroma/chats` added. It is exactly like `GET /api/v1/pleroma/chats` except supports pagination. +- Pleroma API: Add `idempotency_key` to the chat message entity that can be used for optimistic message sending. +- Pleroma API: (`GET /api/v1/pleroma/federation_status`) Add a way to get a list of unreachable instances. +- Mastodon API: User and conversation mutes can now auto-expire if `expires_in` parameter was given while adding the mute. +- Admin API: An endpoint to manage frontends. +- Streaming API: Add follow relationships updates. +- WebPush: Introduce `pleroma:chat_mention` and `pleroma:emoji_reaction` notification types. +- Mastodon API: Add monthly active users to `/api/v1/instance` (`pleroma.stats.mau`). +- Mastodon API: Home, public, hashtag & list timelines accept `only_media`, `remote` & `local` parameters for filtration. +- Mastodon API: `/api/v1/accounts/:id` & `/api/v1/mutes` endpoints accept `with_relationships` parameter and return filled `pleroma.relationship` field. +- Mastodon API: Endpoint to remove a conversation (`DELETE /api/v1/conversations/:id`). +- Mastodon API: `expires_in` in the scheduled post `params` field on `/api/v1/statuses` and `/api/v1/scheduled_statuses/:id` endpoints. +
+ +### Fixed + +- Users with `is_discoverable` field set to false (default value) will appear in in-service search results but be hidden from external services (search bots etc.). +- Streaming API: Posts and notifications are not dropped, when CLI task is executing. +- Creating incorrect IPv4 address-style HTTP links when encountering certain numbers. +- Reblog API Endpoint: Do not set visibility parameter to public by default and let CommonAPI to infer it from status, so a user can reblog their private status without explicitly setting reblog visibility to private. +- Tag URLs in statuses are now absolute +- Removed duplicate jobs to purge expired activities +- File extensions of some attachments were incorrectly changed. This feature has been disabled for now. +- Mix task pleroma.instance creates missing parent directories if the configuration or SQL output paths are changed. + +
+ API Changes + - Mastodon API: Current user is now included in conversation if it's the only participant. + - Mastodon API: Fixed last_status.account being not filled with account data. + - Mastodon API: Fix not being able to add or remove multiple users at once in lists. + - Mastodon API: Fixed own_votes being not returned with poll data. + - Mastodon API: Fixed creation of scheduled posts with polls. + - Mastodon API: Support for expires_in/expires_at in the Filters. +
+ +## [2.2.2] - 2021-01-18 + +### Fixed + +- StealEmojiPolicy creates dir for emojis, if it doesn't exist. +- Updated `elixir_make` to a non-retired version + +### Upgrade notes + +1. Restart Pleroma + +## [2.2.1] - 2020-12-22 + +### Changed +- Updated Pleroma FE + +### Fixed + +- Config generation: rename `Pleroma.Upload.Filter.ExifTool` to `Pleroma.Upload.Filter.Exiftool`. +- S3 Uploads with Elixir 1.11. +- Mix task pleroma.user delete_activities for source installations. +- Search: RUM index search speed has been fixed. +- Rich Media Previews sometimes showed the wrong preview due to a bug following redirects. +- Fixes for the autolinker. +- Forwarded reports duplication from Pleroma instances. +- Emoji Reaction activity filtering from blocked and muted accounts. + +-
+ API + - Statuses were not displayed for Mastodon forwarded reports. +
+ +### Upgrade notes + +1. Restart Pleroma + +## [2.2.0] - 2020-11-12 + +### Security + +- Fixed the possibility of using file uploads to spoof posts. + +### Changed + +- **Breaking** Requires `libmagic` (or `file`) to guess file types. +- **Breaking:** App metrics endpoint (`/api/pleroma/app_metrics`) is disabled by default, check `docs/API/prometheus.md` on enabling and configuring. +- **Breaking:** Pleroma Admin API: emoji packs and files routes changed. +- **Breaking:** Sensitive/NSFW statuses no longer disable link previews. +- Search: Users are now findable by their urls. +- Renamed `:await_up_timeout` in `:connections_pool` namespace to `:connect_timeout`, old name is deprecated. +- Renamed `:timeout` in `pools` namespace to `:recv_timeout`, old name is deprecated. +- The `discoverable` field in the `User` struct will now add a NOINDEX metatag to profile pages when false. +- Users with the `is_discoverable` field set to false will not show up in searches ([bug](https://git.pleroma.social/pleroma/pleroma/-/issues/2301)). +- Minimum lifetime for ephmeral activities changed to 10 minutes and made configurable (`:min_lifetime` option). +- Introduced optional dependencies on `ffmpeg`, `ImageMagick`, `exiftool` software packages. Please refer to `docs/installation/optional/media_graphics_packages.md`. +-
+ API Changes +- API: Empty parameter values for integer parameters are now ignored in non-strict validaton mode. +
+ +### Removed + +- **Breaking:** `Pleroma.Workers.Cron.StatsWorker` setting from Oban `:crontab` (moved to a simpler implementation). +- **Breaking:** `Pleroma.Workers.Cron.ClearOauthTokenWorker` setting from Oban `:crontab` (moved to scheduled jobs). +- **Breaking:** `Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker` setting from Oban `:crontab` (moved to scheduled jobs). +- Removed `:managed_config` option. In practice, it was accidentally removed with 2.0.0 release when frontends were +switched to a new configuration mechanism, however it was not officially removed until now. + +### Added + +- Media preview proxy (requires `ffmpeg` and `ImageMagick` to be installed and media proxy to be enabled; see `:media_preview_proxy` config for more details). +- Mix tasks for controlling user account confirmation status in bulk (`mix pleroma.user confirm_all` and `mix pleroma.user unconfirm_all`) +- Mix task for sending confirmation emails to all unconfirmed users (`mix pleroma.email resend_confirmation_emails`) +- Mix task option for force-unfollowing relays +- App metrics: ability to restrict access to specified IP whitelist. + +
+ API Changes + +- Admin API: Importing emoji from a zip file +- Pleroma API: Importing the mutes users from CSV files. +- Pleroma API: Pagination for remote/local packs and emoji. + +
+ +### Fixed + +- Add documented-but-missing chat pagination. +- Allow sending out emails again. +- Allow sending chat messages to yourself +- OStatus / static FE endpoints: fixed inaccessibility for anonymous users on non-federating instances, switched to handling per `:restrict_unauthenticated` setting. +- Fix remote users with a whitespace name. + +### Upgrade notes + +1. Install libmagic and development headers (`libmagic-dev` on Ubuntu/Debian, `file-dev` on Alpine Linux) +2. Run database migrations (inside Pleroma directory): + - OTP: `./bin/pleroma_ctl migrate` + - From Source: `mix ecto.migrate` +3. Restart Pleroma + +## [2.1.2] - 2020-09-17 + +### Security + +- Fix most MRF rules either crashing or not being applied to objects passed into the Common Pipeline (ChatMessage, Question, Answer, Audio, Event). + +### Fixed + +- Welcome Chat messages preventing user registration with MRF Simple Policy applied to the local instance. +- Mastodon API: the public timeline returning an error when the `reply_visibility` parameter is set to `self` for an unauthenticated user. +- Mastodon Streaming API: Handler crashes on authentication failures, resulting in error logs. +- Mastodon Streaming API: Error logs on client pings. +- Rich media: Log spam on failures. Now the error is only logged once per attempt. + +### Changed + +- Rich Media: A HEAD request is now done to the url, to ensure it has the appropriate content type and size before proceeding with a GET. + +### Upgrade notes + +1. Restart Pleroma + +## [2.1.1] - 2020-09-08 + +### Security +- Fix possible DoS in Mastodon API user search due to an error in match clauses, leading to an infinite recursion and subsequent OOM with certain inputs. +- Fix metadata leak for accounts and statuses on private instances. +- Fix possible DoS in Admin API search using an atom leak vulnerability. Authentication with admin rights was required to exploit. + +### Changed + +- **Breaking:** The metadata providers RelMe and Feed are no longer configurable. RelMe should always be activated and Feed only provides a header tag for the actual RSS/Atom feed when the instance is public. +- Improved error message when cmake is not available at build stage. + +### Added +- Rich media failure tracking (along with `:failure_backoff` option). + +
+ Admin API Changes + +- Add `PATCH /api/pleroma/admin/instance_document/:document_name` to modify the Terms of Service and Instance Panel HTML pages via Admin API +
+ +### Fixed +- Default HTTP adapter not respecting pool setting, leading to possible OOM. +- Fixed uploading webp images when the Exiftool Upload Filter is enabled by skipping them +- Mastodon API: Search parameter `following` now correctly returns the followings rather than the followers +- Mastodon API: Timelines hanging for (`number of posts with links * rich media timeout`) in the worst case. + Reduced to just rich media timeout. +- Mastodon API: Cards being wrong for preview statuses due to cache key collision. +- Password resets no longer processed for deactivated accounts. +- Favicon scraper raising exceptions on URLs longer than 255 characters. + +## [2.1.0] - 2020-08-28 + +### Changed + +- **Breaking:** The default descriptions on uploads are now empty. The old behavior (filename as default) can be configured, see the cheat sheet. +- **Breaking:** Added the ObjectAgePolicy to the default set of MRFs. This will delist and strip the follower collection of any message received that is older than 7 days. This will stop users from seeing very old messages in the timelines. The messages can still be viewed on the user's page and in conversations. They also still trigger notifications. +- **Breaking:** Elixir >=1.9 is now required (was >= 1.8) +- **Breaking:** Configuration: `:auto_linker, :opts` moved to `:pleroma, Pleroma.Formatter`. Old config namespace is deprecated. +- **Breaking:** Configuration: `:instance, welcome_user_nickname` moved to `:welcome, :direct_message, :sender_nickname`, `:instance, :welcome_message` moved to `:welcome, :direct_message, :message`. Old config namespace is deprecated. +- **Breaking:** LDAP: Fallback to local database authentication has been removed for security reasons and lack of a mechanism to ensure the passwords are synchronized when LDAP passwords are updated. +- **Breaking** Changed defaults for `:restrict_unauthenticated` so that when `:instance, :public` is set to `false` then all `:restrict_unauthenticated` items be effectively set to `true`. If you'd like to allow unauthenticated access to specific API endpoints on a private instance, please explicitly set `:restrict_unauthenticated` to non-default value in `config/prod.secret.exs`. +- In Conversations, return only direct messages as `last_status` +- Using the `only_media` filter on timelines will now exclude reblog media +- MFR policy to set global expiration for all local Create activities +- OGP rich media parser merged with TwitterCard +- Configuration: `:instance, rewrite_policy` moved to `:mrf, policies`, `:instance, :mrf_transparency` moved to `:mrf, :transparency`, `:instance, :mrf_transparency_exclusions` moved to `:mrf, :transparency_exclusions`. Old config namespace is deprecated. +- Configuration: `:media_proxy, whitelist` format changed to host with scheme (e.g. `http://example.com` instead of `example.com`). Domain format is deprecated. + +
+ API Changes + +- **Breaking:** Pleroma API: The routes to update avatar, banner and background have been removed. +- **Breaking:** Image description length is limited now. +- **Breaking:** Emoji API: changed methods and renamed routes. +- **Breaking:** Notification Settings API for suppressing notifications has been simplified down to `block_from_strangers`. +- **Breaking:** Notification Settings API option for hiding push notification contents has been renamed to `hide_notification_contents`. +- MastodonAPI: Allow removal of avatar, banner and background. +- Streaming: Repeats of a user's posts will no longer be pushed to the user's stream. +- Mastodon API: Added `pleroma.metadata.fields_limits` to /api/v1/instance +- Mastodon API: On deletion, returns the original post text. +- Mastodon API: Add `pleroma.unread_count` to the Marker entity. +- Mastodon API: Added `pleroma.metadata.post_formats` to /api/v1/instance +- Mastodon API (legacy): Allow query parameters for `/api/v1/domain_blocks`, e.g. `/api/v1/domain_blocks?domain=badposters.zone` +- Mastodon API: Make notifications about statuses from muted users and threads read automatically +- Pleroma API: `/api/pleroma/captcha` responses now include `seconds_valid` with an integer value. + +
+ +
+ Admin API Changes + +- **Breaking** Changed relay `/api/pleroma/admin/relay` endpoints response format. +- Status visibility stats: now can return stats per instance. +- Mix task to refresh counter cache (`mix pleroma.refresh_counter_cache`) + +
+ +### Removed + +- **Breaking:** removed `with_move` parameter from notifications timeline. + +### Added + +- Frontends: Add mix task to install frontends. +- Frontends: Add configurable frontends for primary and admin fe. +- Configuration: Added a blacklist for email servers. +- Chats: Added `accepts_chat_messages` field to user, exposed in APIs and federation. +- Chats: Added support for federated chats. For details, see the docs. +- ActivityPub: Added support for existing AP ids for instances migrated from Mastodon. +- Instance: Add `background_image` to configuration and `/api/v1/instance` +- Instance: Extend `/api/v1/instance` with Pleroma-specific information. +- NodeInfo: `pleroma:api/v1/notifications:include_types_filter` to the `features` list. +- NodeInfo: `pleroma_emoji_reactions` to the `features` list. +- Configuration: `:restrict_unauthenticated` setting, restrict access for unauthenticated users to timelines (public and federate), user profiles and statuses. +- Configuration: Add `:database_config_whitelist` setting to whitelist settings which can be configured from AdminFE. +- Configuration: `filename_display_max_length` option to set filename truncate limit, if filename display enabled (0 = no limit). +- New HTTP adapter [gun](https://github.com/ninenines/gun). Gun adapter requires minimum OTP version of 22.2 otherwise Pleroma won’t start. For hackney OTP update is not required. +- Mix task to create trusted OAuth App. +- Mix task to reset MFA for user accounts +- Notifications: Added `follow_request` notification type. +- Added `:reject_deletes` group to SimplePolicy +- MRF (`EmojiStealPolicy`): New MRF Policy which allows to automatically download emojis from remote instances +- Support pagination in emoji packs API (for packs and for files in pack) +- Support for viewing instances favicons next to posts and accounts +- Added Pleroma.Upload.Filter.Exiftool as an alternate EXIF stripping mechanism targeting GPS/location metadata. +- "By approval" registrations mode. +- Configuration: Added `:welcome` settings for the welcome message to newly registered users. You can send a welcome message as a direct message, chat or email. +- Ability to hide favourites and emoji reactions in the API with `[:instance, :show_reactions]` config. + +
+ API Changes + +- Mastodon API: Add pleroma.parent_visible field to statuses. +- Mastodon API: Extended `/api/v1/instance`. +- Mastodon API: Support for `include_types` in `/api/v1/notifications`. +- Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. +- Mastodon API: Add support for filtering replies in public and home timelines. +- Mastodon API: Support for `bot` field in `/api/v1/accounts/update_credentials`. +- Mastodon API: Support irreversible property for filters. +- Mastodon API: Add pleroma.favicon field to accounts. +- Admin API: endpoints for create/update/delete OAuth Apps. +- Admin API: endpoint for status view. +- OTP: Add command to reload emoji packs +
+ +### Fixed +- Fix list pagination and other list issues. +- Support pagination in conversations API +- **Breaking**: SimplePolicy `:reject` and `:accept` allow deletions again +- Fix follower/blocks import when nicknames starts with @ +- Filtering of push notifications on activities from blocked domains +- Resolving Peertube accounts with Webfinger +- `blob:` urls not being allowed by connect-src CSP +- Mastodon API: fix `GET /api/v1/notifications` not returning the full result set +- Rich Media Previews for Twitter links +- Admin API: fix `GET /api/pleroma/admin/users/:nickname/credentials` returning 404 when getting the credentials of a remote user while `:instance, :limit_to_local_content` is set to `:unauthenticated` +- Fix CSP policy generation to include remote Captcha services +- Fix edge case where MediaProxy truncates media, usually caused when Caddy is serving content for the other Federated instance. +- Emoji Packs could not be listed when instance was set to `public: false` +- Fix whole_word always returning false on filter get requests +- Migrations not working on OTP releases if the database was connected over ssl +- Fix relay following + +## [2.0.7] - 2020-06-13 + +### Security +- Fix potential DoSes exploiting atom leaks in rich media parser and the `UserAllowListPolicy` MRF policy + +### Fixed +- CSP: not allowing images/media from every host when mediaproxy is disabled +- CSP: not adding mediaproxy base url to image/media hosts +- StaticFE missing the CSS file + +### Upgrade notes + +1. Restart Pleroma + +## [2.0.6] - 2020-06-09 + +### Security +- CSP: harden `image-src` and `media-src` when MediaProxy is used + +### Fixed +- AP C2S: Fix pagination in inbox/outbox +- Various compilation errors on OTP 23 +- Mastodon API streaming: Repeats from muted threads not being filtered + +### Changed +- Various database performance improvements + +### Upgrade notes +1. Run database migrations (inside Pleroma directory): + - OTP: `./bin/pleroma_ctl migrate` + - From Source: `mix ecto.migrate` +2. Restart Pleroma + +## [2.0.5] - 2020-05-13 + +### Security +- Fix possible private status leaks in Mastodon Streaming API + +### Fixed +- Crashes when trying to block a user if block federation is disabled +- Not being able to start the instance without `erlang-eldap` installed +- Users with bios over the limit getting rejected +- Follower counters not being updated on incoming follow accepts + +### Upgrade notes + +1. Restart Pleroma + +## [2.0.4] - 2020-05-10 + +### Security +- AP C2S: Fix a potential DoS by creating nonsensical objects that break timelines + +### Fixed +- Peertube user lookups not working +- `InsertSkeletonsForDeletedUsers` migration failing on some instances +- Healthcheck reporting the number of memory currently used, rather than allocated in total +- LDAP not being usable in OTP releases +- Default apache configuration having tls chain issues + +### Upgrade notes + +#### Apache only + +1. Remove the following line from your config: +``` + SSLCertificateFile /etc/letsencrypt/live/${servername}/cert.pem +``` + +#### Everyone + +1. Restart Pleroma + +## [2.0.3] - 2020-05-02 + +### Security +- Disallow re-registration of previously deleted users, which allowed viewing direct messages addressed to them +- Mastodon API: Fix `POST /api/v1/follow_requests/:id/authorize` allowing to force a follow from a local user even if they didn't request to follow +- CSP: Sandbox uploads + +### Fixed +- Notifications from blocked domains +- Potential federation issues with Mastodon versions before 3.0.0 +- HTTP Basic Authentication permissions issue +- Follow/Block imports not being able to find the user if the nickname started with an `@` +- Instance stats counting internal users +- Inability to run a From Source release without git +- ObjectAgePolicy didn't filter out old messages +- `blob:` urls not being allowed by CSP + +### Added +- NodeInfo: ObjectAgePolicy settings to the `federation` list. +- Follow request notifications +
+ API Changes +- Admin API: `GET /api/pleroma/admin/need_reboot`. +
+ +### Upgrade notes + +1. Restart Pleroma +2. Run database migrations (inside Pleroma directory): + - OTP: `./bin/pleroma_ctl migrate` + - From Source: `mix ecto.migrate` +3. Reset status visibility counters (inside Pleroma directory): + - OTP: `./bin/pleroma_ctl refresh_counter_cache` + - From Source: `mix pleroma.refresh_counter_cache` + + +## [2.0.2] - 2020-04-08 +### Added +- Support for Funkwhale's `Audio` activity +- Admin API: `PATCH /api/pleroma/admin/users/:nickname/update_credentials` + +### Fixed +- Blocked/muted users still generating push notifications +- Input textbox for bio ignoring newlines +- OTP: Inability to use PostgreSQL databases with SSL +- `user delete_activities` breaking when trying to delete already deleted posts +- Incorrect URL for Funkwhale channels + +### Upgrade notes +1. Restart Pleroma + +## [2.0.1] - 2020-03-15 +### Security +- Static-FE: Fix remote posts not being sanitized + +### Fixed +- Rate limiter crashes when there is no explicitly specified ip in the config +- 500 errors when no `Accept` header is present if Static-FE is enabled +- Instance panel not being updated immediately due to wrong `Cache-Control` headers +- Statuses posted with BBCode/Markdown having unncessary newlines in Pleroma-FE +- OTP: Fix some settings not being migrated to in-database config properly +- No `Cache-Control` headers on attachment/media proxy requests +- Character limit enforcement being off by 1 +- Mastodon Streaming API: hashtag timelines not working + +### Changed +- BBCode and Markdown formatters will no longer return any `\n` and only use `
` for newlines +- Mastodon API: Allow registration without email if email verification is not enabled + +### Upgrade notes +#### Nginx only +1. Remove `proxy_ignore_headers Cache-Control;` and `proxy_hide_header Cache-Control;` from your config. + +#### Everyone +1. Run database migrations (inside Pleroma directory): + - OTP: `./bin/pleroma_ctl migrate` + - From Source: `mix ecto.migrate` +2. Restart Pleroma + +## [2.0.0] - 2019-03-08 +### Security +- Mastodon API: Fix being able to request enormous amount of statuses in timelines leading to DoS. Now limited to 40 per request. + +### Removed +- **Breaking**: Removed 1.0+ deprecated configurations `Pleroma.Upload, :strip_exif` and `:instance, :dedupe_media` +- **Breaking**: OStatus protocol support +- **Breaking**: MDII uploader +- **Breaking**: Using third party engines for user recommendation +
+ API Changes + +- **Breaking**: AdminAPI: migrate_from_db endpoint +
+ +### Changed +- **Breaking:** Pleroma won't start if it detects unapplied migrations +- **Breaking:** Elixir >=1.8 is now required (was >= 1.7) +- **Breaking:** `Pleroma.Plugs.RemoteIp` and `:rate_limiter` enabled by default. Please ensure your reverse proxy forwards the real IP! +- **Breaking:** attachment links (`config :pleroma, :instance, no_attachment_links` and `config :pleroma, Pleroma.Upload, link_name`) disabled by default +- **Breaking:** OAuth: defaulted `[:auth, :enforce_oauth_admin_scope_usage]` setting to `true` which demands `admin` OAuth scope to perform admin actions (in addition to `is_admin` flag on User); make sure to use bundled or newer versions of AdminFE & PleromaFE to access admin / moderator features. +- **Breaking:** Dynamic configuration has been rearchitected. The `:pleroma, :instance, dynamic_configuration` setting has been replaced with `config :pleroma, configurable_from_database`. Please backup your configuration to a file and run the migration task to ensure consistency with the new schema. +- **Breaking:** `:instance, no_attachment_links` has been replaced with `:instance, attachment_links` which still takes a boolean value but doesn't use double negative language. +- Replaced [pleroma_job_queue](https://git.pleroma.social/pleroma/pleroma_job_queue) and `Pleroma.Web.Federator.RetryQueue` with [Oban](https://github.com/sorentwo/oban) (see [`docs/config.md`](docs/config.md) on migrating customized worker / retry settings) +- Introduced [quantum](https://github.com/quantum-elixir/quantum-core) job scheduler +- Enabled `:instance, extended_nickname_format` in the default config +- Add `rel="ugc"` to all links in statuses, to prevent SEO spam +- Extract RSS functionality from OStatus +- MRF (Simple Policy): Also use `:accept`/`:reject` on the actors rather than only their activities +- OStatus: Extract RSS functionality +- Deprecated `User.Info` embedded schema (fields moved to `User`) +- Store status data inside Flag activity +- Deprecated (reorganized as `UserRelationship` entity) User fields with user AP IDs (`blocks`, `mutes`, `muted_reblogs`, `muted_notifications`, `subscribers`). +- Rate limiter is now disabled for localhost/socket (unless remoteip plug is enabled) +- Logger: default log level changed from `warn` to `info`. +- Config mix task `migrate_to_db` truncates `config` table before migrating the config file. +- Allow account registration without an email +- Default to `prepare: :unnamed` in the database configuration. +- Instance stats are now loaded on startup instead of being empty until next hourly job. +
+ API Changes + +- **Breaking** EmojiReactions: Change endpoints and responses to align with Mastodon +- **Breaking** Admin API: `PATCH /api/pleroma/admin/users/:nickname/force_password_reset` is now `PATCH /api/pleroma/admin/users/force_password_reset` (accepts `nicknames` array in the request body) +- **Breaking:** Admin API: Return link alongside with token on password reset +- **Breaking:** Admin API: `PUT /api/pleroma/admin/reports/:id` is now `PATCH /api/pleroma/admin/reports`, see admin_api.md for details +- **Breaking:** `/api/pleroma/admin/users/invite_token` now uses `POST`, changed accepted params and returns full invite in json instead of only token string. +- **Breaking** replying to reports is now "report notes", endpoint changed from `POST /api/pleroma/admin/reports/:id/respond` to `POST /api/pleroma/admin/reports/:id/notes` +- Mastodon API: stopped sanitizing display names, field names and subject fields since they are supposed to be treated as plaintext +- Admin API: Return `total` when querying for reports +- Mastodon API: Return `pleroma.direct_conversation_id` when creating a direct message (`POST /api/v1/statuses`) +- Admin API: Return link alongside with token on password reset +- Admin API: Support authentication via `x-admin-token` HTTP header +- Mastodon API: Add `pleroma.direct_conversation_id` to the status endpoint (`GET /api/v1/statuses/:id`) +- Mastodon API: `pleroma.thread_muted` to the Status entity +- Mastodon API: Mark the direct conversation as read for the author when they send a new direct message +- Mastodon API, streaming: Add `pleroma.direct_conversation_id` to the `conversation` stream event payload. +- Admin API: Render whole status in grouped reports +- Mastodon API: User timelines will now respect blocks, unless you are getting the user timeline of somebody you blocked (which would be empty otherwise). +- Mastodon API: Favoriting / Repeating a post multiple times will now return the identical response every time. Before, executing that action twice would return an error ("already favorited") on the second try. +- Mastodon API: Limit timeline requests to 3 per timeline per 500ms per user/ip by default. +- Admin API: `PATCH /api/pleroma/admin/users/:nickname/credentials` and `GET /api/pleroma/admin/users/:nickname/credentials` +
+ +### Added +- `:chat_limit` option to limit chat characters. +- `cleanup_attachments` option to remove attachments along with statuses. Does not affect duplicate files and attachments without status. Enabling this will increase load to database when deleting statuses on larger instances. +- Refreshing poll results for remote polls +- Authentication: Added rate limit for password-authorized actions / login existence checks +- Static Frontend: Add the ability to render user profiles and notices server-side without requiring JS app. +- Mix task to re-count statuses for all users (`mix pleroma.count_statuses`) +- Mix task to list all users (`mix pleroma.user list`) +- Mix task to send a test email (`mix pleroma.email test`) +- Support for `X-Forwarded-For` and similar HTTP headers which used by reverse proxies to pass a real user IP address to the backend. Must not be enabled unless your instance is behind at least one reverse proxy (such as Nginx, Apache HTTPD or Varnish Cache). +- MRF: New module which handles incoming posts based on their age. By default, all incoming posts that are older than 2 days will be unlisted and not shown to their followers. +- User notification settings: Add `privacy_option` option. +- Support for custom Elixir modules (such as MRF policies) +- User settings: Add _This account is a_ option. +- A new users admin digest email +- OAuth: admin scopes support (relevant setting: `[:auth, :enforce_oauth_admin_scope_usage]`). +- Add an option `authorized_fetch_mode` to require HTTP signatures for AP fetches. +- ActivityPub: support for `replies` collection (output for outgoing federation & fetching on incoming federation). +- Mix task to refresh counter cache (`mix pleroma.refresh_counter_cache`) +
+ API Changes + +- Job queue stats to the healthcheck page +- Admin API: Add ability to fetch reports, grouped by status `GET /api/pleroma/admin/grouped_reports` +- Admin API: Add ability to require password reset +- Mastodon API: Account entities now include `follow_requests_count` (planned Mastodon 3.x addition) +- Pleroma API: `GET /api/v1/pleroma/accounts/:id/scrobbles` to get a list of recently scrobbled items +- Pleroma API: `POST /api/v1/pleroma/scrobble` to scrobble a media item +- Mastodon API: Add `upload_limit`, `avatar_upload_limit`, `background_upload_limit`, and `banner_upload_limit` to `/api/v1/instance` +- Mastodon API: Add `pleroma.unread_conversation_count` to the Account entity +- OAuth: support for hierarchical permissions / [Mastodon 2.4.3 OAuth permissions](https://docs.joinmastodon.org/api/permissions/) +- Metadata Link: Atom syndication Feed +- Mix task to re-count statuses for all users (`mix pleroma.count_statuses`) +- Mastodon API: Add `exclude_visibilities` parameter to the timeline and notification endpoints +- Admin API: `/users/:nickname/toggle_activation` endpoint is now deprecated in favor of: `/users/activate`, `/users/deactivate`, both accept `nicknames` array +- Admin API: Multiple endpoints now require `nicknames` array, instead of singe `nickname`: + - `POST/DELETE /api/pleroma/admin/users/:nickname/permission_group/:permission_group` are deprecated in favor of: `POST/DELETE /api/pleroma/admin/users/permission_group/:permission_group` + - `DELETE /api/pleroma/admin/users` (`nickname` query param or `nickname` sent in JSON body) is deprecated in favor of: `DELETE /api/pleroma/admin/users` (`nicknames` query array param or `nicknames` sent in JSON body) +- Admin API: Add `GET /api/pleroma/admin/relay` endpoint - lists all followed relays +- Pleroma API: `POST /api/v1/pleroma/conversations/read` to mark all conversations as read +- ActivityPub: Support `Move` activities +- Mastodon API: Add `/api/v1/markers` for managing timeline read markers +- Mastodon API: Add the `recipients` parameter to `GET /api/v1/conversations` +- Configuration: `feed` option for user atom feed. +- Pleroma API: Add Emoji reactions +- Admin API: Add `/api/pleroma/admin/instances/:instance/statuses` - lists all statuses from a given instance +- Admin API: Add `/api/pleroma/admin/users/:nickname/statuses` - lists all statuses from a given user +- Admin API: `PATCH /api/pleroma/users/confirm_email` to confirm email for multiple users, `PATCH /api/pleroma/users/resend_confirmation_email` to resend confirmation email for multiple users +- ActivityPub: Configurable `type` field of the actors. +- Mastodon API: `/api/v1/accounts/:id` has `source/pleroma/actor_type` field. +- Mastodon API: `/api/v1/update_credentials` accepts `actor_type` field. +- Captcha: Support native provider +- Captcha: Enable by default +- Mastodon API: Add support for `account_id` param to filter notifications by the account +- Mastodon API: Add `emoji_reactions` property to Statuses +- Mastodon API: Change emoji reaction reply format +- Notifications: Added `pleroma:emoji_reaction` notification type +- Mastodon API: Change emoji reaction reply format once more +- Configuration: `feed.logo` option for tag feed. +- Tag feed: `/tags/:tag.rss` - list public statuses by hashtag. +- Mastodon API: Add `reacted` property to `emoji_reactions` +- Pleroma API: Add reactions for a single emoji. +- ActivityPub: `[:activitypub, :note_replies_output_limit]` setting sets the number of note self-replies to output on outgoing federation. +- Admin API: `GET /api/pleroma/admin/stats` to get status count by visibility scope +- Admin API: `GET /api/pleroma/admin/statuses` - list all statuses (accepts `godmode` and `local_only`) +
+ +### Fixed +- Report emails now include functional links to profiles of remote user accounts +- Not being able to log in to some third-party apps when logged in to MastoFE +- MRF: `Delete` activities being exempt from MRF policies +- OTP releases: Not being able to configure OAuth expired token cleanup interval +- OTP releases: Not being able to configure HTML sanitization policy +- OTP releases: Not being able to change upload limit (again) +- Favorites timeline now ordered by favorite date instead of post date +- Support for cancellation of a follow request +
+ API Changes + +- Mastodon API: Fix private and direct statuses not being filtered out from the public timeline for an authenticated user (`GET /api/v1/timelines/public`) +- Mastodon API: Inability to get some local users by nickname in `/api/v1/accounts/:id_or_nickname` +- AdminAPI: If some status received reports both in the "new" format and "old" format it was considered reports on two different statuses (in the context of grouped reports) +- Admin API: Error when trying to update reports in the "old" format +- Mastodon API: Marking a conversation as read (`POST /api/v1/conversations/:id/read`) now no longer brings it to the top in the user's direct conversation list +
+ +## [1.1.9] - 2020-02-10 +### Fixed +- OTP: Inability to set the upload limit (again) +- Not being able to pin polls +- Streaming API: incorrect handling of reblog mutes +- Rejecting the user when field length limit is exceeded +- OpenGraph provider: html entities in descriptions + +## [1.1.8] - 2020-01-10 +### Fixed +- Captcha generation issues +- Returned Kocaptcha endpoint to configuration +- Captcha validity is now 5 minutes + +## [1.1.7] - 2019-12-13 +### Fixed +- OTP: Inability to set the upload limit +- OTP: Inability to override node name/distribution type to run 2 Pleroma instances on the same machine + +### Added +- Integrated captcha provider + +### Changed +- Captcha enabled by default +- Default Captcha provider changed from `Pleroma.Captcha.Kocaptcha` to `Pleroma.Captcha.Native` +- Better `Cache-Control` header for static content + +### Bundled Pleroma-FE Changes +#### Added +- Icons in the navigation panel + +#### Fixed +- Improved support unauthenticated view of private instances + +#### Removed +- Whitespace hack on empty post content + +## [1.1.6] - 2019-11-19 +### Fixed +- Not being able to log into to third party apps when the browser is logged into mastofe +- Email confirmation not being required even when enabled +- Mastodon API: conversations API crashing when one status is malformed + +### Bundled Pleroma-FE Changes +#### Added +- About page +- Meme arrows + +#### Fixed +- Image modal not closing unless clicked outside of image +- Attachment upload spinner not being centered +- Showing follow counters being 0 when they are actually hidden + +## [1.1.5] - 2019-11-09 +### Fixed +- Polls having different numbers in timelines/notifications/poll api endpoints due to cache desyncronization +- Pleroma API: OAuth token endpoint not being found when ".json" suffix is appended + +### Changed +- Frontend bundle updated to [044c9ad0](https://git.pleroma.social/pleroma/pleroma-fe/commit/044c9ad0562af059dd961d50961a3880fca9c642) + +## [1.1.4] - 2019-11-01 +### Fixed +- Added a migration that fills up empty user.info fields to prevent breakage after previous unsafe migrations. +- Failure to migrate from pre-1.0.0 versions +- Mastodon API: Notification stream not including follow notifications + +## [1.1.3] - 2019-10-25 +### Fixed +- Blocked users showing up in notifications collapsed as if they were muted +- `pleroma_ctl` not working on Debian's default shell + +## [1.1.2] - 2019-10-18 +### Fixed +- `pleroma_ctl` trying to connect to a running instance when generating the config, which of course doesn't exist. + +## [1.1.1] - 2019-10-18 +### Fixed +- One of the migrations between 1.0.0 and 1.1.0 wiping user info of the relay user because of unexpected behavior of postgresql's `jsonb_set`, resulting in inability to post in the default configuration. If you were affected, please run the following query in postgres console, the relay user will be recreated automatically: +``` +delete from users where ap_id = 'https://your.instance.hostname/relay'; +``` +- Bad user search matches + +## [1.1.0] - 2019-10-14 +**Breaking:** The stable branch has been changed from `master` to `stable`. If you want to keep using 1.0, the `release/1.0` branch will receive security updates for 6 months after 1.1 release. + +**OTP Note:** `pleroma_ctl` in 1.0 defaults to `master` and doesn't support specifying arbitrary branches, making `./pleroma_ctl update` fail. To fix this, fetch a version of `pleroma_ctl` from 1.1 using the command below and proceed with the update normally: +``` +curl -Lo ./bin/pleroma_ctl 'https://git.pleroma.social/pleroma/pleroma/raw/develop/rel/files/bin/pleroma_ctl' +``` +### Security +- Mastodon API: respect post privacy in `/api/v1/statuses/:id/{favourited,reblogged}_by` + +### Removed +- **Breaking:** GNU Social API with Qvitter extensions support +- Emoji: Remove longfox emojis. +- Remove `Reply-To` header from report emails for admins. +- ActivityPub: The `/objects/:uuid/likes` endpoint. + +### Changed +- **Breaking:** Configuration: A setting to explicitly disable the mailer was added, defaulting to true, if you are using a mailer add `config :pleroma, Pleroma.Emails.Mailer, enabled: true` to your config +- **Breaking:** Configuration: `/media/` is now removed when `base_url` is configured, append `/media/` to your `base_url` config to keep the old behaviour if desired +- **Breaking:** `/api/pleroma/notifications/read` is moved to `/api/v1/pleroma/notifications/read` and now supports `max_id` and responds with Mastodon API entities. +- Configuration: added `config/description.exs`, from which `docs/config.md` is generated +- Configuration: OpenGraph and TwitterCard providers enabled by default +- Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text +- Federation: Return 403 errors when trying to request pages from a user's follower/following collections if they have `hide_followers`/`hide_follows` set +- NodeInfo: Return `skipThreadContainment` in `metadata` for the `skip_thread_containment` option +- NodeInfo: Return `mailerEnabled` in `metadata` +- Mastodon API: Unsubscribe followers when they unfollow a user +- Mastodon API: `pleroma.thread_muted` key in the Status entity +- AdminAPI: Add "godmode" while fetching user statuses (i.e. admin can see private statuses) +- Improve digest email template +– Pagination: (optional) return `total` alongside with `items` when paginating +- The `Pleroma.FlakeId` module has been replaced with the `flake_id` library. + +### Fixed +- Following from Osada +- Favorites timeline doing database-intensive queries +- Metadata rendering errors resulting in the entire page being inaccessible +- `federation_incoming_replies_max_depth` option being ignored in certain cases +- Mastodon API: Handling of search timeouts (`/api/v1/search` and `/api/v2/search`) +- Mastodon API: Misskey's endless polls being unable to render +- Mastodon API: Embedded relationships not being properly rendered in the Account entity of Status entity +- Mastodon API: Notifications endpoint crashing if one notification failed to render +- Mastodon API: `exclude_replies` is correctly handled again. +- Mastodon API: Add `account_id`, `type`, `offset`, and `limit` to search API (`/api/v1/search` and `/api/v2/search`) +- Mastodon API, streaming: Fix filtering of notifications based on blocks/mutes/thread mutes +- Mastodon API: Fix private and direct statuses not being filtered out from the public timeline for an authenticated user (`GET /api/v1/timelines/public`) +- Mastodon API: Ensure the `account` field is not empty when rendering Notification entities. +- Mastodon API: Inability to get some local users by nickname in `/api/v1/accounts/:id_or_nickname` +- Mastodon API: Blocks are now treated consistently between the Streaming API and the Timeline APIs +- Rich Media: Parser failing when no TTL can be found by image TTL setters +- Rich Media: The crawled URL is now spliced into the rich media data. +- ActivityPub S2S: sharedInbox usage has been mostly aligned with the rules in the AP specification. +- ActivityPub C2S: follower/following collection pages being inaccessible even when authentifucated if `hide_followers`/ `hide_follows` was set +- ActivityPub: Deactivated user deletion +- ActivityPub: Fix `/users/:nickname/inbox` crashing without an authenticated user +- MRF: fix ability to follow a relay when AntiFollowbotPolicy was enabled +- ActivityPub: Correct addressing of Undo. +- ActivityPub: Correct addressing of profile update activities. +- ActivityPub: Polls are now refreshed when necessary. +- Report emails now include functional links to profiles of remote user accounts +- Existing user id not being preserved on insert conflict +- Pleroma.Upload base_url was not automatically whitelisted by MediaProxy. Now your custom CDN or file hosting will be accessed directly as expected. +- Report email not being sent to admins when the reporter is a remote user +- Reverse Proxy limiting `max_body_length` was incorrectly defined and only checked `Content-Length` headers which may not be sufficient in some circumstances + +### Added +- Expiring/ephemeral activites. All activities can have expires_at value set, which controls when they should be deleted automatically. +- Mastodon API: in post_status, the expires_in parameter lets you set the number of seconds until an activity expires. It must be at least one hour. +- Mastodon API: all status JSON responses contain a `pleroma.expires_at` item which states when an activity will expire. The value is only shown to the user who created the activity. To everyone else it's empty. +- Configuration: `ActivityExpiration.enabled` controls whether expired activites will get deleted at the appropriate time. Enabled by default. +- Conversations: Add Pleroma-specific conversation endpoints and status posting extensions. Run the `bump_all_conversations` task again to create the necessary data. +- MRF: Support for priming the mediaproxy cache (`Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`) +- MRF: Support for excluding specific domains from Transparency. +- MRF: Support for filtering posts based on who they mention (`Pleroma.Web.ActivityPub.MRF.MentionPolicy`) +- Mastodon API: Support for the [`tagged` filter](https://github.com/tootsuite/mastodon/pull/9755) in [`GET /api/v1/accounts/:id/statuses`](https://docs.joinmastodon.org/api/rest/accounts/#get-api-v1-accounts-id-statuses) +- Mastodon API, streaming: Add support for passing the token in the `Sec-WebSocket-Protocol` header +- Mastodon API, extension: Ability to reset avatar, profile banner, and background +- Mastodon API: Add support for `fields_attributes` API parameter (setting custom fields) +- Mastodon API: Add support for categories for custom emojis by reusing the group feature. +- Mastodon API: Add support for muting/unmuting notifications +- Mastodon API: Add support for the `blocked_by` attribute in the relationship API (`GET /api/v1/accounts/relationships`). +- Mastodon API: Add support for the `domain_blocking` attribute in the relationship API (`GET /api/v1/accounts/relationships`). +- Mastodon API: Add `pleroma.deactivated` to the Account entity +- Mastodon API: added `/auth/password` endpoint for password reset with rate limit. +- Mastodon API: /api/v1/accounts/:id/statuses now supports nicknames or user id +- Mastodon API: Improve support for the user profile custom fields +- Mastodon API: Add support for `fields_attributes` API parameter (setting custom fields) +- Mastodon API: Added an endpoint to get multiple statuses by IDs (`GET /api/v1/statuses/?ids[]=1&ids[]=2`) +- Admin API: Return users' tags when querying reports +- Admin API: Return avatar and display name when querying users +- Admin API: Allow querying user by ID +- Admin API: Added support for `tuples`. +- Admin API: Added endpoints to run mix tasks pleroma.config migrate_to_db & pleroma.config migrate_from_db +- Added synchronization of following/followers counters for external users +- Configuration: `enabled` option for `Pleroma.Emails.Mailer`, defaulting to `false`. +- Configuration: Pleroma.Plugs.RateLimiter `bucket_name`, `params` options. +- Configuration: `user_bio_length` and `user_name_length` options. +- Addressable lists +- Twitter API: added rate limit for `/api/account/password_reset` endpoint. +- ActivityPub: Add an internal service actor for fetching ActivityPub objects. +- ActivityPub: Optional signing of ActivityPub object fetches. +- Admin API: Endpoint for fetching latest user's statuses +- Pleroma API: Add `/api/v1/pleroma/accounts/confirmation_resend?email=` for resending account confirmation. +- Pleroma API: Email change endpoint. +- Admin API: Added moderation log +- Web response cache (currently, enabled for ActivityPub) +- Reverse Proxy: Do not retry failed requests to limit pressure on the peer + +### Changed +- Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text +- Admin API: changed json structure for saving config settings. +- RichMedia: parsers and their order are configured in `rich_media` config. +- RichMedia: add the rich media ttl based on image expiration time. + +## [1.0.7] - 2019-09-26 +### Fixed +- Broken federation on Erlang 22 (previous versions of hackney http client were using an option that got deprecated) +### Changed +- ActivityPub: The first page in inboxes/outboxes is no longer embedded. + +## [1.0.6] - 2019-08-14 +### Fixed +- MRF: fix use of unserializable keyword lists in describe() implementations +- ActivityPub S2S: POST requests are now signed with `(request-target)` pseudo-header. + +## [1.0.5] - 2019-08-13 +### Fixed +- Mastodon API: follower/following counters not being nullified, when `hide_follows`/`hide_followers` is set +- Mastodon API: `muted` in the Status entity, using author's account to determine if the thread was muted +- Mastodon API: return the actual profile URL in the Account entity's `url` property when appropriate +- Templates: properly style anchor tags +- Objects being re-embedded to activities after being updated (e.g faved/reposted). Running 'mix pleroma.database prune_objects' again is advised. +- Not being able to access the Mastodon FE login page on private instances +- MRF: ensure that subdomain_match calls are case-insensitive +- Fix internal server error when using the healthcheck API. + +### Added +- **Breaking:** MRF describe API, which adds support for exposing configuration information about MRF policies to NodeInfo. + Custom modules will need to be updated by adding, at the very least, `def describe, do: {:ok, %{}}` to the MRF policy modules. +- Relays: Added a task to list relay subscriptions. +- MRF: Support for filtering posts based on ActivityStreams vocabulary (`Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`) +- MRF (Simple Policy): Support for wildcard domains. +- Support for wildcard domains in user domain blocks setting. +- Configuration: `quarantined_instances` support wildcard domains. +- Mix Tasks: `mix pleroma.database fix_likes_collections` +- Configuration: `federation_incoming_replies_max_depth` option + +### Removed +- Federation: Remove `likes` from objects. +- **Breaking:** ActivityPub: The `accept_blocks` configuration setting. + +## [1.0.4] - 2019-08-01 +### Fixed +- Invalid SemVer version generation, when the current branch does not have commits ahead of tag/checked out on a tag + +## [1.0.3] - 2019-07-31 +### Security +- OStatus: eliminate the possibility of a protocol downgrade attack. +- OStatus: prevent following locked accounts, bypassing the approval process. +- TwitterAPI: use CommonAPI to handle remote follows instead of OStatus. + +## [1.0.2] - 2019-07-28 +### Fixed +- Not being able to pin unlisted posts +- Mastodon API: represent poll IDs as strings +- MediaProxy: fix matching filenames +- MediaProxy: fix filename encoding +- Migrations: fix a sporadic migration failure +- Metadata rendering errors resulting in the entire page being inaccessible +- Federation/MediaProxy not working with instances that have wrong certificate order +- ActivityPub S2S: remote user deletions now work the same as local user deletions. + +### Changed +- Configuration: OpenGraph and TwitterCard providers enabled by default +- Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text + +## [1.0.1] - 2019-07-14 +### Security +- OStatus: fix an object spoofing vulnerability. + +## [1.0.0] - 2019-06-29 +### Security +- Mastodon API: Fix display names not being sanitized +- Rich media: Do not crawl private IP ranges + +### Added +- Digest email for inactive users +- Add a generic settings store for frontends / clients to use. +- Explicit addressing option for posting. +- Optional SSH access mode. (Needs `erlang-ssh` package on some distributions). +- [MongooseIM](https://github.com/esl/MongooseIM) http authentication support. +- LDAP authentication +- External OAuth provider authentication +- Support for building a release using [`mix release`](https://hexdocs.pm/mix/master/Mix.Tasks.Release.html) +- A [job queue](https://git.pleroma.social/pleroma/pleroma_job_queue) for federation, emails, web push, etc. +- [Prometheus](https://prometheus.io/) metrics +- Support for Mastodon's remote interaction +- Mix Tasks: `mix pleroma.database bump_all_conversations` +- Mix Tasks: `mix pleroma.database remove_embedded_objects` +- Mix Tasks: `mix pleroma.database update_users_following_followers_counts` +- Mix Tasks: `mix pleroma.user toggle_confirmed` +- Mix Tasks: `mix pleroma.config migrate_to_db` +- Mix Tasks: `mix pleroma.config migrate_from_db` +- Federation: Support for `Question` and `Answer` objects +- Federation: Support for reports +- Configuration: `poll_limits` option +- Configuration: `pack_extensions` option +- Configuration: `safe_dm_mentions` option +- Configuration: `link_name` option +- Configuration: `fetch_initial_posts` option +- Configuration: `notify_email` option +- Configuration: Media proxy `whitelist` option +- Configuration: `report_uri` option +- Configuration: `email_notifications` option +- Configuration: `limit_to_local_content` option +- Pleroma API: User subscriptions +- Pleroma API: Healthcheck endpoint +- Pleroma API: `/api/v1/pleroma/mascot` per-user frontend mascot configuration endpoints +- Admin API: Endpoints for listing/revoking invite tokens +- Admin API: Endpoints for making users follow/unfollow each other +- Admin API: added filters (role, tags, email, name) for users endpoint +- Admin API: Endpoints for managing reports +- Admin API: Endpoints for deleting and changing the scope of individual reported statuses +- Admin API: Endpoints to view and change config settings. +- AdminFE: initial release with basic user management accessible at /pleroma/admin/ +- Mastodon API: Add chat token to `verify_credentials` response +- Mastodon API: Add background image setting to `update_credentials` +- Mastodon API: [Scheduled statuses](https://docs.joinmastodon.org/api/rest/scheduled-statuses/) +- Mastodon API: `/api/v1/notifications/destroy_multiple` (glitch-soc extension) +- Mastodon API: `/api/v1/pleroma/accounts/:id/favourites` (API extension) +- Mastodon API: [Reports](https://docs.joinmastodon.org/api/rest/reports/) +- Mastodon API: `POST /api/v1/accounts` (account creation API) +- Mastodon API: [Polls](https://docs.joinmastodon.org/api/rest/polls/) +- ActivityPub C2S: OAuth endpoints +- Metadata: RelMe provider +- OAuth: added support for refresh tokens +- Emoji packs and emoji pack manager +- Object pruning (`mix pleroma.database prune_objects`) +- OAuth: added job to clean expired access tokens +- MRF: Support for rejecting reports from specific instances (`mrf_simple`) +- MRF: Support for stripping avatars and banner images from specific instances (`mrf_simple`) +- MRF: Support for running subchains. +- Configuration: `skip_thread_containment` option +- Configuration: `rate_limit` option. See `Pleroma.Plugs.RateLimiter` documentation for details. +- MRF: Support for filtering out likely spam messages by rejecting posts from new users that contain links. +- Configuration: `ignore_hosts` option +- Configuration: `ignore_tld` option +- Configuration: default syslog tag "Pleroma" is now lowercased to "pleroma" + +### Changed +- **Breaking:** bind to 127.0.0.1 instead of 0.0.0.0 by default +- **Breaking:** Configuration: move from Pleroma.Mailer to Pleroma.Emails.Mailer +- Thread containment / test for complete visibility will be skipped by default. +- Enforcement of OAuth scopes +- Add multiple use/time expiring invite token +- Restyled OAuth pages to fit with Pleroma's default theme +- Link/mention/hashtag detection is now handled by [auto_linker](https://git.pleroma.social/pleroma/auto_linker) +- NodeInfo: Return `safe_dm_mentions` feature flag +- Federation: Expand the audience of delete activities to all recipients of the deleted object +- Federation: Removed `inReplyToStatusId` from objects +- Configuration: Dedupe enabled by default +- Configuration: Default log level in `prod` environment is now set to `warn` +- Configuration: Added `extra_cookie_attrs` for setting non-standard cookie attributes. Defaults to ["SameSite=Lax"] so that remote follows work. +- Timelines: Messages involving people you have blocked will be excluded from the timeline in all cases instead of just repeats. +- Admin API: Move the user related API to `api/pleroma/admin/users` +- Admin API: `POST /api/pleroma/admin/users` will take list of users +- Pleroma API: Support for emoji tags in `/api/pleroma/emoji` resulting in a breaking API change +- Mastodon API: Support for `exclude_types`, `limit` and `min_id` in `/api/v1/notifications` +- Mastodon API: Add `languages` and `registrations` to `/api/v1/instance` +- Mastodon API: Provide plaintext versions of cw/content in the Status entity +- Mastodon API: Add `pleroma.conversation_id`, `pleroma.in_reply_to_account_acct` fields to the Status entity +- Mastodon API: Add `pleroma.tags`, `pleroma.relationship{}`, `pleroma.is_moderator`, `pleroma.is_admin`, `pleroma.confirmation_pending`, `pleroma.hide_followers`, `pleroma.hide_follows`, `pleroma.hide_favorites` fields to the User entity +- Mastodon API: Add `pleroma.show_role`, `pleroma.no_rich_text` fields to the Source subentity +- Mastodon API: Add support for updating `no_rich_text`, `hide_followers`, `hide_follows`, `hide_favorites`, `show_role` in `PATCH /api/v1/update_credentials` +- Mastodon API: Add `pleroma.is_seen` to the Notification entity +- Mastodon API: Add `pleroma.local` to the Status entity +- Mastodon API: Add `preview` parameter to `POST /api/v1/statuses` +- Mastodon API: Add `with_muted` parameter to timeline endpoints +- Mastodon API: Actual reblog hiding instead of a dummy +- Mastodon API: Remove attachment limit in the Status entity +- Mastodon API: Added support max_id & since_id for bookmark timeline endpoints. +- Deps: Updated Cowboy to 2.6 +- Deps: Updated Ecto to 3.0.7 +- Don't ship finmoji by default, they can be installed as an emoji pack +- Hide deactivated users and their statuses +- Posts which are marked sensitive or tagged nsfw no longer have link previews. +- HTTP connection timeout is now set to 10 seconds. +- Respond with a 404 Not implemented JSON error message when requested API is not implemented +- Rich Media: crawl only https URLs. + +### Fixed +- Follow requests don't get 'stuck' anymore. +- Added an FTS index on objects. Running `vacuum analyze` and setting a larger `work_mem` is recommended. +- Followers counter not being updated when a follower is blocked +- Deactivated users being able to request an access token +- Limit on request body in rich media/relme parsers being ignored resulting in a possible memory leak +- Proper Twitter Card generation instead of a dummy +- Deletions failing for users with a large number of posts +- NodeInfo: Include admins in `staffAccounts` +- ActivityPub: Crashing when requesting empty local user's outbox +- Federation: Handling of objects without `summary` property +- Federation: Add a language tag to activities as required by ActivityStreams 2.0 +- Federation: Do not federate avatar/banner if set to default allowing other servers/clients to use their defaults +- Federation: Cope with missing or explicitly nulled address lists +- Federation: Explicitly ensure activities addressed to `as:Public` become addressed to the followers collection +- Federation: Better cope with actors which do not declare a followers collection and use `as:Public` with these semantics +- Federation: Follow requests from remote users who have been blocked will be automatically rejected if appropriate +- MediaProxy: Parse name from content disposition headers even for non-whitelisted types +- MediaProxy: S3 link encoding +- Rich Media: Reject any data which cannot be explicitly encoded into JSON +- Pleroma API: Importing follows from Mastodon 2.8+ +- Twitter API: Exposing default scope, `no_rich_text` of the user to anyone +- Twitter API: Returning the `role` object in user entity despite `show_role = false` +- Mastodon API: `/api/v1/favourites` serving only public activities +- Mastodon API: Reblogs having `in_reply_to_id` - `null` even when they are replies +- Mastodon API: Streaming API broadcasting wrong activity id +- Mastodon API: 500 errors when requesting a card for a private conversation +- Mastodon API: Handling of `reblogs` in `/api/v1/accounts/:id/follow` +- Mastodon API: Correct `reblogged`, `favourited`, and `bookmarked` values in the reblog status JSON +- Mastodon API: Exposing default scope of the user to anyone +- Mastodon API: Make `irreversible` field default to `false` [`POST /api/v1/filters`] +- Mastodon API: Replace missing non-nullable Card attributes with empty strings +- User-Agent is now sent correctly for all HTTP requests. +- MRF: Simple policy now properly delists imported or relayed statuses + +## Removed +- Configuration: `config :pleroma, :fe` in favor of the more flexible `config :pleroma, :frontend_configurations` + +## [0.9.99999] - 2019-05-31 +### Security +- Mastodon API: Fix lists leaking private posts + +## [0.9.9999] - 2019-04-05 +### Security +- Mastodon API: Fix content warnings skipping HTML sanitization + +## [0.9.999] - 2019-03-13 +Frontend changes only. +### Added +- Added floating action button for posting status on mobile +### Changed +- Changed user-settings icon to a pencil +### Fixed +- Keyboard shortcuts activating when typing a message +- Gaps when scrolling down on a timeline after showing new + +## [0.9.99] - 2019-03-08 +### Changed +- Update the frontend to the 0.9.99 tag +### Fixed +- Sign the date header in federation to fix Mastodon federation. + +## [0.9.9] - 2019-02-22 +This is our first stable release. diff --git a/CHANGELOG_soapbox.md b/CHANGELOG_soapbox.md new file mode 100644 index 0000000..829e4b5 --- /dev/null +++ b/CHANGELOG_soapbox.md @@ -0,0 +1,66 @@ +# Changelog + +All notable changes to this project will be documented in this file. +This file is only for changes to Soapbox. +For changes to Pleroma, see `CHANGELOG.md` + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## Unreleased + +- Added `AntiDuplicationPolicy` and `AntiMentionSpamPolicy` for spam. + +### Fixed +- Link previews not working for some websites. Let rich media user-agent be configurable (with `:pleroma, :rich_media, user_agent: "whatever"`). + +## [3.0.0] - 2022-05-24 + +Based on Pleroma 2.4 develop. + +TODO: a full changelog + +### Added + +- Events +- Translations + +## [1.1.1] - 2021-05-21 + +Based on Pleroma 2.3.0-stable. + +### Fixed +- Broken frontend due to not all files being in the repo. + +## [1.1.0] - 2021-05-18 + +Based on Pleroma 2.3.0-stable. + +### Added +- Retain uploaded image aspect ratios. ([!18](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/18)) +- Blurhash support. ([!21](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/21)) + +### Fixed +- Rich media not working for certain links. ([!19](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/19), [!20](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/20)) + +### Changed +- Prepared installation files for use with asdf version manager. ([!22](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/22), [!23](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/23), [!24](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/24)) +- **BREAKING:** `imagemagick` and `exiftool` are required to be installed on the system. + +## [1.0.0] - 2021-05-11 + +Based on Pleroma 2.3.0-stable. + +### Added +- Rich media embeds for sites like YouTube, etc. ([!13](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/13)) +- Twitter-like block behavior, configured under "ActivityPub > Blockers visible" in AdminFE. ([!9](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/9)) +- The Soapbox version in `/api/v1/instance` ([!6](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/6)) + +### Changed +- Soapbox FE is set as the default frontend. ([!16](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/16)) +- Twitter-like block behavior is now the default. ([!9](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/9)) + +### Fixed +- Domain blocks: reposts from a blocked domain are now correctly blocked. ([!11](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/11)) +- Fixed some (not all) Markdown issues, such as broken trailing slash in links. ([!10](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/10)) +- Don't crash so hard when email settings are invalid. ([!12](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/12)) +- Return OpenGraph metadata on Soapbox FE routes. ([!14](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/14)) diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..4c2a28a --- /dev/null +++ b/COPYING @@ -0,0 +1,56 @@ +Unless otherwise stated this repository is copyright © 2017-2022 +Pleroma Authors , and is distributed under +The GNU Affero General Public License Version 3, you should have received a +copy of the license file as AGPL-3. + +--- + +Files inside docs directory are copyright © 2022 Pleroma Authors +, and are distributed under the Creative Commons +Attribution 4.0 International license, you should have received +a copy of the license file as CC-BY-4.0. + +--- + +The following files are copyright © 2019 shitposter.club, and are distributed +under the Creative Commons Attribution-ShareAlike 4.0 International license, +you should have received a copy of the license file as CC-BY-SA-4.0. + +priv/static/images/pleroma-fox-tan.png +priv/static/images/pleroma-fox-tan-smol.png +priv/static/images/pleroma-tan.png + +--- + +The following files are copyright © 2019 shitposter.club, and are distributed +under the Creative Commons Attribution 4.0 International license, you should +have received a copy of the license file as CC-BY-4.0. + +priv/static/images/pleroma-fox-tan-shy.png + +--- + +The following files are copyright © 2017-2022 Pleroma Authors +, and are distributed under the Creative Commons +Attribution-ShareAlike 4.0 International license, you should have received +a copy of the license file as CC-BY-SA-4.0. + +priv/static/images/avi.png +priv/static/images/banner.png +priv/static/instance/thumbnail.jpeg + +--- + +All photos published on Unsplash can be used for free. You can use them for +commercial and noncommercial purposes. You do not need to ask permission from +or provide credit to the photographer or Unsplash, although it is appreciated +when possible. + +More precisely, Unsplash grants you an irrevocable, nonexclusive, worldwide +copyright license to download, copy, modify, distribute, perform, and use +photos from Unsplash for free, including for commercial purposes, without +permission from or attributing the photographer or Unsplash. This license +does not include the right to compile photos from Unsplash to replicate +a similar or competing service. + +priv/static/images/city.jpg diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e3fabf5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +FROM ubuntu:22.04 as build + +ARG MIX_ENV=prod \ + OAUTH_CONSUMER_STRATEGIES="twitter facebook google microsoft slack github keycloak:ueberauth_keycloak_strategy" + +WORKDIR /src + +RUN apt-get update &&\ + apt-get install -y git elixir erlang-dev erlang-nox build-essential cmake libssl-dev libmagic-dev automake autoconf libncurses5-dev &&\ + mix local.hex --force &&\ + mix local.rebar --force + +COPY . /src + +RUN cd /src &&\ + mix deps.get --only prod &&\ + mkdir release &&\ + mix release --path release + +FROM ubuntu:22.04 + +ARG BUILD_DATE +ARG VCS_REF + +ARG DEBIAN_FRONTEND="noninteractive" +ENV TZ="Etc/UTC" + +LABEL maintainer="hello@soapbox.pub" \ + org.opencontainers.image.title="rebased" \ + org.opencontainers.image.description="Rebased" \ + org.opencontainers.image.authors="hello@soapbox.pub" \ + org.opencontainers.image.vendor="soapbox.pub" \ + org.opencontainers.image.documentation="https://gitlab.com/soapbox-pub/rebased" \ + org.opencontainers.image.licenses="AGPL-3.0" \ + org.opencontainers.image.url="https://soapbox.pub" \ + org.opencontainers.image.revision=$VCS_REF \ + org.opencontainers.image.created=$BUILD_DATE + +ARG HOME=/opt/pleroma +ARG DATA=/var/lib/pleroma + +RUN apt-get update &&\ + apt-get install -y --no-install-recommends curl ca-certificates imagemagick libmagic-dev ffmpeg libimage-exiftool-perl libncurses5 postgresql-client fasttext &&\ + adduser --system --shell /bin/false --home ${HOME} pleroma &&\ + mkdir -p ${DATA}/uploads &&\ + mkdir -p ${DATA}/static &&\ + chown -R pleroma ${DATA} &&\ + mkdir -p /etc/pleroma &&\ + chown -R pleroma /etc/pleroma &&\ + mkdir -p /usr/share/fasttext &&\ + curl -L https://dl.fbaipublicfiles.com/fasttext/supervised-models/lid.176.ftz -o /usr/share/fasttext/lid.176.ftz &&\ + chmod 0644 /usr/share/fasttext/lid.176.ftz + +USER pleroma + +COPY --from=build --chown=pleroma:0 /src/release ${HOME} + +COPY --chown=pleroma --chmod=640 ./config/docker.exs /etc/pleroma/config.exs +COPY ./docker-entrypoint.sh ${HOME} + +ENTRYPOINT ["/opt/pleroma/docker-entrypoint.sh"] diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..7ac187b --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +web: mix phx.server +release: mix ecto.migrate diff --git a/README.md b/README.md new file mode 100644 index 0000000..82d42a0 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Rebased + +![Rebased](rebased.png) + +**Rebased** is a Fediverse backend written in Elixir. +It's compatible with the Mastodon API and is the recommended backend for Soapbox. + +## Your social media server + +Rebased empowers people to take control of their social media experience. +Hosting your own server means that *you* get to decide the rules. + +Rebased connects to over 4,000 other servers on the Fediverse. +It is designed to spread your message far and wide, while being resilient to deplatforming. + +## Installation + +See [the installation guide](https://soapbox.pub/install/). + +## License + +Rebased is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Rebased is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with Rebased. If not, see . diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c009d21 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,16 @@ +# Pleroma backend security policy + +## Supported versions + +Currently, Pleroma offers bugfixes and security patches only for the latest minor release. + +| Version | Support +|---------| -------- +| 2.2 | Bugfixes and security patches + +## Reporting a vulnerability + +Please use confidential issues (tick the "This issue is confidential and should only be visible to team members with at least Reporter access." box when submitting) at our [bugtracker](https://git.pleroma.social/pleroma/pleroma/-/issues/new) for reporting vulnerabilities. +## Announcements + +New releases are announced at [pleroma.social](https://pleroma.social/announcements/). All security releases are tagged with ["Security"](https://pleroma.social/announcements/tags/security/). You can be notified of them by subscribing to an Atom feed at . diff --git a/app.json b/app.json new file mode 100644 index 0000000..b0b28b9 --- /dev/null +++ b/app.json @@ -0,0 +1,13 @@ +{ + "name": "Rebased", + "description": "Rebased, the recommended backend for Soapbox written in Elixir.", + "keywords": [ + "fediverse" + ], + "website": "https://soapbox.pub", + "dokku": { + "plugins": [ + "postgres" + ] + } +} \ No newline at end of file diff --git a/benchmarks/load_testing/activities.ex b/benchmarks/load_testing/activities.ex new file mode 100644 index 0000000..7f262d2 --- /dev/null +++ b/benchmarks/load_testing/activities.ex @@ -0,0 +1,595 @@ +defmodule Pleroma.LoadTesting.Activities do + @moduledoc """ + Module for generating different activities. + """ + import Ecto.Query + import Pleroma.LoadTesting.Helper, only: [to_sec: 1] + + alias Ecto.UUID + alias Pleroma.Constants + alias Pleroma.LoadTesting.Users + alias Pleroma.Repo + alias Pleroma.Web.CommonAPI + + require Constants + + @defaults [ + iterations: 170, + friends_used: 20, + non_friends_used: 20 + ] + + @max_concurrency 10 + + @visibility ~w(public private direct unlisted) + @types [ + :simple, + :simple_filtered, + :emoji, + :mentions, + :hell_thread, + :attachment, + :tag, + :like, + :reblog, + :simple_thread + ] + @groups [:friends_local, :friends_remote, :non_friends_local, :non_friends_local] + @remote_groups [:friends_remote, :non_friends_remote] + @friends_groups [:friends_local, :friends_remote] + @non_friends_groups [:non_friends_local, :non_friends_remote] + + @spec generate(User.t(), keyword()) :: :ok + def generate(user, opts \\ []) do + {:ok, _} = + Agent.start_link(fn -> %{} end, + name: :benchmark_state + ) + + opts = Keyword.merge(@defaults, opts) + + users = Users.prepare_users(user, opts) + + {:ok, _} = Agent.start_link(fn -> users[:non_friends_remote] end, name: :non_friends_remote) + + task_data = + for visibility <- @visibility, + type <- @types, + group <- [:user | @groups], + do: {visibility, type, group} + + IO.puts("Starting generating #{opts[:iterations]} iterations of activities...") + + public_long_thread = fn -> + generate_long_thread("public", users, opts) + end + + private_long_thread = fn -> + generate_long_thread("private", users, opts) + end + + iterations = opts[:iterations] + + {time, _} = + :timer.tc(fn -> + Enum.each( + 1..iterations, + fn + i when i == iterations - 2 -> + spawn(public_long_thread) + spawn(private_long_thread) + generate_activities(users, Enum.shuffle(task_data), opts) + + _ -> + generate_activities(users, Enum.shuffle(task_data), opts) + end + ) + end) + + IO.puts("Generating iterations of activities took #{to_sec(time)} sec.\n") + :ok + end + + def generate_power_intervals(opts \\ []) do + count = Keyword.get(opts, :count, 20) + power = Keyword.get(opts, :power, 2) + IO.puts("Generating #{count} intervals for a power #{power} series...") + counts = Enum.map(1..count, fn n -> :math.pow(n, power) end) + sum = Enum.sum(counts) + + densities = + Enum.map(counts, fn c -> + c / sum + end) + + densities + |> Enum.reduce(0, fn density, acc -> + if acc == 0 do + [{0, density}] + else + [{_, lower} | _] = acc + [{lower, lower + density} | acc] + end + end) + |> Enum.reverse() + end + + def generate_tagged_activities(opts \\ []) do + tag_count = Keyword.get(opts, :tag_count, 20) + users = Keyword.get(opts, :users, Repo.all(Pleroma.User)) + activity_count = Keyword.get(opts, :count, 200_000) + + intervals = generate_power_intervals(count: tag_count) + + IO.puts( + "Generating #{activity_count} activities using #{tag_count} different tags of format `tag_n`, starting at tag_0" + ) + + Enum.each(1..activity_count, fn _ -> + random = :rand.uniform() + i = Enum.find_index(intervals, fn {lower, upper} -> lower <= random && upper > random end) + CommonAPI.post(Enum.random(users), %{status: "a post with the tag #tag_#{i}"}) + end) + end + + defp generate_long_thread(visibility, users, _opts) do + group = + if visibility == "public", + do: :friends_local, + else: :user + + tasks = get_reply_tasks(visibility, group) |> Stream.cycle() |> Enum.take(50) + + {:ok, activity} = + CommonAPI.post(users[:user], %{ + status: "Start of #{visibility} long thread", + visibility: visibility + }) + + Agent.update(:benchmark_state, fn state -> + key = + if visibility == "public", + do: :public_thread, + else: :private_thread + + Map.put(state, key, activity) + end) + + acc = {activity.id, ["@" <> users[:user].nickname, "reply to long thread"]} + insert_replies_for_long_thread(tasks, visibility, users, acc) + IO.puts("Generating #{visibility} long thread ended\n") + end + + defp insert_replies_for_long_thread(tasks, visibility, users, acc) do + Enum.reduce(tasks, acc, fn + :user, {id, data} -> + user = users[:user] + insert_reply(user, List.delete(data, "@" <> user.nickname), id, visibility) + + group, {id, data} -> + replier = Enum.random(users[group]) + insert_reply(replier, List.delete(data, "@" <> replier.nickname), id, visibility) + end) + end + + defp generate_activities(users, task_data, opts) do + Task.async_stream( + task_data, + fn {visibility, type, group} -> + insert_activity(type, visibility, group, users, opts) + end, + max_concurrency: @max_concurrency, + timeout: 30_000 + ) + |> Stream.run() + end + + defp insert_local_activity(visibility, group, users, status) do + {:ok, _} = + group + |> get_actor(users) + |> CommonAPI.post(%{status: status, visibility: visibility}) + end + + defp insert_remote_activity(visibility, group, users, status) do + actor = get_actor(group, users) + {act_data, obj_data} = prepare_activity_data(actor, visibility, users[:user]) + {activity_data, object_data} = other_data(actor, status) + + activity_data + |> Map.merge(act_data) + |> Map.put("object", Map.merge(object_data, obj_data)) + |> Pleroma.Web.ActivityPub.ActivityPub.insert(false) + end + + defp user_mentions(users) do + user_mentions = + Enum.reduce( + @groups, + [], + fn group, acc -> + acc ++ get_random_mentions(users[group], Enum.random(0..2)) + end + ) + + if Enum.random([true, false]), + do: ["@" <> users[:user].nickname | user_mentions], + else: user_mentions + end + + defp hell_thread_mentions(users) do + with {:ok, nil} <- Cachex.get(:user_cache, "hell_thread_mentions") do + cached = + @groups + |> Enum.reduce([users[:user]], fn group, acc -> + acc ++ Enum.take(users[group], 5) + end) + |> Enum.map(&"@#{&1.nickname}") + |> Enum.join(", ") + + Cachex.put(:user_cache, "hell_thread_mentions", cached) + cached + else + {:ok, cached} -> cached + end + end + + defp insert_activity(:simple, visibility, group, users, _opts) + when group in @remote_groups do + insert_remote_activity(visibility, group, users, "Remote status") + end + + defp insert_activity(:simple, visibility, group, users, _opts) do + insert_local_activity(visibility, group, users, "Simple status") + end + + defp insert_activity(:simple_filtered, visibility, group, users, _opts) + when group in @remote_groups do + insert_remote_activity(visibility, group, users, "Remote status which must be filtered") + end + + defp insert_activity(:simple_filtered, visibility, group, users, _opts) do + insert_local_activity(visibility, group, users, "Simple status which must be filtered") + end + + defp insert_activity(:emoji, visibility, group, users, _opts) + when group in @remote_groups do + insert_remote_activity(visibility, group, users, "Remote status with emoji :firefox:") + end + + defp insert_activity(:emoji, visibility, group, users, _opts) do + insert_local_activity(visibility, group, users, "Simple status with emoji :firefox:") + end + + defp insert_activity(:mentions, visibility, group, users, _opts) + when group in @remote_groups do + mentions = user_mentions(users) + + status = Enum.join(mentions, ", ") <> " remote status with mentions" + + insert_remote_activity(visibility, group, users, status) + end + + defp insert_activity(:mentions, visibility, group, users, _opts) do + mentions = user_mentions(users) + + status = Enum.join(mentions, ", ") <> " simple status with mentions" + insert_remote_activity(visibility, group, users, status) + end + + defp insert_activity(:hell_thread, visibility, group, users, _) + when group in @remote_groups do + mentions = hell_thread_mentions(users) + insert_remote_activity(visibility, group, users, mentions <> " remote hell thread status") + end + + defp insert_activity(:hell_thread, visibility, group, users, _opts) do + mentions = hell_thread_mentions(users) + + insert_local_activity(visibility, group, users, mentions <> " hell thread status") + end + + defp insert_activity(:attachment, visibility, group, users, _opts) do + actor = get_actor(group, users) + + obj_data = %{ + "actor" => actor.ap_id, + "name" => "4467-11.jpg", + "type" => "Document", + "url" => [ + %{ + "href" => + "#{Pleroma.Web.Endpoint.url()}/media/b1b873552422a07bf53af01f3c231c841db4dfc42c35efde681abaf0f2a4eab7.jpg", + "mediaType" => "image/jpeg", + "type" => "Link" + } + ] + } + + object = Repo.insert!(%Pleroma.Object{data: obj_data}) + + {:ok, _activity} = + CommonAPI.post(actor, %{ + status: "Post with attachment", + visibility: visibility, + media_ids: [object.id] + }) + end + + defp insert_activity(:tag, visibility, group, users, _opts) do + insert_local_activity(visibility, group, users, "Status with #tag") + end + + defp insert_activity(:like, visibility, group, users, opts) do + actor = get_actor(group, users) + + with activity_id when not is_nil(activity_id) <- get_random_create_activity_id(), + {:ok, _activity} <- CommonAPI.favorite(actor, activity_id) do + :ok + else + {:error, _} -> + insert_activity(:like, visibility, group, users, opts) + + nil -> + Process.sleep(15) + insert_activity(:like, visibility, group, users, opts) + end + end + + defp insert_activity(:reblog, visibility, group, users, opts) do + actor = get_actor(group, users) + + with activity_id when not is_nil(activity_id) <- get_random_create_activity_id(), + {:ok, _activity} <- CommonAPI.repeat(activity_id, actor) do + :ok + else + {:error, _} -> + insert_activity(:reblog, visibility, group, users, opts) + + nil -> + Process.sleep(15) + insert_activity(:reblog, visibility, group, users, opts) + end + end + + defp insert_activity(:simple_thread, "direct", group, users, _opts) do + actor = get_actor(group, users) + tasks = get_reply_tasks("direct", group) + + list = + case group do + :user -> + group = Enum.random(@friends_groups) + Enum.take(users[group], 3) + + _ -> + Enum.take(users[group], 3) + end + + data = Enum.map(list, &("@" <> &1.nickname)) + + {:ok, activity} = + CommonAPI.post(actor, %{ + status: Enum.join(data, ", ") <> "simple status", + visibility: "direct" + }) + + acc = {activity.id, ["@" <> users[:user].nickname | data] ++ ["reply to status"]} + insert_direct_replies(tasks, users[:user], list, acc) + end + + defp insert_activity(:simple_thread, visibility, group, users, _opts) do + actor = get_actor(group, users) + tasks = get_reply_tasks(visibility, group) + + {:ok, activity} = + CommonAPI.post(users[:user], %{status: "Simple status", visibility: visibility}) + + acc = {activity.id, ["@" <> actor.nickname, "reply to status"]} + insert_replies(tasks, visibility, users, acc) + end + + defp get_actor(:user, %{user: user}), do: user + defp get_actor(group, users), do: Enum.random(users[group]) + + defp other_data(actor, content) do + %{host: host} = URI.parse(actor.ap_id) + datetime = DateTime.utc_now() |> to_string() + context_id = "https://#{host}/contexts/#{UUID.generate()}" + activity_id = "https://#{host}/activities/#{UUID.generate()}" + object_id = "https://#{host}/objects/#{UUID.generate()}" + + activity_data = %{ + "actor" => actor.ap_id, + "context" => context_id, + "id" => activity_id, + "published" => datetime, + "type" => "Create", + "directMessage" => false + } + + object_data = %{ + "actor" => actor.ap_id, + "attachment" => [], + "attributedTo" => actor.ap_id, + "bcc" => [], + "bto" => [], + "content" => content, + "context" => context_id, + "conversation" => context_id, + "emoji" => %{}, + "id" => object_id, + "published" => datetime, + "sensitive" => false, + "summary" => "", + "tag" => [], + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "type" => "Note" + } + + {activity_data, object_data} + end + + defp prepare_activity_data(actor, "public", _mention) do + obj_data = %{ + "cc" => [actor.follower_address], + "to" => [Constants.as_public()] + } + + act_data = %{ + "cc" => [actor.follower_address], + "to" => [Constants.as_public()] + } + + {act_data, obj_data} + end + + defp prepare_activity_data(actor, "private", _mention) do + obj_data = %{ + "cc" => [], + "to" => [actor.follower_address] + } + + act_data = %{ + "cc" => [], + "to" => [actor.follower_address] + } + + {act_data, obj_data} + end + + defp prepare_activity_data(actor, "unlisted", _mention) do + obj_data = %{ + "cc" => [Constants.as_public()], + "to" => [actor.follower_address] + } + + act_data = %{ + "cc" => [Constants.as_public()], + "to" => [actor.follower_address] + } + + {act_data, obj_data} + end + + defp prepare_activity_data(_actor, "direct", mention) do + %{host: mentioned_host} = URI.parse(mention.ap_id) + + obj_data = %{ + "cc" => [], + "content" => + "@#{ + mention.nickname + } direct message", + "tag" => [ + %{ + "href" => mention.ap_id, + "name" => "@#{mention.nickname}@#{mentioned_host}", + "type" => "Mention" + } + ], + "to" => [mention.ap_id] + } + + act_data = %{ + "cc" => [], + "directMessage" => true, + "to" => [mention.ap_id] + } + + {act_data, obj_data} + end + + defp get_reply_tasks("public", :user) do + [:friends_local, :friends_remote, :non_friends_local, :non_friends_remote, :user] + end + + defp get_reply_tasks("public", group) when group in @friends_groups do + [:non_friends_local, :non_friends_remote, :user, :friends_local, :friends_remote] + end + + defp get_reply_tasks("public", group) when group in @non_friends_groups do + [:user, :friends_local, :friends_remote, :non_friends_local, :non_friends_remote] + end + + defp get_reply_tasks(visibility, :user) when visibility in ["unlisted", "private"] do + [:friends_local, :friends_remote, :user, :friends_local, :friends_remote] + end + + defp get_reply_tasks(visibility, group) + when visibility in ["unlisted", "private"] and group in @friends_groups do + [:user, :friends_remote, :friends_local, :user] + end + + defp get_reply_tasks(visibility, group) + when visibility in ["unlisted", "private"] and + group in @non_friends_groups, + do: [] + + defp get_reply_tasks("direct", :user), do: [:friends_local, :user, :friends_remote] + + defp get_reply_tasks("direct", group) when group in @friends_groups, + do: [:user, group, :user] + + defp get_reply_tasks("direct", group) when group in @non_friends_groups do + [:user, :non_friends_remote, :user, :non_friends_local] + end + + defp insert_replies(tasks, visibility, users, acc) do + Enum.reduce(tasks, acc, fn + :user, {id, data} -> + insert_reply(users[:user], data, id, visibility) + + group, {id, data} -> + replier = Enum.random(users[group]) + insert_reply(replier, data, id, visibility) + end) + end + + defp insert_direct_replies(tasks, user, list, acc) do + Enum.reduce(tasks, acc, fn + :user, {id, data} -> + {reply_id, _} = insert_reply(user, List.delete(data, "@" <> user.nickname), id, "direct") + {reply_id, data} + + _, {id, data} -> + actor = Enum.random(list) + + {reply_id, _} = + insert_reply(actor, List.delete(data, "@" <> actor.nickname), id, "direct") + + {reply_id, data} + end) + end + + defp insert_reply(actor, data, activity_id, visibility) do + {:ok, reply} = + CommonAPI.post(actor, %{ + status: Enum.join(data, ", "), + visibility: visibility, + in_reply_to_status_id: activity_id + }) + + {reply.id, ["@" <> actor.nickname | data]} + end + + defp get_random_mentions(_users, count) when count == 0, do: [] + + defp get_random_mentions(users, count) do + users + |> Enum.shuffle() + |> Enum.take(count) + |> Enum.map(&"@#{&1.nickname}") + end + + defp get_random_create_activity_id do + Repo.one( + from(a in Pleroma.Activity, + where: fragment("(?)->>'type' = ?", a.data, ^"Create"), + order_by: fragment("RANDOM()"), + limit: 1, + select: a.id + ) + ) + end +end diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex new file mode 100644 index 0000000..607b7d4 --- /dev/null +++ b/benchmarks/load_testing/fetcher.ex @@ -0,0 +1,624 @@ +defmodule Pleroma.LoadTesting.Fetcher do + alias Pleroma.Activity + alias Pleroma.Pagination + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.MastodonAPI.MastodonAPI + alias Pleroma.Web.MastodonAPI.StatusView + + @spec run_benchmarks(User.t()) :: any() + def run_benchmarks(user) do + fetch_user(user) + fetch_timelines(user) + render_views(user) + end + + defp formatters do + [ + Benchee.Formatters.Console + ] + end + + defp fetch_user(user) do + Benchee.run( + %{ + "By id" => fn -> Repo.get_by(User, id: user.id) end, + "By ap_id" => fn -> Repo.get_by(User, ap_id: user.ap_id) end, + "By email" => fn -> Repo.get_by(User, email: user.email) end, + "By nickname" => fn -> Repo.get_by(User, nickname: user.nickname) end + }, + formatters: formatters() + ) + end + + defp create_filter(user) do + Pleroma.Filter.create(%{ + user_id: user.id, + phrase: "must be filtered", + hide: true, + context: ["home"] + }) + end + + defp delete_filter(filter), do: Repo.delete(filter) + + defp fetch_timelines(user) do + fetch_home_timeline(user) + fetch_home_timeline_with_filter(user) + fetch_direct_timeline(user) + fetch_public_timeline(user) + fetch_public_timeline_with_filter(user) + fetch_public_timeline(user, :with_blocks) + fetch_public_timeline(user, :local) + fetch_public_timeline(user, :tag) + fetch_notifications(user) + fetch_favourites(user) + fetch_long_thread(user) + fetch_timelines_with_reply_filtering(user) + end + + defp render_views(user) do + render_timelines(user) + render_long_thread(user) + end + + defp opts_for_home_timeline(user) do + %{ + blocking_user: user, + count: "20", + muting_user: user, + type: ["Create", "Announce"], + user: user, + with_muted: true + } + end + + defp fetch_home_timeline(user, title_end \\ "") do + opts = opts_for_home_timeline(user) + + recipients = [user.ap_id | User.following(user)] + + first_page_last = + ActivityPub.fetch_activities(recipients, opts) |> Enum.reverse() |> List.last() + + second_page_last = + ActivityPub.fetch_activities(recipients, Map.put(opts, :max_id, first_page_last.id)) + |> Enum.reverse() + |> List.last() + + third_page_last = + ActivityPub.fetch_activities(recipients, Map.put(opts, :max_id, second_page_last.id)) + |> Enum.reverse() + |> List.last() + + forth_page_last = + ActivityPub.fetch_activities(recipients, Map.put(opts, :max_id, third_page_last.id)) + |> Enum.reverse() + |> List.last() + + title = "home timeline " <> title_end + + Benchee.run( + %{ + title => fn opts -> ActivityPub.fetch_activities(recipients, opts) end + }, + inputs: %{ + "1 page" => opts, + "2 page" => Map.put(opts, :max_id, first_page_last.id), + "3 page" => Map.put(opts, :max_id, second_page_last.id), + "4 page" => Map.put(opts, :max_id, third_page_last.id), + "5 page" => Map.put(opts, :max_id, forth_page_last.id), + "1 page only media" => Map.put(opts, :only_media, true), + "2 page only media" => + Map.put(opts, :max_id, first_page_last.id) |> Map.put(:only_media, true), + "3 page only media" => + Map.put(opts, :max_id, second_page_last.id) |> Map.put(:only_media, true), + "4 page only media" => + Map.put(opts, :max_id, third_page_last.id) |> Map.put(:only_media, true), + "5 page only media" => + Map.put(opts, :max_id, forth_page_last.id) |> Map.put(:only_media, true) + }, + formatters: formatters() + ) + end + + defp fetch_home_timeline_with_filter(user) do + {:ok, filter} = create_filter(user) + + fetch_home_timeline(user, "with filters") + + delete_filter(filter) + end + + defp opts_for_direct_timeline(user) do + %{ + visibility: "direct", + blocking_user: user, + count: "20", + type: "Create", + user: user, + with_muted: true + } + end + + defp fetch_direct_timeline(user) do + recipients = [user.ap_id] + + opts = opts_for_direct_timeline(user) + + first_page_last = + recipients + |> ActivityPub.fetch_activities_query(opts) + |> Pagination.fetch_paginated(opts) + |> List.last() + + opts2 = Map.put(opts, :max_id, first_page_last.id) + + second_page_last = + recipients + |> ActivityPub.fetch_activities_query(opts2) + |> Pagination.fetch_paginated(opts2) + |> List.last() + + opts3 = Map.put(opts, :max_id, second_page_last.id) + + third_page_last = + recipients + |> ActivityPub.fetch_activities_query(opts3) + |> Pagination.fetch_paginated(opts3) + |> List.last() + + opts4 = Map.put(opts, :max_id, third_page_last.id) + + forth_page_last = + recipients + |> ActivityPub.fetch_activities_query(opts4) + |> Pagination.fetch_paginated(opts4) + |> List.last() + + Benchee.run( + %{ + "direct timeline" => fn opts -> + ActivityPub.fetch_activities_query(recipients, opts) |> Pagination.fetch_paginated(opts) + end + }, + inputs: %{ + "1 page" => opts, + "2 page" => opts2, + "3 page" => opts3, + "4 page" => opts4, + "5 page" => Map.put(opts4, :max_id, forth_page_last.id) + }, + formatters: formatters() + ) + end + + defp opts_for_public_timeline(user) do + %{ + type: ["Create", "Announce"], + local_only: false, + blocking_user: user, + muting_user: user + } + end + + defp opts_for_public_timeline(user, :local) do + %{ + type: ["Create", "Announce"], + local_only: true, + blocking_user: user, + muting_user: user + } + end + + defp opts_for_public_timeline(user, :tag) do + %{ + blocking_user: user, + count: "20", + local_only: nil, + muting_user: user, + tag: ["tag"], + tag_all: [], + tag_reject: [], + type: "Create", + user: user, + with_muted: true + } + end + + defp fetch_public_timeline(user) do + opts = opts_for_public_timeline(user) + + fetch_public_timeline(opts, "public timeline") + end + + defp fetch_public_timeline_with_filter(user) do + {:ok, filter} = create_filter(user) + opts = opts_for_public_timeline(user) + + fetch_public_timeline(opts, "public timeline with filters") + delete_filter(filter) + end + + defp fetch_public_timeline(user, :local) do + opts = opts_for_public_timeline(user, :local) + + fetch_public_timeline(opts, "public timeline only local") + end + + defp fetch_public_timeline(user, :tag) do + opts = opts_for_public_timeline(user, :tag) + + fetch_public_timeline(opts, "hashtag timeline") + end + + defp fetch_public_timeline(user, :only_media) do + opts = opts_for_public_timeline(user) |> Map.put(:only_media, true) + + fetch_public_timeline(opts, "public timeline only media") + end + + defp fetch_public_timeline(user, :with_blocks) do + opts = opts_for_public_timeline(user) + + remote_non_friends = Agent.get(:non_friends_remote, & &1) + + Benchee.run(%{ + "public timeline without blocks" => fn -> + ActivityPub.fetch_public_activities(opts) + end + }) + + Enum.each(remote_non_friends, fn non_friend -> + {:ok, _} = User.block(user, non_friend) + end) + + user = User.get_by_id(user.id) + + opts = Map.put(opts, :blocking_user, user) + + Benchee.run(%{ + "public timeline with user block" => fn -> + ActivityPub.fetch_public_activities(opts) + end + }) + + domains = + Enum.reduce(remote_non_friends, [], fn non_friend, domains -> + {:ok, _user} = User.unblock(user, non_friend) + %{host: host} = URI.parse(non_friend.ap_id) + [host | domains] + end) + + domains = Enum.uniq(domains) + + Enum.each(domains, fn domain -> + {:ok, _} = User.block_domain(user, domain) + end) + + user = User.get_by_id(user.id) + opts = Map.put(opts, :blocking_user, user) + + Benchee.run(%{ + "public timeline with domain block" => fn -> + ActivityPub.fetch_public_activities(opts) + end + }) + end + + defp fetch_public_timeline(opts, title) when is_binary(title) do + first_page_last = ActivityPub.fetch_public_activities(opts) |> List.last() + + second_page_last = + ActivityPub.fetch_public_activities(Map.put(opts, :max_id, first_page_last.id)) + |> List.last() + + third_page_last = + ActivityPub.fetch_public_activities(Map.put(opts, :max_id, second_page_last.id)) + |> List.last() + + forth_page_last = + ActivityPub.fetch_public_activities(Map.put(opts, :max_id, third_page_last.id)) + |> List.last() + + Benchee.run( + %{ + title => fn opts -> + ActivityPub.fetch_public_activities(opts) + end + }, + inputs: %{ + "1 page" => opts, + "2 page" => Map.put(opts, :max_id, first_page_last.id), + "3 page" => Map.put(opts, :max_id, second_page_last.id), + "4 page" => Map.put(opts, :max_id, third_page_last.id), + "5 page" => Map.put(opts, :max_id, forth_page_last.id) + }, + formatters: formatters() + ) + end + + defp opts_for_notifications do + %{count: "20", with_muted: true} + end + + defp fetch_notifications(user) do + opts = opts_for_notifications() + + first_page_last = MastodonAPI.get_notifications(user, opts) |> List.last() + + second_page_last = + MastodonAPI.get_notifications(user, Map.put(opts, :max_id, first_page_last.id)) + |> List.last() + + third_page_last = + MastodonAPI.get_notifications(user, Map.put(opts, :max_id, second_page_last.id)) + |> List.last() + + forth_page_last = + MastodonAPI.get_notifications(user, Map.put(opts, :max_id, third_page_last.id)) + |> List.last() + + Benchee.run( + %{ + "Notifications" => fn opts -> + MastodonAPI.get_notifications(user, opts) + end + }, + inputs: %{ + "1 page" => opts, + "2 page" => Map.put(opts, :max_id, first_page_last.id), + "3 page" => Map.put(opts, :max_id, second_page_last.id), + "4 page" => Map.put(opts, :max_id, third_page_last.id), + "5 page" => Map.put(opts, :max_id, forth_page_last.id) + }, + formatters: formatters() + ) + end + + defp fetch_favourites(user) do + first_page_last = ActivityPub.fetch_favourites(user) |> List.last() + + second_page_last = + ActivityPub.fetch_favourites(user, %{:max_id => first_page_last.id}) |> List.last() + + third_page_last = + ActivityPub.fetch_favourites(user, %{:max_id => second_page_last.id}) |> List.last() + + forth_page_last = + ActivityPub.fetch_favourites(user, %{:max_id => third_page_last.id}) |> List.last() + + Benchee.run( + %{ + "Favourites" => fn opts -> + ActivityPub.fetch_favourites(user, opts) + end + }, + inputs: %{ + "1 page" => %{}, + "2 page" => %{:max_id => first_page_last.id}, + "3 page" => %{:max_id => second_page_last.id}, + "4 page" => %{:max_id => third_page_last.id}, + "5 page" => %{:max_id => forth_page_last.id} + }, + formatters: formatters() + ) + end + + defp opts_for_long_thread(user) do + %{ + blocking_user: user, + user: user + } + end + + defp fetch_long_thread(user) do + %{public_thread: public, private_thread: private} = + Agent.get(:benchmark_state, fn state -> state end) + + opts = opts_for_long_thread(user) + + private_input = {private.data["context"], Map.put(opts, :exclude_id, private.id)} + + public_input = {public.data["context"], Map.put(opts, :exclude_id, public.id)} + + Benchee.run( + %{ + "fetch context" => fn {context, opts} -> + ActivityPub.fetch_activities_for_context(context, opts) + end + }, + inputs: %{ + "Private long thread" => private_input, + "Public long thread" => public_input + }, + formatters: formatters() + ) + end + + defp render_timelines(user) do + opts = opts_for_home_timeline(user) + + recipients = [user.ap_id | User.following(user)] + + home_activities = ActivityPub.fetch_activities(recipients, opts) |> Enum.reverse() + + recipients = [user.ap_id] + + opts = opts_for_direct_timeline(user) + + direct_activities = + recipients + |> ActivityPub.fetch_activities_query(opts) + |> Pagination.fetch_paginated(opts) + + opts = opts_for_public_timeline(user) + + public_activities = ActivityPub.fetch_public_activities(opts) + + opts = opts_for_public_timeline(user, :tag) + + tag_activities = ActivityPub.fetch_public_activities(opts) + + opts = opts_for_notifications() + + notifications = MastodonAPI.get_notifications(user, opts) + + favourites = ActivityPub.fetch_favourites(user) + + Benchee.run( + %{ + "Rendering home timeline" => fn -> + StatusView.render("index.json", %{ + activities: home_activities, + for: user, + as: :activity + }) + end, + "Rendering direct timeline" => fn -> + StatusView.render("index.json", %{ + activities: direct_activities, + for: user, + as: :activity + }) + end, + "Rendering public timeline" => fn -> + StatusView.render("index.json", %{ + activities: public_activities, + for: user, + as: :activity + }) + end, + "Rendering tag timeline" => fn -> + StatusView.render("index.json", %{ + activities: tag_activities, + for: user, + as: :activity + }) + end, + "Rendering notifications" => fn -> + Pleroma.Web.MastodonAPI.NotificationView.render("index.json", %{ + notifications: notifications, + for: user + }) + end, + "Rendering favourites timeline" => fn -> + StatusView.render("index.json", %{ + activities: favourites, + for: user, + as: :activity + }) + end + }, + formatters: formatters() + ) + end + + defp render_long_thread(user) do + %{public_thread: public, private_thread: private} = + Agent.get(:benchmark_state, fn state -> state end) + + opts = %{for: user} + public_activity = Activity.get_by_id_with_object(public.id) + private_activity = Activity.get_by_id_with_object(private.id) + + Benchee.run( + %{ + "render" => fn opts -> + StatusView.render("show.json", opts) + end + }, + inputs: %{ + "Public root" => Map.put(opts, :activity, public_activity), + "Private root" => Map.put(opts, :activity, private_activity) + }, + formatters: formatters() + ) + + fetch_opts = opts_for_long_thread(user) + + public_context = + ActivityPub.fetch_activities_for_context( + public.data["context"], + Map.put(fetch_opts, :exclude_id, public.id) + ) + + private_context = + ActivityPub.fetch_activities_for_context( + private.data["context"], + Map.put(fetch_opts, :exclude_id, private.id) + ) + + Benchee.run( + %{ + "render" => fn opts -> + StatusView.render("context.json", opts) + end + }, + inputs: %{ + "Public context" => %{user: user, activity: public_activity, activities: public_context}, + "Private context" => %{ + user: user, + activity: private_activity, + activities: private_context + } + }, + formatters: formatters() + ) + end + + defp fetch_timelines_with_reply_filtering(user) do + public_params = opts_for_public_timeline(user) + + Benchee.run( + %{ + "Public timeline without reply filtering" => fn -> + ActivityPub.fetch_public_activities(public_params) + end, + "Public timeline with reply filtering - following" => fn -> + public_params + |> Map.put(:reply_visibility, "following") + |> Map.put(:reply_filtering_user, user) + |> ActivityPub.fetch_public_activities() + end, + "Public timeline with reply filtering - self" => fn -> + public_params + |> Map.put(:reply_visibility, "self") + |> Map.put(:reply_filtering_user, user) + |> ActivityPub.fetch_public_activities() + end + }, + formatters: formatters() + ) + + private_params = opts_for_home_timeline(user) + + recipients = [user.ap_id | User.following(user)] + + Benchee.run( + %{ + "Home timeline without reply filtering" => fn -> + ActivityPub.fetch_activities(recipients, private_params) + end, + "Home timeline with reply filtering - following" => fn -> + private_params = + private_params + |> Map.put(:reply_filtering_user, user) + |> Map.put(:reply_visibility, "following") + + ActivityPub.fetch_activities(recipients, private_params) + end, + "Home timeline with reply filtering - self" => fn -> + private_params = + private_params + |> Map.put(:reply_filtering_user, user) + |> Map.put(:reply_visibility, "self") + + ActivityPub.fetch_activities(recipients, private_params) + end + }, + formatters: formatters() + ) + end +end diff --git a/benchmarks/load_testing/helper.ex b/benchmarks/load_testing/helper.ex new file mode 100644 index 0000000..cab60ac --- /dev/null +++ b/benchmarks/load_testing/helper.ex @@ -0,0 +1,14 @@ +defmodule Pleroma.LoadTesting.Helper do + alias Ecto.Adapters.SQL + alias Pleroma.Repo + + def to_sec(microseconds), do: microseconds / 1_000_000 + + def clean_tables do + IO.puts("Deleting old data...\n") + SQL.query!(Repo, "TRUNCATE users CASCADE;") + SQL.query!(Repo, "TRUNCATE activities CASCADE;") + SQL.query!(Repo, "TRUNCATE objects CASCADE;") + SQL.query!(Repo, "TRUNCATE oban_jobs CASCADE;") + end +end diff --git a/benchmarks/load_testing/users.ex b/benchmarks/load_testing/users.ex new file mode 100644 index 0000000..0a33cbf --- /dev/null +++ b/benchmarks/load_testing/users.ex @@ -0,0 +1,189 @@ +defmodule Pleroma.LoadTesting.Users do + @moduledoc """ + Module for generating users with friends. + """ + import Ecto.Query + import Pleroma.LoadTesting.Helper, only: [to_sec: 1] + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.User.Query + + @defaults [ + users: 20_000, + friends: 100 + ] + + @max_concurrency 10 + + @spec generate(keyword()) :: User.t() + def generate(opts \\ []) do + opts = Keyword.merge(@defaults, opts) + + generate_users(opts[:users]) + + main_user = + Repo.one(from(u in User, where: u.local == true, order_by: fragment("RANDOM()"), limit: 1)) + + make_friends(main_user, opts[:friends]) + + User.get_by_id(main_user.id) + end + + def generate_users(max) do + IO.puts("Starting generating #{max} users...") + + {time, users} = + :timer.tc(fn -> + Task.async_stream( + 1..max, + &generate_user(&1), + max_concurrency: @max_concurrency, + timeout: 30_000 + ) + |> Enum.to_list() + end) + + IO.puts("Generating users took #{to_sec(time)} sec.\n") + users + end + + defp generate_user(i) do + remote = Enum.random([true, false]) + + %User{ + name: "Test テスト User #{i}", + email: "user#{i}@example.com", + nickname: "nick#{i}", + password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test"), + bio: "Tester Number #{i}", + local: !remote + } + |> user_urls() + |> Repo.insert!() + end + + defp user_urls(%{local: true} = user) do + urls = %{ + ap_id: User.ap_id(user), + follower_address: User.ap_followers(user), + following_address: User.ap_following(user) + } + + Map.merge(user, urls) + end + + defp user_urls(%{local: false} = user) do + base_domain = Enum.random(["domain1.com", "domain2.com", "domain3.com"]) + + ap_id = "https://#{base_domain}/users/#{user.nickname}" + + urls = %{ + ap_id: ap_id, + follower_address: ap_id <> "/followers", + following_address: ap_id <> "/following" + } + + Map.merge(user, urls) + end + + def make_friends(main_user, max) when is_integer(max) do + IO.puts("Starting making friends for #{max} users...") + + {time, _} = + :timer.tc(fn -> + number_of_users = + (max / 2) + |> Kernel.trunc() + + main_user + |> get_users(%{limit: number_of_users, local: :local}) + |> run_stream(main_user) + + main_user + |> get_users(%{limit: number_of_users, local: :external}) + |> run_stream(main_user) + end) + + IO.puts("Making friends took #{to_sec(time)} sec.\n") + end + + def make_friends(%User{} = main_user, %User{} = user) do + {:ok, _, _} = User.follow(main_user, user) + {:ok, _, _} = User.follow(user, main_user) + end + + @spec get_users(User.t(), keyword()) :: [User.t()] + def get_users(user, opts) do + criteria = %{limit: opts[:limit]} + + criteria = + if opts[:local] do + Map.put(criteria, opts[:local], true) + else + criteria + end + + criteria = + if opts[:friends?] do + Map.put(criteria, :friends, user) + else + criteria + end + + query = + criteria + |> Query.build() + |> random_without_user(user) + + query = + if opts[:friends?] == false do + friends_ids = + %{friends: user} + |> Query.build() + |> Repo.all() + |> Enum.map(& &1.id) + + from(u in query, where: u.id not in ^friends_ids) + else + query + end + + Repo.all(query) + end + + defp random_without_user(query, user) do + from(u in query, + where: u.id != ^user.id, + order_by: fragment("RANDOM()") + ) + end + + defp run_stream(users, main_user) do + Task.async_stream(users, &make_friends(main_user, &1), + max_concurrency: @max_concurrency, + timeout: 30_000 + ) + |> Stream.run() + end + + @spec prepare_users(User.t(), keyword()) :: map() + def prepare_users(user, opts) do + friends_limit = opts[:friends_used] + non_friends_limit = opts[:non_friends_used] + + %{ + user: user, + friends_local: fetch_users(user, friends_limit, :local, true), + friends_remote: fetch_users(user, friends_limit, :external, true), + non_friends_local: fetch_users(user, non_friends_limit, :local, false), + non_friends_remote: fetch_users(user, non_friends_limit, :external, false) + } + end + + defp fetch_users(user, limit, local, friends?) do + user + |> get_users(limit: limit, local: local, friends?: friends?) + |> Enum.shuffle() + end +end diff --git a/benchmarks/mix/tasks/pleroma/benchmark.ex b/benchmarks/mix/tasks/pleroma/benchmark.ex new file mode 100644 index 0000000..42b2847 --- /dev/null +++ b/benchmarks/mix/tasks/pleroma/benchmark.ex @@ -0,0 +1,125 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Benchmark do + @shortdoc "Benchmarks" + @moduledoc """ + Benchmark tasks available: + + adapters + render_timeline + search + tag + + MIX_ENV=benchmark mix pleroma.benchmark adapters + """ + + use Mix.Task + import Mix.Pleroma + + def run(["search"]) do + start_pleroma() + + Benchee.run(%{ + "search" => fn -> + Pleroma.Activity.search(nil, "cofe") + end + }) + end + + def run(["tag"]) do + start_pleroma() + + Benchee.run(%{ + "tag" => fn -> + %{"type" => "Create", "tag" => "cofe"} + |> Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities() + end + }) + end + + def run(["render_timeline", nickname | _] = args) do + start_pleroma() + user = Pleroma.User.get_by_nickname(nickname) + + activities = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + |> Map.put("limit", 4096) + |> Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities() + |> Enum.reverse() + + inputs = %{ + "1 activity" => Enum.take_random(activities, 1), + "10 activities" => Enum.take_random(activities, 10), + "20 activities" => Enum.take_random(activities, 20), + "40 activities" => Enum.take_random(activities, 40), + "80 activities" => Enum.take_random(activities, 80) + } + + inputs = + if Enum.at(args, 2) == "extended" do + Map.merge(inputs, %{ + "200 activities" => Enum.take_random(activities, 200), + "500 activities" => Enum.take_random(activities, 500), + "2000 activities" => Enum.take_random(activities, 2000), + "4096 activities" => Enum.take_random(activities, 4096) + }) + else + inputs + end + + Benchee.run( + %{ + "Standard rendering" => fn activities -> + Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ + activities: activities, + for: user, + as: :activity + }) + end + }, + inputs: inputs + ) + end + + def run(["adapters"]) do + start_pleroma() + + :ok = + Pleroma.Gun.Conn.open( + "https://httpbin.org/stream-bytes/1500", + :gun_connections + ) + + Process.sleep(1_500) + + Benchee.run( + %{ + "Without conn and without pool" => fn -> + {:ok, %Tesla.Env{}} = + Pleroma.HTTP.get("https://httpbin.org/stream-bytes/1500", [], + pool: :no_pool, + receive_conn: false + ) + end, + "Without conn and with pool" => fn -> + {:ok, %Tesla.Env{}} = + Pleroma.HTTP.get("https://httpbin.org/stream-bytes/1500", [], receive_conn: false) + end, + "With reused conn and without pool" => fn -> + {:ok, %Tesla.Env{}} = + Pleroma.HTTP.get("https://httpbin.org/stream-bytes/1500", [], pool: :no_pool) + end, + "With reused conn and with pool" => fn -> + {:ok, %Tesla.Env{}} = Pleroma.HTTP.get("https://httpbin.org/stream-bytes/1500") + end + }, + parallel: 10 + ) + end +end diff --git a/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex b/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex new file mode 100644 index 0000000..a32de2d --- /dev/null +++ b/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex @@ -0,0 +1,114 @@ +defmodule Mix.Tasks.Pleroma.Benchmarks.Tags do + use Mix.Task + + import Pleroma.LoadTesting.Helper, only: [clean_tables: 0] + import Ecto.Query + + alias Pleroma.Repo + + def run(_args) do + Mix.Pleroma.start_pleroma() + activities_count = Repo.aggregate(from(a in Pleroma.Activity), :count, :id) + + if activities_count == 0 do + IO.puts("Did not find any activities, cleaning and generating") + clean_tables() + Pleroma.LoadTesting.Users.generate_users(10) + Pleroma.LoadTesting.Activities.generate_tagged_activities() + else + IO.puts("Found #{activities_count} activities, won't generate new ones") + end + + tags = Enum.map(0..20, fn i -> {"For #tag_#{i}", "tag_#{i}"} end) + + Enum.each(tags, fn {_, tag} -> + query = + from(o in Pleroma.Object, + where: fragment("(?)->'tag' \\? (?)", o.data, ^tag) + ) + + count = Repo.aggregate(query, :count, :id) + IO.puts("Database contains #{count} posts tagged with #{tag}") + end) + + user = Repo.all(Pleroma.User) |> List.first() + + Benchee.run( + %{ + "Hashtag fetching, any" => fn tags -> + hashtag_fetching( + %{ + "any" => tags + }, + user, + false + ) + end, + # Will always return zero results because no overlapping hashtags are generated. + "Hashtag fetching, all" => fn tags -> + hashtag_fetching( + %{ + "all" => tags + }, + user, + false + ) + end + }, + inputs: + tags + |> Enum.map(fn {_, v} -> v end) + |> Enum.chunk_every(2) + |> Enum.map(fn tags -> {"For #{inspect(tags)}", tags} end), + time: 5 + ) + + Benchee.run( + %{ + "Hashtag fetching" => fn tag -> + hashtag_fetching( + %{ + "tag" => tag + }, + user, + false + ) + end + }, + inputs: tags, + time: 5 + ) + end + + defp hashtag_fetching(params, user, local_only) do + tags = + [params["tag"], params["any"]] + |> List.flatten() + |> Enum.uniq() + |> Enum.filter(& &1) + |> Enum.map(&String.downcase(&1)) + + tag_all = + params + |> Map.get("all", []) + |> Enum.map(&String.downcase(&1)) + + tag_reject = + params + |> Map.get("none", []) + |> Enum.map(&String.downcase(&1)) + + _activities = + %{ + type: "Create", + local_only: local_only, + blocking_user: user, + muting_user: user, + user: user, + tag: tags, + tag_all: tag_all, + tag_reject: tag_reject, + } + |> Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities() + end +end diff --git a/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex b/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex new file mode 100644 index 0000000..3770ca1 --- /dev/null +++ b/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex @@ -0,0 +1,70 @@ +defmodule Mix.Tasks.Pleroma.Benchmarks.Timelines do + use Mix.Task + + import Pleroma.LoadTesting.Helper, only: [clean_tables: 0] + + alias Pleroma.Web.CommonAPI + alias Plug.Conn + + def run(_args) do + Mix.Pleroma.start_pleroma() + + # Cleaning tables + clean_tables() + + [{:ok, user} | users] = Pleroma.LoadTesting.Users.generate_users(1000) + + # Let the user make 100 posts + + 1..100 + |> Enum.each(fn i -> CommonAPI.post(user, %{status: to_string(i)}) end) + + # Let 10 random users post + posts = + users + |> Enum.take_random(10) + |> Enum.map(fn {:ok, random_user} -> + {:ok, activity} = CommonAPI.post(random_user, %{status: "."}) + activity + end) + + # let our user repeat them + posts + |> Enum.each(fn activity -> + CommonAPI.repeat(activity.id, user) + end) + + Benchee.run( + %{ + "user timeline, no followers" => fn reading_user -> + conn = + Phoenix.ConnTest.build_conn() + |> Conn.assign(:user, reading_user) + |> Conn.assign(:skip_link_headers, true) + + Pleroma.Web.MastodonAPI.AccountController.statuses(conn, %{id: user.id}) + end + }, + inputs: %{"user" => user, "no user" => nil}, + time: 60 + ) + + users + |> Enum.each(fn {:ok, follower} -> Pleroma.User.follow(follower, user) end) + + Benchee.run( + %{ + "user timeline, all following" => fn reading_user -> + conn = + Phoenix.ConnTest.build_conn() + |> Conn.assign(:user, reading_user) + |> Conn.assign(:skip_link_headers, true) + + Pleroma.Web.MastodonAPI.AccountController.statuses(conn, %{id: user.id}) + end + }, + inputs: %{"user" => user, "no user" => nil}, + time: 60 + ) + end +end diff --git a/benchmarks/mix/tasks/pleroma/load_testing.ex b/benchmarks/mix/tasks/pleroma/load_testing.ex new file mode 100644 index 0000000..3888832 --- /dev/null +++ b/benchmarks/mix/tasks/pleroma/load_testing.ex @@ -0,0 +1,67 @@ +defmodule Mix.Tasks.Pleroma.LoadTesting do + use Mix.Task + import Ecto.Query + import Pleroma.LoadTesting.Helper, only: [clean_tables: 0] + + alias Pleroma.Repo + alias Pleroma.User + + @shortdoc "Factory for generation data" + @moduledoc """ + Generates data like: + - local/remote users + - local/remote activities with differrent visibility: + - simple activiities + - with emoji + - with mentions + - hellthreads + - with attachments + - with tags + - likes + - reblogs + - simple threads + - long threads + + ## Generate data + MIX_ENV=benchmark mix pleroma.load_testing --users 20000 --friends 1000 --iterations 170 --friends_used 20 --non_friends_used 20 + MIX_ENV=benchmark mix pleroma.load_testing -u 20000 -f 1000 -i 170 -fu 20 -nfu 20 + + Options: + - `--users NUMBER` - number of users to generate. Defaults to: 20000. Alias: `-u` + - `--friends NUMBER` - number of friends for main user. Defaults to: 1000. Alias: `-f` + - `--iterations NUMBER` - number of iterations to generate activities. For each iteration in database is inserted about 120+ activities with different visibility, actors and types.Defaults to: 170. Alias: `-i` + - `--friends_used NUMBER` - number of main user friends used in activity generation. Defaults to: 20. Alias: `-fu` + - `--non_friends_used NUMBER` - number of non friends used in activity generation. Defaults to: 20. Alias: `-nfu` + """ + + @aliases [u: :users, f: :friends, i: :iterations, fu: :friends_used, nfu: :non_friends_used] + @switches [ + users: :integer, + friends: :integer, + iterations: :integer, + friends_used: :integer, + non_friends_used: :integer + ] + + def run(args) do + Logger.configure(level: :error) + Mix.Pleroma.start_pleroma() + clean_tables() + {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) + + user = Pleroma.LoadTesting.Users.generate(opts) + Pleroma.LoadTesting.Activities.generate(user, opts) + + IO.puts("Users in DB: #{Repo.aggregate(from(u in User), :count, :id)}") + + IO.puts("Activities in DB: #{Repo.aggregate(from(a in Pleroma.Activity), :count, :id)}") + + IO.puts("Objects in DB: #{Repo.aggregate(from(o in Pleroma.Object), :count, :id)}") + + IO.puts( + "Notifications in DB: #{Repo.aggregate(from(n in Pleroma.Notification), :count, :id)}" + ) + + Pleroma.LoadTesting.Fetcher.run_benchmarks(user) + end +end diff --git a/changelog.d/2.6.0-mergeback.skip b/changelog.d/2.6.0-mergeback.skip new file mode 100644 index 0000000..e69de29 diff --git a/changelog.d/3895.add b/changelog.d/3895.add new file mode 100644 index 0000000..5b1737e --- /dev/null +++ b/changelog.d/3895.add @@ -0,0 +1 @@ +Uploaded media content-type is scrubbed before serving the files to limit the security impact of some attachments diff --git a/changelog.d/3896.add b/changelog.d/3896.add new file mode 100644 index 0000000..e8fde4c --- /dev/null +++ b/changelog.d/3896.add @@ -0,0 +1 @@ +Validate Host header for Uploads and return a 302 if the base_url has changed diff --git a/changelog.d/3900.change b/changelog.d/3900.change new file mode 100644 index 0000000..fe0cc2f --- /dev/null +++ b/changelog.d/3900.change @@ -0,0 +1 @@ +Update to Phoenix 1.7 diff --git a/changelog.d/akkoma-xml-remote-entities.security b/changelog.d/akkoma-xml-remote-entities.security new file mode 100644 index 0000000..5e6725e --- /dev/null +++ b/changelog.d/akkoma-xml-remote-entities.security @@ -0,0 +1 @@ +Fix XML External Entity (XXE) loading vulnerability allowing to fetch arbitary files from the server's filesystem diff --git a/changelog.d/authorize-interaction.add b/changelog.d/authorize-interaction.add new file mode 100644 index 0000000..8692209 --- /dev/null +++ b/changelog.d/authorize-interaction.add @@ -0,0 +1 @@ +Support /authorize-interaction route used by Mastodon \ No newline at end of file diff --git a/changelog.d/bare_uri_test.skip b/changelog.d/bare_uri_test.skip new file mode 100644 index 0000000..e69de29 diff --git a/changelog.d/benchee.skip b/changelog.d/benchee.skip new file mode 100644 index 0000000..e69de29 diff --git a/changelog.d/check-attachment-attribution.security b/changelog.d/check-attachment-attribution.security new file mode 100644 index 0000000..e0e4652 --- /dev/null +++ b/changelog.d/check-attachment-attribution.security @@ -0,0 +1 @@ +CommonAPI: Prevent users from accessing media of other users by creating a status with reused attachment ID diff --git a/changelog.d/digest_emails.fix b/changelog.d/digest_emails.fix new file mode 100644 index 0000000..335a244 --- /dev/null +++ b/changelog.d/digest_emails.fix @@ -0,0 +1 @@ +Fix the processing of email digest jobs. diff --git a/changelog.d/docs-max-elixir-erlang.change b/changelog.d/docs-max-elixir-erlang.change new file mode 100644 index 0000000..a58b7fc --- /dev/null +++ b/changelog.d/docs-max-elixir-erlang.change @@ -0,0 +1 @@ +- Document maximum supported version of Erlang & Elixir diff --git a/changelog.d/emoji-pack-sanitization.security b/changelog.d/emoji-pack-sanitization.security new file mode 100644 index 0000000..f3218ab --- /dev/null +++ b/changelog.d/emoji-pack-sanitization.security @@ -0,0 +1 @@ +Emoji pack loader sanitizes pack names diff --git a/changelog.d/federation_status-access.change b/changelog.d/federation_status-access.change new file mode 100644 index 0000000..9522544 --- /dev/null +++ b/changelog.d/federation_status-access.change @@ -0,0 +1 @@ +- Make `/api/v1/pleroma/federation_status` publicly available diff --git a/changelog.d/healthcheck-disabled-error.fix b/changelog.d/healthcheck-disabled-error.fix new file mode 100644 index 0000000..984384a --- /dev/null +++ b/changelog.d/healthcheck-disabled-error.fix @@ -0,0 +1 @@ +TwitterAPI: Return proper error when healthcheck is disabled diff --git a/changelog.d/meilisearch.add b/changelog.d/meilisearch.add new file mode 100644 index 0000000..4856eea --- /dev/null +++ b/changelog.d/meilisearch.add @@ -0,0 +1 @@ +Add meilisearch, make search engines pluggable diff --git a/changelog.d/nginx-media-redirect-fix.fix b/changelog.d/nginx-media-redirect-fix.fix new file mode 100644 index 0000000..fb57fd0 --- /dev/null +++ b/changelog.d/nginx-media-redirect-fix.fix @@ -0,0 +1 @@ +Fixing 302 redirect issue with media diff --git a/changelog.d/otp_perms.security b/changelog.d/otp_perms.security new file mode 100644 index 0000000..a3da1c6 --- /dev/null +++ b/changelog.d/otp_perms.security @@ -0,0 +1 @@ +- Reduced permissions of config files and directories, distros requiring greater permissions like group-read need to pre-create the directories \ No newline at end of file diff --git a/changelog.d/quotes-count.skip b/changelog.d/quotes-count.skip new file mode 100644 index 0000000..e69de29 diff --git a/changelog.d/scrobble-url.add b/changelog.d/scrobble-url.add new file mode 100644 index 0000000..24bdeed --- /dev/null +++ b/changelog.d/scrobble-url.add @@ -0,0 +1 @@ +Adds the capability to add a URL to a scrobble (optional field) diff --git a/changelog.d/system-cflags.fix b/changelog.d/system-cflags.fix new file mode 100644 index 0000000..84de5ad --- /dev/null +++ b/changelog.d/system-cflags.fix @@ -0,0 +1 @@ +- Fix eblurhash and elixir-captcha not using system cflags diff --git a/ci/Dockerfile b/ci/Dockerfile new file mode 100644 index 0000000..6d86111 --- /dev/null +++ b/ci/Dockerfile @@ -0,0 +1,8 @@ +FROM elixir:1.12.3 + +# Single RUN statement, otherwise intermediate images are created +# https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#run +RUN apt-get update &&\ + apt-get install -y libmagic-dev cmake libimage-exiftool-perl ffmpeg &&\ + mix local.hex --force &&\ + mix local.rebar --force diff --git a/ci/build_and_push.sh b/ci/build_and_push.sh new file mode 100755 index 0000000..d0adca6 --- /dev/null +++ b/ci/build_and_push.sh @@ -0,0 +1 @@ +docker build -t gitlab.com/soapbox-pub/rebased/ci:latest --push . diff --git a/config/benchmark.exs b/config/benchmark.exs new file mode 100644 index 0000000..870ead1 --- /dev/null +++ b/config/benchmark.exs @@ -0,0 +1,88 @@ +import Config + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :pleroma, Pleroma.Web.Endpoint, + http: [port: 4001], + url: [port: 4001] + +# Disable captha for tests +config :pleroma, Pleroma.Captcha, + # It should not be enabled for automatic tests + enabled: false, + # A fake captcha service for tests + method: Pleroma.Captcha.Mock + +# Print only warnings and errors during test +config :logger, level: :warn + +config :pleroma, :auth, oauth_consumer_strategies: [] + +config :pleroma, Pleroma.Upload, filters: [], link_name: false + +config :pleroma, Pleroma.Uploaders.Local, uploads: "test/uploads" + +config :pleroma, Pleroma.Emails.Mailer, adapter: Swoosh.Adapters.Test, enabled: true + +config :pleroma, :instance, + email: "admin@example.com", + notify_email: "noreply@example.com", + skip_thread_containment: false, + federating: false, + external_user_synchronization: false + +config :pleroma, :activitypub, sign_object_fetches: false + +# Configure your database +config :pleroma, Pleroma.Repo, + adapter: Ecto.Adapters.Postgres, + username: "postgres", + password: "postgres", + database: "pleroma_benchmark", + hostname: System.get_env("DB_HOST") || "localhost", + port: System.get_env("DB_PORT") || "5432", + pool_size: 10 + +# Reduce hash rounds for testing +config :pleroma, :password, iterations: 1 + +config :tesla, adapter: Tesla.Mock + +config :pleroma, :rich_media, + enabled: false, + ignore_hosts: [], + ignore_tld: ["local", "localdomain", "lan"] + +config :web_push_encryption, :vapid_details, + subject: "mailto:administrator@example.com", + public_key: + "BLH1qVhJItRGCfxgTtONfsOKDc9VRAraXw-3NsmjMngWSh7NxOizN6bkuRA7iLTMPS82PjwJAr3UoK9EC1IFrz4", + private_key: "_-XZ0iebPrRfZ_o0-IatTdszYa8VCH1yLN-JauK7HHA" + +config :pleroma, Pleroma.ScheduledActivity, + daily_user_limit: 2, + total_user_limit: 3, + enabled: false + +config :pleroma, :rate_limit, + search: [{1000, 30}, {1000, 30}], + app_account_creation: {10_000, 5}, + password_reset: {1000, 30} + +config :pleroma, :http_security, report_uri: "https://endpoint.com" + +config :pleroma, :http, send_user_agent: false + +rum_enabled = System.get_env("RUM_ENABLED") == "true" +config :pleroma, :database, rum_enabled: rum_enabled +IO.puts("RUM enabled: #{rum_enabled}") + +config :pleroma, Pleroma.ReverseProxy.Client, Pleroma.ReverseProxy.ClientMock + +if File.exists?("./config/benchmark.secret.exs") do + import_config "benchmark.secret.exs" +else + IO.puts( + "You may want to create benchmark.secret.exs to declare custom database connection parameters." + ) +end diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..6ebfb4a --- /dev/null +++ b/config/config.exs @@ -0,0 +1,958 @@ +# .i;;;;i. +# iYcviii;vXY: +# .YXi .i1c. +# .YC. . in7. +# .vc. ...... ;1c. +# i7, .. .;1; +# i7, .. ... .Y1i +# ,7v .6MMM@; .YX, +# .7;. ..IMMMMMM1 :t7. +# .;Y. ;$MMMMMM9. :tc. +# vY. .. .nMMM@MMU. ;1v. +# i7i ... .#MM@M@C. .....:71i +# it: .... $MMM@9;.,i;;;i,;tti +# :t7. ..... 0MMMWv.,iii:::,,;St. +# .nC. ..... IMMMQ..,::::::,.,czX. +# .ct: ....... .ZMMMI..,:::::::,,:76Y. +# c2: ......,i..Y$M@t..:::::::,,..inZY +# vov ......:ii..c$MBc..,,,,,,,,,,..iI9i +# i9Y ......iii:..7@MA,..,,,,,,,,,....;AA: +# iIS. ......:ii::..;@MI....,............;Ez. +# .I9. ......:i::::...8M1..................C0z. +# .z9; ......:i::::,.. .i:...................zWX. +# vbv ......,i::::,,. ................. :AQY +# c6Y. .,...,::::,,..:t0@@QY. ................ :8bi +# :6S. ..,,...,:::,,,..EMMMMMMI. ............... .;bZ, +# :6o, .,,,,..:::,,,..i#MMMMMM#v................. YW2. +# .n8i ..,,,,,,,::,,,,.. tMMMMM@C:.................. .1Wn +# 7Uc. .:::,,,,,::,,,,.. i1t;,..................... .UEi +# 7C...::::::::::::,,,,.. .................... vSi. +# ;1;...,,::::::,......... .................. Yz: +# v97,......... .voC. +# izAotX7777777777777777777777777777777777777777Y7n92: +# .;CoIIIIIUAA666666699999ZZZZZZZZZZZZZZZZZZZZ6ov. +# +# !!! ATTENTION !!! +# DO NOT EDIT THIS FILE! THIS FILE CONTAINS THE DEFAULT VALUES FOR THE CON- +# FIGURATION! EDIT YOUR SECRET FILE (either prod.secret.exs, dev.secret.exs). +# +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. +import Config + +# General application configuration +config :pleroma, ecto_repos: [Pleroma.Repo] + +config :pleroma, Pleroma.Repo, + telemetry_event: [Pleroma.Repo.Instrumenter], + migration_lock: nil + +config :pleroma, Pleroma.Captcha, + enabled: true, + seconds_valid: 300, + method: Pleroma.Captcha.Native + +config :pleroma, Pleroma.Captcha.Kocaptcha, endpoint: "https://captcha.kotobank.ch" + +# Upload configuration +config :pleroma, Pleroma.Upload, + uploader: Pleroma.Uploaders.Local, + filters: [Pleroma.Upload.Filter.Dedupe], + link_name: false, + proxy_remote: false, + filename_display_max_length: 30, + default_description: nil, + base_url: nil + +config :pleroma, Pleroma.Uploaders.Local, uploads: "uploads" + +config :pleroma, Pleroma.Uploaders.S3, + bucket: nil, + bucket_namespace: nil, + truncated_namespace: nil, + streaming_enabled: true + +config :ex_aws, :s3, + # host: "s3.wasabisys.com", # required if not Amazon AWS + access_key_id: nil, + secret_access_key: nil, + # region: "us-east-1", # may be required for Amazon AWS + scheme: "https://" + +config :pleroma, :emoji, + shortcode_globs: ["/emoji/custom/**/*.png"], + pack_extensions: [".png", ".gif"], + groups: [ + Custom: ["/emoji/*.png", "/emoji/**/*.png"] + ], + default_manifest: "https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json", + shared_pack_cache_seconds_per_file: 60 + +config :pleroma, :uri_schemes, + valid_schemes: [ + "https", + "http", + "dat", + "dweb", + "gopher", + "hyper", + "ipfs", + "ipns", + "irc", + "ircs", + "magnet", + "mailto", + "mumble", + "ssb", + "xmpp" + ] + +# Configures the endpoint +config :pleroma, Pleroma.Web.Endpoint, + url: [host: "localhost"], + http: [ + ip: {127, 0, 0, 1}, + dispatch: [ + {:_, + [ + {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []}, + {:_, Plug.Cowboy.Handler, {Pleroma.Web.Endpoint, []}} + ]} + ] + ], + protocol: "https", + secret_key_base: "aK4Abxf29xU9TTDKre9coZPUgevcVCFQJe/5xP/7Lt4BEif6idBIbjupVbOrbKxl", + live_view: [signing_salt: "U5ELgdEwTD3n1+D5s0rY0AMg8/y1STxZ3Zvsl3bWh+oBcGrYdil0rXqPMRd3Glcq"], + signing_salt: "CqaoopA2", + render_errors: [view: Pleroma.Web.ErrorView, accepts: ~w(json)], + pubsub_server: Pleroma.PubSub, + secure_cookie_flag: true, + extra_cookie_attrs: [ + "SameSite=Lax" + ] + +# Configures Elixir's Logger +config :logger, :console, + level: :debug, + format: "\n$time $metadata[$level] $message\n", + metadata: [:request_id] + +config :logger, :ex_syslogger, + level: :debug, + ident: "pleroma", + format: "$metadata[$level] $message", + metadata: [:request_id] + +config :mime, :types, %{ + "application/xml" => ["xml"], + "application/xrd+xml" => ["xrd+xml"], + "application/jrd+json" => ["jrd+json"], + "application/activity+json" => ["activity+json"], + "application/ld+json" => ["activity+json"] +} + +config :tesla, adapter: Tesla.Adapter.Hackney + +# Configures http settings, upstream proxy etc. +config :pleroma, :http, + proxy_url: nil, + send_user_agent: true, + user_agent: :default, + adapter: [] + +config :pleroma, :instance, + name: "Pleroma", + email: "example@example.com", + notify_email: "noreply@example.com", + description: "Pleroma: An efficient and flexible fediverse server", + short_description: "", + background_image: "/images/city.jpg", + instance_thumbnail: "/instance/thumbnail.png", + favicon: "/favicon.png", + limit: 5_000, + description_limit: 5_000, + remote_limit: 100_000, + upload_limit: 16_000_000, + avatar_upload_limit: 2_000_000, + background_upload_limit: 4_000_000, + banner_upload_limit: 4_000_000, + poll_limits: %{ + max_options: 20, + max_option_chars: 200, + min_expiration: 0, + max_expiration: 365 * 24 * 60 * 60 + }, + registrations_open: true, + invites_enabled: false, + account_activation_required: false, + account_approval_required: true, + federating: true, + federation_incoming_replies_max_depth: 100, + federation_reachability_timeout_days: 7, + federation_publisher_modules: [ + Pleroma.Web.ActivityPub.Publisher + ], + allow_relay: true, + public: true, + quarantined_instances: [], + static_dir: "instance/static/", + allowed_post_formats: [ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode" + ], + autofollowed_nicknames: [], + autofollowing_nicknames: [], + max_pinned_statuses: 1, + attachment_links: false, + max_report_comment_size: 1000, + report_strip_status: true, + safe_dm_mentions: false, + healthcheck: false, + remote_post_retention_days: 90, + skip_thread_containment: true, + limit_to_local_content: :unauthenticated, + user_bio_length: 5000, + user_name_length: 100, + user_location_length: 50, + max_account_fields: 10, + max_remote_account_fields: 20, + account_field_name_length: 512, + account_field_value_length: 2048, + registration_reason_length: 500, + external_user_synchronization: true, + extended_nickname_format: true, + cleanup_attachments: false, + multi_factor_authentication: [ + totp: [ + # digits 6 or 8 + digits: 6, + period: 30 + ], + backup_codes: [ + number: 5, + length: 16 + ] + ], + show_reactions: true, + password_reset_token_validity: 60 * 60 * 24, + profile_directory: true, + admin_privileges: [ + :users_read, + :users_manage_invites, + :users_manage_activation_state, + :users_manage_tags, + :users_manage_credentials, + :users_delete, + :messages_read, + :messages_delete, + :instances_delete, + :reports_manage_reports, + :moderation_log_read, + :announcements_manage_announcements, + :emoji_manage_emoji, + :statistics_read + ], + moderator_privileges: [ + :users_read, + :users_manage_invites, + :users_manage_activation_state, + :users_manage_tags, + :users_manage_credentials, + :users_delete, + :messages_read, + :messages_delete, + :instances_delete, + :reports_manage_reports, + :moderation_log_read, + :announcements_manage_announcements, + :emoji_manage_emoji, + :statistics_read + ], + max_endorsed_users: 20, + birthday_required: false, + birthday_min_age: 0, + max_media_attachments: 1_000, + migration_cooldown_period: 30 + +config :pleroma, :welcome, + direct_message: [ + enabled: false, + sender_nickname: nil, + message: nil + ], + chat_message: [ + enabled: false, + sender_nickname: nil, + message: nil + ], + email: [ + enabled: false, + sender: nil, + subject: "Welcome to <%= instance_name %>", + html: "Welcome to <%= instance_name %>", + text: "Welcome to <%= instance_name %>" + ] + +config :pleroma, :feed, + post_title: %{ + max_length: 100, + omission: "..." + } + +config :pleroma, :markup, + allow_inline_images: false, + allow_headings: false, + allow_tables: false, + allow_fonts: false, + scrub_policy: [ + Pleroma.HTML.Scrubber.Default, + Pleroma.HTML.Transform.MediaProxy + ] + +config :pleroma, :frontend_configurations, + pleroma_fe: %{ + alwaysShowSubjectInput: true, + background: "/images/city.jpg", + collapseMessageWithSubject: false, + disableChat: false, + greentext: false, + hideFilteredStatuses: false, + hideMutedPosts: false, + hidePostStats: false, + hideSitename: false, + hideUserStats: false, + loginMethod: "password", + logo: "/static/logo.svg", + logoMargin: ".1em", + logoMask: true, + minimalScopesMode: false, + noAttachmentLinks: false, + nsfwCensorImage: "", + postContentType: "text/plain", + redirectRootLogin: "/main/friends", + redirectRootNoLogin: "/main/all", + scopeCopy: true, + sidebarRight: false, + showFeaturesPanel: true, + showInstanceSpecificPanel: false, + subjectLineBehavior: "email", + theme: "pleroma-dark", + webPushNotifications: false + } + +config :pleroma, :assets, + mascots: [ + pleroma_fox_tan: %{ + url: "/images/pleroma-fox-tan-smol.png", + mime_type: "image/png" + }, + pleroma_fox_tan_shy: %{ + url: "/images/pleroma-fox-tan-shy.png", + mime_type: "image/png" + } + ], + default_mascot: :pleroma_fox_tan + +config :pleroma, :manifest, + icons: [ + %{ + src: "/static/logo.svg", + type: "image/svg+xml" + } + ], + theme_color: "#282c37", + background_color: "#191b22" + +config :pleroma, :activitypub, + unfollow_blocked: true, + outgoing_blocks: true, + blockers_visible: true, + follow_handshake_timeout: 500, + note_replies_output_limit: 5, + sign_object_fetches: true, + authorized_fetch_mode: false, + fetch_actor_origin: nil + +config :pleroma, :streamer, + workers: 3, + overflow_workers: 2 + +config :pleroma, :user, deny_follow_blocked: true + +config :pleroma, :mrf_normalize_markup, scrub_policy: Pleroma.HTML.Scrubber.Default + +config :pleroma, :mrf_rejectnonpublic, + allow_followersonly: false, + allow_direct: false + +config :pleroma, :mrf_hellthread, + delist_threshold: 10, + reject_threshold: 20 + +config :pleroma, :mrf_simple, + media_removal: [], + media_nsfw: [], + federated_timeline_removal: [], + report_removal: [], + reject: [], + followers_only: [], + accept: [], + avatar_removal: [], + banner_removal: [], + reject_deletes: [] + +config :pleroma, :mrf_keyword, + reject: [], + federated_timeline_removal: [], + replace: [] + +config :pleroma, :mrf_emoji, + remove_url: [], + remove_shortcode: [], + federated_timeline_removal_url: [], + federated_timeline_removal_shortcode: [] + +config :pleroma, :mrf_hashtag, + sensitive: ["nsfw"], + reject: [], + federated_timeline_removal: [] + +config :pleroma, :mrf_subchain, match_actor: %{} + +config :pleroma, :mrf_activity_expiration, days: 365 + +config :pleroma, :mrf_vocabulary, + accept: [], + reject: [] + +# threshold of 7 days +config :pleroma, :mrf_object_age, + threshold: 604_800, + actions: [:delist, :strip_followers] + +config :pleroma, :mrf_nsfw_api, + url: "http://127.0.0.1:5000/", + threshold: 0.7, + mark_sensitive: true, + unlist: false, + reject: false + +config :pleroma, :mrf_follow_bot, follower_nickname: nil + +config :pleroma, :mrf_inline_quote, template: "RT: {url}" + +config :pleroma, :mrf_remote_report, + reject_all: false, + reject_anonymous: true, + reject_empty_message: true + +config :pleroma, :mrf_anti_duplication, + ttl: 60_000, + min_length: 50 + +config :pleroma, :rich_media, + enabled: true, + ignore_hosts: [], + ignore_tld: ["local", "localdomain", "lan"], + parsers: [ + Pleroma.Web.RichMedia.Parsers.OEmbed, + Pleroma.Web.RichMedia.Parsers.TwitterCard + ], + oembed_providers_enabled: true, + failure_backoff: 60_000, + ttl_setters: [Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl] + +config :pleroma, :media_proxy, + enabled: false, + invalidation: [ + enabled: false, + provider: Pleroma.Web.MediaProxy.Invalidation.Script + ], + proxy_opts: [ + redirect_on_failure: false, + max_body_length: 25 * 1_048_576, + # Note: max_read_duration defaults to Pleroma.ReverseProxy.max_read_duration_default/1 + max_read_duration: 30_000, + http: [ + follow_redirect: true, + pool: :media + ] + ], + whitelist: [] + +config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Http, + method: :purge, + headers: [], + options: [] + +config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script, + script_path: nil, + url_format: nil + +# Note: media preview proxy depends on media proxy to be enabled +config :pleroma, :media_preview_proxy, + enabled: false, + thumbnail_max_width: 600, + thumbnail_max_height: 600, + image_quality: 85, + min_content_length: 100 * 1024 + +config :phoenix, :format_encoders, json: Jason, "activity+json": Jason, ics: ICalendar + +config :phoenix, :json_library, Jason + +config :phoenix, :filter_parameters, ["password", "confirm"] + +config :pleroma, :gopher, + enabled: false, + ip: {0, 0, 0, 0}, + port: 9999 + +config :pleroma, Pleroma.Web.Metadata, + providers: [ + Pleroma.Web.Metadata.Providers.OpenGraph, + Pleroma.Web.Metadata.Providers.TwitterCard + ], + unfurl_nsfw: false + +config :pleroma, Pleroma.Web.Preload, + providers: [ + Pleroma.Web.Preload.Providers.Instance + ] + +config :pleroma, :http_security, + enabled: true, + sts: false, + sts_max_age: 31_536_000, + ct_max_age: 2_592_000, + referrer_policy: "same-origin" + +config :cors_plug, + max_age: 86_400, + methods: ["POST", "PUT", "DELETE", "GET", "PATCH", "OPTIONS"], + expose: [ + "Link", + "X-RateLimit-Reset", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-Request-Id", + "Idempotency-Key" + ], + credentials: true, + headers: ["Authorization", "Content-Type", "Idempotency-Key"] + +config :pleroma, Pleroma.User, + restricted_nicknames: [ + ".well-known", + "~", + "about", + "activities", + "api", + "auth", + "check_password", + "dev", + "friend-requests", + "inbox", + "internal", + "main", + "media", + "nodeinfo", + "notice", + "oauth", + "objects", + "ostatus_subscribe", + "pleroma", + "proxy", + "push", + "registration", + "relay", + "settings", + "status", + "tag", + "user-search", + "user_exists", + "users", + "web", + "verify_credentials", + "update_credentials", + "relationships", + "search", + "confirmation_resend", + "mfa" + ], + email_blacklist: [] + +config :pleroma, Oban, + repo: Pleroma.Repo, + log: false, + queues: [ + activity_expiration: 10, + token_expiration: 5, + filter_expiration: 1, + backup: 1, + federator_incoming: 50, + federator_outgoing: 50, + ingestion_queue: 50, + web_push: 50, + mailer: 10, + transmogrifier: 20, + scheduled_activities: 10, + poll_notifications: 10, + notifications: 20, + background: 5, + remote_fetcher: 2, + attachments_cleanup: 1, + new_users_digest: 1, + mute_expire: 5, + search_indexing: 10 + ], + plugins: [Oban.Plugins.Pruner], + crontab: [ + {"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker}, + {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker} + ] + +config :pleroma, :workers, + retries: [ + federator_incoming: 5, + federator_outgoing: 5, + search_indexing: 2 + ] + +config :pleroma, Pleroma.Formatter, + class: false, + rel: "ugc", + new_window: false, + truncate: false, + strip_prefix: false, + extra: true, + validate_tld: :no_scheme + +config :pleroma, :ldap, + enabled: System.get_env("LDAP_ENABLED") == "true", + host: System.get_env("LDAP_HOST") || "localhost", + port: String.to_integer(System.get_env("LDAP_PORT") || "389"), + ssl: System.get_env("LDAP_SSL") == "true", + sslopts: [], + tls: System.get_env("LDAP_TLS") == "true", + tlsopts: [], + base: System.get_env("LDAP_BASE") || "dc=example,dc=com", + uid: System.get_env("LDAP_UID") || "cn" + +oauth_consumer_strategies = + System.get_env("OAUTH_CONSUMER_STRATEGIES") + |> to_string() + |> String.split() + |> Enum.map(&hd(String.split(&1, ":"))) + +ueberauth_providers = + for strategy <- oauth_consumer_strategies do + strategy_module_name = "Elixir.Ueberauth.Strategy.#{String.capitalize(strategy)}" + strategy_module = String.to_atom(strategy_module_name) + + params = + case strategy do + "keycloak" -> [uid_field: :email, default_scope: "openid profile"] + _ -> [callback_params: ["state"]] + end + + {String.to_atom(strategy), {strategy_module, params}} + end + +config :ueberauth, + Ueberauth, + base_path: "/oauth", + providers: ueberauth_providers + +config :pleroma, :auth, oauth_consumer_strategies: oauth_consumer_strategies + +config :pleroma, :auth, basic_auth: false + +config :pleroma, :auth, mongoose_im: false + +config :pleroma, Pleroma.Emails.Mailer, adapter: Swoosh.Adapters.Sendmail, enabled: false + +config :pleroma, Pleroma.Emails.UserEmail, + logo: nil, + styling: %{ + link_color: "#d8a070", + background_color: "#2C3645", + content_background_color: "#1B2635", + header_color: "#d8a070", + text_color: "#b9b9ba", + text_muted_color: "#b9b9ba" + } + +config :pleroma, Pleroma.Emails.NewUsersDigestEmail, enabled: false + +config :prometheus, Pleroma.Web.Endpoint.MetricsExporter, + enabled: false, + auth: false, + ip_whitelist: [], + path: "/api/pleroma/app_metrics", + format: :text + +config :pleroma, Pleroma.ScheduledActivity, + daily_user_limit: 25, + total_user_limit: 300, + enabled: true + +config :pleroma, :email_notifications, + digest: %{ + active: false, + interval: 7, + inactivity_threshold: 7 + } + +config :pleroma, :oauth2, + token_expires_in: 3600 * 24 * 365 * 100, + issue_new_refresh_token: true, + clean_expired_tokens: false + +config :pleroma, :database, rum_enabled: false + +config :pleroma, :features, improved_hashtag_timeline: :auto + +config :pleroma, :populate_hashtags_table, fault_rate_allowance: 0.01 + +config :pleroma, :delete_context_objects, fault_rate_allowance: 0.01 + +config :pleroma, :env, Mix.env() + +config :http_signatures, + adapter: Pleroma.Signature + +config :pleroma, :rate_limit, + authentication: {60_000, 15}, + timeline: {500, 3}, + search: [{1000, 10}, {1000, 30}], + app_account_creation: {1_800_000, 25}, + relations_actions: {10_000, 10}, + relation_id_action: {60_000, 2}, + statuses_actions: {10_000, 15}, + status_id_action: {60_000, 3}, + events_actions: {10_000, 15}, + password_reset: {1_800_000, 5}, + account_confirmation_resend: {8_640_000, 5}, + ap_routes: {60_000, 15} + +config :pleroma, Pleroma.Workers.PurgeExpiredActivity, enabled: true, min_lifetime: 600 + +config :pleroma, Pleroma.Web.Plugs.RemoteIp, + enabled: true, + headers: ["x-forwarded-for"], + proxies: [], + reserved: [ + "127.0.0.0/8", + "::1/128", + "fc00::/7", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16" + ] + +config :pleroma, :static_fe, enabled: false + +# Example of frontend configuration +# This example will make us serve the primary frontend from the +# frontends directory within your `:pleroma, :instance, static_dir`. +# e.g., instance/static/frontends/pleroma/develop/ +# +# With no frontend configuration, the bundled files from the `static` directory will +# be used. +# +# config :pleroma, :frontends, +# primary: %{"name" => "pleroma-fe", "ref" => "develop"}, +# admin: %{"name" => "admin-fe", "ref" => "stable"}, +# available: %{...} + +config :pleroma, :frontends, + available: %{ + "kenoma" => %{ + "name" => "kenoma", + "git" => "https://git.pleroma.social/lambadalambda/kenoma", + "build_url" => + "https://git.pleroma.social/lambadalambda/kenoma/-/jobs/artifacts/${ref}/download?job=build", + "ref" => "master" + }, + "pleroma-fe" => %{ + "name" => "pleroma-fe", + "git" => "https://git.pleroma.social/pleroma/pleroma-fe", + "build_url" => + "https://git.pleroma.social/pleroma/pleroma-fe/-/jobs/artifacts/${ref}/download?job=build", + "ref" => "develop" + }, + "fedi-fe" => %{ + "name" => "fedi-fe", + "git" => "https://git.pleroma.social/pleroma/fedi-fe", + "build_url" => + "https://git.pleroma.social/pleroma/fedi-fe/-/jobs/artifacts/${ref}/download?job=build_release", + "ref" => "master", + "custom-http-headers" => [ + {"service-worker-allowed", "/"} + ] + }, + "admin-fe" => %{ + "name" => "admin-fe", + "git" => "https://git.pleroma.social/pleroma/admin-fe", + "build_url" => + "https://git.pleroma.social/pleroma/admin-fe/-/jobs/artifacts/${ref}/download?job=build", + "ref" => "develop" + }, + "soapbox" => %{ + "name" => "soapbox", + "git" => "https://gitlab.com/soapbox-pub/soapbox", + "build_url" => + "https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/${ref}/download?job=build", + "ref" => "main" + }, + "glitch-lily" => %{ + "name" => "glitch-lily", + "git" => "https://lily-is.land/infra/glitch-lily", + "build_url" => + "https://lily-is.land/infra/glitch-lily/-/jobs/artifacts/${ref}/download?job=build", + "ref" => "servant", + "build_dir" => "public" + } + } + +config :pleroma, :web_cache_ttl, + activity_pub: nil, + activity_pub_question: 30_000 + +config :pleroma, :modules, runtime_dir: "instance/modules" + +config :pleroma, configurable_from_database: false + +config :pleroma, Pleroma.Repo, + parameters: [gin_fuzzy_search_limit: "500"], + prepare: :unnamed + +config :pleroma, :connections_pool, + reclaim_multiplier: 0.1, + connection_acquisition_wait: 250, + connection_acquisition_retries: 5, + max_connections: 250, + max_idle_time: 30_000, + retry: 0, + connect_timeout: 5_000 + +config :pleroma, :pools, + federation: [ + size: 50, + max_waiting: 10, + recv_timeout: 10_000 + ], + media: [ + size: 50, + max_waiting: 20, + recv_timeout: 15_000 + ], + upload: [ + size: 25, + max_waiting: 5, + recv_timeout: 15_000 + ], + default: [ + size: 10, + max_waiting: 2, + recv_timeout: 5_000 + ] + +config :pleroma, :hackney_pools, + federation: [ + max_connections: 50, + timeout: 150_000 + ], + media: [ + max_connections: 50, + timeout: 150_000 + ], + upload: [ + max_connections: 25, + timeout: 300_000 + ] + +config :pleroma, :majic_pool, size: 2 + +private_instance? = :if_instance_is_private + +config :pleroma, :restrict_unauthenticated, + timelines: %{local: private_instance?, federated: private_instance?}, + profiles: %{local: private_instance?, remote: private_instance?}, + activities: %{local: private_instance?, remote: private_instance?} + +config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false + +config :pleroma, :mrf, + policies: [ + Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy, + Pleroma.Web.ActivityPub.MRF.TagPolicy, + Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy + ], + transparency: true, + transparency_exclusions: [] + +config :tzdata, :http_client, Pleroma.HTTP.Tzdata + +config :ex_aws, http_client: Pleroma.HTTP.ExAws + +config :web_push_encryption, http_client: Pleroma.HTTP.WebPush + +config :pleroma, :instances_favicons, enabled: false + +config :floki, :html_parser, Floki.HTMLParser.FastHtml + +config :pleroma, Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.PleromaAuthenticator + +config :pleroma, Pleroma.User.Backup, + purge_after_days: 30, + limit_days: 7, + dir: nil, + process_wait_time: 30_000, + process_chunk_size: 100 + +config :pleroma, ConcurrentLimiter, [ + {Pleroma.Web.RichMedia.Helpers, [max_running: 5, max_waiting: 5]}, + {Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy, [max_running: 5, max_waiting: 5]}, + {Pleroma.Search, [max_running: 30, max_waiting: 50]}, + {Pleroma.Webhook.Notify, [max_running: 5, max_waiting: 200]} +] + +config :pleroma, Pleroma.Web.WebFinger, domain: nil, update_nickname_on_user_fetch: false + +config :pleroma, Pleroma.Language.Translation, allow_unauthenticated: false, allow_remote: true + +config :geospatial, Geospatial.Service, service: Geospatial.Providers.Nominatim + +config :geospatial, Geospatial.Providers.GoogleMaps, + api_key: nil, + fetch_place_details: true + +config :geospatial, Geospatial.Providers.Nominatim, + endpoint: "https://nominatim.openstreetmap.org", + api_key: nil + +config :geospatial, Geospatial.Providers.Pelias, + endpoint: "https://api.geocode.earth", + api_key: nil + +config :geospatial, Geospatial.HTTP, user_agent: &Pleroma.Application.user_agent/0 + +import_config "soapbox.exs" + +config :pleroma, Pleroma.Search, module: Pleroma.Search.DatabaseSearch + +config :pleroma, Pleroma.Search.Meilisearch, + url: "http://127.0.0.1:7700/", + private_key: nil, + initial_indexing_chunk_size: 100_000 + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{Mix.env()}.exs" diff --git a/config/description.exs b/config/description.exs new file mode 100644 index 0000000..73ca480 --- /dev/null +++ b/config/description.exs @@ -0,0 +1,3744 @@ +import Config + +websocket_config = [ + path: "/websocket", + serializer: [ + {Phoenix.Socket.V1.JSONSerializer, "~> 1.0.0"}, + {Phoenix.Socket.V2.JSONSerializer, "~> 2.0.0"} + ], + timeout: 60_000, + transport_log: false, + compress: false +] + +installed_frontend_options = [ + %{ + key: "name", + label: "Name", + type: :string, + description: + "Name of the installed frontend. Valid config must include both `Name` and `Reference` values." + }, + %{ + key: "ref", + label: "Reference", + type: :string, + description: + "Reference of the installed frontend to be used. Valid config must include both `Name` and `Reference` values." + } +] + +frontend_options = [ + %{ + key: "name", + label: "Name", + type: :string, + description: "Name of the frontend." + }, + %{ + key: "ref", + label: "Reference", + type: :string, + description: "Reference of the frontend to be used." + }, + %{ + key: "git", + label: "Git Repository URL", + type: :string, + description: "URL of the git repository of the frontend" + }, + %{ + key: "build_url", + label: "Build URL", + type: :string, + description: + "Either an url to a zip file containing the frontend or a template to build it by inserting the `ref`. The string `${ref}` will be replaced by the configured `ref`.", + example: "https://some.url/builds/${ref}.zip" + }, + %{ + key: "build_dir", + label: "Build directory", + type: :string, + description: "The directory inside the zip file " + }, + %{ + key: "custom-http-headers", + label: "Custom HTTP headers", + type: {:list, :string}, + description: "The custom HTTP headers for the frontend" + } +] + +config :pleroma, :config_description, [ + %{ + group: :pleroma, + key: Pleroma.Upload, + type: :group, + description: "Upload general settings", + children: [ + %{ + key: :uploader, + type: :module, + description: "Module which will be used for uploads", + suggestions: {:list_behaviour_implementations, Pleroma.Uploaders.Uploader} + }, + %{ + key: :filters, + type: {:list, :module}, + description: + "List of filter modules for uploads. Module names are shortened (removed leading `Pleroma.Upload.Filter.` part), but on adding custom module you need to use full name.", + suggestions: {:list_behaviour_implementations, Pleroma.Upload.Filter} + }, + %{ + key: :link_name, + type: :boolean, + description: + "If enabled, a name parameter will be added to the URL of the upload. For example `https://instance.tld/media/imagehash.png?name=realname.png`." + }, + %{ + key: :base_url, + label: "Base URL", + type: :string, + description: + "Base URL for the uploads. Required if you use a CDN or host attachments under a different domain.", + suggestions: [ + "https://cdn-host.com" + ] + }, + %{ + key: :proxy_remote, + type: :boolean, + description: """ + Proxy requests to the remote uploader.\n + Useful if media upload endpoint is not internet accessible. + """ + }, + %{ + key: :filename_display_max_length, + type: :integer, + description: "Set max length of a filename to display. 0 = no limit. Default: 30" + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Uploaders.Local, + type: :group, + description: "Local uploader-related settings", + children: [ + %{ + key: :uploads, + type: :string, + description: "Path where user's uploads will be saved", + suggestions: [ + "uploads" + ] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Uploaders.S3, + type: :group, + description: "S3 uploader-related settings", + children: [ + %{ + key: :bucket, + type: :string, + description: "S3 bucket", + suggestions: [ + "bucket" + ] + }, + %{ + key: :bucket_namespace, + type: :string, + description: "S3 bucket namespace", + suggestions: ["pleroma"] + }, + %{ + key: :truncated_namespace, + type: :string, + description: + "If you use S3 compatible service such as Digital Ocean Spaces or CDN, set folder name or \"\" etc." <> + " For example, when using CDN to S3 virtual host format, set \"\". At this time, write CNAME to CDN in Upload base_url." + }, + %{ + key: :streaming_enabled, + type: :boolean, + description: + "Enable streaming uploads, when enabled the file will be sent to the server in chunks as it's being read. This may be unsupported by some providers, try disabling this if you have upload problems." + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Upload.Filter.Mogrify, + type: :group, + description: "Uploads mogrify filter settings", + children: [ + %{ + key: :args, + type: [:string, {:list, :string}, {:list, :tuple}], + description: + "List of actions for the mogrify command. It's possible to add self-written settings as string. " <> + "For example `auto-orient, strip, {\"resize\", \"3840x1080>\"}` value will be parsed into valid list of the settings.", + suggestions: [ + "strip", + "auto-orient", + {"implode", "1"} + ] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Upload.Filter.AnonymizeFilename, + type: :group, + description: "Filter replaces the filename of the upload", + children: [ + %{ + key: :text, + type: :string, + description: + "Text to replace filenames in links. If no setting, {random}.extension will be used. You can get the original" <> + " filename extension by using {extension}, for example custom-file-name.{extension}.", + suggestions: [ + "custom-file-name.{extension}" + ] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Emails.Mailer, + type: :group, + description: "Mailer-related settings", + children: [ + %{ + key: :enabled, + label: "Mailer Enabled", + type: :boolean + }, + %{ + key: :adapter, + type: :module, + description: + "One of the mail adapters listed in [Swoosh documentation](https://hexdocs.pm/swoosh/Swoosh.html#module-adapters)", + suggestions: [ + Swoosh.Adapters.AmazonSES, + Swoosh.Adapters.Dyn, + Swoosh.Adapters.Gmail, + Swoosh.Adapters.Mailgun, + Swoosh.Adapters.Mailjet, + Swoosh.Adapters.Mandrill, + Swoosh.Adapters.Postmark, + Swoosh.Adapters.SMTP, + Swoosh.Adapters.Sendgrid, + Swoosh.Adapters.Sendmail, + Swoosh.Adapters.SocketLabs, + Swoosh.Adapters.SparkPost + ] + }, + %{ + group: {:subgroup, Swoosh.Adapters.SMTP}, + key: :relay, + type: :string, + description: "Hostname or IP address", + suggestions: ["smtp.example.com"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.SMTP}, + key: :port, + type: :integer, + description: "SMTP port", + suggestions: ["1025"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.SMTP}, + key: :username, + type: :string, + description: "SMTP AUTH username", + suggestions: ["user@example.com"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.SMTP}, + key: :password, + type: :string, + description: "SMTP AUTH password", + suggestions: ["password"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.SMTP}, + key: :ssl, + label: "Use SSL", + type: :boolean, + description: "Use Implicit SSL/TLS. e.g. port 465" + }, + %{ + group: {:subgroup, Swoosh.Adapters.SMTP}, + key: :tls, + label: "STARTTLS Mode", + type: {:dropdown, :atom}, + description: "Explicit TLS (STARTTLS) enforcement mode", + suggestions: [:if_available, :always, :never] + }, + %{ + group: {:subgroup, Swoosh.Adapters.SMTP}, + key: :auth, + label: "AUTH Mode", + type: {:dropdown, :atom}, + description: "SMTP AUTH enforcement mode", + suggestions: [:if_available, :always, :never] + }, + %{ + group: {:subgroup, Swoosh.Adapters.SMTP}, + key: :retries, + type: :integer, + description: "SMTP temporary (4xx) error retries", + suggestions: [1] + }, + %{ + group: {:subgroup, Swoosh.Adapters.Sendgrid}, + key: :api_key, + label: "SendGrid API Key", + type: :string, + suggestions: ["YOUR_API_KEY"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.Sendmail}, + key: :cmd_path, + type: :string, + suggestions: ["/usr/bin/sendmail"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.Sendmail}, + key: :cmd_args, + type: :string, + suggestions: ["-N delay,failure,success"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.Sendmail}, + key: :qmail, + label: "Qmail compat mode", + type: :boolean + }, + %{ + group: {:subgroup, Swoosh.Adapters.Mandrill}, + key: :api_key, + label: "Mandrill API Key", + type: :string, + suggestions: ["YOUR_API_KEY"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.Mailgun}, + key: :api_key, + label: "Mailgun API Key", + type: :string, + suggestions: ["YOUR_API_KEY"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.Mailgun}, + key: :domain, + type: :string, + suggestions: ["YOUR_DOMAIN_NAME"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.Mailjet}, + key: :api_key, + label: "MailJet Public API Key", + type: :string, + suggestions: ["MJ_APIKEY_PUBLIC"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.Mailjet}, + key: :secret, + label: "MailJet Private API Key", + type: :string, + suggestions: ["MJ_APIKEY_PRIVATE"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.Postmark}, + key: :api_key, + label: "Postmark API Key", + type: :string, + suggestions: ["X-Postmark-Server-Token"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.SparkPost}, + key: :api_key, + label: "SparkPost API key", + type: :string, + suggestions: ["YOUR_API_KEY"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.SparkPost}, + key: :endpoint, + type: :string, + suggestions: ["https://api.sparkpost.com/api/v1"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.AmazonSES}, + key: :access_key, + label: "AWS Access Key", + type: :string, + suggestions: ["AWS_ACCESS_KEY"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.AmazonSES}, + key: :secret, + label: "AWS Secret Key", + type: :string, + suggestions: ["AWS_SECRET_KEY"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.AmazonSES}, + key: :region, + label: "AWS Region", + type: :string, + suggestions: ["us-east-1", "us-east-2"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.Dyn}, + key: :api_key, + label: "Dyn API Key", + type: :string, + suggestions: ["apikey"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.SocketLabs}, + key: :api_key, + label: "SocketLabs API Key", + type: :string, + suggestions: ["INJECTION_API_KEY"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.SocketLabs}, + key: :server_id, + label: "Server ID", + type: :string, + suggestions: ["SERVER_ID"] + }, + %{ + group: {:subgroup, Swoosh.Adapters.Gmail}, + key: :access_token, + label: "GMail API Access Token", + type: :string, + suggestions: ["GMAIL_API_ACCESS_TOKEN"] + } + ] + }, + %{ + group: :pleroma, + key: :uri_schemes, + label: "URI Schemes", + type: :group, + description: "URI schemes related settings", + children: [ + %{ + key: :valid_schemes, + type: {:list, :string}, + description: "List of the scheme part that is considered valid to be an URL", + suggestions: [ + "https", + "http", + "dat", + "dweb", + "gopher", + "hyper", + "ipfs", + "ipns", + "irc", + "ircs", + "magnet", + "mailto", + "mumble", + "ssb", + "xmpp" + ] + } + ] + }, + %{ + group: :pleroma, + key: :features, + type: :group, + description: "Customizable features", + children: [ + %{ + key: :improved_hashtag_timeline, + type: {:dropdown, :atom}, + description: + "Setting to force toggle / force disable improved hashtags timeline. `:enabled` forces hashtags to be fetched from `hashtags` table for hashtags timeline. `:disabled` forces object-embedded hashtags to be used (slower). Keep it `:auto` for automatic behaviour (it is auto-set to `:enabled` [unless overridden] when HashtagsTableMigrator completes).", + suggestions: [:auto, :enabled, :disabled] + } + ] + }, + %{ + group: :pleroma, + key: :populate_hashtags_table, + type: :group, + description: "`populate_hashtags_table` background migration settings", + children: [ + %{ + key: :fault_rate_allowance, + type: :float, + description: + "Max accepted rate of objects that failed in the migration. Any value from 0.0 which tolerates no errors to 1.0 which will enable the feature even if hashtags transfer failed for all records.", + suggestions: [0.01] + }, + %{ + key: :sleep_interval_ms, + type: :integer, + description: + "Sleep interval between each chunk of processed records in order to decrease the load on the system (defaults to 0 and should be keep default on most instances)." + } + ] + }, + %{ + group: :pleroma, + key: :delete_context_objects, + type: :group, + description: "`delete_context_objects` background migration settings", + children: [ + %{ + key: :fault_rate_allowance, + type: :float, + description: + "Max accepted rate of objects that failed in the migration. Any value from 0.0 which tolerates no errors to 1.0 which will enable the feature even if context object deletion failed for all records.", + suggestions: [0.01] + }, + %{ + key: :sleep_interval_ms, + type: :integer, + description: + "Sleep interval between each chunk of processed records in order to decrease the load on the system (defaults to 0 and should be keep default on most instances)." + } + ] + }, + %{ + group: :pleroma, + key: :instance, + type: :group, + description: "Instance-related settings", + children: [ + %{ + key: :name, + type: :string, + description: "Name of the instance", + suggestions: [ + "Pleroma" + ] + }, + %{ + key: :email, + label: "Admin Email Address", + type: :string, + description: "Email used to reach an Administrator/Moderator of the instance", + suggestions: [ + "email@example.com" + ] + }, + %{ + key: :notify_email, + label: "Sender Email Address", + type: :string, + description: "Envelope FROM address for mail sent via Pleroma", + suggestions: [ + "notify@example.com" + ] + }, + %{ + key: :description, + type: :string, + description: + "The instance's description. It can be seen in nodeinfo and `/api/v1/instance`", + suggestions: [ + "Very cool instance" + ] + }, + %{ + key: :short_description, + type: :string, + description: + "Shorter version of instance description. It can be seen on `/api/v1/instance`", + suggestions: [ + "Cool instance" + ] + }, + %{ + key: :contact_username, + type: :string, + description: "Instance owner username", + suggestions: ["admin"] + }, + %{ + key: :limit, + type: :integer, + description: "Posts character limit (CW/Subject included in the counter)", + suggestions: [ + 5_000 + ] + }, + %{ + key: :remote_limit, + type: :integer, + description: "Hard character limit beyond which remote posts will be dropped", + suggestions: [ + 100_000 + ] + }, + %{ + key: :max_media_attachments, + type: :integer, + description: "Maximum number of post media attachments", + suggestions: [ + 1_000_000 + ] + }, + %{ + key: :upload_limit, + type: :integer, + description: "File size limit of uploads (except for avatar, background, banner)", + suggestions: [ + 16_000_000 + ] + }, + %{ + key: :avatar_upload_limit, + type: :integer, + description: "File size limit of user's profile avatars", + suggestions: [ + 2_000_000 + ] + }, + %{ + key: :background_upload_limit, + type: :integer, + description: "File size limit of user's profile backgrounds", + suggestions: [ + 4_000_000 + ] + }, + %{ + key: :banner_upload_limit, + type: :integer, + description: "File size limit of user's profile banners", + suggestions: [ + 4_000_000 + ] + }, + %{ + key: :poll_limits, + type: :map, + description: "A map with poll limits for local polls", + suggestions: [ + %{ + max_options: 20, + max_option_chars: 200, + min_expiration: 0, + max_expiration: 31_536_000 + } + ], + children: [ + %{ + key: :max_options, + type: :integer, + description: "Maximum number of options", + suggestions: [20] + }, + %{ + key: :max_option_chars, + type: :integer, + description: "Maximum number of characters per option", + suggestions: [200] + }, + %{ + key: :min_expiration, + type: :integer, + description: "Minimum expiration time (in seconds)", + suggestions: [0] + }, + %{ + key: :max_expiration, + type: :integer, + description: "Maximum expiration time (in seconds)", + suggestions: [3600] + } + ] + }, + %{ + key: :registrations_open, + type: :boolean, + description: + "Enable registrations for anyone. Invitations require this setting to be disabled." + }, + %{ + key: :invites_enabled, + type: :boolean, + description: + "Enable user invitations for admins (depends on `registrations_open` being disabled)" + }, + %{ + key: :account_activation_required, + type: :boolean, + description: "Require users to confirm their emails before signing in" + }, + %{ + key: :account_approval_required, + type: :boolean, + description: "Require users to be manually approved by an admin before signing in" + }, + %{ + key: :federating, + type: :boolean, + description: "Enable federation with other instances" + }, + %{ + key: :federation_incoming_replies_max_depth, + label: "Fed. incoming replies max depth", + type: :integer, + description: + "Max. depth of reply-to and reply activities fetching on incoming federation, to prevent out-of-memory situations while" <> + " fetching very long threads. If set to `nil`, threads of any depth will be fetched. Lower this value if you experience out-of-memory crashes.", + suggestions: [ + 100 + ] + }, + %{ + key: :federation_reachability_timeout_days, + label: "Fed. reachability timeout days", + type: :integer, + description: + "Timeout (in days) of each external federation target being unreachable prior to pausing federating to it", + suggestions: [ + 7 + ] + }, + %{ + key: :allow_relay, + type: :boolean, + description: + "Permits remote instances to subscribe to all public posts of your instance. (Important!) This may increase the visibility of your instance." + }, + %{ + key: :public, + type: :boolean, + description: + "Makes the client API in authenticated mode-only except for user-profiles." <> + " Useful for disabling the Local Timeline and The Whole Known Network. " <> + " Note: when setting to `false`, please also check `:restrict_unauthenticated` setting." + }, + %{ + key: :quarantined_instances, + type: {:list, :tuple}, + key_placeholder: "instance", + value_placeholder: "reason", + description: + "List of ActivityPub instances where private (DMs, followers-only) activities will not be sent and the reason for doing so", + suggestions: [ + {"quarantined.com", "Reason"}, + {"*.quarantined.com", "Reason"} + ] + }, + %{ + key: :static_dir, + type: :string, + description: "Instance static directory", + suggestions: [ + "instance/static/" + ] + }, + %{ + key: :allowed_post_formats, + type: {:list, :string}, + description: "MIME-type list of formats allowed to be posted (transformed into HTML)", + suggestions: [ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode" + ] + }, + %{ + key: :extended_nickname_format, + type: :boolean, + description: + "Enable to use extended local nicknames format (allows underscores/dashes)." <> + " This will break federation with older software for theses nicknames." + }, + %{ + key: :cleanup_attachments, + type: :boolean, + description: """ + Enable to remove associated attachments when status is removed. + This will not affect duplicates and attachments without status. + Enabling this will increase load to database when deleting statuses on larger instances. + """ + }, + %{ + key: :max_pinned_statuses, + type: :integer, + description: "The maximum number of pinned statuses. 0 will disable the feature.", + suggestions: [ + 0, + 1, + 3 + ] + }, + %{ + key: :max_endorsed_users, + type: :integer, + description: "The maximum number of recommended accounts. 0 will disable the feature.", + suggestions: [ + 0, + 1, + 3 + ] + }, + %{ + key: :autofollowed_nicknames, + type: {:list, :string}, + description: + "Set to nicknames of (local) users that every new user should automatically follow" + }, + %{ + key: :autofollowing_nicknames, + type: {:list, :string}, + description: + "Set to nicknames of (local) users that automatically follows every newly registered user" + }, + %{ + key: :attachment_links, + type: :boolean, + description: "Enable to automatically add attachment link text to statuses" + }, + %{ + key: :max_report_comment_size, + type: :integer, + description: "The maximum size of the report comment. Default: 1000.", + suggestions: [ + 1_000 + ] + }, + %{ + key: :report_strip_status, + label: "Report strip status", + type: :boolean, + description: + "Strip associated statuses in reports to ids when closed/resolved, otherwise keep a copy" + }, + %{ + key: :safe_dm_mentions, + label: "Safe DM mentions", + type: :boolean, + description: + "If enabled, only mentions at the beginning of a post will be used to address people in direct messages." <> + " This is to prevent accidental mentioning of people when talking about them (e.g. \"@admin please keep an eye on @bad_actor\")." <> + " Default: disabled" + }, + %{ + key: :healthcheck, + type: :boolean, + description: "If enabled, system data will be shown on `/api/pleroma/healthcheck`" + }, + %{ + key: :remote_post_retention_days, + type: :integer, + description: + "The default amount of days to retain remote posts when pruning the database", + suggestions: [ + 90 + ] + }, + %{ + key: :user_bio_length, + type: :integer, + description: "A user bio maximum length. Default: 5000.", + suggestions: [ + 5_000 + ] + }, + %{ + key: :user_name_length, + type: :integer, + description: "A user name maximum length. Default: 100.", + suggestions: [ + 100 + ] + }, + %{ + key: :user_location_length, + type: :integer, + description: "A user location maximum length. Default: 50.", + suggestions: [ + 50 + ] + }, + %{ + key: :skip_thread_containment, + type: :boolean, + description: "Skip filtering out broken threads. Default: enabled." + }, + %{ + key: :limit_to_local_content, + type: {:dropdown, :atom}, + description: + "Limit unauthenticated users to search for local statutes and users only. Default: `:unauthenticated`.", + suggestions: [ + :unauthenticated, + :all, + false + ] + }, + %{ + key: :max_account_fields, + type: :integer, + description: "The maximum number of custom fields in the user profile. Default: 10.", + suggestions: [ + 10 + ] + }, + %{ + key: :max_remote_account_fields, + type: :integer, + description: + "The maximum number of custom fields in the remote user profile. Default: 20.", + suggestions: [ + 20 + ] + }, + %{ + key: :account_field_name_length, + type: :integer, + description: "An account field name maximum length. Default: 512.", + suggestions: [ + 512 + ] + }, + %{ + key: :account_field_value_length, + type: :integer, + description: "An account field value maximum length. Default: 2048.", + suggestions: [ + 2048 + ] + }, + %{ + key: :registration_reason_length, + type: :integer, + description: "Maximum registration reason length. Default: 500.", + suggestions: [ + 500 + ] + }, + %{ + key: :external_user_synchronization, + type: :boolean, + description: "Enabling following/followers counters synchronization for external users" + }, + %{ + key: :multi_factor_authentication, + type: :keyword, + description: "Multi-factor authentication settings", + suggestions: [ + [ + totp: [digits: 6, period: 30], + backup_codes: [number: 5, length: 16] + ] + ], + children: [ + %{ + key: :totp, + label: "TOTP settings", + type: :keyword, + description: "TOTP settings", + suggestions: [digits: 6, period: 30], + children: [ + %{ + key: :digits, + type: :integer, + suggestions: [6], + description: + "Determines the length of a one-time pass-code, in characters. Defaults to 6 characters." + }, + %{ + key: :period, + type: :integer, + suggestions: [30], + description: + "A period for which the TOTP code will be valid, in seconds. Defaults to 30 seconds." + } + ] + }, + %{ + key: :backup_codes, + type: :keyword, + description: "MFA backup codes settings", + suggestions: [number: 5, length: 16], + children: [ + %{ + key: :number, + type: :integer, + suggestions: [5], + description: "Number of backup codes to generate." + }, + %{ + key: :length, + type: :integer, + suggestions: [16], + description: + "Determines the length of backup one-time pass-codes, in characters. Defaults to 16 characters." + } + ] + } + ] + }, + %{ + key: :instance_thumbnail, + type: {:string, :image}, + description: + "The instance thumbnail can be any image that represents your instance and is used by some apps or services when they display information about your instance.", + suggestions: ["/instance/thumbnail.jpeg"] + }, + %{ + key: :favicon, + type: {:string, :image}, + description: + "Shortcut icon displayed in the browser, and possibly displayed by other instances.", + suggestions: ["/favicon.png"] + }, + %{ + key: :show_reactions, + type: :boolean, + description: "Let favourites and emoji reactions be viewed through the API." + }, + %{ + key: :profile_directory, + type: :boolean, + description: "Enable profile directory." + }, + %{ + key: :admin_privileges, + type: {:list, :atom}, + suggestions: [ + :users_read, + :users_manage_invites, + :users_manage_activation_state, + :users_manage_tags, + :users_manage_credentials, + :users_delete, + :messages_read, + :messages_delete, + :instances_delete, + :reports_manage_reports, + :moderation_log_read, + :announcements_manage_announcements, + :emoji_manage_emoji, + :statistics_read + ], + description: + "What extra privileges to allow admins (e.g. updating user credentials, get password reset token, delete users, index and read private statuses and chats)" + }, + %{ + key: :moderator_privileges, + type: {:list, :atom}, + suggestions: [ + :users_read, + :users_manage_invites, + :users_manage_activation_state, + :users_manage_tags, + :users_manage_credentials, + :users_delete, + :messages_read, + :messages_delete, + :instances_delete, + :reports_manage_reports, + :moderation_log_read, + :announcements_manage_announcements, + :emoji_manage_emoji, + :statistics_read + ], + description: + "What extra privileges to allow moderators (e.g. updating user credentials, get password reset token, delete users, index and read private statuses and chats)" + }, + %{ + key: :birthday_required, + type: :boolean, + description: "Require users to enter their birthday." + }, + %{ + key: :birthday_min_age, + type: :integer, + description: + "Minimum required age (in days) for users to create account. Only used if birthday is required.", + suggestions: [6570] + }, + %{ + key: :migration_cooldown_period, + type: :integer, + description: + "Number of days for which users won't be able to migrate account again after successful migration.", + suggestions: [30] + }, + %{ + key: :languages, + type: {:list, :string}, + description: + "Languages to be exposed in /api/v1/instance. Should be in the format of BCP47 language codes.", + suggestions: [ + "en" + ] + } + ] + }, + %{ + group: :pleroma, + key: :welcome, + type: :group, + description: "Welcome messages settings", + children: [ + %{ + key: :direct_message, + type: :keyword, + descpiption: "Direct message settings", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables sending a direct message to newly registered users" + }, + %{ + key: :message, + type: :string, + description: "A message that will be sent to newly registered users", + suggestions: [ + "Hi, @username! Welcome on board!" + ] + }, + %{ + key: :sender_nickname, + type: :string, + description: "The nickname of the local user that sends a welcome message", + suggestions: [ + "lain" + ] + } + ] + }, + %{ + key: :chat_message, + type: :keyword, + descpiption: "Chat message settings", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables sending a chat message to newly registered users" + }, + %{ + key: :message, + type: :string, + description: + "A message that will be sent to newly registered users as a chat message", + suggestions: [ + "Hello, welcome on board!" + ] + }, + %{ + key: :sender_nickname, + type: :string, + description: "The nickname of the local user that sends a welcome chat message", + suggestions: [ + "lain" + ] + } + ] + }, + %{ + key: :email, + type: :keyword, + descpiption: "Email message settings", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables sending an email to newly registered users" + }, + %{ + key: :sender, + type: [:string, :tuple], + description: + "Email address and/or nickname that will be used to send the welcome email.", + suggestions: [ + {"Pleroma App", "welcome@pleroma.app"} + ] + }, + %{ + key: :subject, + type: :string, + description: + "Subject of the welcome email. EEX template with user and instance_name variables can be used.", + suggestions: ["Welcome to <%= instance_name%>"] + }, + %{ + key: :html, + type: :string, + description: + "HTML content of the welcome email. EEX template with user and instance_name variables can be used.", + suggestions: ["

Hello <%= user.name%>. Welcome to <%= instance_name%>

"] + }, + %{ + key: :text, + type: :string, + description: + "Text content of the welcome email. EEX template with user and instance_name variables can be used.", + suggestions: ["Hello <%= user.name%>. \n Welcome to <%= instance_name%>\n"] + } + ] + } + ] + }, + %{ + group: :logger, + type: :group, + description: "Logger-related settings", + children: [ + %{ + key: :backends, + type: [:atom, :tuple, :module], + description: + "Where logs will be sent, :console - send logs to stdout, { ExSyslogger, :ex_syslogger } - to syslog, Quack.Logger - to Slack.", + suggestions: [:console, {ExSyslogger, :ex_syslogger}, Quack.Logger] + } + ] + }, + %{ + group: :logger, + type: :group, + key: :ex_syslogger, + label: "ExSyslogger", + description: "ExSyslogger-related settings", + children: [ + %{ + key: :level, + type: {:dropdown, :atom}, + description: "Log level", + suggestions: [:debug, :info, :warn, :error] + }, + %{ + key: :ident, + type: :string, + description: + "A string that's prepended to every message, and is typically set to the app name", + suggestions: ["pleroma"] + }, + %{ + key: :format, + type: :string, + description: "Default: \"$date $time [$level] $levelpad$node $metadata $message\"", + suggestions: ["$metadata[$level] $message"] + }, + %{ + key: :metadata, + type: {:list, :atom}, + suggestions: [:request_id] + } + ] + }, + %{ + group: :logger, + type: :group, + key: :console, + label: "Console Logger", + description: "Console logger settings", + children: [ + %{ + key: :level, + type: {:dropdown, :atom}, + description: "Log level", + suggestions: [:debug, :info, :warn, :error] + }, + %{ + key: :format, + type: :string, + description: "Default: \"$date $time [$level] $levelpad$node $metadata $message\"", + suggestions: ["$metadata[$level] $message"] + }, + %{ + key: :metadata, + type: {:list, :atom}, + suggestions: [:request_id] + } + ] + }, + %{ + group: :pleroma, + key: :frontend_configurations, + type: :group, + description: + "This form can be used to configure a keyword list that keeps the configuration data for any " <> + "kind of frontend. By default, settings for pleroma_fe are configured. If you want to " <> + "add your own configuration your settings all fields must be complete.", + children: [ + %{ + key: :pleroma_fe, + label: "Pleroma FE", + type: :map, + description: "Settings for Pleroma FE", + suggestions: [ + %{ + alwaysShowSubjectInput: true, + background: "/static/aurora_borealis.jpg", + collapseMessageWithSubject: false, + greentext: false, + hideFilteredStatuses: false, + hideMutedPosts: false, + hidePostStats: false, + hideSitename: false, + hideUserStats: false, + loginMethod: "password", + logo: "/static/logo.svg", + logoMargin: ".1em", + logoMask: true, + minimalScopesMode: false, + noAttachmentLinks: false, + nsfwCensorImage: "/static/img/nsfw.74818f9.png", + postContentType: "text/plain", + redirectRootLogin: "/main/friends", + redirectRootNoLogin: "/main/all", + scopeCopy: true, + sidebarRight: false, + showFeaturesPanel: true, + showInstanceSpecificPanel: false, + subjectLineBehavior: "email", + theme: "pleroma-dark", + webPushNotifications: false + } + ], + children: [ + %{ + key: :alwaysShowSubjectInput, + label: "Always show subject input", + type: :boolean, + description: "When disabled, auto-hide the subject field if it's empty" + }, + %{ + key: :background, + type: {:string, :image}, + description: + "URL of the background, unless viewing a user profile with a background that is set", + suggestions: ["/images/city.jpg"] + }, + %{ + key: :collapseMessageWithSubject, + label: "Collapse message with subject", + type: :boolean, + description: + "When a message has a subject (aka Content Warning), collapse it by default" + }, + %{ + key: :greentext, + label: "Greentext", + type: :boolean, + description: "Enables green text on lines prefixed with the > character" + }, + %{ + key: :hideFilteredStatuses, + label: "Hide Filtered Statuses", + type: :boolean, + description: "Hides filtered statuses from timelines" + }, + %{ + key: :hideMutedPosts, + label: "Hide Muted Posts", + type: :boolean, + description: "Hides muted statuses from timelines" + }, + %{ + key: :hidePostStats, + label: "Hide post stats", + type: :boolean, + description: "Hide notices statistics (repeats, favorites, ...)" + }, + %{ + key: :hideSitename, + label: "Hide Sitename", + type: :boolean, + description: "Hides instance name from PleromaFE banner" + }, + %{ + key: :hideUserStats, + label: "Hide user stats", + type: :boolean, + description: + "Hide profile statistics (posts, posts per day, followers, followings, ...)" + }, + %{ + key: :logo, + type: {:string, :image}, + description: "URL of the logo, defaults to Pleroma's logo", + suggestions: ["/static/logo.svg"] + }, + %{ + key: :logoMargin, + label: "Logo margin", + type: :string, + description: + "Allows you to adjust vertical margins between logo boundary and navbar borders. " <> + "The idea is that to have logo's image without any extra margins and instead adjust them to your need in layout.", + suggestions: [".1em"] + }, + %{ + key: :logoMask, + label: "Logo mask", + type: :boolean, + description: + "By default it assumes logo used will be monochrome with alpha channel to be compatible with both light and dark themes. " <> + "If you want a colorful logo you must disable logoMask." + }, + %{ + key: :minimalScopesMode, + label: "Minimal scopes mode", + type: :boolean, + description: + "Limit scope selection to Direct, User default, and Scope of post replying to. " <> + "Also prevents replying to a DM with a public post from PleromaFE." + }, + %{ + key: :nsfwCensorImage, + label: "NSFW Censor Image", + type: {:string, :image}, + description: + "URL of the image to use for hiding NSFW media attachments in the timeline", + suggestions: ["/static/img/nsfw.74818f9.png"] + }, + %{ + key: :postContentType, + label: "Post Content Type", + type: {:dropdown, :atom}, + description: "Default post formatting option", + suggestions: ["text/plain", "text/html", "text/markdown", "text/bbcode"] + }, + %{ + key: :redirectRootNoLogin, + label: "Redirect root no login", + type: :string, + description: + "Relative URL which indicates where to redirect when a user isn't logged in", + suggestions: ["/main/all"] + }, + %{ + key: :redirectRootLogin, + label: "Redirect root login", + type: :string, + description: + "Relative URL which indicates where to redirect when a user is logged in", + suggestions: ["/main/friends"] + }, + %{ + key: :scopeCopy, + label: "Scope copy", + type: :boolean, + description: "Copy the scope (private/unlisted/public) in replies to posts by default" + }, + %{ + key: :sidebarRight, + label: "Sidebar on Right", + type: :boolean, + description: "Change alignment of sidebar and panels to the right" + }, + %{ + key: :showFeaturesPanel, + label: "Show instance features panel", + type: :boolean, + description: + "Enables panel displaying functionality of the instance on the About page" + }, + %{ + key: :showInstanceSpecificPanel, + label: "Show instance specific panel", + type: :boolean, + description: "Whether to show the instance's custom panel" + }, + %{ + key: :subjectLineBehavior, + label: "Subject line behavior", + type: :string, + description: "Allows changing the default behaviour of subject lines in replies. + `email`: copy and preprend re:, as in email, + `masto`: copy verbatim, as in Mastodon, + `noop`: don't copy the subject.", + suggestions: ["email", "masto", "noop"] + }, + %{ + key: :theme, + type: :string, + description: "Which theme to use. Available themes are defined in styles.json", + suggestions: ["pleroma-dark"] + } + ] + } + ] + }, + %{ + group: :pleroma, + key: :assets, + type: :group, + description: + "This section configures assets to be used with various frontends. Currently the only option relates to mascots on the mastodon frontend", + children: [ + %{ + key: :mascots, + type: {:keyword, :map}, + description: + "Keyword of mascots, each element must contain both an URL and a mime_type key", + suggestions: [ + pleroma_fox_tan: %{ + url: "/images/pleroma-fox-tan-smol.png", + mime_type: "image/png" + }, + pleroma_fox_tan_shy: %{ + url: "/images/pleroma-fox-tan-shy.png", + mime_type: "image/png" + } + ] + }, + %{ + key: :default_mascot, + type: :atom, + description: + "This will be used as the default mascot on MastoFE. Default: `:pleroma_fox_tan`", + suggestions: [ + :pleroma_fox_tan + ] + }, + %{ + key: :default_user_avatar, + type: {:string, :image}, + description: "URL of the default user avatar", + suggestions: ["/images/avi.png"] + } + ] + }, + %{ + group: :pleroma, + key: :manifest, + type: :group, + description: + "This section describe PWA manifest instance-specific values. Currently this option relate only for MastoFE.", + children: [ + %{ + key: :icons, + type: {:list, :map}, + description: "Describe the icons of the app", + suggestion: [ + %{ + src: "/static/logo.png" + }, + %{ + src: "/static/icon.png", + type: "image/png" + }, + %{ + src: "/static/icon.ico", + sizes: "72x72 96x96 128x128 256x256" + } + ] + }, + %{ + key: :theme_color, + type: :string, + description: "Describe the theme color of the app", + suggestions: ["#282c37", "mediumpurple"] + }, + %{ + key: :background_color, + type: :string, + description: "Describe the background color of the app", + suggestions: ["#191b22", "aliceblue"] + } + ] + }, + %{ + group: :pleroma, + key: :media_proxy, + type: :group, + description: "Media proxy", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables proxying of remote media via the instance's proxy" + }, + %{ + key: :base_url, + label: "Base URL", + type: :string, + description: + "The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts.", + suggestions: ["https://example.com"] + }, + %{ + key: :invalidation, + type: :keyword, + descpiption: "", + suggestions: [ + enabled: true, + provider: Pleroma.Web.MediaProxy.Invalidation.Script + ], + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables media cache object invalidation." + }, + %{ + key: :provider, + type: :module, + description: "Module which will be used to purge objects from the cache.", + suggestions: [ + Pleroma.Web.MediaProxy.Invalidation.Script, + Pleroma.Web.MediaProxy.Invalidation.Http + ] + } + ] + }, + %{ + key: :proxy_opts, + label: "Advanced MediaProxy Options", + type: :keyword, + description: "Internal Pleroma.ReverseProxy settings", + suggestions: [ + redirect_on_failure: false, + max_body_length: 25 * 1_048_576, + max_read_duration: 30_000 + ], + children: [ + %{ + key: :redirect_on_failure, + type: :boolean, + description: """ + Redirects the client to the origin server upon encountering HTTP errors.\n + Note that files larger than Max Body Length will trigger an error. (e.g., Peertube videos)\n\n + **WARNING:** This setting will allow larger files to be accessed, but exposes the\n + IP addresses of your users to the other servers, bypassing the MediaProxy. + """ + }, + %{ + key: :max_body_length, + type: :integer, + description: + "Maximum file size (in bytes) allowed through the Pleroma MediaProxy cache." + }, + %{ + key: :max_read_duration, + type: :integer, + description: "Timeout (in milliseconds) of GET request to the remote URI." + } + ] + }, + %{ + key: :whitelist, + type: {:list, :string}, + description: "List of hosts with scheme to bypass the MediaProxy", + suggestions: ["http://example.com"] + } + ] + }, + %{ + group: :pleroma, + key: :media_preview_proxy, + type: :group, + description: "Media preview proxy", + children: [ + %{ + key: :enabled, + type: :boolean, + description: + "Enables proxying of remote media preview to the instance's proxy. Requires enabled media proxy." + }, + %{ + key: :thumbnail_max_width, + type: :integer, + description: + "Max width of preview thumbnail for images (video preview always has original dimensions)." + }, + %{ + key: :thumbnail_max_height, + type: :integer, + description: + "Max height of preview thumbnail for images (video preview always has original dimensions)." + }, + %{ + key: :image_quality, + type: :integer, + description: "Quality of the output. Ranges from 0 (min quality) to 100 (max quality)." + }, + %{ + key: :min_content_length, + type: :integer, + description: + "Min content length (in bytes) to perform preview. Media smaller in size will be served without thumbnailing." + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Web.MediaProxy.Invalidation.Http, + type: :group, + description: "HTTP invalidate settings", + children: [ + %{ + key: :method, + type: :atom, + description: "HTTP method of request. Default: :purge" + }, + %{ + key: :headers, + type: {:keyword, :string}, + description: "HTTP headers of request", + suggestions: [{"x-refresh", 1}] + }, + %{ + key: :options, + type: :keyword, + description: "Request options", + children: [ + %{ + key: :params, + type: {:map, :string} + } + ] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Web.MediaProxy.Invalidation.Script, + type: :group, + description: "Invalidation script settings", + children: [ + %{ + key: :script_path, + type: :string, + description: "Path to executable script which will purge cached items.", + suggestions: ["./installation/nginx-cache-purge.sh.example"] + }, + %{ + key: :url_format, + label: "URL Format", + type: :string, + description: + "Optional URL format preprocessing. Only required for Apache's htcacheclean.", + suggestions: [":htcacheclean"] + } + ] + }, + %{ + group: :pleroma, + key: :gopher, + type: :group, + description: "Gopher settings", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables the gopher interface" + }, + %{ + key: :ip, + label: "IP", + type: :tuple, + description: "IP address to bind to", + suggestions: [{0, 0, 0, 0}] + }, + %{ + key: :port, + type: :integer, + description: "Port to bind to", + suggestions: [9999] + }, + %{ + key: :dstport, + type: :integer, + description: "Port advertised in URLs (optional, defaults to port)", + suggestions: [9999] + } + ] + }, + %{ + group: :pleroma, + key: :activitypub, + label: "ActivityPub", + type: :group, + description: "ActivityPub-related settings", + children: [ + %{ + key: :unfollow_blocked, + type: :boolean, + description: "Whether blocks result in people getting unfollowed" + }, + %{ + key: :outgoing_blocks, + type: :boolean, + description: "Whether to federate blocks to other instances" + }, + %{ + key: :blockers_visible, + type: :boolean, + description: "Whether a user can see someone who has blocked them" + }, + %{ + key: :sign_object_fetches, + type: :boolean, + description: "Sign object fetches with HTTP signatures" + }, + %{ + key: :authorized_fetch_mode, + type: :boolean, + description: "Require HTTP signatures for AP fetches" + }, + %{ + key: :note_replies_output_limit, + type: :integer, + description: + "The number of Note replies' URIs to be included with outgoing federation (`5` to match Mastodon hardcoded value, `0` to disable the output)" + }, + %{ + key: :follow_handshake_timeout, + type: :integer, + description: "Following handshake timeout", + suggestions: [500] + } + ] + }, + %{ + group: :pleroma, + key: :http_security, + label: "HTTP security", + type: :group, + description: "HTTP security settings", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Whether the managed content security policy is enabled" + }, + %{ + key: :sts, + label: "STS", + type: :boolean, + description: "Whether to additionally send a Strict-Transport-Security header" + }, + %{ + key: :sts_max_age, + label: "STS max age", + type: :integer, + description: "The maximum age for the Strict-Transport-Security header if sent", + suggestions: [31_536_000] + }, + %{ + key: :ct_max_age, + label: "CT max age", + type: :integer, + description: "The maximum age for the Expect-CT header if sent", + suggestions: [2_592_000] + }, + %{ + key: :referrer_policy, + type: :string, + description: "The referrer policy to use, either \"same-origin\" or \"no-referrer\"", + suggestions: ["same-origin", "no-referrer"] + }, + %{ + key: :report_uri, + label: "Report URI", + type: :string, + description: "Adds the specified URL to report-uri and report-to group in CSP header", + suggestions: ["https://example.com/report-uri"] + } + ] + }, + %{ + group: :web_push_encryption, + key: :vapid_details, + label: "Vapid Details", + type: :group, + description: + "Web Push Notifications configuration. You can use the mix task mix web_push.gen.keypair to generate it.", + children: [ + %{ + key: :subject, + type: :string, + description: + "A mailto link for the administrative contact." <> + " It's best if this email is not a personal email address, but rather a group email to the instance moderation team.", + suggestions: ["mailto:moderators@pleroma.com"] + }, + %{ + key: :public_key, + type: :string, + description: "VAPID public key", + suggestions: ["Public key"] + }, + %{ + key: :private_key, + type: :string, + description: "VAPID private key", + suggestions: ["Private key"] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Captcha, + type: :group, + description: "Captcha-related settings", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Whether the captcha should be shown on registration" + }, + %{ + key: :method, + type: :module, + description: "The method/service to use for captcha", + suggestions: [Pleroma.Captcha.Kocaptcha, Pleroma.Captcha.Native] + }, + %{ + key: :seconds_valid, + type: :integer, + description: "The time in seconds for which the captcha is valid", + suggestions: [60] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Captcha.Kocaptcha, + type: :group, + description: + "Kocaptcha is a very simple captcha service with a single API endpoint, the source code is" <> + " here: https://github.com/koto-bank/kocaptcha. The default endpoint (https://captcha.kotobank.ch) is hosted by the developer.", + children: [ + %{ + key: :endpoint, + type: :string, + description: "The kocaptcha endpoint to use", + suggestions: ["https://captcha.kotobank.ch"] + } + ] + }, + %{ + group: :pleroma, + label: "Pleroma Admin Token", + type: :group, + description: + "Allows setting a token that can be used to authenticate requests with admin privileges without a normal user account token. Append the `admin_token` parameter to requests to utilize it. (Please reconsider using HTTP Basic Auth or OAuth-based authentication if possible)", + children: [ + %{ + key: :admin_token, + type: :string, + description: "Admin token", + suggestions: [ + "Please use a high entropy string or UUID" + ] + } + ] + }, + %{ + group: :pleroma, + key: Oban, + type: :group, + description: + "[Oban](https://github.com/sorentwo/oban) asynchronous job processor configuration.", + children: [ + %{ + key: :log, + type: {:dropdown, :atom}, + description: "Logs verbose mode", + suggestions: [false, :error, :warn, :info, :debug] + }, + %{ + key: :queues, + type: {:keyword, :integer}, + description: + "Background jobs queues (keys: queues, values: max numbers of concurrent jobs)", + suggestions: [ + activity_expiration: 10, + attachments_cleanup: 5, + background: 5, + federator_incoming: 50, + federator_outgoing: 50, + mailer: 10, + scheduled_activities: 10, + poll_notifications: 10, + notifications: 20, + transmogrifier: 20, + web_push: 50 + ], + children: [ + %{ + key: :activity_expiration, + type: :integer, + description: "Activity expiration queue", + suggestions: [10] + }, + %{ + key: :backup, + type: :integer, + description: "Backup queue", + suggestions: [1] + }, + %{ + key: :attachments_cleanup, + type: :integer, + description: "Attachment deletion queue", + suggestions: [5] + }, + %{ + key: :background, + type: :integer, + description: "Background queue", + suggestions: [5] + }, + %{ + key: :federator_incoming, + type: :integer, + description: "Incoming federation queue", + suggestions: [50] + }, + %{ + key: :federator_outgoing, + type: :integer, + description: "Outgoing federation queue", + suggestions: [50] + }, + %{ + key: :mailer, + type: :integer, + description: "Email sender queue, see Pleroma.Emails.Mailer", + suggestions: [10] + }, + %{ + key: :scheduled_activities, + type: :integer, + description: "Scheduled activities queue, see Pleroma.ScheduledActivities", + suggestions: [10] + }, + %{ + key: :poll_notifications, + type: :integer, + description: "Stores poll expirations so it can notify users when a poll ends", + suggestions: [10] + }, + %{ + key: :notifications, + type: :integer, + description: "Creates notifications for activities in the background", + suggestions: [20] + }, + %{ + key: :transmogrifier, + type: :integer, + description: "Transmogrifier queue", + suggestions: [20] + }, + %{ + key: :web_push, + type: :integer, + description: "Web push notifications queue", + suggestions: [50] + } + ] + }, + %{ + key: :crontab, + type: {:list, :tuple}, + description: "Settings for cron background jobs", + suggestions: [ + {"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker}, + {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker} + ] + } + ] + }, + %{ + group: :pleroma, + key: :workers, + type: :group, + description: "Includes custom worker options not interpretable directly by `Oban`", + children: [ + %{ + key: :retries, + type: {:keyword, :integer}, + description: "Max retry attempts for failed jobs, per `Oban` queue", + suggestions: [ + federator_incoming: 5, + federator_outgoing: 5 + ] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Web.Metadata, + type: :group, + description: "Metadata-related settings", + children: [ + %{ + key: :providers, + type: {:list, :module}, + description: "List of metadata providers to enable", + suggestions: [ + Pleroma.Web.Metadata.Providers.OpenGraph, + Pleroma.Web.Metadata.Providers.TwitterCard, + Pleroma.Web.Metadata.Providers.RelMe, + Pleroma.Web.Metadata.Providers.Feed + ] + }, + %{ + key: :unfurl_nsfw, + label: "Unfurl NSFW", + type: :boolean, + description: "When enabled NSFW attachments will be shown in previews" + } + ] + }, + %{ + group: :pleroma, + key: :rich_media, + type: :group, + description: + "If enabled the instance will parse metadata from attached links to generate link previews", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables RichMedia parsing of URLs" + }, + %{ + key: :oembed_providers_enabled, + type: :boolean, + description: + "Embed rich media from a list of known providers. This takes precedence over other parsers." + }, + %{ + key: :ignore_hosts, + type: {:list, :string}, + description: "List of hosts which will be ignored by the metadata parser", + suggestions: ["accounts.google.com", "xss.website"] + }, + %{ + key: :ignore_tld, + label: "Ignore TLD", + type: {:list, :string}, + description: "List TLDs (top-level domains) which will ignore for parse metadata", + suggestions: ["local", "localdomain", "lan"] + }, + %{ + key: :parsers, + type: {:list, :module}, + description: + "List of Rich Media parsers. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parsers.` part), but on adding custom module you need to use full name.", + suggestions: [ + Pleroma.Web.RichMedia.Parsers.OEmbed, + Pleroma.Web.RichMedia.Parsers.TwitterCard + ] + }, + %{ + key: :ttl_setters, + label: "TTL setters", + type: {:list, :module}, + description: + "List of rich media TTL setters. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parser.` part), but on adding custom module you need to use full name.", + suggestions: [ + Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl + ] + }, + %{ + key: :failure_backoff, + type: :integer, + description: + "Amount of milliseconds after request failure, during which the request will not be retried.", + suggestions: [60_000] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Formatter, + label: "Linkify", + type: :group, + description: + "Configuration for Pleroma's link formatter which parses mentions, hashtags, and URLs.", + children: [ + %{ + key: :class, + type: [:string, :boolean], + description: "Specify the class to be added to the generated link. Disable to clear.", + suggestions: ["auto-linker", false] + }, + %{ + key: :rel, + type: [:string, :boolean], + description: "Override the rel attribute. Disable to clear.", + suggestions: ["ugc", "noopener noreferrer", false] + }, + %{ + key: :new_window, + type: :boolean, + description: "Link URLs will open in a new window/tab." + }, + %{ + key: :truncate, + type: [:integer, :boolean], + description: + "Set to a number to truncate URLs longer than the number. Truncated URLs will end in `...`", + suggestions: [15, false] + }, + %{ + key: :strip_prefix, + type: :boolean, + description: "Strip the scheme prefix." + }, + %{ + key: :extra, + type: :boolean, + description: "Link URLs with rarely used schemes (magnet, ipfs, irc, etc.)" + }, + %{ + key: :validate_tld, + type: [:atom, :boolean], + description: + "Set to false to disable TLD validation for URLs/emails. Can be set to :no_scheme to validate TLDs only for URLs without a scheme (e.g `example.com` will be validated, but `http://example.loki` won't)", + suggestions: [:no_scheme, true] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.ScheduledActivity, + type: :group, + description: "Scheduled activities settings", + children: [ + %{ + key: :daily_user_limit, + type: :integer, + description: + "The number of scheduled activities a user is allowed to create in a single day. Default: 25.", + suggestions: [25] + }, + %{ + key: :total_user_limit, + type: :integer, + description: + "The number of scheduled activities a user is allowed to create in total. Default: 300.", + suggestions: [300] + }, + %{ + key: :enabled, + type: :boolean, + description: "Whether scheduled activities are sent to the job queue to be executed" + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Workers.PurgeExpiredActivity, + type: :group, + description: "Expired activities settings", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables expired activities addition & deletion" + }, + %{ + key: :min_lifetime, + type: :integer, + description: "Minimum lifetime for ephemeral activity (in seconds)", + suggestions: [600] + } + ] + }, + %{ + group: :pleroma, + label: "Pleroma Authenticator", + type: :group, + description: "Authenticator", + children: [ + %{ + key: Pleroma.Web.Auth.Authenticator, + type: :module, + suggestions: [Pleroma.Web.Auth.PleromaAuthenticator, Pleroma.Web.Auth.LDAPAuthenticator] + } + ] + }, + %{ + group: :pleroma, + key: :ldap, + label: "LDAP", + type: :group, + description: + "Use LDAP for user authentication. When a user logs in to the Pleroma instance, the name and password" <> + " will be verified by trying to authenticate (bind) to a LDAP server." <> + " If a user exists in the LDAP directory but there is no account with the same name yet on the" <> + " Pleroma instance then a new Pleroma account will be created with the same name as the LDAP user name.", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables LDAP authentication" + }, + %{ + key: :host, + type: :string, + description: "LDAP server hostname", + suggestions: ["localhosts"] + }, + %{ + key: :port, + type: :integer, + description: "LDAP port, e.g. 389 or 636", + suggestions: [389, 636] + }, + %{ + key: :ssl, + label: "SSL", + type: :boolean, + description: "Enable to use SSL, usually implies the port 636" + }, + %{ + key: :sslopts, + label: "SSL options", + type: :keyword, + description: "Additional SSL options", + suggestions: [cacertfile: "path/to/file/with/PEM/cacerts", verify: :verify_peer], + children: [ + %{ + key: :cacertfile, + type: :string, + description: "Path to file with PEM encoded cacerts", + suggestions: ["path/to/file/with/PEM/cacerts"] + }, + %{ + key: :verify, + type: :atom, + description: "Type of cert verification", + suggestions: [:verify_peer] + } + ] + }, + %{ + key: :tls, + label: "TLS", + type: :boolean, + description: "Enable to use STARTTLS, usually implies the port 389" + }, + %{ + key: :tlsopts, + label: "TLS options", + type: :keyword, + description: "Additional TLS options", + suggestions: [cacertfile: "path/to/file/with/PEM/cacerts", verify: :verify_peer], + children: [ + %{ + key: :cacertfile, + type: :string, + description: "Path to file with PEM encoded cacerts", + suggestions: ["path/to/file/with/PEM/cacerts"] + }, + %{ + key: :verify, + type: :atom, + description: "Type of cert verification", + suggestions: [:verify_peer] + } + ] + }, + %{ + key: :base, + type: :string, + description: "LDAP base, e.g. \"dc=example,dc=com\"", + suggestions: ["dc=example,dc=com"] + }, + %{ + key: :uid, + label: "UID", + type: :string, + description: + "LDAP attribute name to authenticate the user, e.g. when \"cn\", the filter will be \"cn=username,base\"", + suggestions: ["cn"] + } + ] + }, + %{ + group: :pleroma, + key: :auth, + type: :group, + description: "Authentication / authorization settings", + children: [ + %{ + key: :enforce_oauth_admin_scope_usage, + label: "Enforce OAuth admin scope usage", + type: :boolean, + description: + "OAuth admin scope requirement toggle. " <> + "If enabled, admin actions explicitly demand admin OAuth scope(s) presence in OAuth token " <> + "(client app must support admin scopes). If disabled and token doesn't have admin scope(s), " <> + "`is_admin` user flag grants access to admin-specific actions." + }, + %{ + key: :auth_template, + type: :string, + description: + "Authentication form template. By default it's `show.html` which corresponds to `lib/pleroma/web/templates/o_auth/o_auth/show.html.ee`.", + suggestions: ["show.html"] + }, + %{ + key: :oauth_consumer_template, + label: "OAuth consumer template", + type: :string, + description: + "OAuth consumer mode authentication form template. By default it's `consumer.html` which corresponds to" <> + " `lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex`.", + suggestions: ["consumer.html"] + }, + %{ + key: :oauth_consumer_strategies, + label: "OAuth consumer strategies", + type: {:list, :string}, + description: + "The list of enabled OAuth consumer strategies. By default it's set by OAUTH_CONSUMER_STRATEGIES environment variable." <> + " Each entry in this space-delimited string should be of format \"strategy\" or \"strategy:dependency\"" <> + " (e.g. twitter or keycloak:ueberauth_keycloak_strategy in case dependency is named differently than ueberauth_).", + suggestions: ["twitter", "keycloak:ueberauth_keycloak_strategy"] + } + ] + }, + %{ + group: :pleroma, + key: :email_notifications, + type: :group, + description: "Email notifications settings", + children: [ + %{ + key: :digest, + type: :map, + description: + "emails of \"what you've missed\" for users who have been inactive for a while", + suggestions: [ + %{ + active: false, + schedule: "0 0 * * 0", + interval: 7, + inactivity_threshold: 7 + } + ], + children: [ + %{ + key: :active, + label: "Enabled", + type: :boolean, + description: "Globally enable or disable digest emails" + }, + %{ + key: :schedule, + type: :string, + description: + "When to send digest email, in crontab format. \"0 0 0\" is the default, meaning \"once a week at midnight on Sunday morning\".", + suggestions: ["0 0 * * 0"] + }, + %{ + key: :interval, + type: :integer, + description: "Minimum interval between digest emails to one user", + suggestions: [7] + }, + %{ + key: :inactivity_threshold, + type: :integer, + description: "Minimum user inactivity threshold", + suggestions: [7] + } + ] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Emails.UserEmail, + type: :group, + description: "Email template settings", + children: [ + %{ + key: :logo, + type: {:string, :image}, + description: "A path to a custom logo. Set it to `nil` to use the default Pleroma logo.", + suggestions: ["some/path/logo.png"] + }, + %{ + key: :styling, + type: :map, + description: "A map with color settings for email templates.", + suggestions: [ + %{ + link_color: "#d8a070", + background_color: "#2C3645", + content_background_color: "#1B2635", + header_color: "#d8a070", + text_color: "#b9b9ba", + text_muted_color: "#b9b9ba" + } + ], + children: [ + %{ + key: :link_color, + type: :string, + suggestions: ["#d8a070"] + }, + %{ + key: :background_color, + type: :string, + suggestions: ["#2C3645"] + }, + %{ + key: :content_background_color, + type: :string, + suggestions: ["#1B2635"] + }, + %{ + key: :header_color, + type: :string, + suggestions: ["#d8a070"] + }, + %{ + key: :text_color, + type: :string, + suggestions: ["#b9b9ba"] + }, + %{ + key: :text_muted_color, + type: :string, + suggestions: ["#b9b9ba"] + } + ] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Emails.NewUsersDigestEmail, + type: :group, + description: "New users admin email digest", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables new users admin digest email when `true`" + } + ] + }, + %{ + group: :pleroma, + key: :oauth2, + label: "OAuth2", + type: :group, + description: "Configure OAuth 2 provider capabilities", + children: [ + %{ + key: :token_expires_in, + type: :integer, + description: "The lifetime in seconds of the access token", + suggestions: [2_592_000] + }, + %{ + key: :issue_new_refresh_token, + type: :boolean, + description: + "Keeps old refresh token or generate new refresh token when to obtain an access token" + }, + %{ + key: :clean_expired_tokens, + type: :boolean, + description: "Enable a background job to clean expired OAuth tokens. Default: disabled." + } + ] + }, + %{ + group: :pleroma, + key: :emoji, + type: :group, + children: [ + %{ + key: :shortcode_globs, + type: {:list, :string}, + description: "Location of custom emoji files. * can be used as a wildcard.", + suggestions: ["/emoji/custom/**/*.png"] + }, + %{ + key: :pack_extensions, + type: {:list, :string}, + description: + "A list of file extensions for emojis, when no emoji.txt for a pack is present", + suggestions: [".png", ".gif"] + }, + %{ + key: :groups, + type: {:keyword, {:list, :string}}, + description: + "Emojis are ordered in groups (tags). This is an array of key-value pairs where the key is the group name" <> + " and the value is the location or array of locations. * can be used as a wildcard.", + suggestions: [ + Custom: ["/emoji/*.png", "/emoji/**/*.png"] + ] + }, + %{ + key: :default_manifest, + type: :string, + description: + "Location of the JSON-manifest. This manifest contains information about the emoji-packs you can download." <> + " Currently only one manifest can be added (no arrays).", + suggestions: ["https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json"] + }, + %{ + key: :shared_pack_cache_seconds_per_file, + label: "Shared pack cache s/file", + type: :integer, + descpiption: + "When an emoji pack is shared, the archive is created and cached in memory" <> + " for this amount of seconds multiplied by the number of files.", + suggestions: [60] + } + ] + }, + %{ + group: :pleroma, + key: :rate_limit, + type: :group, + description: + "Rate limit settings. This is an advanced feature enabled only for :authentication by default.", + children: [ + %{ + key: :search, + type: [:tuple, {:list, :tuple}], + description: "For the search requests (account & status search etc.)", + suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]] + }, + %{ + key: :timeline, + type: [:tuple, {:list, :tuple}], + description: "For requests to timelines (each timeline has it's own limiter)", + suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]] + }, + %{ + key: :app_account_creation, + type: [:tuple, {:list, :tuple}], + description: "For registering user accounts from the same IP address", + suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]] + }, + %{ + key: :relations_actions, + type: [:tuple, {:list, :tuple}], + description: "For actions on relationships with all users (follow, unfollow)", + suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]] + }, + %{ + key: :relation_id_action, + label: "Relation ID action", + type: [:tuple, {:list, :tuple}], + description: "For actions on relation with a specific user (follow, unfollow)", + suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]] + }, + %{ + key: :statuses_actions, + type: [:tuple, {:list, :tuple}], + description: + "For create / delete / fav / unfav / reblog / unreblog actions on any statuses", + suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]] + }, + %{ + key: :status_id_action, + label: "Status ID action", + type: [:tuple, {:list, :tuple}], + description: + "For fav / unfav or reblog / unreblog actions on the same status by the same user", + suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]] + }, + %{ + key: :events_actions, + type: [:tuple, {:list, :tuple}], + description: "For create / update / join / leave actions on any statuses", + suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]] + }, + %{ + key: :authentication, + type: [:tuple, {:list, :tuple}], + description: "For authentication create / password check / user existence check requests", + suggestions: [{60_000, 15}] + } + ] + }, + %{ + group: :mime, + label: "Mime Types", + type: :group, + description: "Mime Types settings", + children: [ + %{ + key: :types, + type: :map, + suggestions: [ + %{ + "application/xml" => ["xml"], + "application/xrd+xml" => ["xrd+xml"], + "application/jrd+json" => ["jrd+json"], + "application/activity+json" => ["activity+json"], + "application/ld+json" => ["activity+json"] + } + ], + children: [ + %{ + key: "application/xml", + type: {:list, :string}, + suggestions: ["xml"] + }, + %{ + key: "application/xrd+xml", + type: {:list, :string}, + suggestions: ["xrd+xml"] + }, + %{ + key: "application/jrd+json", + type: {:list, :string}, + suggestions: ["jrd+json"] + }, + %{ + key: "application/activity+json", + type: {:list, :string}, + suggestions: ["activity+json"] + }, + %{ + key: "application/ld+json", + type: {:list, :string}, + suggestions: ["activity+json"] + } + ] + } + ] + }, + %{ + group: :pleroma, + key: :http, + label: "HTTP", + type: :group, + description: "HTTP settings", + children: [ + %{ + key: :proxy_url, + label: "Proxy URL", + type: [:string, :tuple], + description: "Proxy URL", + suggestions: ["localhost:9020", {:socks5, :localhost, 3090}] + }, + %{ + key: :send_user_agent, + type: :boolean + }, + %{ + key: :user_agent, + type: [:string, :atom], + description: + "What user agent to use. Must be a string or an atom `:default`. Default value is `:default`.", + suggestions: ["Pleroma", :default] + }, + %{ + key: :adapter, + type: :keyword, + description: "Adapter specific options", + suggestions: [], + children: [ + %{ + key: :ssl_options, + type: :keyword, + label: "SSL Options", + description: "SSL options for HTTP adapter", + children: [ + %{ + key: :versions, + type: {:list, :atom}, + description: "List of TLS version to use", + suggestions: [:tlsv1, ":tlsv1.1", ":tlsv1.2", ":tlsv1.3"] + } + ] + } + ] + } + ] + }, + %{ + group: :pleroma, + key: :markup, + label: "Markup Settings", + type: :group, + children: [ + %{ + key: :allow_inline_images, + type: :boolean + }, + %{ + key: :allow_headings, + type: :boolean + }, + %{ + key: :allow_tables, + type: :boolean + }, + %{ + key: :allow_fonts, + type: :boolean + }, + %{ + key: :scrub_policy, + type: {:list, :module}, + description: + "Module names are shortened (removed leading `Pleroma.HTML.` part), but on adding custom module you need to use full name.", + suggestions: [Pleroma.HTML.Transform.MediaProxy, Pleroma.HTML.Scrubber.Default] + } + ] + }, + %{ + group: :pleroma, + key: :user, + type: :group, + children: [ + %{ + key: :deny_follow_blocked, + type: :boolean + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.User, + type: :group, + children: [ + %{ + key: :restricted_nicknames, + type: {:list, :string}, + description: "List of nicknames users may not register with.", + suggestions: [ + ".well-known", + "~", + "about", + "activities", + "api", + "auth", + "check_password", + "dev", + "friend-requests", + "inbox", + "internal", + "main", + "media", + "nodeinfo", + "notice", + "oauth", + "objects", + "ostatus_subscribe", + "pleroma", + "proxy", + "push", + "registration", + "relay", + "settings", + "status", + "tag", + "user-search", + "user_exists", + "users", + "web" + ] + }, + %{ + key: :email_blacklist, + type: {:list, :string}, + description: "List of email domains users may not register with.", + suggestions: ["mailinator.com", "maildrop.cc"] + } + ] + }, + %{ + group: :cors_plug, + label: "CORS plug config", + type: :group, + children: [ + %{ + key: :max_age, + type: :integer, + suggestions: [86_400] + }, + %{ + key: :methods, + type: {:list, :string}, + suggestions: ["POST", "PUT", "DELETE", "GET", "PATCH", "OPTIONS"] + }, + %{ + key: :expose, + type: {:list, :string}, + suggestions: [ + "Link", + "X-RateLimit-Reset", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-Request-Id", + "Idempotency-Key" + ] + }, + %{ + key: :credentials, + type: :boolean + }, + %{ + key: :headers, + type: {:list, :string}, + suggestions: ["Authorization", "Content-Type", "Idempotency-Key"] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Web.Plugs.RemoteIp, + type: :group, + description: """ + `Pleroma.Web.Plugs.RemoteIp` is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration. + **If your instance is not behind at least one reverse proxy, you should not enable this plug.** + """, + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enable/disable the plug. Default: disabled." + }, + %{ + key: :headers, + type: {:list, :string}, + description: """ + A list of strings naming the HTTP headers to use when deriving the true client IP. Default: `["x-forwarded-for"]`. + """ + }, + %{ + key: :proxies, + type: {:list, :string}, + description: + "A list of upstream proxy IP subnets in CIDR notation from which we will parse the content of `headers`. Defaults to `[]`. IPv4 entries without a bitmask will be assumed to be /32 and IPv6 /128." + }, + %{ + key: :reserved, + type: {:list, :string}, + description: """ + A list of reserved IP subnets in CIDR notation which should be ignored if found in `headers`. Defaults to `["127.0.0.0/8", "::1/128", "fc00::/7", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]` + """ + } + ] + }, + %{ + group: :pleroma, + key: :web_cache_ttl, + label: "Web cache TTL", + type: :group, + description: + "The expiration time for the web responses cache. Values should be in milliseconds or `nil` to disable expiration.", + children: [ + %{ + key: :activity_pub, + type: :integer, + description: + "Activity pub routes (except question activities). Default: `nil` (no expiration).", + suggestions: [nil] + }, + %{ + key: :activity_pub_question, + type: :integer, + description: "Activity pub routes (question activities). Default: `30_000` (30 seconds).", + suggestions: [30_000] + } + ] + }, + %{ + group: :pleroma, + key: :static_fe, + label: "Static FE", + type: :group, + description: + "Render profiles and posts using server-generated HTML that is viewable without using JavaScript", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables the rendering of static HTML. Default: disabled." + } + ] + }, + %{ + group: :pleroma, + key: :feed, + type: :group, + description: "Configure feed rendering", + children: [ + %{ + key: :post_title, + type: :map, + description: "Configure title rendering", + children: [ + %{ + key: :max_length, + type: :integer, + description: "Maximum number of characters before truncating title", + suggestions: [100] + }, + %{ + key: :omission, + type: :string, + description: "Replacement which will be used after truncating string", + suggestions: ["..."] + } + ] + } + ] + }, + %{ + group: :pleroma, + key: :mrf_follow_bot, + tab: :mrf, + related_policy: "Pleroma.Web.ActivityPub.MRF.FollowBotPolicy", + label: "MRF FollowBot Policy", + type: :group, + description: "Automatically follows newly discovered accounts.", + children: [ + %{ + key: :follower_nickname, + type: :string, + description: "The name of the bot account to use for following newly discovered users.", + suggestions: ["followbot"] + } + ] + }, + %{ + group: :pleroma, + key: :modules, + type: :group, + description: "Custom Runtime Modules", + children: [ + %{ + key: :runtime_dir, + type: :string, + description: "A path to custom Elixir modules (such as MRF policies)." + } + ] + }, + %{ + group: :pleroma, + key: :streamer, + type: :group, + description: "Settings for notifications streamer", + children: [ + %{ + key: :workers, + type: :integer, + description: "Number of workers to send notifications", + suggestions: [3] + }, + %{ + key: :overflow_workers, + type: :integer, + description: "Maximum number of workers created if pool is empty", + suggestions: [2] + } + ] + }, + %{ + group: :pleroma, + key: :connections_pool, + type: :group, + description: "Advanced settings for `Gun` connections pool", + children: [ + %{ + key: :connection_acquisition_wait, + type: :integer, + description: + "Timeout to acquire a connection from pool. The total max time is this value multiplied by the number of retries. Default: 250ms.", + suggestions: [250] + }, + %{ + key: :connection_acquisition_retries, + type: :integer, + description: + "Number of attempts to acquire the connection from the pool if it is overloaded. Default: 5", + suggestions: [5] + }, + %{ + key: :max_connections, + type: :integer, + description: "Maximum number of connections in the pool. Default: 250 connections.", + suggestions: [250] + }, + %{ + key: :connect_timeout, + type: :integer, + description: "Timeout while `gun` will wait until connection is up. Default: 5000ms.", + suggestions: [5000] + }, + %{ + key: :reclaim_multiplier, + type: :integer, + description: + "Multiplier for the number of idle connection to be reclaimed if the pool is full. For example if the pool maxes out at 250 connections and this setting is set to 0.3, the pool will reclaim at most 75 idle connections if it's overloaded. Default: 0.1", + suggestions: [0.1] + } + ] + }, + %{ + group: :pleroma, + key: :pools, + type: :group, + description: "Advanced settings for `Gun` workers pools", + children: + Enum.map([:federation, :media, :upload, :default], fn pool_name -> + %{ + key: pool_name, + type: :keyword, + description: "Settings for #{pool_name} pool.", + children: [ + %{ + key: :size, + type: :integer, + description: "Maximum number of concurrent requests in the pool.", + suggestions: [50] + }, + %{ + key: :max_waiting, + type: :integer, + description: + "Maximum number of requests waiting for other requests to finish. After this number is reached, the pool will start returning errrors when a new request is made", + suggestions: [10] + }, + %{ + key: :recv_timeout, + type: :integer, + description: "Timeout for the pool while gun will wait for response", + suggestions: [10_000] + } + ] + } + end) + }, + %{ + group: :pleroma, + key: :hackney_pools, + type: :group, + description: "Advanced settings for `Hackney` connections pools", + children: [ + %{ + key: :federation, + type: :keyword, + description: "Settings for federation pool.", + children: [ + %{ + key: :max_connections, + type: :integer, + description: "Number workers in the pool.", + suggestions: [50] + }, + %{ + key: :timeout, + type: :integer, + description: "Timeout while `hackney` will wait for response.", + suggestions: [150_000] + } + ] + }, + %{ + key: :media, + type: :keyword, + description: "Settings for media pool.", + children: [ + %{ + key: :max_connections, + type: :integer, + description: "Number workers in the pool.", + suggestions: [50] + }, + %{ + key: :timeout, + type: :integer, + description: "Timeout while `hackney` will wait for response.", + suggestions: [150_000] + } + ] + }, + %{ + key: :upload, + type: :keyword, + description: "Settings for upload pool.", + children: [ + %{ + key: :max_connections, + type: :integer, + description: "Number workers in the pool.", + suggestions: [25] + }, + %{ + key: :timeout, + type: :integer, + description: "Timeout while `hackney` will wait for response.", + suggestions: [300_000] + } + ] + } + ] + }, + %{ + group: :pleroma, + key: :restrict_unauthenticated, + label: "Restrict Unauthenticated", + type: :group, + description: + "Disallow viewing timelines, user profiles and statuses for unauthenticated users.", + children: [ + %{ + key: :timelines, + type: :map, + description: "Settings for public and federated timelines.", + children: [ + %{ + key: :local, + type: :boolean, + description: "Disallow view public timeline." + }, + %{ + key: :federated, + type: :boolean, + description: "Disallow view federated timeline." + } + ] + }, + %{ + key: :profiles, + type: :map, + description: "Settings for user profiles.", + children: [ + %{ + key: :local, + type: :boolean, + description: "Disallow view local user profiles." + }, + %{ + key: :remote, + type: :boolean, + description: "Disallow view remote user profiles." + } + ] + }, + %{ + key: :activities, + type: :map, + description: "Settings for statuses.", + children: [ + %{ + key: :local, + type: :boolean, + description: "Disallow view local statuses." + }, + %{ + key: :remote, + type: :boolean, + description: "Disallow view remote statuses." + } + ] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Web.ApiSpec.CastAndValidate, + type: :group, + children: [ + %{ + key: :strict, + type: :boolean, + description: + "Enables strict input validation (useful in development, not recommended in production)" + } + ] + }, + %{ + group: :pleroma, + key: :instances_favicons, + type: :group, + description: "Control favicons for instances", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Allow/disallow displaying and getting instances favicons" + } + ] + }, + %{ + group: :ex_aws, + key: :s3, + type: :group, + descriptions: "S3 service related settings", + children: [ + %{ + key: :access_key_id, + type: :string, + description: "S3 access key ID", + suggestions: ["AKIAQ8UKHTGIYN7DMWWJ"] + }, + %{ + key: :secret_access_key, + type: :string, + description: "Secret access key", + suggestions: ["JFGt+fgH1UQ7vLUQjpW+WvjTdV/UNzVxcwn7DkaeFKtBS5LvoXvIiME4NQBsT6ZZ"] + }, + %{ + key: :host, + type: :string, + description: "S3 host", + suggestions: ["s3.eu-central-1.amazonaws.com"] + }, + %{ + key: :region, + type: :string, + description: "S3 region (for AWS)", + suggestions: ["us-east-1"] + } + ] + }, + %{ + group: :pleroma, + key: :frontends, + type: :group, + description: "Installed frontends management", + children: [ + %{ + key: :primary, + type: :map, + description: "Primary frontend, the one that is served for all pages by default", + children: installed_frontend_options + }, + %{ + key: :admin, + type: :map, + description: "Admin frontend", + children: installed_frontend_options + }, + %{ + key: :available, + type: :map, + description: + "A map containing available frontends and parameters for their installation.", + children: frontend_options + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Web.Preload, + type: :group, + description: "Preload-related settings", + children: [ + %{ + key: :providers, + type: {:list, :module}, + description: "List of preload providers to enable", + suggestions: [ + Pleroma.Web.Preload.Providers.Instance, + Pleroma.Web.Preload.Providers.User, + Pleroma.Web.Preload.Providers.Timelines, + Pleroma.Web.Preload.Providers.StatusNet + ] + } + ] + }, + %{ + group: :pleroma, + key: :majic_pool, + type: :group, + description: "Majic/libmagic configuration", + children: [ + %{ + key: :size, + type: :integer, + description: "Number of majic workers to start.", + suggestions: [2] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.User.Backup, + type: :group, + description: "Account Backup", + children: [ + %{ + key: :purge_after_days, + type: :integer, + description: "Remove backup achives after N days", + suggestions: [30] + }, + %{ + key: :limit_days, + type: :integer, + description: "Limit user to export not more often than once per N days", + suggestions: [7] + }, + %{ + key: :process_wait_time, + type: :integer, + label: "Process Wait Time", + description: + "The amount of time to wait for backup to report progress, in milliseconds. If no progress is received from the backup job for that much time, terminate it and deem it failed.", + suggestions: [30_000] + }, + %{ + key: :process_chunk_size, + type: :integer, + label: "Process Chunk Size", + description: "The number of activities to fetch in the backup job for each chunk.", + suggestions: [100] + } + ] + }, + %{ + group: :prometheus, + key: Pleroma.Web.Endpoint.MetricsExporter, + type: :group, + description: "Prometheus app metrics endpoint configuration", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "[Pleroma extension] Enables app metrics endpoint." + }, + %{ + key: :ip_whitelist, + label: "IP Whitelist", + type: [{:list, :string}, {:list, :charlist}, {:list, :tuple}], + description: "Restrict access of app metrics endpoint to the specified IP addresses." + }, + %{ + key: :auth, + type: [:boolean, :tuple], + description: "Enables HTTP Basic Auth for app metrics endpoint.", + suggestion: [false, {:basic, "myusername", "mypassword"}] + }, + %{ + key: :path, + type: :string, + description: "App metrics endpoint URI path.", + suggestions: ["/api/pleroma/app_metrics"] + }, + %{ + key: :format, + type: :atom, + description: "App metrics endpoint output format.", + suggestions: [:text, :protobuf] + } + ] + }, + %{ + group: :pleroma, + key: ConcurrentLimiter, + type: :group, + description: "Limits configuration for background tasks.", + children: [ + %{ + key: Pleroma.Web.RichMedia.Helpers, + type: :keyword, + description: "Concurrent limits configuration for getting RichMedia for activities.", + suggestions: [max_running: 5, max_waiting: 5], + children: [ + %{ + key: :max_running, + type: :integer, + description: "Max running concurrently jobs.", + suggestion: [5] + }, + %{ + key: :max_waiting, + type: :integer, + description: "Max waiting jobs.", + suggestion: [5] + } + ] + }, + %{ + key: Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy, + type: :keyword, + description: "Concurrent limits configuration for MediaProxyWarmingPolicy.", + suggestions: [max_running: 5, max_waiting: 5], + children: [ + %{ + key: :max_running, + type: :integer, + description: "Max running concurrently jobs.", + suggestion: [5] + }, + %{ + key: :max_waiting, + type: :integer, + description: "Max waiting jobs.", + suggestion: [5] + } + ] + }, + %{ + key: Pleroma.Webhook.Notify, + type: :keyword, + description: "Concurrent limits configuration for webhooks.", + suggestions: [max_running: 5, max_waiting: 5], + children: [ + %{ + key: :max_running, + type: :integer, + description: "Max running concurrently jobs.", + suggestion: [5] + }, + %{ + key: :max_waiting, + type: :integer, + description: "Max waiting jobs.", + suggestion: [5] + } + ] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Web.WebFinger, + type: :group, + description: "Webfinger", + children: [ + %{ + key: :update_nickname_on_user_fetch, + type: :boolean, + description: "Update nickname according to host-meta, when refetching the user" + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Language.Translation, + type: :group, + description: "Translation providers", + children: [ + %{ + key: :provider, + type: :module, + suggestions: [ + Pleroma.Language.Translation.Deepl, + Pleroma.Language.Translation.Libretranslate, + Pleroma.Language.Translation.TranslateLocally + ] + }, + %{ + key: :allow_unauthenticated, + type: :boolean, + label: "Allow unauthenticated", + description: "Whether to let unauthenticated users translate posts" + }, + %{ + key: :allow_remote, + type: :boolean, + label: "Allow remote", + description: "Whether to allow translation of remote posts" + }, + %{ + group: {:subgroup, Pleroma.Language.Translation.Deepl}, + key: :base_url, + label: "DeepL base URL", + type: :string, + suggestions: ["https://api-free.deepl.com", "https://api.deepl.com"] + }, + %{ + group: {:subgroup, Pleroma.Language.Translation.Deepl}, + key: :api_key, + label: "DeepL API Key", + type: :string, + suggestions: ["YOUR_API_KEY"] + }, + %{ + group: {:subgroup, Pleroma.Language.Translation.Libretranslate}, + key: :base_url, + label: "LibreTranslate instance URL", + type: :string, + suggestions: ["https://libretranslate.com"] + }, + %{ + group: {:subgroup, Pleroma.Language.Translation.Libretranslate}, + key: :api_key, + label: "LibreTranslate API Key", + type: :string, + suggestions: ["YOUR_API_KEY"] + }, + %{ + group: {:subgroup, Pleroma.Language.Translation.TranslateLocally}, + key: :intermediate_language, + label: + "translateLocally intermediate language (used when direct source->target model is not available)", + type: :string, + suggestions: ["en"] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Language.LanguageDetector, + type: :group, + description: "Language detection providers", + children: [ + %{ + key: :provider, + type: :module, + label: "Language detection provider", + suggestions: [ + Pleroma.Language.LanguageDetector.Fasttext + ] + }, + %{ + group: {:subgroup, Pleroma.Language.LanguageDetector.Fasttext}, + key: :model, + label: "fastText language detection model", + type: :string, + suggestions: ["/usr/share/fasttext/lid.176.bin"] + } + ] + }, + %{ + group: :geospatial, + key: Geospatial.Service, + type: :group, + description: "Geospatial service providers", + children: [ + %{ + key: :service, + type: :module, + label: "Geospatial service provider", + suggestions: [ + Geospatial.Providers.GoogleMaps, + Geospatial.Providers.Nominatim, + Geospatial.Providers.Pelias + ] + } + ] + }, + %{ + group: :geospatial, + key: Geospatial.Providers.Nominatim, + type: :group, + description: "Nominatim provider configuration", + children: [ + %{ + key: :endpoint, + type: :string, + description: "Nominatim endpoint", + suggestions: ["https://nominatim.openstreetmap.org"] + }, + %{ + key: :api_key, + type: :string, + description: "Nominatim API key", + suggestions: [nil] + } + ] + }, + %{ + group: :geospatial, + key: Geospatial.Providers.GoogleMaps, + type: :group, + description: "Google Maps provider configuration", + children: [ + %{ + key: :api_key, + type: :string, + description: "Google Maps API key", + suggestions: [nil] + }, + %{ + key: :fetch_place_details, + type: :boolean, + description: "Fetch place details" + } + ] + }, + %{ + group: :geospatial, + key: Geospatial.Providers.Pelias, + type: :group, + description: "Pelias provider configuration", + children: [ + %{ + key: :endpoint, + type: :string, + description: "Pelias endpoint", + suggestions: ["https://api.geocode.earth"] + }, + %{ + key: :api_key, + type: :string, + description: "Pelias API key", + suggestions: [nil] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Search, + type: :group, + description: "General search settings.", + children: [ + %{ + key: :module, + type: :keyword, + description: "Selected search module.", + suggestion: [Pleroma.Search.DatabaseSearch, Pleroma.Search.Meilisearch] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Search.Meilisearch, + type: :group, + description: "Meilisearch settings.", + children: [ + %{ + key: :url, + type: :string, + description: "Meilisearch URL.", + suggestion: ["http://127.0.0.1:7700/"] + }, + %{ + key: :private_key, + type: :string, + description: + "Private key for meilisearch authentication, or `nil` to disable private key authentication.", + suggestion: [nil] + }, + %{ + key: :initial_indexing_chunk_size, + type: :int, + description: + "Amount of posts in a batch when running the initial indexing operation. Should probably not be more than 100000" <> + " since there's a limit on maximum insert size", + suggestion: [100_000] + } + ] + } +] diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..c34d4c5 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,78 @@ +import Config + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we use it +# with brunch.io to recompile .js and .css sources. +config :pleroma, Pleroma.Web.Endpoint, + http: [ + port: 4000, + protocol_options: [max_request_line_length: 8192, max_header_value_length: 8192] + ], + protocol: "http", + debug_errors: true, + code_reloader: true, + check_origin: false, + watchers: [], + secure_cookie_flag: false + +config :pleroma, Pleroma.Emails.Mailer, adapter: Swoosh.Adapters.Local + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# command from your terminal: +# +# openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com" -keyout priv/server.key -out priv/server.pem +# +# The `http:` config above can be replaced with: +# +# https: [port: 4000, keyfile: "priv/server.key", certfile: "priv/server.pem"], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Configure your database +config :pleroma, Pleroma.Repo, + adapter: Ecto.Adapters.Postgres, + username: "postgres", + password: "postgres", + database: "pleroma_dev", + hostname: "localhost", + pool_size: 10 + +config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: true + +# Reduce recompilation time +# https://dashbit.co/blog/speeding-up-re-compilation-of-elixir-projects +config :phoenix, :plug_init_mode, :runtime + +config :pleroma, Pleroma.PromEx, + disabled: false, + manual_metrics_start_delay: :no_delay, + drop_metrics_groups: [], + grafana: :disabled, + metrics_server: :disabled + +if File.exists?("./config/dev.secret.exs") do + import_config "dev.secret.exs" +else + IO.puts( + :stderr, + "!!! RUNNING IN LOCALHOST DEV MODE! !!!\nFEDERATION WON'T WORK UNTIL YOU CONFIGURE A dev.secret.exs" + ) +end + +if File.exists?("./config/dev.exported_from_db.secret.exs"), + do: import_config("dev.exported_from_db.secret.exs") diff --git a/config/docker.exs b/config/docker.exs new file mode 100644 index 0000000..443638f --- /dev/null +++ b/config/docker.exs @@ -0,0 +1,80 @@ +import Config + +config :pleroma, Pleroma.Web.Endpoint, + url: [host: System.get_env("DOMAIN", "localhost"), scheme: "https", port: 443], + http: [ip: {0, 0, 0, 0}, port: System.get_env("PORT", "5000")] + +config :pleroma, :instance, + name: System.get_env("INSTANCE_NAME", "Soapbox"), + email: System.get_env("ADMIN_EMAIL"), + notify_email: System.get_env("NOTIFY_EMAIL"), + limit: 5000, + registrations_open: false, + healthcheck: true + +# Prefer `DATABASE_URL` if set, otherwise use granular env. +case System.get_env("DATABASE_URL") do + database_url when is_binary(database_url) -> + config :pleroma, Pleroma.Repo, url: database_url + + _ -> + config :pleroma, Pleroma.Repo, + username: System.get_env("DB_USER", "postgres"), + password: System.get_env("DB_PASS", "postgres"), + database: System.get_env("DB_NAME", "postgres"), + hostname: System.get_env("DB_HOST", "db"), + port: System.get_env("DB_PORT", "5432") +end + +# Configure web push notifications +config :web_push_encryption, :vapid_details, subject: "mailto:#{System.get_env("NOTIFY_EMAIL")}" + +config :pleroma, :database, rum_enabled: false +config :pleroma, :instance, static_dir: "/var/lib/pleroma/static" +config :pleroma, Pleroma.Uploaders.Local, uploads: "/var/lib/pleroma/uploads" + +config :pleroma, Pleroma.Language.LanguageDetector, + provider: Pleroma.Language.LanguageDetector.Fasttext + +config :pleroma, Pleroma.Language.LanguageDetector.Fasttext, + model: "/usr/share/fasttext/lid.176.ftz" + +# We can't store the secrets in this file, since this is baked into the docker image +if not File.exists?("/var/lib/pleroma/secret.exs") do + secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64) + signing_salt = :crypto.strong_rand_bytes(8) |> Base.encode64() |> binary_part(0, 8) + {web_push_public_key, web_push_private_key} = :crypto.generate_key(:ecdh, :prime256v1) + + secret_file = + EEx.eval_string( + """ + import Config + + config :pleroma, Pleroma.Web.Endpoint, + secret_key_base: "<%= secret %>", + signing_salt: "<%= signing_salt %>" + + config :web_push_encryption, :vapid_details, + public_key: "<%= web_push_public_key %>", + private_key: "<%= web_push_private_key %>" + """, + secret: secret, + signing_salt: signing_salt, + web_push_public_key: Base.url_encode64(web_push_public_key, padding: false), + web_push_private_key: Base.url_encode64(web_push_private_key, padding: false) + ) + + File.write("/var/lib/pleroma/secret.exs", secret_file) +end + +import_config("/var/lib/pleroma/secret.exs") + +# For additional user config +if File.exists?("/var/lib/pleroma/config.exs"), + do: import_config("/var/lib/pleroma/config.exs"), + else: + File.write("/var/lib/pleroma/config.exs", """ + import Config + + # For additional configuration outside of environmental variables + """) diff --git a/config/dokku.exs b/config/dokku.exs new file mode 100644 index 0000000..1cc396c --- /dev/null +++ b/config/dokku.exs @@ -0,0 +1,25 @@ +import Config + +config :pleroma, Pleroma.Web.Endpoint, + http: [ + port: String.to_integer(System.get_env("PORT") || "4000"), + protocol_options: [max_request_line_length: 8192, max_header_value_length: 8192] + ], + protocol: "http", + secure_cookie_flag: false, + url: [host: System.get_env("APP_HOST"), scheme: "https", port: 443], + secret_key_base: "+S+ULgf7+N37c/lc9K66SMphnjQIRGklTu0BRr2vLm2ZzvK0Z6OH/PE77wlUNtvP" + +database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ + +config :pleroma, Pleroma.Repo, + # ssl: true, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") + +config :pleroma, :instance, name: "#{System.get_env("APP_NAME")} CI Instance" diff --git a/config/emoji.txt b/config/emoji.txt new file mode 100644 index 0000000..52b714e --- /dev/null +++ b/config/emoji.txt @@ -0,0 +1,3 @@ +firefox, /emoji/Firefox.gif, Gif,Fun +blank, /emoji/blank.png, Fun +dinosaur, /emoji/dino walking.gif, Gif diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..9c2fab4 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,97 @@ +import Config + +# For production, we often load configuration from external +# sources, such as your system environment. For this reason, +# you won't find the :http configuration below, but set inside +# Pleroma.Web.Endpoint.load_from_system_env/1 dynamically. +# Any dynamic configuration should be moved to such function. +# +# Don't forget to configure the url host to something meaningful, +# Phoenix uses this information when generating URLs. +# +# Finally, we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the mix phoenix.digest task +# which you typically run after static files are built. +config :pleroma, Pleroma.Web.Endpoint, + http: [port: 4000], + protocol: "http" + +config :phoenix, serve_endpoints: true + +# Do not print debug messages in production +config :logger, :console, level: :info +config :logger, :ex_syslogger, level: :info + +# PromEx set up +config :pleroma, Pleroma.Web.Plugs.MetricsPredicate, + auth_token: System.get_env("PROMETHEUS_AUTH_TOKEN", "supersecret") + +config :pleroma, Pleroma.PromEx, + prometheus_data_source_id: + System.get_env( + "PROMETHEUS_DATASOURCE_ID", + "Prometheus" + ), + grafana: [ + host: System.get_env("GRAFANA_HOST", "http://localhost:3000"), + auth_token: System.get_env("GRAFANA_AUTH_TOKEN", "LOLNO"), + upload_dashboards_on_start: true, + folder_name: "Pleroma - PromEx", + annotate_app_lifecycle: true + ] + +# ## SSL Support +# +# To get SSL working, you will need to add the `https` key +# to the previous section and set your `:url` port to 443: +# +# config :pleroma, Pleroma.Web.Endpoint, +# ... +# url: [host: "example.com", port: 443], +# https: [:inet6, +# port: 443, +# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), +# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] +# +# Where those two env variables return an absolute path to +# the key and cert in disk or a relative path inside priv, +# for example "priv/ssl/server.key". +# +# We also recommend setting `force_ssl`, ensuring no data is +# ever sent via http, always redirecting to https: +# +# config :pleroma, Pleroma.Web.Endpoint, +# force_ssl: [hsts: true] +# +# Check `Plug.SSL` for all available options in `force_ssl`. + +# ## Using releases +# +# If you are doing OTP releases, you need to instruct Phoenix +# to start the server for all endpoints: +# +# config :phoenix, :serve_endpoints, true +# +# Alternatively, you can configure exactly which server to +# start per endpoint: +# +# config :pleroma, Pleroma.Web.Endpoint, server: true +# + +# Finally import the config/prod.secret.exs +# which should be versioned separately. +cond do + File.exists?("./config/prod.secret.exs") -> + import_config "prod.secret.exs" + + System.get_env("CI") == "true" -> + nil + + true -> + "`config/prod.secret.exs` not found. You may want to create one by running `mix pleroma.instance gen`" + |> IO.warn([]) +end + +if File.exists?("./config/prod.exported_from_db.secret.exs"), + do: import_config("prod.exported_from_db.secret.exs") diff --git a/config/soapbox.exs b/config/soapbox.exs new file mode 100644 index 0000000..049fec1 --- /dev/null +++ b/config/soapbox.exs @@ -0,0 +1,58 @@ +# Soapbox default config overrides +# This file gets loaded after config.exs +# and before prod.secret.exs +import Config + +# Twitter-like block behavior +config :pleroma, :activitypub, blockers_visible: false + +# Sane default upload filters +config :pleroma, Pleroma.Upload, + filters: [ + Pleroma.Upload.Filter.AnalyzeMetadata, + Pleroma.Upload.Filter.Dedupe, + Pleroma.Upload.Filter.Exiftool.StripLocation + ] + +# Default MRF policies +config :pleroma, :mrf, + policies: [ + Pleroma.Web.ActivityPub.MRF.SimplePolicy, + Pleroma.Web.ActivityPub.MRF.HellthreadPolicy, + Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy, + Pleroma.Web.ActivityPub.MRF.TagPolicy, + Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy + ] + +# Increase the pool size and timeout +config :pleroma, :dangerzone, override_repo_pool_size: true + +config :pleroma, Pleroma.Repo, + pool_size: 40, + timeout: 30_000 + +# Allow privileged staff +config :pleroma, :instance, privileged_staff: true + +# Enable instance favicons +config :pleroma, :instances_favicons, enabled: true + +# Hellthread limits +config :pleroma, :mrf_hellthread, + delist_threshold: 15, + reject_threshold: 100 + +# Sane default media attachment limit +config :pleroma, :instance, max_media_attachments: 20 + +# Use Soapbox branding +config :pleroma, :instance, + name: "Soapbox", + description: "Social media owned by you", + instance_thumbnail: "/instance/thumbnail.png" + +# Background migration performance +config :pleroma, :delete_context_objects, sleep_interval_ms: 3_000 + +# Pretend to be WhatsApp because some sites don't return link previews otherwise +config :pleroma, :rich_media, user_agent: "WhatsApp/2" diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..53a516a --- /dev/null +++ b/config/test.exs @@ -0,0 +1,164 @@ +import Config + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :pleroma, Pleroma.Web.Endpoint, + http: [port: 4001], + url: [port: 4001], + server: true + +# Disable captha for tests +config :pleroma, Pleroma.Captcha, + # It should not be enabled for automatic tests + enabled: false, + # A fake captcha service for tests + method: Pleroma.Captcha.Mock + +# Print only warnings and errors during test +config :logger, :console, + level: :warning, + format: "\n[$level] $message\n" + +config :pleroma, :auth, oauth_consumer_strategies: [] + +config :pleroma, Pleroma.Upload, + filters: [], + link_name: false, + default_description: :filename + +config :pleroma, Pleroma.Uploaders.Local, uploads: "test/uploads" + +config :pleroma, Pleroma.Emails.Mailer, adapter: Swoosh.Adapters.Test, enabled: true + +config :pleroma, :instance, + name: "Pleroma", + description: "Pleroma: An efficient and flexible fediverse server", + instance_thumbnail: "/instance/thumbnail.jpeg", + email: "admin@example.com", + notify_email: "noreply@example.com", + skip_thread_containment: false, + federating: false, + account_approval_required: false, + external_user_synchronization: false, + static_dir: "test/instance_static/" + +config :pleroma, :activitypub, sign_object_fetches: false, follow_handshake_timeout: 0 + +# Configure your database +config :pleroma, Pleroma.Repo, + adapter: Ecto.Adapters.Postgres, + username: "postgres", + password: "postgres", + database: "pleroma_test", + hostname: System.get_env("DB_HOST") || "localhost", + port: System.get_env("DB_PORT") || "5432", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: 50 + +config :pleroma, :dangerzone, override_repo_pool_size: true + +# Reduce hash rounds for testing +config :pleroma, :password, iterations: 1 + +config :tesla, adapter: Tesla.Mock + +config :tesla, Geospatial.HTTP, adapter: Tesla.Mock + +config :pleroma, :rich_media, + enabled: false, + ignore_hosts: [], + ignore_tld: ["local", "localdomain", "lan"] + +config :pleroma, :instance, + multi_factor_authentication: [ + totp: [ + # digits 6 or 8 + digits: 6, + period: 30 + ], + backup_codes: [ + number: 2, + length: 6 + ] + ] + +config :web_push_encryption, :vapid_details, + subject: "mailto:administrator@example.com", + public_key: + "BLH1qVhJItRGCfxgTtONfsOKDc9VRAraXw-3NsmjMngWSh7NxOizN6bkuRA7iLTMPS82PjwJAr3UoK9EC1IFrz4", + private_key: "_-XZ0iebPrRfZ_o0-IatTdszYa8VCH1yLN-JauK7HHA" + +config :pleroma, Oban, testing: :manual + +config :pleroma, Pleroma.ScheduledActivity, + daily_user_limit: 2, + total_user_limit: 3, + enabled: false + +config :pleroma, :rate_limit, %{} + +config :pleroma, :http_security, report_uri: "https://endpoint.com" + +config :pleroma, :http, send_user_agent: false + +rum_enabled = System.get_env("RUM_ENABLED") == "true" +config :pleroma, :database, rum_enabled: rum_enabled +IO.puts("RUM enabled: #{rum_enabled}") + +config :joken, default_signer: "yU8uHKq+yyAkZ11Hx//jcdacWc8yQ1bxAAGrplzB0Zwwjkp35v0RK9SO8WTPr6QZ" + +config :pleroma, Pleroma.ReverseProxy.Client, Pleroma.ReverseProxy.ClientMock + +config :pleroma, :modules, runtime_dir: "test/fixtures/modules" + +config :pleroma, Pleroma.Gun, Pleroma.GunMock + +config :pleroma, Pleroma.Emails.NewUsersDigestEmail, enabled: true + +config :pleroma, Pleroma.Web.Plugs.RemoteIp, enabled: false + +config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: true + +config :tzdata, :autoupdate, :disabled + +config :pleroma, :mrf, policies: [] + +config :pleroma, :instances_favicons, enabled: false + +config :pleroma, :pipeline, + object_validator: Pleroma.Web.ActivityPub.ObjectValidatorMock, + mrf: Pleroma.Web.ActivityPub.MRFMock, + activity_pub: Pleroma.Web.ActivityPub.ActivityPubMock, + side_effects: Pleroma.Web.ActivityPub.SideEffectsMock, + federator: Pleroma.Web.FederatorMock, + config: Pleroma.ConfigMock + +config :pleroma, :cachex, provider: Pleroma.CachexMock + +config :pleroma, Pleroma.Web.WebFinger, update_nickname_on_user_fetch: false + +config :pleroma, :side_effects, + ap_streamer: Pleroma.Web.ActivityPub.ActivityPubMock, + logger: Pleroma.LoggerMock + +config :pleroma, Pleroma.Search, module: Pleroma.Search.DatabaseSearch + +config :pleroma, Pleroma.Search.Meilisearch, url: "http://127.0.0.1:7700/", private_key: nil + +# Reduce recompilation time +# https://dashbit.co/blog/speeding-up-re-compilation-of-elixir-projects +config :phoenix, :plug_init_mode, :runtime + +# Allow inline images in tests (for now). +# FIXME: rework/remove tests that depend on this. +config :pleroma, :markup, allow_inline_images: true + +config :pleroma, :config_impl, Pleroma.UnstubbedConfigMock + +if File.exists?("./config/test.secret.exs") do + import_config "test.secret.exs" +else + IO.puts( + "You may want to create test.secret.exs to declare custom database connection parameters." + ) +end diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 0000000..8652591 --- /dev/null +++ b/coveralls.json @@ -0,0 +1,7 @@ +{ + "skip_files": [ + "test/support", + "lib/mix/tasks/pleroma/benchmark.ex", + "lib/credo/check/consistency/file_location.ex" + ] +} \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..c8d9aae --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +DATABASE_URL=${DATABASE_URL:-"postgres://${DB_USER:-postgres}:${DB_PASS:-postgres}@${DB_HOST:-db}:5432/${DB_NAME:-postgres}"} + +echo "-- Waiting for database..." +while ! pg_isready -d $DATABASE_URL -t 1; do + sleep 1s +done + +echo "-- Running migrations..." +$HOME/bin/pleroma_ctl migrate + +echo "-- Starting!" +exec $HOME/bin/pleroma start diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7deb64b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,4 @@ +# Rebased + +**Rebased** is a Fediverse backend written in Elixir. +It's compatible with the Mastodon API and is the recommended backend for Soapbox. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 0000000..5018630 --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,16 @@ +# Summary + +* [Introduction](README.md) + +* Administration + * [Backing up](administration/backup.md) + * [Updating](administration/updating.md) + +## Configuration + +* [Cheatsheet](configuration/cheatsheet.md) +* [Hardening](configuration/hardening.md) + +* Guides + * [Setting up the media proxy](configuration/howto_proxy.md) + * [Saving config in the database](configuration/howto_database_config.md) \ No newline at end of file diff --git a/docs/administration/CLI_tasks/config.md b/docs/administration/CLI_tasks/config.md new file mode 100644 index 0000000..fc9f3cb --- /dev/null +++ b/docs/administration/CLI_tasks/config.md @@ -0,0 +1,157 @@ +# Transfering the config to/from the database + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Transfer config from file to DB. + +!!! note + You need to add the following to your config before executing this command: + + ```elixir + config :pleroma, configurable_from_database: true + ``` + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config migrate_to_db + ``` + +=== "From Source" + + ```sh + mix pleroma.config migrate_to_db + ``` + +## Transfer config from DB to `config/env.exported_from_db.secret.exs` + +!!! note + In-Database configuration will still be applied after executing this command unless you set the following in your config: + + ```elixir + config :pleroma, configurable_from_database: false + ``` + +Options: + +- `` - where to save migrated config. E.g. `--path=/tmp`. If file saved into non standart folder, you must manually copy file into directory where Pleroma can read it. For OTP install path will be `PLEROMA_CONFIG_PATH` or `/etc/pleroma`. For installation from source - `config` directory in the pleroma folder. +- `` - environment, for which is migrated config. By default is `prod`. +- To delete transferred settings from database optional flag `-d` can be used + +=== "OTP" + ```sh + ./bin/pleroma_ctl config migrate_from_db [--env=] [-d] [--path=] + ``` + +=== "From Source" + ```sh + mix pleroma.config migrate_from_db [--env=] [-d] [--path=] + ``` + +## Dump all of the config settings defined in the database + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config dump + ``` + +=== "From Source" + + ```sh + mix pleroma.config dump + ``` + +## List individual configuration groups in the database + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config groups + ``` + +=== "From Source" + + ```sh + mix pleroma.config groups + ``` + +## Dump the saved configuration values for a specific group or key + +e.g., this shows all the settings under `config :pleroma` + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config dump pleroma + ``` + +=== "From Source" + + ```sh + mix pleroma.config dump pleroma + ``` + +To get values under a specific key: + +e.g., this shows all the settings under `config :pleroma, :instance` + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config dump pleroma instance + ``` + +=== "From Source" + + ```sh + mix pleroma.config dump pleroma instance + ``` + +## Delete the saved configuration values for a specific group or key + +e.g., this deletes all the settings under `config :tesla` + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config delete [--force] tesla + ``` + +=== "From Source" + + ```sh + mix pleroma.config delete [--force] tesla + ``` + +To delete values under a specific key: + +e.g., this deletes all the settings under `config :phoenix, :stacktrace_depth` + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config delete [--force] phoenix stacktrace_depth + ``` + +=== "From Source" + + ```sh + mix pleroma.config delete [--force] phoenix stacktrace_depth + ``` + +## Remove all settings from the database + +This forcibly removes all saved values in the database. + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config [--force] reset + ``` + +=== "From Source" + + ```sh + mix pleroma.config [--force] reset + ``` diff --git a/docs/administration/CLI_tasks/database.md b/docs/administration/CLI_tasks/database.md new file mode 100644 index 0000000..c53c499 --- /dev/null +++ b/docs/administration/CLI_tasks/database.md @@ -0,0 +1,161 @@ +# Database maintenance tasks + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +!!! danger + These mix tasks can take a long time to complete. Many of them were written to address specific database issues that happened because of bugs in migrations or other specific scenarios. Do not run these tasks "just in case" if everything is fine your instance. + +## Replace embedded objects with their references + +Replaces embedded objects with references to them in the `objects` table. Only needs to be ran once if the instance was created before Pleroma 1.0.5. The reason why this is not a migration is because it could significantly increase the database size after being ran, however after this `VACUUM FULL` will be able to reclaim about 20% (really depends on what is in the database, your mileage may vary) of the db size before the migration. + +=== "OTP" + + ```sh + ./bin/pleroma_ctl database remove_embedded_objects [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.database remove_embedded_objects [option ...] + ``` + + +### Options +- `--vacuum` - run `VACUUM FULL` after the embedded objects are replaced with their references + +## Prune old remote posts from the database + +This will prune remote posts older than 90 days (configurable with [`config :pleroma, :instance, remote_post_retention_days`](../../configuration/cheatsheet.md#instance)) from the database, they will be refetched from source when accessed. + +!!! danger + The disk space will only be reclaimed after `VACUUM FULL`. You may run out of disk space during the execution of the task or vacuuming if you don't have about 1/3rds of the database size free. + +=== "OTP" + + ```sh + ./bin/pleroma_ctl database prune_objects [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.database prune_objects [option ...] + ``` + +### Options +- `--vacuum` - run `VACUUM FULL` after the objects are pruned + +## Create a conversation for all existing DMs + +Can be safely re-run + +=== "OTP" + + ```sh + ./bin/pleroma_ctl database bump_all_conversations + ``` + +=== "From Source" + + ```sh + mix pleroma.database bump_all_conversations + ``` + +## Remove duplicated items from following and update followers count for all users + +=== "OTP" + + ```sh + ./bin/pleroma_ctl database update_users_following_followers_counts + ``` + +=== "From Source" + + ```sh + mix pleroma.database update_users_following_followers_counts + ``` + +## Fix the pre-existing "likes" collections for all objects + +=== "OTP" + + ```sh + ./bin/pleroma_ctl database fix_likes_collections + ``` + +=== "From Source" + + ```sh + mix pleroma.database fix_likes_collections + ``` + +## Vacuum the database + +### Analyze + +Running an `analyze` vacuum job can improve performance by updating statistics used by the query planner. **It is safe to cancel this.** + +=== "OTP" + + ```sh + ./bin/pleroma_ctl database vacuum analyze + ``` + +=== "From Source" + + ```sh + mix pleroma.database vacuum analyze + ``` + +### Full + +Running a `full` vacuum job rebuilds your entire database by reading all of the data and rewriting it into smaller +and more compact files with an optimized layout. This process will take a long time and use additional disk space as +it builds the files side-by-side the existing database files. It can make your database faster and use less disk space, +but should only be run if necessary. **It is safe to cancel this.** + +=== "OTP" + + ```sh + ./bin/pleroma_ctl database vacuum full + ``` + +=== "From Source" + + ```sh + mix pleroma.database vacuum full + ``` + +## Add expiration to all local statuses + +=== "OTP" + + ```sh + ./bin/pleroma_ctl database ensure_expiration + ``` + +=== "From Source" + + ```sh + mix pleroma.database ensure_expiration + ``` + +## Change Text Search Configuration + +Change `default_text_search_config` for database and (if necessary) text_search_config used in index, then rebuild index (it may take time). + +=== "OTP" + + ```sh + ./bin/pleroma_ctl database set_text_search_config english + ``` + +=== "From Source" + + ```sh + mix pleroma.database set_text_search_config english + ``` + +See [PostgreSQL documentation](https://www.postgresql.org/docs/current/textsearch-configuration.html) and `docs/configuration/howto_search_cjk.md` for more detail. diff --git a/docs/administration/CLI_tasks/digest.md b/docs/administration/CLI_tasks/digest.md new file mode 100644 index 0000000..a590581 --- /dev/null +++ b/docs/administration/CLI_tasks/digest.md @@ -0,0 +1,33 @@ +# Managing digest emails + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Send digest email since given date (user registration date by default) ignoring user activity status. + +=== "OTP" + + ```sh + ./bin/pleroma_ctl digest test [since_date] + ``` + +=== "From Source" + + ```sh + mix pleroma.digest test [since_date] + ``` + + +Example: + +=== "OTP" + + ```sh + ./bin/pleroma_ctl digest test donaldtheduck 2019-05-20 + ``` + +=== "From Source" + + ```sh + mix pleroma.digest test donaldtheduck 2019-05-20 + ``` + diff --git a/docs/administration/CLI_tasks/email.md b/docs/administration/CLI_tasks/email.md new file mode 100644 index 0000000..2bb57be --- /dev/null +++ b/docs/administration/CLI_tasks/email.md @@ -0,0 +1,45 @@ +# EMail administration tasks + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Send test email (instance email by default) + +=== "OTP" + + ```sh + ./bin/pleroma_ctl email test [--to ] + ``` + +=== "From Source" + + ```sh + mix pleroma.email test [--to ] + ``` + +Example: + +=== "OTP" + + ```sh + ./bin/pleroma_ctl email test --to root@example.org + ``` + +=== "From Source" + + ```sh + mix pleroma.email test --to root@example.org + ``` + +## Send confirmation emails to all unconfirmed user accounts + +=== "OTP" + + ```sh + ./bin/pleroma_ctl email resend_confirmation_emails + ``` + +=== "From Source" + + ```sh + mix pleroma.email resend_confirmation_emails + ``` diff --git a/docs/administration/CLI_tasks/emoji.md b/docs/administration/CLI_tasks/emoji.md new file mode 100644 index 0000000..e3d1b21 --- /dev/null +++ b/docs/administration/CLI_tasks/emoji.md @@ -0,0 +1,61 @@ +# Managing emoji packs + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Lists emoji packs and metadata specified in the manifest + +=== "OTP" + ```sh + ./bin/pleroma_ctl emoji ls-packs [option ...] + ``` + +=== "From Source" + ```sh + mix pleroma.emoji ls-packs [option ...] + ``` + + +### Options +- `-m, --manifest PATH/URL` - path to a custom manifest, it can either be an URL starting with `http`, in that case the manifest will be fetched from that address, or a local path + +## Fetch, verify and install the specified packs from the manifest into `STATIC-DIR/emoji/PACK-NAME` + +=== "OTP" + ```sh + ./bin/pleroma_ctl emoji get-packs [option ...] + ``` + +=== "From Source" + ```sh + mix pleroma.emoji get-packs [option ...] + ``` + +### Options +- `-m, --manifest PATH/URL` - same as [`ls-packs`](#ls-packs) + +## Create a new manifest entry and a file list from the specified remote pack file + +=== "OTP" + ```sh + ./bin/pleroma_ctl emoji gen-pack PACK-URL + ``` + +=== "From Source" + ```sh + mix pleroma.emoji gen-pack PACK-URL + ``` + +Currently, only .zip archives are recognized as remote pack files and packs are therefore assumed to be zip archives. This command is intended to run interactively and will first ask you some basic questions about the pack, then download the remote file and generate an SHA256 checksum for it, then generate an emoji file list for you. + + The manifest entry will either be written to a newly created `pack_name.json` file (pack name is asked in questions) or appended to the existing one, *replacing* the old pack with the same name if it was in the file previously. + + The file list will be written to the file specified previously, *replacing* that file. You _should_ check that the file list doesn't contain anything you don't need in the pack, that is, anything that is not an emoji (the whole pack is downloaded, but only emoji files are extracted). + +## Reload emoji packs + +=== "OTP" + ```sh + ./bin/pleroma_ctl emoji reload + ``` + +This command only works with OTP releases. diff --git a/docs/administration/CLI_tasks/frontend.md b/docs/administration/CLI_tasks/frontend.md new file mode 100644 index 0000000..0f0bff2 --- /dev/null +++ b/docs/administration/CLI_tasks/frontend.md @@ -0,0 +1,113 @@ +# Managing frontends + +=== "OTP" + + ```sh + ./bin/pleroma_ctl frontend install [--ref ] [--file ] [--build-url ] [--path ] [--build-dir ] [--primary] [--admin] + ./bin/pleroma_ctl frontend enable [--ref ] [--file ] [--build-url ] [--path ] [--build-dir ] [--primary] [--admin] + ``` + +=== "From Source" + + ```sh + mix pleroma.frontend install [--ref ] [--file ] [--build-url ] [--path ] [--build-dir ] [--primary] [--admin] + mix pleroma.frontend enable [--ref ] [--file ] [--build-url ] [--path ] [--build-dir ] [--primary] [--admin] + ``` + +Frontend can be installed either from local zip file, or automatically downloaded from the web. + +You can give all the options directly on the command line, but missing information will be filled out by looking at the data configured under `frontends.available` in the config files. + +Currently, known `` values are: + +- [admin-fe](https://git.pleroma.social/pleroma/admin-fe) +- [kenoma](http://git.pleroma.social/lambadalambda/kenoma) +- [pleroma-fe](http://git.pleroma.social/pleroma/pleroma-fe) +- [fedi-fe](https://git.pleroma.social/pleroma/fedi-fe) +- [soapbox](https://gitlab.com/soapbox-pub/soapbox) + +You can still install frontends that are not configured, see below. + +## Example installations for a known frontend + +For a frontend configured under the `available` key, it's enough to install it by name. + +=== "OTP" + + ```sh + ./bin/pleroma_ctl frontend install pleroma + ``` + +=== "From Source" + + ```sh + mix pleroma.frontend install pleroma + ``` + +This will download the latest build for the pre-configured `ref` and install it. It can then be configured as the one of the served frontends in the config file (see `primary` or `admin`). + +You can override any of the details. To install a pleroma build from a different URL, you could do this: + +=== "OTP" + + ```sh + ./bin/pleroma_ctl frontend install pleroma --ref 2hu_edition --build-url https://example.org/raymoo.zip + ``` + +=== "From Source" + + ```sh + mix pleroma.frontend install pleroma --ref 2hu_edition --build-url https://example.org/raymoo.zip + ``` + +Similarly, you can also install from a local zip file. + +=== "OTP" + + ```sh + ./bin/pleroma_ctl frontend install pleroma --ref mybuild --file ~/Downloads/doomfe.zip + ``` + +=== "From Source" + + ```sh + mix pleroma.frontend install pleroma --ref mybuild --file ~/Downloads/doomfe.zip + ``` + +The resulting frontend will always be installed into a folder of this template: `${instance_static}/frontends/${name}/${ref}`. + +Careful: This folder will be completely replaced on installation. + +## Example installation for an unknown frontend + +The installation process is the same, but you will have to give all the needed options on the command line. For example: + +=== "OTP" + + ```sh + ./bin/pleroma_ctl frontend install gensokyo --ref master --build-url https://gensokyo.2hu/builds/marisa.zip + ``` + +=== "From Source" + + ```sh + mix pleroma.frontend install gensokyo --ref master --build-url https://gensokyo.2hu/builds/marisa.zip + ``` + +If you don't have a zip file but just want to install a frontend from a local path, you can simply copy the files over a folder of this template: `${instance_static}/frontends/${name}/${ref}`. + +## Enabling a frontend + +Once installed, a frontend can be enabled with the `enable` command: + +=== "OTP" + + ```sh + ./bin/pleroma_ctl frontend enable gensokyo --primary + ``` + +=== "From Source" + + ```sh + mix pleroma.frontend enable gensokyo --primary + ``` diff --git a/docs/administration/CLI_tasks/general_cli_task_info.include b/docs/administration/CLI_tasks/general_cli_task_info.include new file mode 100644 index 0000000..a1ff1da --- /dev/null +++ b/docs/administration/CLI_tasks/general_cli_task_info.include @@ -0,0 +1,5 @@ +Every command should be ran as the `pleroma` user from it's home directory. For example if you are superuser, you would have to wrap the command in `su pleroma -s $SHELL -lc "$COMMAND"`. + +??? note "From source note about `MIX_ENV`" + + The `mix` command should be prefixed with the name of environment your Pleroma server is running in, usually it's `MIX_ENV=prod` diff --git a/docs/administration/CLI_tasks/instance.md b/docs/administration/CLI_tasks/instance.md new file mode 100644 index 0000000..88509cf --- /dev/null +++ b/docs/administration/CLI_tasks/instance.md @@ -0,0 +1,45 @@ +# Managing instance configuration + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Generate a new configuration file +=== "OTP" + + ```sh + ./bin/pleroma_ctl instance gen [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.instance gen [option ...] + ``` + + +If any of the options are left unspecified, you will be prompted interactively. + +### Options +- `-f`, `--force` - overwrite any output files +- `-o `, `--output ` - the output file for the generated configuration +- `--output-psql ` - the output file for the generated PostgreSQL setup +- `--domain ` - the domain of your instance +- `--instance-name ` - the name of your instance +- `--admin-email ` - the email address of the instance admin +- `--notify-email ` - email address for notifications +- `--dbhost ` - the hostname of the PostgreSQL database to use +- `--dbname ` - the name of the database to use +- `--dbuser ` - the user (aka role) to use for the database connection +- `--dbpass ` - the password to use for the database connection +- `--rum ` - Whether to enable RUM indexes +- `--indexable ` - Allow/disallow indexing site by search engines +- `--db-configurable ` - Allow/disallow configuring instance from admin part +- `--uploads-dir ` - the directory uploads go in when using a local uploader +- `--static-dir ` - the directory custom public files should be read from (custom emojis, frontend bundle overrides, robots.txt, etc.) +- `--listen-ip ` - the ip the app should listen to, defaults to 127.0.0.1 +- `--listen-port ` - the port the app should listen to, defaults to 4000 +- `--strip-uploads-location ` - use ExifTool to strip uploads of sensitive location data +- `--read-uploads-description ` - use ExifTool to read image descriptions from uploads +- `--anonymize-uploads ` - randomize uploaded filenames +- `--dedupe-uploads ` - store files based on their hash to reduce data storage requirements if duplicates are uploaded with different filenames +- `--skip-release-env` - skip generation the release environment file +- `--release-env-file` - release environment file path diff --git a/docs/administration/CLI_tasks/oauth_app.md b/docs/administration/CLI_tasks/oauth_app.md new file mode 100644 index 0000000..f056849 --- /dev/null +++ b/docs/administration/CLI_tasks/oauth_app.md @@ -0,0 +1,20 @@ +# Creating trusted OAuth App + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Create trusted OAuth App. + +Optional params: + * `-s SCOPES` - scopes for app, e.g. `read,write,follow,push`. + +=== "OTP" + + ```sh + ./bin/pleroma_ctl app create -n APP_NAME -r REDIRECT_URI + ``` + +=== "From Source" + + ```sh + mix pleroma.app create -n APP_NAME -r REDIRECT_URI + ``` \ No newline at end of file diff --git a/docs/administration/CLI_tasks/relay.md b/docs/administration/CLI_tasks/relay.md new file mode 100644 index 0000000..bdd7e8b --- /dev/null +++ b/docs/administration/CLI_tasks/relay.md @@ -0,0 +1,45 @@ +# Managing relays + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Follow a relay + +=== "OTP" + + ```sh + ./bin/pleroma_ctl relay follow + ``` + +=== "From Source" + + ```sh + mix pleroma.relay follow + ``` + +## Unfollow a remote relay + +=== "OTP" + + ```sh + ./bin/pleroma_ctl relay unfollow + ``` + +=== "From Source" + + ```sh + mix pleroma.relay unfollow + ``` + +## List relay subscriptions + +=== "OTP" + + ```sh + ./bin/pleroma_ctl relay list + ``` + +=== "From Source" + + ```sh + mix pleroma.relay list + ``` diff --git a/docs/administration/CLI_tasks/robots_txt.md b/docs/administration/CLI_tasks/robots_txt.md new file mode 100644 index 0000000..7eeedf5 --- /dev/null +++ b/docs/administration/CLI_tasks/robots_txt.md @@ -0,0 +1,21 @@ +# Managing robots.txt + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Generate a new robots.txt file and add it to the static directory + +The `robots.txt` that ships by default is permissive. It allows well-behaved search engines to index all of your instance's URIs. + +If you want to generate a restrictive `robots.txt`, you can run the following mix task. The generated `robots.txt` will be written in your instance [static directory](../../../configuration/static_dir/). + +=== "OTP" + + ```sh + ./bin/pleroma_ctl robots_txt disallow_all + ``` + +=== "From Source" + + ```sh + mix pleroma.robots_txt disallow_all + ``` diff --git a/docs/administration/CLI_tasks/uploads.md b/docs/administration/CLI_tasks/uploads.md new file mode 100644 index 0000000..8585ec7 --- /dev/null +++ b/docs/administration/CLI_tasks/uploads.md @@ -0,0 +1,21 @@ +# Managing uploads + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Migrate uploads from local to remote storage +=== "OTP" + + ```sh + ./bin/pleroma_ctl uploads migrate_local [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.uploads migrate_local [option ...] + ``` + +### Options +- `--delete` - delete local uploads after migrating them to the target uploader + +A list of available uploaders can be seen in [Configuration Cheat Sheet](../../configuration/cheatsheet.md#pleromaupload) diff --git a/docs/administration/CLI_tasks/user.md b/docs/administration/CLI_tasks/user.md new file mode 100644 index 0000000..24fdaea --- /dev/null +++ b/docs/administration/CLI_tasks/user.md @@ -0,0 +1,302 @@ +# Managing users + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Create a user + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user new [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.user new [option ...] + ``` + + +### Options +- `--name ` - the user's display name +- `--bio ` - the user's bio +- `--password ` - the user's password +- `--moderator`/`--no-moderator` - whether the user should be a moderator +- `--admin`/`--no-admin` - whether the user should be an admin +- `-y`, `--assume-yes`/`--no-assume-yes` - whether to assume yes to all questions + +## List local users + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user list + ``` + +=== "From Source" + + ```sh + mix pleroma.user list + ``` + + +## Generate an invite link + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user invite [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.user invite [option ...] + ``` + + +### Options +- `--expires-at DATE` - last day on which token is active (e.g. "2019-04-05") +- `--max-use NUMBER` - maximum numbers of token uses + +## List generated invites + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user invites + ``` + +=== "From Source" + + ```sh + mix pleroma.user invites + ``` + + +## Revoke invite + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user revoke_invite + ``` + +=== "From Source" + + ```sh + mix pleroma.user revoke_invite + ``` + + +## Delete a user + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user rm + ``` + +=== "From Source" + + ```sh + mix pleroma.user rm + ``` + + +## Delete user's posts and interactions + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user delete_activities + ``` + +=== "From Source" + + ```sh + mix pleroma.user delete_activities + ``` + + +## Sign user out from all applications (delete user's OAuth tokens and authorizations) + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user sign_out + ``` + +=== "From Source" + + ```sh + mix pleroma.user sign_out + ``` + +## Activate a user + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user activate NICKNAME + ``` + +=== "From Source" + + ```sh + mix pleroma.user activate NICKNAME + ``` + +## Deactivate a user and unsubscribes local users from the user + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user deactivate NICKNAME + ``` + +=== "From Source" + + ```sh + mix pleroma.user deactivate NICKNAME + ``` + + +## Deactivate all accounts from an instance and unsubscribe local users on it + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user deactivate_all_from_instance + ``` + +=== "From Source" + + ```sh + mix pleroma.user deactivate_all_from_instance + ``` + + +## Create a password reset link for user + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user reset_password + ``` + +=== "From Source" + + ```sh + mix pleroma.user reset_password + ``` + + +## Disable Multi Factor Authentication (MFA/2FA) for a user + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user reset_mfa + ``` + +=== "From Source" + + ```sh + mix pleroma.user reset_mfa + ``` + + +## Set the value of the given user's settings + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user set [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.user set [option ...] + ``` + +### Options +- `--admin`/`--no-admin` - whether the user should be an admin +- `--confirmed`/`--no-confirmed` - whether the user account is confirmed +- `--locked`/`--no-locked` - whether the user should be locked +- `--moderator`/`--no-moderator` - whether the user should be a moderator + +## Add tags to a user + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user tag + ``` + +=== "From Source" + + ```sh + mix pleroma.user tag + ``` + + +## Delete tags from a user + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user untag + ``` + +=== "From Source" + + ```sh + mix pleroma.user untag + ``` + + +## Toggle confirmation status of the user + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user confirm + ``` + +=== "From Source" + + ```sh + mix pleroma.user confirm + ``` + +## Set confirmation status for all regular active users +*Admins and moderators are excluded* + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user confirm_all + ``` + +=== "From Source" + + ```sh + mix pleroma.user confirm_all + ``` + +## Revoke confirmation status for all regular active users +*Admins and moderators are excluded* + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user unconfirm_all + ``` + +=== "From Source" + + ```sh + mix pleroma.user unconfirm_all + ``` diff --git a/docs/administration/backup.md b/docs/administration/backup.md new file mode 100644 index 0000000..5f279ab --- /dev/null +++ b/docs/administration/backup.md @@ -0,0 +1,41 @@ +# Backup/Restore/Move/Remove your instance + +## Backup + +1. Stop the Pleroma service. +2. Go to the working directory of Pleroma (default is `/opt/pleroma`) +3. Run `sudo -Hu postgres pg_dump -d --format=custom -f ` (make sure the postgres user has write access to the destination file) +4. Copy `pleroma.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 Pleroma service. + +## Restore/Move + +1. Optionally reinstall Pleroma (either on the same server or on another server if you want to move servers). +2. Stop the Pleroma service. +3. Go to the working directory of Pleroma (default is `/opt/pleroma`) +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 ;';` `sudo -Hu postgres psql -c 'DROP USER ;'` +6. Restore the database schema and pleroma 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 pleroma 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 Pleroma instance's data into the empty database schema: `sudo -Hu postgres pg_restore -d -v -1 ` +8. If you installed a newer Pleroma version, you should run `mix ecto.migrate`[^1]. This task performs database migrations, if there were any. +9. Restart the Pleroma 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/pleroma.nginx` config sample or reference the Pleroma 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. + +## 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). +2. Stop the Pleroma service `systemctl stop pleroma` +3. Disable pleroma from systemd `systemctl disable pleroma` +4. Remove the files and folders you created during installation (see installation guide). This includes the pleroma, 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 ;';` `sudo -Hu postgres psql -c 'DROP USER ;'` +7. Remove the system user `userdel pleroma` +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! diff --git a/docs/administration/updating.md b/docs/administration/updating.md new file mode 100644 index 0000000..00eca36 --- /dev/null +++ b/docs/administration/updating.md @@ -0,0 +1,27 @@ +# Updating your instance + +You should **always check the [release notes/changelog](https://git.pleroma.social/pleroma/pleroma/-/releases)** in case there are config deprecations, special update steps, etc. + +Besides that, doing the following is generally enough: + +## For OTP installations + +```sh +# Download the new release +su pleroma -s $SHELL -lc "./bin/pleroma_ctl update" + +# Migrate the database, you are advised to stop the instance before doing that +su pleroma -s $SHELL -lc "./bin/pleroma_ctl migrate" +``` + +## For from source installations (using git) + +1. Go to the working directory of Pleroma (default is `/opt/pleroma`) +2. Run `git checkout ` [^1]. e.g. `git checkout v2.4.5` This pulls the [tagged release](https://git.pleroma.social/pleroma/pleroma/-/releases) from upstream. +3. Run `mix deps.get` [^1]. This pulls in any new dependencies. +4. Stop the Pleroma service. +5. Run `mix ecto.migrate` [^1] [^2]. This task performs database migrations, if there were any. +6. Start the Pleroma service. + +[^1]: Depending on which install guide you followed (for example on Debian/Ubuntu), you want to run `git` and `mix` tasks as `pleroma` user by adding `sudo -Hu pleroma` before the command. +[^2]: Prefix with `MIX_ENV=prod` to run it using the production config file. diff --git a/docs/clients.md b/docs/clients.md new file mode 100644 index 0000000..ad7eb78 --- /dev/null +++ b/docs/clients.md @@ -0,0 +1,125 @@ +# Pleroma Clients +Note: Additional clients may be working but theses are officially supporting Pleroma. +Feel free to contact us to be added to this list! + +## Desktop +### Social +- Source Code: +- Contact: [@brainblasted@social.libre.fi](https://social.libre.fi/users/brainblasted) +- Platforms: Linux (GNOME) +- Note(2019-01-28): Not at a pre-alpha stage yet +- Features: MastoAPI + +### Whalebird +- Homepage: +- Source Code: +- Contact: [@whalebird@pleroma.io](https://pleroma.io/users/whalebird) +- Platforms: Windows, Mac, Linux +- Features: MastoAPI, Streaming Ready + +### Fedistar +- Homepage: +- Source Code: +- Contact: [@fedistar@pleroma.io](https://pleroma.io/users/fedistar) +- Platforms: Windows, Mac, Linux +- Features: MastoAPI, Streaming Ready + +## Handheld +### AndStatus +- Homepage: +- Source Code: +- Platforms: Android +- Features: MastoAPI, ActivityPub (Client-to-Server) + +### Amaroq +- Homepage: +- Source Code: +- Contact: [@eurasierboy@mastodon.social](https://mastodon.social/users/eurasierboy) +- Platforms: iOS +- Features: MastoAPI, No Streaming + +### Fedilab +- Homepage: +- Source Code: +- Contact: [@fedilab@framapiaf.org](https://framapiaf.org/users/fedilab) +- Platforms: Android +- Features: MastoAPI, Streaming Ready, Moderation, Text Formatting + +### Kyclos +- Source Code: +- Platforms: SailfishOS +- Features: MastoAPI, No Streaming + +### Husky +- Source code: +- Contact: [@Husky@enigmatic.observer](https://enigmatic.observer/users/Husky) +- Platforms: Android +- Features: MastoAPI, No Streaming, Emoji Reactions, Text Formatting, FE Stickers + +### Fedi +- Homepage: +- Source Code: Proprietary, but gratis +- Platforms: iOS, Android +- Features: MastoAPI, Pleroma-specific features like Reactions + +### Tusky +- Homepage: +- Source Code: +- Contact: [@ConnyDuck@mastodon.social](https://mastodon.social/users/ConnyDuck) +- Platforms: Android +- Features: MastoAPI, No Streaming + +### Twidere +- Homepage: +- Source Code: +- Contact: +- Platform: Android +- Features: MastoAPI, No Streaming + +### Indigenous +- Homepage: +- Source Code: +- Contact: [@swentel@realize.be](https://realize.be) +- Platforms: Android +- Features: MastoAPI, No Streaming + +## Alternative Web Interfaces +### Brutaldon +- Homepage: +- Source Code: +- Contact: [@gcupc@glitch.social](https://glitch.social/users/gcupc) +- Features: MastoAPI, No Streaming + +### Halcyon +- Source Code: +- Contact: [@halcyon@social.csswg.org](https://social.csswg.org/users/halcyon) +- Features: MastoAPI, Streaming Ready + +### Pinafore +- Homepage: +- Source Code: +- Contact: [@pinafore@mastodon.technology](https://mastodon.technology/users/pinafore) +- Note: Pleroma support is a secondary goal +- Features: MastoAPI, No Streaming + +### Sengi +- Homepage: +- Source Code: +- Contact: [@sengi_app@mastodon.social](https://mastodon.social/users/sengi_app) +- Features: MastoAPI + +### DashFE +- Source Code: +- Contact: [@dashfe@stereophonic.space](https://stereophonic.space/users/dashfe) + +### BloatFE +- Source Code: +- Contact: [@r@freesoftwareextremist.com](https://freesoftwareextremist.com/users/r) +- Features: Does not requires JavaScript +- Features: MastoAPI + +### Glitch-lily +- Source Code: +- Contact: [@tusooa@kazv.moe](https://kazv.moe/users/tusooa) +- Features: MastoAPI +- Based on [glitch-soc](https://github.com/glitch-soc/mastodon) frontend diff --git a/docs/configuration/auth.md b/docs/configuration/auth.md new file mode 100644 index 0000000..c80f094 --- /dev/null +++ b/docs/configuration/auth.md @@ -0,0 +1 @@ +See `Authentication` section of [the configuration cheatsheet](../configuration/cheatsheet.md#authentication). diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md new file mode 100644 index 0000000..a52232f --- /dev/null +++ b/docs/configuration/cheatsheet.md @@ -0,0 +1,1198 @@ +# Configuration Cheat Sheet + +This is a cheat sheet for Pleroma configuration file, any setting possible to configure should be listed here. + +For OTP installations the configuration is typically stored in `/etc/pleroma/config.exs`. + +For from source installations Pleroma configuration works by first importing the base config `config/config.exs`, then overriding it by the environment config `config/$MIX_ENV.exs` and then overriding it by user config `config/$MIX_ENV.secret.exs`. In from source installations you should always make the changes to the user config and NEVER to the base config to avoid breakages and merge conflicts. So for production you change/add configuration to `config/prod.secret.exs`. + +To add configuration to your config file, you can copy it from the base config. The latest version of it can be viewed [here](https://git.pleroma.social/pleroma/pleroma/blob/develop/config/config.exs). You can also use this file if you don't know how an option is supposed to be formatted. + +## :instance +* `name`: The instance’s name. +* `email`: Email used to reach an Administrator/Moderator of the instance. +* `notify_email`: Email used for notifications. +* `description`: The instance’s description, can be seen in nodeinfo and ``/api/v1/instance``. +* `short_description`: Shorter version of instance description, can be seen on ``/api/v1/instance``. +* `limit`: Posts character limit (CW/Subject included in the counter). +* `description_limit`: The character limit for image descriptions. +* `remote_limit`: Hard character limit beyond which remote posts will be dropped. +* `upload_limit`: File size limit of uploads (except for avatar, background, banner). +* `avatar_upload_limit`: File size limit of user’s profile avatars. +* `background_upload_limit`: File size limit of user’s profile backgrounds. +* `banner_upload_limit`: File size limit of user’s profile banners. +* `poll_limits`: A map with poll limits for **local** polls. + * `max_options`: Maximum number of options. + * `max_option_chars`: Maximum number of characters per option. + * `min_expiration`: Minimum expiration time (in seconds). + * `max_expiration`: Maximum expiration time (in seconds). +* `registrations_open`: Enable registrations for anyone, invitations can be enabled when false. +* `invites_enabled`: Enable user invitations for admins (depends on `registrations_open: false`). +* `account_activation_required`: Require users to confirm their emails before signing in. +* `account_approval_required`: Require users to be manually approved by an admin before signing in. +* `federating`: Enable federation with other instances. +* `federation_incoming_replies_max_depth`: Max. depth of reply-to activities fetching on incoming federation, to prevent out-of-memory situations while fetching very long threads. If set to `nil`, threads of any depth will be fetched. Lower this value if you experience out-of-memory crashes. +* `federation_reachability_timeout_days`: Timeout (in days) of each external federation target being unreachable prior to pausing federating to it. +* `allow_relay`: Permits remote instances to subscribe to all public posts of your instance. This may increase the visibility of your instance. +* `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. Note that there is a dependent setting restricting or allowing unauthenticated access to specific resources, see `restrict_unauthenticated` for more details. +* `quarantined_instances`: ActivityPub instances where private (DMs, followers-only) activities will not be send. +* `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML). +* `extended_nickname_format`: Set to `true` to use extended local nicknames format (allows underscores/dashes). This will break federation with + older software for theses nicknames. +* `max_pinned_statuses`: The maximum number of pinned statuses. `0` will disable the feature. +* `autofollowed_nicknames`: Set to nicknames of (local) users that every new user should automatically follow. +* `autofollowing_nicknames`: Set to nicknames of (local) users that automatically follows every newly registered user. +* `attachment_links`: Set to true to enable automatically adding attachment link text to statuses. +* `max_report_comment_size`: The maximum size of the report comment (Default: `1000`). +* `report_strip_status`: Strip associated statuses in reports to ids when closed/resolved, otherwise keep a copy. +* `safe_dm_mentions`: If set to true, only mentions at the beginning of a post will be used to address people in direct messages. This is to prevent accidental mentioning of people when talking about them (e.g. "@friend hey i really don't like @enemy"). Default: `false`. +* `healthcheck`: If set to true, system data will be shown on ``/api/v1/pleroma/healthcheck``. +* `remote_post_retention_days`: The default amount of days to retain remote posts when pruning the database. +* `user_bio_length`: A user bio maximum length (default: `5000`). +* `user_name_length`: A user name maximum length (default: `100`). +* `user_location_length`: A user location maximum length (default: `50`). +* `skip_thread_containment`: Skip filter out broken threads. The default is `false`. +* `limit_to_local_content`: Limit unauthenticated users to search for local statutes and users only. Possible values: `:unauthenticated`, `:all` and `false`. The default is `:unauthenticated`. +* `max_account_fields`: The maximum number of custom fields in the user profile (default: `10`). +* `max_remote_account_fields`: The maximum number of custom fields in the remote user profile (default: `20`). +* `account_field_name_length`: An account field name maximum length (default: `512`). +* `account_field_value_length`: An account field value maximum length (default: `2048`). +* `registration_reason_length`: Maximum registration reason length (default: `500`). +* `external_user_synchronization`: Enabling following/followers counters synchronization for external users. +* `cleanup_attachments`: Remove attachments along with statuses. Does not affect duplicate files and attachments without status. Enabling this will increase load to database when deleting statuses on larger instances. +* `show_reactions`: Let favourites and emoji reactions be viewed through the API (default: `true`). +* `password_reset_token_validity`: The time after which reset tokens aren't accepted anymore, in seconds (default: one day). +* `admin_privileges`: A list of privileges an admin has (e.g. delete messages, manage reports...) + * Possible values are: + * `:users_read` + * Allows admins to fetch users through the admin API. + * `:users_manage_invites` + * Allows admins to manage invites. This includes sending, resending, revoking and approving invites. + * `:users_manage_activation_state` + * Allows admins to activate and deactivate accounts. This also allows them to see deactivated users through the Mastodon API. + * `:users_manage_tags` + * Allows admins to set and remove tags for users. This can be useful in combination with MRF policies, such as `Pleroma.Web.ActivityPub.MRF.TagPolicy`. + * `:users_manage_credentials` + * Allows admins to trigger a password reset and set new credentials for an user. + * `:users_delete` + * Allows admins to delete accounts. Note that deleting an account is actually deactivating it and removing all data like posts, profile information, etc. + * `:messages_read` + * Allows admins to read messages through the admin API, including non-public posts and chats. + * `:messages_delete` + * Allows admins to delete messages from other users. + * `:instances_delete,` + * Allows admins to remove a whole remote instance from your instance. This will delete all users and messages from that remote instance. + * `:reports_manage_reports` + * Allows admins to see and manage reports. + * `:moderation_log_read,` + * Allows admins to read the entries in the moderation log. + * `:emoji_manage_emoji` + * Allows admins to manage custom emoji on the instance. + * `:statistics_read,` + * Allows admins to see some simple statistics about the instance. +* `moderator_privileges`: A list of privileges a moderator has (e.g. delete messages, manage reports...) + * Possible values are the same as for `admin_privileges` + +## :database +* `improved_hashtag_timeline`: Setting to force toggle / force disable improved hashtags timeline. `:enabled` forces hashtags to be fetched from `hashtags` table for hashtags timeline. `:disabled` forces object-embedded hashtags to be used (slower). Keep it `:auto` for automatic behaviour (it is auto-set to `:enabled` [unless overridden] when HashtagsTableMigrator completes). + +## Background migrations +* `populate_hashtags_table/sleep_interval_ms`: Sleep interval between each chunk of processed records in order to decrease the load on the system (defaults to 0 and should be keep default on most instances). +* `populate_hashtags_table/fault_rate_allowance`: Max rate of failed objects to actually processed objects in order to enable the feature (any value from 0.0 which tolerates no errors to 1.0 which will enable the feature even if hashtags transfer failed for all records). + +## Welcome +* `direct_message`: - welcome message sent as a direct message. + * `enabled`: Enables the send a direct message to a newly registered user. Defaults to `false`. + * `sender_nickname`: The nickname of the local user that sends the welcome message. + * `message`: A message that will be send to a newly registered users as a direct message. +* `chat_message`: - welcome message sent as a chat message. + * `enabled`: Enables the send a chat message to a newly registered user. Defaults to `false`. + * `sender_nickname`: The nickname of the local user that sends the welcome message. + * `message`: A message that will be send to a newly registered users as a chat message. +* `email`: - welcome message sent as a email. + * `enabled`: Enables the send a welcome email to a newly registered user. Defaults to `false`. + * `sender`: The email address or tuple with `{nickname, email}` that will use as sender to the welcome email. + * `subject`: A subject of welcome email. + * `html`: A html that will be send to a newly registered users as a email. + * `text`: A text that will be send to a newly registered users as a email. + + Example: + + ```elixir + config :pleroma, :welcome, + direct_message: [ + enabled: true, + sender_nickname: "lain", + message: "Hi! Welcome on board!" + ], + email: [ + enabled: true, + sender: {"Pleroma App", "welcome@pleroma.app"}, + subject: "Welcome to <%= instance_name %>", + html: "Welcome to <%= instance_name %>", + text: "Welcome to <%= instance_name %>" + ] + ``` + +## Message rewrite facility + +### :mrf +* `policies`: Message Rewrite Policy, either one or a list. Here are the ones available by default: + * `Pleroma.Web.ActivityPub.MRF.NoOpPolicy`: Doesn’t modify activities (default). + * `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesn’t makes sense to use in production. + * `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certains instances (See [`:mrf_simple`](#mrf_simple)). + * `Pleroma.Web.ActivityPub.MRF.TagPolicy`: Applies policies to individual users based on tags, which can be set using pleroma-fe/admin-fe/any other app that supports Pleroma Admin API. For example it allows marking posts from individual users nsfw (sensitive). + * `Pleroma.Web.ActivityPub.MRF.SubchainPolicy`: Selectively runs other MRF policies when messages match (See [`:mrf_subchain`](#mrf_subchain)). + * `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See [`:mrf_rejectnonpublic`](#mrf_rejectnonpublic)). + * `Pleroma.Web.ActivityPub.MRF.EnsureRePrepended`: Rewrites posts to ensure that replies to posts with subjects do not have an identical subject and instead begin with re:. + * `Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy`: Rejects posts from likely spambots by rejecting posts from new users that contain links. + * `Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`: Crawls attachments using their MediaProxy URLs so that the MediaProxy cache is primed. + * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)). + * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)). + * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). + * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Sets a default expiration on all posts made by users of the local instance. Requires `Pleroma.Workers.PurgeExpiredActivity` to be enabled for processing the scheduled delections. + * `Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicy`: Makes all bot posts to disappear from public timelines. + * `Pleroma.Web.ActivityPub.MRF.FollowBotPolicy`: Automatically follows newly discovered users from the specified bot account. Local accounts, locked accounts, and users with "#nobot" in their bio are respected and excluded from being followed. + * `Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy`: Drops follow requests from followbots. Users can still allow bots to follow them by first following the bot. + * `Pleroma.Web.ActivityPub.MRF.KeywordPolicy`: Rejects or removes from the federated timeline or replaces keywords. (See [`:mrf_keyword`](#mrf_keyword)). + * `Pleroma.Web.ActivityPub.MRF.ForceMentionsInContent`: Forces every mentioned user to be reflected in the post content. + * `Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy`: Forces quote post URLs to be reflected in the message content inline. + * `Pleroma.Web.ActivityPub.MRF.QuoteToLinkTagPolicy`: Force a Link tag for posts quoting another post. (may break outgoing federation of quote posts with older Pleroma versions) +* `transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo). +* `transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value. + +## Federation +### MRF policies + +!!! note + Configuring MRF policies is not enough for them to take effect. You have to enable them by specifying their module in `policies` under [:mrf](#mrf) section. + +#### :mrf_simple +* `media_removal`: List of instances to strip media attachments from and the reason for doing so. +* `media_nsfw`: List of instances to tag all media as NSFW (sensitive) from and the reason for doing so. +* `federated_timeline_removal`: List of instances to remove from the Federated Timeline (aka The Whole Known Network) and the reason for doing so. +* `reject`: List of instances to reject activities (except deletes) from and the reason for doing so. +* `accept`: List of instances to only accept activities (except deletes) from and the reason for doing so. +* `followers_only`: Force posts from the given instances to be visible by followers only and the reason for doing so. +* `report_removal`: List of instances to reject reports from and the reason for doing so. +* `avatar_removal`: List of instances to strip avatars from and the reason for doing so. +* `banner_removal`: List of instances to strip banners from and the reason for doing so. +* `reject_deletes`: List of instances to reject deletions from and the reason for doing so. + +#### :mrf_subchain +This policy processes messages through an alternate pipeline when a given message matches certain criteria. +All criteria are configured as a map of regular expressions to lists of policy modules. + +* `match_actor`: Matches a series of regular expressions against the actor field. + +Example: + +```elixir +config :pleroma, :mrf_subchain, + match_actor: %{ + ~r/https:\/\/example.com/s => [Pleroma.Web.ActivityPub.MRF.DropPolicy] + } +``` + +#### :mrf_rejectnonpublic +* `allow_followersonly`: whether to allow followers-only posts. +* `allow_direct`: whether to allow direct messages. + +#### :mrf_hellthread +* `delist_threshold`: Number of mentioned users after which the message gets delisted (the message can still be seen, but it will not show up in public timelines and mentioned users won't get notifications about it). Set to 0 to disable. +* `reject_threshold`: Number of mentioned users after which the messaged gets rejected. Set to 0 to disable. + +#### :mrf_keyword +* `reject`: A list of patterns which result in message being rejected, each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html). +* `federated_timeline_removal`: A list of patterns which result in message being removed from federated timelines (a.k.a unlisted), each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html). +* `replace`: A list of tuples containing `{pattern, replacement}`, `pattern` can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html). + +#### :mrf_mention +* `actors`: A list of actors, for which to drop any posts mentioning. + +#### :mrf_vocabulary +* `accept`: A list of ActivityStreams terms to accept. If empty, all supported messages are accepted. +* `reject`: A list of ActivityStreams terms to reject. If empty, no messages are rejected. + +#### :mrf_user_allowlist + +The keys in this section are the domain names that the policy should apply to. +Each key should be assigned a list of users that should be allowed through by +their ActivityPub ID. + +An example: + +```elixir +config :pleroma, :mrf_user_allowlist, %{ + "example.org" => ["https://example.org/users/admin"] +} +``` + +#### :mrf_object_age +* `threshold`: Required time offset (in seconds) compared to your server clock of an incoming post before actions are taken. + e.g., A value of 900 results in any post with a timestamp older than 15 minutes will be acted upon. +* `actions`: A list of actions to apply to the post: + * `:delist` removes the post from public timelines + * `:strip_followers` removes followers from the ActivityPub recipient list, ensuring they won't be delivered to home timelines, additionally for followers-only it degrades to a direct message + * `:reject` rejects the message entirely + +#### :mrf_steal_emoji +* `hosts`: List of hosts to steal emojis from +* `rejected_shortcodes`: Regex-list of shortcodes to reject +* `size_limit`: File size limit (in bytes), checked before an emoji is saved to the disk + +#### :mrf_activity_expiration + +* `days`: Default global expiration time for all local Create activities (in days) + +#### :mrf_hashtag + +* `sensitive`: List of hashtags to mark activities as sensitive (default: `nsfw`) +* `federated_timeline_removal`: List of hashtags to remove activities from the federated timeline (aka TWNK) +* `reject`: List of hashtags to reject activities from + +Notes: +- The hashtags in the configuration do not have a leading `#`. +- This MRF Policy is always enabled, if you want to disable it you have to set empty lists + +#### :mrf_follow_bot + +* `follower_nickname`: The name of the bot account to use for following newly discovered users. Using `followbot` or similar is strongly suggested. + +#### :mrf_inline_quote +* `prefix`: Prefix before the link (default: `RT`) +* +#### :mrf_emoji +* `remove_url`: A list of patterns which result in emoji whose URL matches being removed from the message. This will apply to statuses, emoji reactions, and user profiles. Each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html). +* `remove_shortcode`: A list of patterns which result in emoji whose shortcode matches being removed from the message. This will apply to statuses, emoji reactions, and user profiles. Each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html). +* `federated_timeline_removal_url`: A list of patterns which result in message with emojis whose URLs match being removed from federated timelines (a.k.a unlisted). This will apply only to statuses. Each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html). +* `federated_timeline_removal_shortcode`: A list of patterns which result in message with emojis whose shortcodes match being removed from federated timelines (a.k.a unlisted). This will apply only to statuses. Each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html). + +#### :mrf_inline_quote +* `template`: The template to append to the post. `{url}` will be replaced with the actual link to the quoted post. Default: `RT: {url}` + +### :activitypub +* `unfollow_blocked`: Whether blocks result in people getting unfollowed +* `outgoing_blocks`: Whether to federate blocks to other instances +* `blockers_visible`: Whether a user can see the posts of users who blocked them +* `deny_follow_blocked`: Whether to disallow following an account that has blocked the user in question +* `sign_object_fetches`: Sign object fetches with HTTP signatures +* `authorized_fetch_mode`: Require HTTP signatures for AP fetches + +## Pleroma.User + +* `restricted_nicknames`: List of nicknames users may not register with. +* `email_blacklist`: List of email domains users may not register with. + +## Pleroma.ScheduledActivity + +* `daily_user_limit`: the number of scheduled activities a user is allowed to create in a single day (Default: `25`) +* `total_user_limit`: the number of scheduled activities a user is allowed to create in total (Default: `300`) +* `enabled`: whether scheduled activities are sent to the job queue to be executed + +### :frontend_configurations + +This can be used to configure a keyword list that keeps the configuration data for any kind of frontend. By default, settings for `pleroma_fe` are configured. You can find the documentation for `pleroma_fe` configuration into [Pleroma-FE configuration and customization for instance administrators](/frontend/CONFIGURATION/#options). + +Frontends can access these settings at `/api/v1/pleroma/frontend_configurations` + +To add your own configuration for PleromaFE, use it like this: + +```elixir +config :pleroma, :frontend_configurations, + pleroma_fe: %{ + theme: "pleroma-dark", + # ... see /priv/static/static/config.json for the available keys. +} +``` + +These settings **need to be complete**, they will override the defaults. + +### :static_fe + +Render profiles and posts using server-generated HTML that is viewable without using JavaScript. + +Available options: + +* `enabled` - Enables the rendering of static HTML. Defaults to `false`. + +### :assets + +This section configures assets to be used with various frontends. Currently the only option +relates to mascots on the mastodon frontend + +* `mascots`: KeywordList of mascots, each element __MUST__ contain both a `url` and a + `mime_type` key. +* `default_mascot`: An element from `mascots` - This will be used as the default mascot + on MastoFE (default: `:pleroma_fox_tan`). + +### :manifest + +This section describe PWA manifest instance-specific values. Currently this option relate only for MastoFE. + +* `icons`: Describe the icons of the app, this a list of maps describing icons in the same way as the + [spec](https://www.w3.org/TR/appmanifest/#imageresource-and-its-members) describes it. + + Example: + + ```elixir + config :pleroma, :manifest, + icons: [ + %{ + src: "/static/logo.png" + }, + %{ + src: "/static/icon.png", + type: "image/png" + }, + %{ + src: "/static/icon.ico", + sizes: "72x72 96x96 128x128 256x256" + } + ] + ``` + +* `theme_color`: Describe the theme color of the app. (Example: `"#282c37"`, `"rebeccapurple"`). +* `background_color`: Describe the background color of the app. (Example: `"#191b22"`, `"aliceblue"`). + +## :emoji + +* `shortcode_globs`: Location of custom emoji files. `*` can be used as a wildcard. Example `["/emoji/custom/**/*.png"]` +* `pack_extensions`: A list of file extensions for emojis, when no emoji.txt for a pack is present. Example `[".png", ".gif"]` +* `groups`: Emojis are ordered in groups (tags). This is an array of key-value pairs where the key is the groupname and the value the location or array of locations. `*` can be used as a wildcard. Example `[Custom: ["/emoji/*.png", "/emoji/custom/*.png"]]` +* `default_manifest`: Location of the JSON-manifest. This manifest contains information about the emoji-packs you can download. Currently only one manifest can be added (no arrays). +* `shared_pack_cache_seconds_per_file`: When an emoji pack is shared, the archive is created and cached in + memory for this amount of seconds multiplied by the number of files. + +## :media_proxy + +* `enabled`: Enables proxying of remote media to the instance’s proxy +* `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts. +* `proxy_opts`: All options defined in `Pleroma.ReverseProxy` documentation, defaults to `[max_body_length: (25*1_048_576)]`. +* `whitelist`: List of hosts with scheme to bypass the mediaproxy (e.g. `https://example.com`) +* `invalidation`: options for remove media from cache after delete object: + * `enabled`: Enables purge cache + * `provider`: Which one of the [purge cache strategy](#purge-cache-strategy) to use. + +## :media_preview_proxy + +* `enabled`: Enables proxying of remote media preview to the instance’s proxy. Requires enabled media proxy (`media_proxy/enabled`). +* `thumbnail_max_width`: Max width of preview thumbnail for images (video preview always has original dimensions). +* `thumbnail_max_height`: Max height of preview thumbnail for images (video preview always has original dimensions). +* `image_quality`: Quality of the output. Ranges from 0 (min quality) to 100 (max quality). +* `min_content_length`: Min content length to perform preview, in bytes. If greater than 0, media smaller in size will be served as is, without thumbnailing. + +### Purge cache strategy + +#### Pleroma.Web.MediaProxy.Invalidation.Script + +This strategy allow perform external shell script to purge cache. +Urls of attachments are passed to the script as arguments. + +* `script_path`: Path to the external script. +* `url_format`: Set to `:htcacheclean` if using Apache's htcacheclean utility. + +Example: + +```elixir +config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script, + script_path: "./installation/nginx-cache-purge.example" +``` + +#### Pleroma.Web.MediaProxy.Invalidation.Http + +This strategy allow perform custom http request to purge cache. + +* `method`: http method. default is `purge` +* `headers`: http headers. +* `options`: request options. + +Example: +```elixir +config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Http, + method: :purge, + headers: [], + options: [] +``` + +## Link previews + +### Pleroma.Web.Metadata (provider) +* `providers`: a list of metadata providers to enable. Providers available: + * `Pleroma.Web.Metadata.Providers.OpenGraph` + * `Pleroma.Web.Metadata.Providers.TwitterCard` +* `unfurl_nsfw`: If set to `true` nsfw attachments will be shown in previews. + +### :rich_media (consumer) +* `enabled`: if enabled the instance will parse metadata from attached links to generate link previews. +* `ignore_hosts`: list of hosts which will be ignored by the metadata parser. For example `["accounts.google.com", "xss.website"]`, defaults to `[]`. +* `ignore_tld`: list TLDs (top-level domains) which will ignore for parse metadata. default is ["local", "localdomain", "lan"]. +* `parsers`: list of Rich Media parsers. +* `oembed_providers_enabled`: Embed rich media from a list of known providers. This takes precedence over other parsers. +* `failure_backoff`: Amount of milliseconds after request failure, during which the request will not be retried. + +## HTTP server + +### Pleroma.Web.Endpoint + +!!! note + `Phoenix` endpoint configuration, all configuration options can be viewed [here](https://hexdocs.pm/phoenix/Phoenix.Endpoint.html#module-dynamic-configuration), only common options are listed here. + +* `http` - a list containing http protocol configuration, all configuration options can be viewed [here](https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html#module-options), only common options are listed here. For deployment using docker, you need to set this to `[ip: {0,0,0,0}, port: 4000]` to make pleroma accessible from other containers (such as your nginx server). + - `ip` - a tuple consisting of 4 integers + - `port` +* `url` - a list containing the configuration for generating urls, accepts + - `host` - the host without the scheme and a post (e.g `example.com`, not `https://example.com:2020`) + - `scheme` - e.g `http`, `https` + - `port` + - `path` +* `extra_cookie_attrs` - a list of `Key=Value` strings to be added as non-standard cookie attributes. Defaults to `["SameSite=Lax"]`. See the [SameSite article](https://www.owasp.org/index.php/SameSite) on OWASP for more info. + +Example: +```elixir +config :pleroma, Pleroma.Web.Endpoint, + url: [host: "example.com", port: 2020, scheme: "https"], + http: [ + port: 8080, + ip: {127, 0, 0, 1} + ] +``` + +This will make Pleroma listen on `127.0.0.1` port `8080` and generate urls starting with `https://example.com:2020` + +### :http_security +* ``enabled``: Whether the managed content security policy is enabled. +* ``sts``: Whether to additionally send a `Strict-Transport-Security` header. +* ``sts_max_age``: The maximum age for the `Strict-Transport-Security` header if sent. +* ``ct_max_age``: The maximum age for the `Expect-CT` header if sent. +* ``referrer_policy``: The referrer policy to use, either `"same-origin"` or `"no-referrer"`. +* ``report_uri``: Adds the specified url to `report-uri` and `report-to` group in CSP header. + +### Pleroma.Web.Plugs.RemoteIp + +!!! warning + If your instance is not behind at least one reverse proxy, you should not enable this plug. + +`Pleroma.Web.Plugs.RemoteIp` is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration. + +Available options: + +* `enabled` - Enable/disable the plug. Defaults to `false`. +* `headers` - A list of strings naming the HTTP headers to use when deriving the true client IP address. Defaults to `["x-forwarded-for"]`. +* `proxies` - A list of upstream proxy IP subnets in CIDR notation from which we will parse the content of `headers`. Defaults to `[]`. IPv4 entries without a bitmask will be assumed to be /32 and IPv6 /128. +* `reserved` - A list of reserved IP subnets in CIDR notation which should be ignored if found in `headers`. Defaults to `["127.0.0.0/8", "::1/128", "fc00::/7", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]`. + + +### :rate_limit + +!!! note + If your instance is behind a reverse proxy ensure [`Pleroma.Web.Plugs.RemoteIp`](#pleroma-plugs-remoteip) is enabled (it is enabled by default). + +A keyword list of rate limiters where a key is a limiter name and value is the limiter configuration. The basic configuration is a tuple where: + +* The first element: `scale` (Integer). The time scale in milliseconds. +* The second element: `limit` (Integer). How many requests to limit in the time scale provided. + +It is also possible to have different limits for unauthenticated and authenticated users: the keyword value must be a list of two tuples where the first one is a config for unauthenticated users and the second one is for authenticated. + +For example: + +```elixir +config :pleroma, :rate_limit, + authentication: {60_000, 15}, + search: [{1000, 10}, {1000, 30}] +``` + +Means that: + +1. In 60 seconds, 15 authentication attempts can be performed from the same IP address. +2. In 1 second, 10 search requests can be performed from the same IP adress by unauthenticated users, while authenticated users can perform 30 search requests per second. + +Supported rate limiters: + +* `:search` - Account/Status search. +* `:timeline` - Timeline requests (each timeline has it's own limiter). +* `:app_account_creation` - Account registration from the API. +* `:relations_actions` - Following/Unfollowing in general. +* `:relation_id_action` - Following/Unfollowing for a specific user. +* `:statuses_actions` - Status actions such as: (un)repeating, (un)favouriting, creating, deleting. +* `:status_id_action` - (un)Repeating/(un)Favouriting a particular status. +* `:events_actions` - Events actions such as: creating, joining, leaving. +* `:authentication` - Authentication actions, i.e getting an OAuth token. +* `:password_reset` - Requesting password reset emails. +* `:account_confirmation_resend` - Requesting resending account confirmation emails. +* `:ap_routes` - Requesting statuses via ActivityPub. + +### :web_cache_ttl + +The expiration time for the web responses cache. Values should be in milliseconds or `nil` to disable expiration. + +Available caches: + +* `:activity_pub` - activity pub routes (except question activities). Defaults to `nil` (no expiration). +* `:activity_pub_question` - activity pub routes (question activities). Defaults to `30_000` (30 seconds). + +## HTTP client + +### :http + +* `proxy_url`: an upstream proxy to fetch posts and/or media with, (default: `nil`) +* `send_user_agent`: should we include a user agent with HTTP requests? (default: `true`) +* `user_agent`: what user agent should we use? (default: `:default`), must be string or `:default` +* `adapter`: array of adapter options + +### :hackney_pools + +Advanced. Tweaks Hackney (http client) connections pools. + +There's three pools used: + +* `:federation` for the federation jobs. + You may want this pool max_connections to be at least equal to the number of federator jobs + retry queue jobs. +* `:media` for rich media, media proxy +* `:upload` for uploaded media (if using a remote uploader and `proxy_remote: true`) + +For each pool, the options are: + +* `max_connections` - how much connections a pool can hold +* `timeout` - retention duration for connections + + +### :connections_pool + +*For `gun` adapter* + +Settings for HTTP connection pool. + +* `:connection_acquisition_wait` - Timeout to acquire a connection from pool.The total max time is this value multiplied by the number of retries. +* `connection_acquisition_retries` - Number of attempts to acquire the connection from the pool if it is overloaded. Each attempt is timed `:connection_acquisition_wait` apart. +* `:max_connections` - Maximum number of connections in the pool. +* `:connect_timeout` - Timeout to connect to the host. +* `:reclaim_multiplier` - Multiplied by `:max_connections` this will be the maximum number of idle connections that will be reclaimed in case the pool is overloaded. + +### :pools + +*For `gun` adapter* + +Settings for request pools. These pools are limited on top of `:connections_pool`. + +There are four pools used: + +* `:federation` for the federation jobs. You may want this pool's max_connections to be at least equal to the number of federator jobs + retry queue jobs. +* `:media` - for rich media, media proxy. +* `:upload` - for proxying media when a remote uploader is used and `proxy_remote: true`. +* `:default` - for other requests. + +For each pool, the options are: + +* `:size` - limit to how much requests can be concurrently executed. +* `:recv_timeout` - timeout while `gun` will wait for response +* `:max_waiting` - limit to how much requests can be waiting for others to finish, after this is reached, subsequent requests will be dropped. + +## Captcha + +### Pleroma.Captcha + +* `enabled`: Whether the captcha should be shown on registration. +* `method`: The method/service to use for captcha. +* `seconds_valid`: The time in seconds for which the captcha is valid. + +### Captcha providers + +#### Pleroma.Captcha.Native + +A built-in captcha provider. Enabled by default. + +#### Pleroma.Captcha.Kocaptcha + +Kocaptcha is a very simple captcha service with a single API endpoint, +the source code is here: [kocaptcha](https://github.com/koto-bank/kocaptcha). The default endpoint +`https://captcha.kotobank.ch` is hosted by the developer. + +* `endpoint`: the Kocaptcha endpoint to use. + +## Uploads + +### Pleroma.Upload + +* `uploader`: Which one of the [uploaders](#uploaders) to use. +* `filters`: List of [upload filters](#upload-filters) to use. +* `link_name`: When enabled Pleroma will add a `name` parameter to the url of the upload, for example `https://instance.tld/media/corndog.png?name=corndog.png`. This is needed to provide the correct filename in Content-Disposition headers when using filters like `Pleroma.Upload.Filter.Dedupe` +* `base_url`: The base URL to access a user-uploaded file. Useful when you want to host the media files via another domain or are using a 3rd party S3 provider. +* `proxy_remote`: If you're using a remote uploader, Pleroma will proxy media requests instead of redirecting to it. +* `proxy_opts`: Proxy options, see `Pleroma.ReverseProxy` documentation. +* `filename_display_max_length`: Set max length of a filename to display. 0 = no limit. Default: 30. +* `default_description`: Sets which default description an image has if none is set explicitly. Options: nil (default) - Don't set a default, :filename - use the filename of the file, a string (e.g. "attachment") - Use this string + +!!! warning + `strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`. + +### Uploaders + +#### Pleroma.Uploaders.Local + +* `uploads`: Which directory to store the user-uploads in, relative to pleroma’s working directory. + +#### Pleroma.Uploaders.S3 + +Don't forget to configure [Ex AWS S3](#ex-aws-s3-settings) + +* `bucket`: S3 bucket name. +* `bucket_namespace`: S3 bucket namespace. +* `truncated_namespace`: If you use S3 compatible service such as Digital Ocean Spaces or CDN, set folder name or "" etc. +* `streaming_enabled`: Enable streaming uploads, when enabled the file will be sent to the server in chunks as it's being read. This may be unsupported by some providers, try disabling this if you have upload problems. + +#### Ex AWS S3 settings + +* `access_key_id`: Access key ID +* `secret_access_key`: Secret access key +* `host`: S3 host + +Example: + +```elixir +config :ex_aws, :s3, + access_key_id: "xxxxxxxxxx", + secret_access_key: "yyyyyyyyyy", + host: "s3.eu-central-1.amazonaws.com" +``` + +### Upload filters + +#### Pleroma.Upload.Filter.AnonymizeFilename + +This filter replaces the filename (not the path) of an upload. For complete obfuscation, add +`Pleroma.Upload.Filter.Dedupe` before AnonymizeFilename. + +* `text`: Text to replace filenames in links. If empty, `{random}.extension` will be used. You can get the original filename extension by using `{extension}`, for example `custom-file-name.{extension}`. + +#### Pleroma.Upload.Filter.Dedupe + +No specific configuration. + +#### Pleroma.Upload.Filter.Exiftool.StripLocation + +This filter only strips the GPS and location metadata with Exiftool leaving color profiles and attributes intact. + +No specific configuration. + +#### Pleroma.Upload.Filter.Exiftool.ReadDescription + +This filter reads the ImageDescription and iptc:Caption-Abstract fields with Exiftool so clients can prefill the media description field. + +No specific configuration. + +#### Pleroma.Upload.Filter.OnlyMedia + +This filter rejects uploads that are not identified with Content-Type matching audio/\*, image/\*, or video/\* + +No specific configuration. + +#### Pleroma.Upload.Filter.Mogrify + +* `args`: List of actions for the `mogrify` command like `"strip"` or `["strip", "auto-orient", {"implode", "1"}]`. + +## Email + +### Pleroma.Emails.Mailer +* `adapter`: one of the mail adapters listed in [Swoosh readme](https://github.com/swoosh/swoosh#adapters), or `Swoosh.Adapters.Local` for in-memory mailbox. +* `api_key` / `password` and / or other adapter-specific settings, per the above documentation. +* `enabled`: Allows enable/disable send emails. Default: `false`. + +An example for Sendgrid adapter: + +```elixir +config :pleroma, Pleroma.Emails.Mailer, + enabled: true, + adapter: Swoosh.Adapters.Sendgrid, + api_key: "YOUR_API_KEY" +``` + +An example for SMTP adapter: + +```elixir +config :pleroma, Pleroma.Emails.Mailer, + enabled: true, + adapter: Swoosh.Adapters.SMTP, + relay: "smtp.gmail.com", + username: "YOUR_USERNAME@gmail.com", + password: "YOUR_SMTP_PASSWORD", + port: 465, + ssl: true, + auth: :always +``` + +### :email_notifications + +Email notifications settings. + + - digest - emails of "what you've missed" for users who have been + inactive for a while. + - active: globally enable or disable digest emails + - schedule: When to send digest email, in [crontab format](https://en.wikipedia.org/wiki/Cron). + "0 0 * * 0" is the default, meaning "once a week at midnight on Sunday morning" + - interval: Minimum interval between digest emails to one user + - inactivity_threshold: Minimum user inactivity threshold + +### Pleroma.Emails.UserEmail + +- `:logo` - a path to a custom logo. Set it to `nil` to use the default Pleroma logo. +- `:styling` - a map with color settings for email templates. + +### Pleroma.Emails.NewUsersDigestEmail + +- `:enabled` - a boolean, enables new users admin digest email when `true`. Defaults to `false`. + +## Background jobs + +### Oban + +[Oban](https://github.com/sorentwo/oban) asynchronous job processor configuration. + +Configuration options described in [Oban readme](https://github.com/sorentwo/oban#usage): + +* `repo` - app's Ecto repo (`Pleroma.Repo`) +* `log` - logs verbosity +* `queues` - job queues (see below) +* `crontab` - periodic jobs, see [`Oban.Cron`](#obancron) + +Pleroma has the following queues: + +* `activity_expiration` - Activity expiration +* `federator_outgoing` - Outgoing federation +* `federator_incoming` - Incoming federation +* `mailer` - Email sender, see [`Pleroma.Emails.Mailer`](#pleromaemailsmailer) +* `transmogrifier` - Transmogrifier +* `web_push` - Web push notifications +* `scheduled_activities` - Scheduled activities, see [`Pleroma.ScheduledActivity`](#pleromascheduledactivity) + +#### Oban.Cron + +Pleroma has these periodic job workers: + +* `Pleroma.Workers.Cron.DigestEmailsWorker` - digest emails for users with new mentions and follows +* `Pleroma.Workers.Cron.NewUsersDigestWorker` - digest emails for admins with new registrations + +```elixir +config :pleroma, Oban, + repo: Pleroma.Repo, + verbose: false, + prune: {:maxlen, 1500}, + queues: [ + federator_incoming: 50, + federator_outgoing: 50 + ], + crontab: [ + {"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker}, + {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker} + ] +``` + +This config contains two queues: `federator_incoming` and `federator_outgoing`. Both have the number of max concurrent jobs set to `50`. + +#### Migrating `pleroma_job_queue` settings + +`config :pleroma_job_queue, :queues` is replaced by `config :pleroma, Oban, :queues` and uses the same format (keys are queues' names, values are max concurrent jobs numbers). + +### :workers + +Includes custom worker options not interpretable directly by `Oban`. + +* `retries` — keyword lists where keys are `Oban` queues (see above) and values are numbers of max attempts for failed jobs. + +Example: + +```elixir +config :pleroma, :workers, + retries: [ + federator_incoming: 5, + federator_outgoing: 5 + ] +``` + +#### Migrating `Pleroma.Web.Federator.RetryQueue` settings + +* `max_retries` is replaced with `config :pleroma, :workers, retries: [federator_outgoing: 5]` +* `enabled: false` corresponds to `config :pleroma, :workers, retries: [federator_outgoing: 1]` +* deprecated options: `max_jobs`, `initial_timeout` + +## :web_push_encryption, :vapid_details + +Web Push Notifications configuration. You can use the mix task `mix web_push.gen.keypair` to generate it. + +* ``subject``: a mailto link for the administrative contact. It’s best if this email is not a personal email address, but rather a group email so that if a person leaves an organization, is unavailable for an extended period, or otherwise can’t respond, someone else on the list can. +* ``public_key``: VAPID public key +* ``private_key``: VAPID private key + +## :logger +* `backends`: `:console` is used to send logs to stdout, `{ExSyslogger, :ex_syslogger}` to log to syslog + +An example to enable ONLY ExSyslogger (f/ex in ``prod.secret.exs``) with info and debug suppressed: +```elixir +config :logger, + backends: [{ExSyslogger, :ex_syslogger}] + +config :logger, :ex_syslogger, + level: :warn +``` + +Another example, keeping console output and adding the pid to syslog output: +```elixir +config :logger, + backends: [:console, {ExSyslogger, :ex_syslogger}] + +config :logger, :ex_syslogger, + level: :warn, + option: [:pid, :ndelay] +``` + +See: [logger’s documentation](https://hexdocs.pm/logger/Logger.html) and [ex_syslogger’s documentation](https://hexdocs.pm/ex_syslogger/) + +An example of logging info to local syslog, but debug to console: +```elixir +config :logger, + backends: [ {ExSyslogger, :ex_syslogger}, :console ], + level: :info + +config :logger, :ex_syslogger, + level: :info, + ident: "pleroma", + format: "$metadata[$level] $message" + +config :logger, :console, + level: :debug, + format: "\n$time $metadata[$level] $message\n", + metadata: [:request_id] +``` + + + +## Database options + +### RUM indexing for full text search + +!!! warning + It is recommended to use PostgreSQL v11 or newer. We have seen some minor issues with lower PostgreSQL versions. + +* `rum_enabled`: If RUM indexes should be used. Defaults to `false`. + +RUM indexes are an alternative indexing scheme that is not included in PostgreSQL by default. While they may eventually be mainlined, for now they have to be installed as a PostgreSQL extension from https://github.com/postgrespro/rum. + +Their advantage over the standard GIN indexes is that they allow efficient ordering of search results by timestamp, which makes search queries a lot faster on larger servers, by one or two orders of magnitude. They take up around 3 times as much space as GIN indexes. + +To enable them, both the `rum_enabled` flag has to be set and the following special migration has to be run: + +`mix ecto.migrate --migrations-path priv/repo/optional_migrations/rum_indexing/` + +This will probably take a long time. + +## Alternative client protocols + +### BBS / SSH access + +This feature has been removed from Pleroma core. +However, a client has been made and is available at https://git.pleroma.social/Duponin/sshocial. + +### :gopher +* `enabled`: Enables the gopher interface +* `ip`: IP address to bind to +* `port`: Port to bind to +* `dstport`: Port advertised in urls (optional, defaults to `port`) + + +## Authentication + +### :admin_token + +Allows to set a token that can be used to authenticate with the admin api without using an actual user by giving it as the `admin_token` parameter or `x-admin-token` HTTP header. Example: + +```elixir +config :pleroma, :admin_token, "somerandomtoken" +``` + +You can then do + +```shell +curl "http://localhost:4000/api/v1/pleroma/admin/users/invites?admin_token=somerandomtoken" +``` + +or + +```shell +curl -H "X-Admin-Token: somerandomtoken" "http://localhost:4000/api/v1/pleroma/admin/users/invites" +``` + +Warning: it's discouraged to use this feature because of the associated security risk: static / rarely changed instance-wide token is much weaker compared to email-password pair of a real admin user; consider using HTTP Basic Auth or OAuth-based authentication instead. + +### :auth + +Authentication / authorization settings. + +* `auth_template`: authentication form template. By default it's `show.html` which corresponds to `lib/pleroma/web/templates/o_auth/o_auth/show.html.eex`. +* `oauth_consumer_template`: OAuth consumer mode authentication form template. By default it's `consumer.html` which corresponds to `lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex`. +* `oauth_consumer_strategies`: the list of enabled OAuth consumer strategies; by default it's set by `OAUTH_CONSUMER_STRATEGIES` environment variable. Each entry in this space-delimited string should be of format `` or `:` (e.g. `twitter` or `keycloak:ueberauth_keycloak_strategy` in case dependency is named differently than `ueberauth_`). + +### Pleroma.Web.Auth.Authenticator + +* `Pleroma.Web.Auth.PleromaAuthenticator`: default database authenticator. +* `Pleroma.Web.Auth.LDAPAuthenticator`: LDAP authentication. + +### :ldap + +Use LDAP for user authentication. When a user logs in to the Pleroma +instance, the name and password will be verified by trying to authenticate +(bind) to an LDAP server. If a user exists in the LDAP directory but there +is no account with the same name yet on the Pleroma instance then a new +Pleroma account will be created with the same name as the LDAP user name. + +* `enabled`: enables LDAP authentication +* `host`: LDAP server hostname +* `port`: LDAP port, e.g. 389 or 636 +* `ssl`: true to use SSL, usually implies the port 636 +* `sslopts`: additional SSL options +* `tls`: true to start TLS, usually implies the port 389 +* `tlsopts`: additional TLS options +* `base`: LDAP base, e.g. "dc=example,dc=com" +* `uid`: LDAP attribute name to authenticate the user, e.g. when "cn", the filter will be "cn=username,base" + +Note, if your LDAP server is an Active Directory server the correct value is commonly `uid: "cn"`, but if you use an +OpenLDAP server the value may be `uid: "uid"`. + +### :oauth2 (Pleroma as OAuth 2.0 provider settings) + +OAuth 2.0 provider settings: + +* `token_expires_in` - The lifetime in seconds of the access token. +* `issue_new_refresh_token` - Keeps old refresh token or generate new refresh token when to obtain an access token. +* `clean_expired_tokens` - Enable a background job to clean expired oauth tokens. Defaults to `false`. + +OAuth 2.0 provider and related endpoints: + +* `POST /api/v1/apps` creates client app basing on provided params. +* `GET/POST /oauth/authorize` renders/submits authorization form. +* `POST /oauth/token` creates/renews OAuth token. +* `POST /oauth/revoke` revokes provided OAuth token. +* `GET /api/v1/accounts/verify_credentials` (with proper `Authorization` header or `access_token` URI param) returns user info on requester (with `acct` field containing local nickname and `fqn` field containing fully-qualified nickname which could generally be used as email stub for OAuth software that demands email field in identity endpoint response, like Peertube). + +### OAuth consumer mode + +OAuth consumer mode allows sign in / sign up via external OAuth providers (e.g. Twitter, Facebook, Google, Microsoft, etc.). +Implementation is based on Ueberauth; see the list of [available strategies](https://github.com/ueberauth/ueberauth/wiki/List-of-Strategies). + +!!! note + Each strategy is shipped as a separate dependency; in order to get the strategies, run `OAUTH_CONSUMER_STRATEGIES="..." mix deps.get`, e.g. `OAUTH_CONSUMER_STRATEGIES="twitter facebook google microsoft" mix deps.get`. The server should also be started with `OAUTH_CONSUMER_STRATEGIES="..." mix phx.server` in case you enable any strategies. + +!!! note + Each strategy requires separate setup (on external provider side and Pleroma side). Below are the guidelines on setting up most popular strategies. + +!!! note + Make sure that `"SameSite=Lax"` is set in `extra_cookie_attrs` when you have this feature enabled. OAuth consumer mode will not work with `"SameSite=Strict"` + +* For Twitter, [register an app](https://developer.twitter.com/en/apps), configure callback URL to https:///oauth/twitter/callback + +* For Facebook, [register an app](https://developers.facebook.com/apps), configure callback URL to https:///oauth/facebook/callback, enable Facebook Login service at https://developers.facebook.com/apps//fb-login/settings/ + +* For Google, [register an app](https://console.developers.google.com), configure callback URL to https:///oauth/google/callback + +* For Microsoft, [register an app](https://portal.azure.com), configure callback URL to https:///oauth/microsoft/callback + +Once the app is configured on external OAuth provider side, add app's credentials and strategy-specific settings (if any — e.g. see Microsoft below) to `config/prod.secret.exs`, +per strategy's documentation (e.g. [ueberauth_twitter](https://github.com/ueberauth/ueberauth_twitter)). Example config basing on environment variables: + +```elixir +# Twitter +config :ueberauth, Ueberauth.Strategy.Twitter.OAuth, + consumer_key: System.get_env("TWITTER_CONSUMER_KEY"), + consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET") + +# Facebook +config :ueberauth, Ueberauth.Strategy.Facebook.OAuth, + client_id: System.get_env("FACEBOOK_APP_ID"), + client_secret: System.get_env("FACEBOOK_APP_SECRET"), + redirect_uri: System.get_env("FACEBOOK_REDIRECT_URI") + +# Google +config :ueberauth, Ueberauth.Strategy.Google.OAuth, + client_id: System.get_env("GOOGLE_CLIENT_ID"), + client_secret: System.get_env("GOOGLE_CLIENT_SECRET"), + redirect_uri: System.get_env("GOOGLE_REDIRECT_URI") + +# Microsoft +config :ueberauth, Ueberauth.Strategy.Microsoft.OAuth, + client_id: System.get_env("MICROSOFT_CLIENT_ID"), + client_secret: System.get_env("MICROSOFT_CLIENT_SECRET") + +config :ueberauth, Ueberauth, + providers: [ + microsoft: {Ueberauth.Strategy.Microsoft, [callback_params: []]} + ] + +# Keycloak +# Note: make sure to add `keycloak:ueberauth_keycloak_strategy` entry to `OAUTH_CONSUMER_STRATEGIES` environment variable +keycloak_url = "https://publicly-reachable-keycloak-instance.org:8080" + +config :ueberauth, Ueberauth.Strategy.Keycloak.OAuth, + client_id: System.get_env("KEYCLOAK_CLIENT_ID"), + client_secret: System.get_env("KEYCLOAK_CLIENT_SECRET"), + site: keycloak_url, + authorize_url: "#{keycloak_url}/auth/realms/master/protocol/openid-connect/auth", + token_url: "#{keycloak_url}/auth/realms/master/protocol/openid-connect/token", + userinfo_url: "#{keycloak_url}/auth/realms/master/protocol/openid-connect/userinfo", + token_method: :post + +config :ueberauth, Ueberauth, + providers: [ + keycloak: {Ueberauth.Strategy.Keycloak, [uid_field: :email]} + ] +``` + +## Link parsing + +### :uri_schemes +* `valid_schemes`: List of the scheme part that is considered valid to be an URL. + +### Pleroma.Formatter + +Configuration for Pleroma's link formatter which parses mentions, hashtags, and URLs. + +* `class` - specify the class to be added to the generated link (default: `false`) +* `rel` - specify the rel attribute (default: `ugc`) +* `new_window` - adds `target="_blank"` attribute (default: `false`) +* `truncate` - Set to a number to truncate URLs longer then the number. Truncated URLs will end in `...` (default: `false`) +* `strip_prefix` - Strip the scheme prefix (default: `false`) +* `extra` - link URLs with rarely used schemes (magnet, ipfs, irc, etc.) (default: `true`) +* `validate_tld` - Set to false to disable TLD validation for URLs/emails. Can be set to :no_scheme to validate TLDs only for urls without a scheme (e.g `example.com` will be validated, but `http://example.loki` won't) (default: `:no_scheme`) + +Example: + +```elixir +config :pleroma, Pleroma.Formatter, + class: false, + rel: "ugc", + new_window: false, + truncate: false, + strip_prefix: false, + extra: true, + validate_tld: :no_scheme +``` + +## Custom Runtime Modules (`:modules`) + +* `runtime_dir`: A path to custom Elixir modules (such as MRF policies). + +## :configurable_from_database + +Boolean, enables/disables in-database configuration. Read [Transfering the config to/from the database](../administration/CLI_tasks/config.md) for more information. + +## :database_config_whitelist + +List of valid configuration sections which are allowed to be configured from the +database. Settings stored in the database before the whitelist is configured are +still applied, so it is suggested to only use the whitelist on instances that +have not migrated the config to the database. + +Example: +```elixir +config :pleroma, :database_config_whitelist, [ + {:pleroma, :instance}, + {:pleroma, Pleroma.Web.Metadata}, + {:auto_linker} +] +``` + +### Multi-factor authentication - :two_factor_authentication +* `totp` - a list containing TOTP configuration + - `digits` - Determines the length of a one-time pass-code in characters. Defaults to 6 characters. + - `period` - a period for which the TOTP code will be valid in seconds. Defaults to 30 seconds. +* `backup_codes` - a list containing backup codes configuration + - `number` - number of backup codes to generate. + - `length` - backup code length. Defaults to 16 characters. + +## Restrict entities access for unauthenticated users + +### :restrict_unauthenticated + +Restrict access for unauthenticated users to timelines (public and federated), user profiles and statuses. + +* `timelines`: public and federated timelines + * `local`: public timeline + * `federated`: federated timeline (includes public timeline) +* `profiles`: user profiles + * `local` + * `remote` +* `activities`: statuses + * `local` + * `remote` + +Note: when `:instance, :public` is set to `false`, all `:restrict_unauthenticated` items be effectively set to `true` by default. If you'd like to allow unauthenticated access to specific API endpoints on a private instance, please explicitly set `:restrict_unauthenticated` to non-default value in `config/prod.secret.exs`. + +Note: setting `restrict_unauthenticated/timelines/local` to `true` has no practical sense if `restrict_unauthenticated/timelines/federated` is set to `false` (since local public activities will still be delivered to unauthenticated users as part of federated timeline). + +## Pleroma.Web.ApiSpec.CastAndValidate + +* `:strict` a boolean, enables strict input validation (useful in development, not recommended in production). Defaults to `false`. + +## :instances_favicons + +Control favicons for instances. + +* `enabled`: Allow/disallow displaying and getting instances favicons + +## Pleroma.User.Backup + +!!! note + Requires enabled email + +* `:purge_after_days` an integer, remove backup achives after N days. +* `:limit_days` an integer, limit user to export not more often than once per N days. +* `:dir` a string with a path to backup temporary directory or `nil` to let Pleroma choose temporary directory in the following order: + 1. the directory named by the TMPDIR environment variable + 2. the directory named by the TEMP environment variable + 3. the directory named by the TMP environment variable + 4. C:\TMP on Windows or /tmp on Unix-like operating systems + 5. as a last resort, the current working directory + +## Frontend management + +Frontends in Pleroma are swappable - you can specify which one to use here. + +You can set a frontends for the key `primary` and `admin` and the options of `name` and `ref`. This will then make Pleroma serve the frontend from a folder constructed by concatenating the instance static path, `frontends` and the name and ref. + +The key `primary` refers to the frontend that will be served by default for general requests. The key `admin` refers to the frontend that will be served at the `/pleroma/admin` path. + +If you don't set anything here, the bundled frontends will be used. + +Example: + +``` +config :pleroma, :frontends, + primary: %{ + "name" => "pleroma", + "ref" => "stable" + }, + admin: %{ + "name" => "admin", + "ref" => "develop" + } +``` + +This would serve the frontend from the the folder at `$instance_static/frontends/pleroma/stable`. You have to copy the frontend into this folder yourself. You can choose the name and ref any way you like, but they will be used by mix tasks to automate installation in the future, the name referring to the project and the ref referring to a commit. + +## Ephemeral activities (Pleroma.Workers.PurgeExpiredActivity) + +Settings to enable and configure expiration for ephemeral activities + +* `:enabled` - enables ephemeral activities creation +* `:min_lifetime` - minimum lifetime for ephemeral activities (in seconds). Default: 10 minutes. + +## ConcurrentLimiter + +Settings to restrict concurrently running jobs. Jobs which can be configured: + +* `Pleroma.Web.RichMedia.Helpers` - generating link previews of URLs in activities +* `Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy` - warming remote media cache via MediaProxyWarmingPolicy + +Each job has these settings: + +* `:max_running` - max concurrently runnings jobs +* `:max_waiting` - max waiting jobs diff --git a/docs/configuration/custom_emoji.md b/docs/configuration/custom_emoji.md new file mode 100644 index 0000000..1648840 --- /dev/null +++ b/docs/configuration/custom_emoji.md @@ -0,0 +1,69 @@ +# Custom Emoji + +Before you add your own custom emoji, check if they are available in an existing pack. +See `Mix.Tasks.Pleroma.Emoji` for information about emoji packs. + +To add custom emoji: + +* Create the `STATIC-DIR/emoji/` directory if it doesn't exist + (`STATIC-DIR` is configurable, `instance/static/` by default) +* Create a directory with whatever name you want (custom is a good name to show the purpose of it). + This will create a local emoji pack. +* Put your `.png` emoji files in that directory. In case of conflicts, you can create an `emoji.txt` + file in that directory and specify a custom shortcode using the following format: + `shortcode, file-path, tag1, tag2, etc`. One emoji per line. Note that if you do so, + you'll have to list all other emojis in the pack too. +* Either restart pleroma or connect to the iex session pleroma's running and + run `Pleroma.Emoji.reload/0` in it. + +Example: + +image files (in `instance/static/emoji/custom`): `happy.png` and `sad.png` + +content of `emoji.txt`: +``` +happy, /emoji/custom/happy.png, Tag1,Tag2 +sad, /emoji/custom/sad.png, Tag1 +foo, /emoji/custom/foo.png +``` + +The files should be PNG (APNG is okay with `.png` for `image/png` Content-type) and under 50kb for compatibility with mastodon. + +Default file extentions and locations for emojis are set in `config.exs`. To use different locations or file-extentions, add the `shortcode_globs` to your secrets file (`prod.secret.exs` or `dev.secret.exs`) and edit it. Note that not all fediverse-software will show emojis with other file extentions: +```elixir +config :pleroma, :emoji, shortcode_globs: ["/emoji/custom/**/*.png", "/emoji/custom/**/*.gif"] +``` + +## Emoji tags (groups) + +Default tags are set in `config.exs`. To set your own tags, copy the structure to your secrets file (`prod.secret.exs` or `dev.secret.exs`) and edit it. +```elixir +config :pleroma, :emoji, + shortcode_globs: ["/emoji/custom/**/*.png"], + groups: [ + Finmoji: "/finmoji/128px/*-128.png", + Custom: ["/emoji/*.png", "/emoji/custom/*.png"] + ] +``` + +Order of the `groups` matters, so to override default tags just put your group on top of the list. E.g: +```elixir +config :pleroma, :emoji, + shortcode_globs: ["/emoji/custom/**/*.png"], + groups: [ + "Finmoji special": "/finmoji/128px/a_trusted_friend-128.png", # special file + "Cirno": "/emoji/custom/cirno*.png", # png files in /emoji/custom/ which start with `cirno` + "Special group": "/emoji/custom/special_folder/*.png", # png files in /emoji/custom/special_folder/ + "Another group": "/emoji/custom/special_folder/*/.png", # png files in /emoji/custom/special_folder/ subfolders + Finmoji: "/finmoji/128px/*-128.png", + Custom: ["/emoji/*.png", "/emoji/custom/*.png"] + ] +``` + +Priority of tags assigns in emoji.txt and custom.txt: + +`tag in file > special group setting in config.exs > default setting in config.exs` + +Priority for globs: + +`special group setting in config.exs > default setting in config.exs` diff --git a/docs/configuration/hardening.md b/docs/configuration/hardening.md new file mode 100644 index 0000000..cc46d1f --- /dev/null +++ b/docs/configuration/hardening.md @@ -0,0 +1,117 @@ +# Hardening your instance +Here are some suggestions which improve the security of parts of your Pleroma instance. + +## Configuration file + +These changes should go into `prod.secret.exs` or `dev.secret.exs`, depending on your `MIX_ENV` value. + +### `http` + +> Recommended value: `[ip: {127, 0, 0, 1}]` + +This sets the Pleroma application server to only listen to the localhost interface. This way, you can only reach your server over the Internet by going through the reverse proxy. By default, Pleroma listens on all interfaces. + +### `secure_cookie_flag` + +> Recommended value: `true` + +This sets the `secure` flag on Pleroma’s session cookie. This makes sure, that the cookie is only accepted over encrypted HTTPs connections. This implicitly renames the cookie from `pleroma_key` to `__Host-pleroma-key` which enforces some restrictions. (see [cookie prefixes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#Cookie_prefixes)) + +### `:http_security` + +> Recommended value: `true` + +This will send additional HTTP security headers to the clients, including: + +* `X-XSS-Protection: "1; mode=block"` +* `X-Permitted-Cross-Domain-Policies: "none"` +* `X-Frame-Options: "DENY"` +* `X-Content-Type-Options: "nosniff"` +* `X-Download-Options: "noopen"` + +A content security policy (CSP) will also be set: + +```csp +content-security-policy: + default-src 'none'; + base-uri 'self'; + frame-ancestors 'none'; + img-src 'self' data: blob: https:; + media-src 'self' https:; + style-src 'self' 'unsafe-inline'; + font-src 'self'; + script-src 'self'; + connect-src 'self' wss://example.tld; + manifest-src 'self'; + upgrade-insecure-requests; +``` + +#### `sts` + +> Recommended value: `true` + +An additional “Strict transport security” header will be sent with the configured `sts_max_age` parameter. This tells the browser, that the domain should only be accessed over a secure HTTPs connection. + +#### `ct_max_age` + +An additional “Expect-CT” header will be sent with the configured `ct_max_age` parameter. This enforces the use of TLS certificates that are published in the certificate transparency log. (see [Expect-CT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expect-CT)) + +#### `referrer_policy` + +> Recommended value: `same-origin` + +If you click on a link, your browser’s request to the other site will include from where it is coming from. The “Referrer policy” header tells the browser how and if it should send this information. (see [Referrer policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy)) + +### Uploaded media and media proxy + +It is STRONGLY RECOMMENDED to serve both the locally-uploaded media and the media proxy from another domain than the domain that Pleroma runs on, if applicable. + +```elixir +config :pleroma, :media_proxy, + base_url: "https://some.other.domain" + +config :pleroma, Pleroma.Upload, + base_url: "https://some.other.domain/media" +``` + +See `installation/pleroma-mediaproxy.nginx` for examples on how to configure your media proxy. + +## systemd + +A systemd unit example is provided at `installation/pleroma.service`. + +### PrivateTmp + +> Recommended value: `true` + +Use private `/tmp` and `/var/tmp` folders inside a new file system namespace, which are discarded after the process stops. + +### ProtectHome + +> Recommended value: `true` + +The `/home`, `/root`, and `/run/user` folders can not be accessed by this service anymore. If your Pleroma user has its home folder in one of the restricted places, or use one of these folders as its working directory, you have to set this to `false`. + +### ProtectSystem + +> Recommended value: `full` + +Mount `/usr`, `/boot`, and `/etc` as read-only for processes invoked by this service. + +### PrivateDevices + +> Recommended value: `true` + +Sets up a new `/dev` mount for the process and only adds API pseudo devices like `/dev/null`, `/dev/zero` or `/dev/random` but not physical devices. This may not work on devices like the Raspberry Pi, where you need to set this to `false`. + +### NoNewPrivileges + +> Recommended value: `true` + +Ensures that the service process and all its children can never gain new privileges through `execve()`. + +### CapabilityBoundingSet + +> Recommended value: `~CAP_SYS_ADMIN` + +Drops the sysadmin capability from the daemon. diff --git a/docs/configuration/how_to_serve_another_domain_for_webfinger.md b/docs/configuration/how_to_serve_another_domain_for_webfinger.md new file mode 100644 index 0000000..5ae3e79 --- /dev/null +++ b/docs/configuration/how_to_serve_another_domain_for_webfinger.md @@ -0,0 +1,62 @@ +# How to use a different domain name for Pleroma and the users it serves + +Pleroma users are primarily identified by a `user@example.org` handle, and you might want this identifier to be the same as your email or jabber account, for instance. +However, in this case, you are almost certainly serving some web content on `https://example.org` already, and you might want to use another domain (say `pleroma.example.org`) for Pleroma itself. + +Pleroma supports that, but it might be tricky to set up, and any error might prevent you from federating with other instances. + +*If you are already running Pleroma on `example.org`, it is no longer possible to move it to `pleroma.example.org`.* + +## Account identifiers + +It is important to understand that for federation purposes, a user in Pleroma has two unique identifiers associated: + +- A webfinger `acct:` URI, used for discovery and as a verifiable global name for the user across Pleroma instances. In our example, our account's acct: URI is `acct:user@example.org` +- An author/actor URI, used in every other aspect of federation. This is the way in which users are identified in ActivityPub, the underlying protocol used for federation with other Pleroma instances. +In our case, it is `https://pleroma.example.org/users/user`. + +Both account identifiers are unique and required for Pleroma. An important risk if you set up your Pleroma instance incorrectly is to create two users (with different acct: URIs) with conflicting author/actor URIs. + +## WebFinger + +As said earlier, each Pleroma user has an `acct`: URI, which is used for discovery and authentication. When you add @user@example.org, a webfinger query is performed. This is done in two steps: + +1. Querying `https://example.org/.well-known/host-meta` (where the domain of the URL matches the domain part of the `acct`: URI) to get information on how to perform the query. +This file will indeed contain a URL template of the form `https://example.org/.well-known/webfinger?resource={uri}` that will be used in the second step. +2. Fill the returned template with the `acct`: URI to be queried and perform the query: `https://example.org/.well-known/webfinger?resource=acct:user@example.org` + +## Configuring your Pleroma instance + +**_DO NOT ATTEMPT TO CONFIGURE YOUR INSTANCE THIS WAY IF YOU DID NOT UNDERSTAND THE ABOVE_** + +### Configuring Pleroma + +Pleroma has a two configuration settings to enable using different domains for your users and Pleroma itself. `host` in `Pleroma.Web.Endpoint` and `domain` in `Pleroma.Web.WebFinger`. When the latter is not set, it defaults to the value of `host`. + +*Be extra careful when configuring your Pleroma instance, as changing `host` may cause remote instances to register different accounts with the same author/actor URI, which will result in federation issues!* + +```elixir +config :pleroma, Pleroma.Web.Endpoint, + url: [host: "pleroma.example.org"] + +config :pleroma, Pleroma.Web.WebFinger, domain: "example.org" +``` + +- `domain` - is the domain for which your Pleroma instance has authority, it's the domain used in `acct:` URI. In our example, `domain` would be set to `example.org`. This is used in WebFinger account ids, which are the canonical account identifier in some other fediverse software like Mastodon. **If you change `domain`, the accounts on your server will be shown as different accounts in those software**. +- `host` - is the domain used for any URL generated for your instance, including the author/actor URL's. In our case, that would be `pleroma.example.org`. This is used in AP ids, which are the canonical account identifier in Pleroma and some other fediverse software. **You should not change this after you have set up the instance**. + +### Configuring WebFinger domain + +Now, you have Pleroma running at `https://pleroma.example.org` as well as a website at `https://example.org`. If you recall how webfinger queries work, the first step is to query `https://example.org/.well-known/host-meta`, which will contain an URL template. + +Therefore, the easiest way to configure `example.org` is to redirect `/.well-known/host-meta` to `pleroma.example.org`. + +With nginx, it would be as simple as adding: + +```nginx +location = /.well-known/host-meta { + return 301 https://pleroma.example.org$request_uri; +} +``` + +in example.org's server block. diff --git a/docs/configuration/howto_database_config.md b/docs/configuration/howto_database_config.md new file mode 100644 index 0000000..e5af909 --- /dev/null +++ b/docs/configuration/howto_database_config.md @@ -0,0 +1,155 @@ +# How to activate Pleroma in-database configuration +## Explanation + +The configuration of Pleroma has traditionally been managed with a config file, e.g. `config/prod.secret.exs`. This method requires a restart of the application for any configuration changes to take effect. We have made it possible to control most settings in the AdminFE interface after running a migration script. + +## Migration to database config + +1. Run the mix task to migrate to the database. + + **Source:** + + ``` + $ mix pleroma.config migrate_to_db + ``` + + or + + **OTP:** + + *Note: OTP users need Pleroma to be running for `pleroma_ctl` commands to work* + + ``` + $ ./bin/pleroma_ctl config migrate_to_db + ``` + + ``` + Migrating settings from file: /home/pleroma/config/dev.secret.exs + + Settings for key instance migrated. + Settings for group :pleroma migrated. + ``` + +2. It is recommended to backup your config file now. + + ``` + cp config/dev.secret.exs config/dev.secret.exs.orig + ``` + +3. Edit your Pleroma config to enable database configuration: + + ``` + config :pleroma, configurable_from_database: true + ``` + +4. ⚠️ **THIS IS NOT REQUIRED** ⚠️ + + Now you can edit your config file and strip it down to the only settings which are not possible to control in the database. e.g., the Postgres (Repo) and webserver (Endpoint) settings cannot be controlled in the database because the application needs the settings to start up and access the database. + + Any settings in the database will override those in the config file, but you may find it less confusing if the setting is only declared in one place. + + A non-exhaustive list of settings that are only possible in the config file include the following: + + * config :pleroma, Pleroma.Web.Endpoint + * config :pleroma, Pleroma.Repo + * config :pleroma, configurable\_from\_database + * config :pleroma, :database, rum_enabled + * config :pleroma, :connections_pool + + Here is an example of a server config stripped down after migration: + + ``` + import Config + + config :pleroma, Pleroma.Web.Endpoint, + url: [host: "cool.pleroma.site", scheme: "https", port: 443] + + config :pleroma, Pleroma.Repo, + adapter: Ecto.Adapters.Postgres, + username: "pleroma", + password: "MySecretPassword", + database: "pleroma_prod", + hostname: "localhost" + + config :pleroma, configurable_from_database: true + ``` + +5. Restart your instance and you can now access the Settings tab in AdminFE. + + +## Reverting back from database config + +1. Run the mix task to migrate back from the database. You'll receive some debugging output and a few messages informing you of what happened. + + **Source:** + + ``` + $ mix pleroma.config migrate_from_db + ``` + + or + + **OTP:** + + ``` + $ ./bin/pleroma_ctl config migrate_from_db + ``` + + ``` + 10:26:30.593 [debug] QUERY OK source="config" db=9.8ms decode=1.2ms queue=26.0ms idle=0.0ms + SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] + + 10:26:30.659 [debug] QUERY OK source="config" db=1.1ms idle=80.7ms + SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] + Database configuration settings have been saved to config/dev.exported_from_db.secret.exs + ``` + +2. Remove `config :pleroma, configurable_from_database: true` from your config. The in-database configuration still exists, but it will not be used. Future migrations will erase the database config before importing your config file again. + +3. Restart your instance. + +## Debugging + +### Clearing database config +You can clear the database config with the following command: + + **Source:** + + ``` + $ mix pleroma.config reset + ``` + + or + + **OTP:** + + ``` + $ ./bin/pleroma_ctl config reset + ``` + +Additionally, every time you migrate the configuration to the database the config table is automatically truncated to ensure a clean migration. + +### Manually removing a setting +If you encounter a situation where the server cannot run properly because of an invalid setting in the database and this is preventing you from accessing AdminFE, you can manually remove the offending setting if you know which one it is. + +e.g., here is an example showing a the removal of the `config :pleroma, :instance` settings: + + **Source:** + + ``` + $ mix pleroma.config delete pleroma instance + Are you sure you want to continue? [n] y + config :pleroma, :instance deleted from the ConfigDB. + ``` + + or + + **OTP:** + + ``` + $ ./bin/pleroma_ctl config delete pleroma instance + Are you sure you want to continue? [n] y + config :pleroma, :instance deleted from the ConfigDB. + ``` + +Now the `config :pleroma, :instance` settings have been removed from the database. diff --git a/docs/configuration/howto_ejabberd.md b/docs/configuration/howto_ejabberd.md new file mode 100644 index 0000000..520a0ac --- /dev/null +++ b/docs/configuration/howto_ejabberd.md @@ -0,0 +1,136 @@ +# Configuring Ejabberd (XMPP Server) to use Pleroma for authentication + +If you want to give your Pleroma users an XMPP (chat) account, you can configure [Ejabberd](https://github.com/processone/ejabberd) to use your Pleroma server for user authentication, automatically giving every local user an XMPP account. + +In general, you just have to follow the configuration described at [https://docs.ejabberd.im/admin/configuration/authentication/#external-script](https://docs.ejabberd.im/admin/configuration/authentication/#external-script). Please read this section carefully. + +Copy the script below to suitable path on your system and set owner and permissions. Also do not forget adjusting `PLEROMA_HOST` and `PLEROMA_PORT`, if necessary. + +```bash +cp pleroma_ejabberd_auth.py /etc/ejabberd/pleroma_ejabberd_auth.py +chown ejabberd /etc/ejabberd/pleroma_ejabberd_auth.py +chmod 700 /etc/ejabberd/pleroma_ejabberd_auth.py +``` + +Set external auth params in ejabberd.yaml file: + +```bash +auth_method: [external] +extauth_program: "python3 /etc/ejabberd/pleroma_ejabberd_auth.py" +extauth_instances: 3 +auth_use_cache: false +``` + +Restart / reload your ejabberd service. + +After restarting your Ejabberd server, your users should now be able to connect with their Pleroma credentials. + + +```python +import sys +import struct +import http.client +from base64 import b64encode +import logging + + +PLEROMA_HOST = "127.0.0.1" +PLEROMA_PORT = "4000" +AUTH_ENDPOINT = "/api/v1/accounts/verify_credentials" +USER_ENDPOINT = "/api/v1/accounts" +LOGFILE = "/var/log/ejabberd/pleroma_auth.log" + +logging.basicConfig(filename=LOGFILE, level=logging.INFO) + + +# Pleroma functions +def create_connection(): + return http.client.HTTPConnection(PLEROMA_HOST, PLEROMA_PORT) + + +def verify_credentials(user: str, password: str) -> bool: + user_pass_b64 = b64encode("{}:{}".format( + user, password).encode('utf-8')).decode("ascii") + params = {} + headers = { + "Authorization": "Basic {}".format(user_pass_b64) + } + + try: + conn = create_connection() + conn.request("GET", AUTH_ENDPOINT, params, headers) + + response = conn.getresponse() + if response.status == 200: + return True + + return False + except Exception as e: + logging.info("Can not connect: %s", str(e)) + return False + + +def does_user_exist(user: str) -> bool: + conn = create_connection() + conn.request("GET", "{}/{}".format(USER_ENDPOINT, user)) + + response = conn.getresponse() + if response.status == 200: + return True + + return False + + +def auth(username: str, server: str, password: str) -> bool: + return verify_credentials(username, password) + + +def isuser(username, server): + return does_user_exist(username) + + +def read(): + (pkt_size,) = struct.unpack('>H', bytes(sys.stdin.read(2), encoding='utf8')) + pkt = sys.stdin.read(pkt_size) + cmd = pkt.split(':')[0] + if cmd == 'auth': + username, server, password = pkt.split(':', 3)[1:] + write(auth(username, server, password)) + elif cmd == 'isuser': + username, server = pkt.split(':', 2)[1:] + write(isuser(username, server)) + elif cmd == 'setpass': + # u, s, p = pkt.split(':', 3)[1:] + write(False) + elif cmd == 'tryregister': + # u, s, p = pkt.split(':', 3)[1:] + write(False) + elif cmd == 'removeuser': + # u, s = pkt.split(':', 2)[1:] + write(False) + elif cmd == 'removeuser3': + # u, s, p = pkt.split(':', 3)[1:] + write(False) + else: + write(False) + + +def write(result): + if result: + sys.stdout.write('\x00\x02\x00\x01') + else: + sys.stdout.write('\x00\x02\x00\x00') + sys.stdout.flush() + + +if __name__ == "__main__": + logging.info("Starting pleroma ejabberd auth daemon...") + while True: + try: + read() + except Exception as e: + logging.info( + "Error while processing data from ejabberd %s", str(e)) + pass + +``` \ No newline at end of file diff --git a/docs/configuration/howto_mediaproxy.md b/docs/configuration/howto_mediaproxy.md new file mode 100644 index 0000000..16c40c5 --- /dev/null +++ b/docs/configuration/howto_mediaproxy.md @@ -0,0 +1,34 @@ +# How to activate mediaproxy +## Explanation + +Without the `mediaproxy` function, Pleroma doesn't store any remote content like pictures, video etc. locally. So every time you open Pleroma, the content is loaded from the source server, from where the post is coming. This can result in slowly loading content or/and increased bandwidth usage on the source server. +With the `mediaproxy` function you can use nginx to cache this content, so users can access it faster, because it's loaded from your server. + +## Activate it + +* Edit your nginx config and add the following location: +``` +location /proxy { + proxy_cache pleroma_media_cache; + proxy_cache_lock on; + proxy_pass http://localhost:4000; +} +``` +Also add the following on top of the configuration, outside of the `server` block: +``` +proxy_cache_path /tmp/pleroma-media-cache levels=1:2 keys_zone=pleroma_media_cache:10m max_size=10g inactive=720m use_temp_path=off; +``` +If you came here from one of the installation guides, take a look at the example configuration `/installation/pleroma.nginx`, where this part is already included. + +* Append the following to your `prod.secret.exs` or `dev.secret.exs` (depends on which mode your instance is running): +``` +config :pleroma, :media_proxy, + enabled: true, + proxy_opts: [ + redirect_on_failure: true + ] + #base_url: "https://cache.pleroma.social" +``` +If you want to use a subdomain to serve the files, uncomment `base_url`, change the url and add a comma after `true` in the previous line. + +* Restart nginx and Pleroma diff --git a/docs/configuration/howto_mongooseim.md b/docs/configuration/howto_mongooseim.md new file mode 100644 index 0000000..a33e590 --- /dev/null +++ b/docs/configuration/howto_mongooseim.md @@ -0,0 +1,10 @@ +# Configuring MongooseIM (XMPP Server) to use Pleroma for authentication + +If you want to give your Pleroma users an XMPP (chat) account, you can configure [MongooseIM](https://github.com/esl/MongooseIM) to use your Pleroma server for user authentication, automatically giving every local user an XMPP account. + +In general, you just have to follow the configuration described at [https://mongooseim.readthedocs.io/en/latest/authentication-backends/HTTP-authentication-module/](https://mongooseim.readthedocs.io/en/latest/authentication-backends/HTTP-authentication-module/) and do these changes to your mongooseim.cfg. + +1. Set the auth_method to `{auth_method, http}`. +2. Add the http auth pool like this: `{http, global, auth, [{workers, 50}], [{server, "https://yourpleromainstance.com"}]}` + +Restart your MongooseIM server, your users should now be able to connect with their Pleroma credentials. diff --git a/docs/configuration/howto_proxy.md b/docs/configuration/howto_proxy.md new file mode 100644 index 0000000..10a6352 --- /dev/null +++ b/docs/configuration/howto_proxy.md @@ -0,0 +1,12 @@ +# How to configure upstream proxy for federation +If you want to proxify all http requests (e.g. for TOR) that pleroma makes to an upstream proxy server, edit you config file (`dev.secret.exs` or `prod.secret.exs`) and add the following: + +``` +config :pleroma, :http, + proxy_url: "127.0.0.1:8123" +``` + +The other way to do it, for example, with Tor you would most likely add something like this: +``` +config :pleroma, :http, proxy_url: {:socks5, :localhost, 9050} +``` diff --git a/docs/configuration/howto_search_cjk.md b/docs/configuration/howto_search_cjk.md new file mode 100644 index 0000000..a73b10d --- /dev/null +++ b/docs/configuration/howto_search_cjk.md @@ -0,0 +1,42 @@ +# How to enable text search for Chinese, Japanese and Korean + +Pleroma's full text search feature is powered by PostgreSQL's native [text search](https://www.postgresql.org/docs/current/textsearch.html), it works well out of box for most of languages, but needs extra configurations for some asian languages like Chinese, Japanese and Korean (CJK). + + +## Setup and test the new search config + +In most cases, you would need an extension installed to support parsing CJK text. Here are a few extensions you may choose from, or you are more than welcome to share additional ones you found working for you with the rest of Pleroma community. + + * [a generic n-gram parser](https://github.com/huangjimmy/pg_cjk_parser) supports Simplifed/Traditional Chinese, Japanese, and Korean + * [a Korean parser](https://github.com/i0seph/textsearch_ko) based on mecab + * [a Japanese parser](https://www.amris.co.jp/tsja/index.html) based on mecab + * [zhparser](https://github.com/amutu/zhparser/) is a PostgreSQL extension base on the Simple Chinese Word Segmentation(SCWS) + * [another Chinese parser](https://github.com/jaiminpan/pg_jieba) based on Jieba Chinese Word Segmentation + +Once you have the new search config , make sure you test it with the `pleroma` user in PostgreSQL (change `YOUR.CONFIG` to your real configuration name) +``` +SELECT ts_debug('YOUR.CONFIG', '安装和配置Nginx, ElixirとErlangをインストールします'); +``` +Check output of the query, and see if it matches your expectation. + + +## Update text search config and index in database + +=== "OTP" + + ```sh + ./bin/pleroma_ctl database set_text_search_config YOUR.CONFIG + ``` + +=== "From Source" + + ```sh + mix pleroma.database set_text_search_config YOUR.CONFIG + ``` + +Note: index update may take a while, and it can be done while the instance is up and running, so you may restart db connection as soon as you see `Recreate index` in task output. + +## Restart database connection +Since some changes above will only apply with a new database connection, you will have to restart either Pleroma or PostgreSQL process, or use `pg_terminate_backend` SQL command without restarting either. + +Now the search results of statuses should be much more friendly for your language of choice, the results for searching users and tags were not changed, as the default parsing/matching should work for most cases. diff --git a/docs/configuration/howto_set_richmedia_cache_ttl_based_on_image.md b/docs/configuration/howto_set_richmedia_cache_ttl_based_on_image.md new file mode 100644 index 0000000..bfee5a9 --- /dev/null +++ b/docs/configuration/howto_set_richmedia_cache_ttl_based_on_image.md @@ -0,0 +1,33 @@ +# How to set rich media cache ttl based on image ttl +## Explanation + +Richmedia are cached without the ttl but the rich media may have image which can expire, like aws signed url. +In such cases the old image url (expired) is returned from the media cache. + +So to avoid such situation we can define a module that will set ttl based on image. +The module must adopt behaviour `Pleroma.Web.RichMedia.Parser.TTL` + +### Example + +```exs +defmodule MyModule do + @behaviour Pleroma.Web.RichMedia.Parser.TTL + + @impl Pleroma.Web.RichMedia.Parser.TTL + def ttl(data, url) do + image_url = Map.get(data, :image) + # do some parsing in the url and get the ttl of the image + # return ttl is unix time + parse_ttl_from_url(image_url) + end +end +``` + +And update the config + +```exs +config :pleroma, :rich_media, + ttl_setters: [Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl, MyModule] +``` + +> For reference there is a parser for AWS signed URL `Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl`, it's enabled by default. diff --git a/docs/configuration/howto_theming_your_instance.md b/docs/configuration/howto_theming_your_instance.md new file mode 100644 index 0000000..cfa00f5 --- /dev/null +++ b/docs/configuration/howto_theming_your_instance.md @@ -0,0 +1,74 @@ +# Theming your instance + +To add a custom theme to your instance, you'll first need to get a custom theme, upload it to the server, make it available to the instance and eventually you can set it as default. + +## Getting a custom theme + +### Create your own theme + +* You can create your own theme using the Pleroma FE by going to settings (gear on the top right) and choose the Theme tab. Here you have the options to create a personal theme. +* To download your theme, you can do Save preset +* If you want to upload a theme to customise it further, you can upload it using Load preset + +This will only save the theme for you personally. To make it available to the whole instance, you'll need to upload it to the server. + +### Get an existing theme + +* You can download a theme from another instance by going to that instance, go to settings and make sure you have the theme selected that you want. Then you can do Save preset to download it. +* You can also find and download custom themes at + +## Adding the custom theme to the instance + +### Upload the theme to the server + +Themes can be found in the [static directory](static_dir.md). Create `STATIC-DIR/static/themes/` if needed and copy your theme there. Next you need to add an entry for your theme to `STATIC-DIR/static/styles.json`. If you use a from source installation, you'll first need to copy the file from `priv/static/static/styles.json`. + +Example of `styles.json` where we add our own `my-awesome-theme.json` +```json +{ + "pleroma-dark": [ "Pleroma Dark", "#121a24", "#182230", "#b9b9ba", "#d8a070", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ], + "pleroma-light": [ "Pleroma Light", "#f2f4f6", "#dbe0e8", "#304055", "#f86f0f", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ], + "classic-dark": [ "Classic Dark", "#161c20", "#282e32", "#b9b9b9", "#baaa9c", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ], + "bird": [ "Bird", "#f8fafd", "#e6ecf0", "#14171a", "#0084b8", "#e0245e", "#17bf63", "#1b95e0", "#fab81e"], + "ir-black": [ "Ir Black", "#000000", "#242422", "#b5b3aa", "#ff6c60", "#FF6C60", "#A8FF60", "#96CBFE", "#FFFFB6" ], + "monokai": [ "Monokai", "#272822", "#383830", "#f8f8f2", "#f92672", "#F92672", "#a6e22e", "#66d9ef", "#f4bf75" ], + + "redmond-xx": "/static/themes/redmond-xx.json", + "redmond-xx-se": "/static/themes/redmond-xx-se.json", + "redmond-xxi": "/static/themes/redmond-xxi.json", + "breezy-dark": "/static/themes/breezy-dark.json", + "breezy-light": "/static/themes/breezy-light.json", + "mammal": "/static/themes/mammal.json", + "my-awesome-theme": "/static/themes/my-awesome-theme.json" +} +``` + +Now you'll already be able to select the theme in Pleroma FE from the drop-down. You don't need to restart Pleroma because we only changed static served files. You may need to refresh the page in your browser. You'll notice however that the theme doesn't have a name, it's just an empty entry in the drop-down. + +### Give the theme a name + +When you open one of the themes that ship with Pleroma, you'll notice that the json has a `"name"` key. Add a key-value pair to your theme where the key name is `"name"` and the value the name you want to give your theme. After this you can refresh te page in your browser and the name should be visible in the drop-down. + +Example of `my-awesome-theme.json` where we add the name "My Awesome Theme" +```json +{ + "_pleroma_theme_version": 2, + "name": "My Awesome Theme", + "theme": {} +} +``` + +### Set as default theme + +Now we can set the new theme as default in the [Pleroma FE configuration](../../../frontend/CONFIGURATION). + +Example of adding the new theme in the back-end config files +```elixir +config :pleroma, :frontend_configurations, + pleroma_fe: %{ + theme: "my-awesome-theme" + } +``` + +If you added it in the back-end configuration file, you'll need to restart your instance for the changes to take effect. If you don't see the changes, it's probably because the browser has cached the previous theme. In that case you'll want to clear browser caches. Alternatively you can use a private/incognito window just to see the changes. + diff --git a/docs/configuration/i2p.md b/docs/configuration/i2p.md new file mode 100644 index 0000000..8c5207d --- /dev/null +++ b/docs/configuration/i2p.md @@ -0,0 +1,196 @@ +# I2P Federation and Accessability + +This guide is going to focus on the Pleroma federation aspect. The actual installation is neatly explained in the official documentation, and more likely to remain up-to-date. +It might be added to this guide if there will be a need for that. + +We're going to use I2PD for its lightweightness over the official client. +Follow the documentation according to your distro: https://i2pd.readthedocs.io/en/latest/user-guide/install/#installing + +How to run it: https://i2pd.readthedocs.io/en/latest/user-guide/run/ + +## I2P Federation + +There are 2 ways to go about this. +One using the config, and one using external software (fedproxy). The external software works better so far. + +### Using the Config + +**Warning:** So far, everytime I followed this way of federating using I2P, the rest of my federation stopped working. I'm leaving this here in case it will help with making it work. + +Assuming you're running in prod, cd to your Pleroma folder and append the following to `config/prod.secret.exs`: +``` +config :pleroma, :http, proxy_url: {:socks5, :localhost, 4447} +``` +And then run the following: +``` +su pleroma +MIX_ENV=prod mix deps.get +MIX_ENV=prod mix ecto.migrate +exit +``` +You can restart I2PD here and finish if you don't wish to make your instance viewable or accessible over I2P. +``` +systemctl stop i2pd.service --no-block +systemctl start i2pd.service +``` +*Notice:* The stop command initiates a graceful shutdown process, i2pd stops after finishing to route transit tunnels (maximum 10 minutes). + +You can change the socks proxy port in `/etc/i2pd/i2pd.conf`. + +### Using Fedproxy + +Fedproxy passes through clearnet requests direct to where they are going. It doesn't force anything over Tor. + +To use [fedproxy](https://github.com/majestrate/fedproxy) you'll need to install Golang. +``` +apt install golang +``` +Use a different user than pleroma or root. Run the following to add the Gopath to your ~/.bashrc. +``` +echo "export GOPATH=/home/ren/.go" >> ~/.bashrc +``` +Restart that bash session (you can exit and log back in). +Run the following to get fedproxy. +``` +go get -u github.com/majestrate/fedproxy$ +cp $(GOPATH)/bin/fedproxy /usr/local/bin/fedproxy +``` +And then the following to start it for I2P only. +``` +fedproxy 127.0.0.1:2000 127.0.0.1:4447 +``` +If you want to also use it for Tor, add `127.0.0.1:9050` to that command. +You'll also need to modify your Pleroma config. + +Assuming you're running in prod, cd to your Pleroma folder and append the following to `config/prod.secret.exs`: +``` +config :pleroma, :http, proxy_url: {:socks5, :localhost, 2000} +``` +And then run the following: +``` +su pleroma +MIX_ENV=prod mix deps.get +MIX_ENV=prod mix ecto.migrate +exit +``` +You can restart I2PD here and finish if you don't wish to make your instance viewable or accessible over I2P. + +``` +systemctl stop i2pd.service --no-block +systemctl start i2pd.service +``` +*Notice:* The stop command initiates a graceful shutdown process, i2pd stops after finishing to route transit tunnels (maximum 10 minutes). + +You can change the socks proxy port in `/etc/i2pd/i2pd.conf`. + +## I2P Instance Access + +Make your instance accessible using I2P. + +Add the following to your I2PD config `/etc/i2pd/tunnels.conf`: +``` +[pleroma] +type = http +host = 127.0.0.1 +port = 14447 +keys = pleroma.dat +``` +Restart I2PD: +``` +systemctl stop i2pd.service --no-block +systemctl start i2pd.service +``` +*Notice:* The stop command initiates a graceful shutdown process, i2pd stops after finishing to route transit tunnels (maximum 10 minutes). + +Now you'll have to find your address. +To do that you can download and use I2PD tools.[^1] +Or you'll need to access your web-console on localhost:7070. +If you don't have a GUI, you'll have to SSH tunnel into it like this: +`ssh -L 7070:127.0.0.1:7070 user@ip -p port`. +Now you can access it at localhost:7070. +Go to I2P tunnels page. Look for Server tunnels and you will see an address that ends with `.b32.i2p` next to "pleroma". +This is your site's address. + +### I2P-only Instance + +If creating an I2P-only instance, open `config/prod.secret.exs` and under "config :pleroma, Pleroma.Web.Endpoint," edit "https" and "port: 443" to the following: +``` + url: [host: "i2paddress", scheme: "http", port: 80], +``` +In addition to that, replace the existing nginx config's contents with the example below. + +### Existing Instance (Clearnet Instance) + +If not an I2P-only instance, add the nginx config below to your existing config at `/etc/nginx/sites-enabled/pleroma.nginx`. + +And for both cases, disable CSP in Pleroma's config (STS is disabled by default) so you can define those yourself separately from the clearnet (if your instance is also on the clearnet). +Copy the following into the `config/prod.secret.exs` in your Pleroma folder (/home/pleroma/pleroma/): +``` +config :pleroma, :http_security, + enabled: false +``` + +Use this as the Nginx config: +``` +proxy_cache_path /tmp/pleroma-media-cache levels=1:2 keys_zone=pleroma_media_cache:10m max_size=10g inactive=720m use_temp_path=off; +# The above already exists in a clearnet instance's config. +# If not, add it. + +server { + listen 127.0.0.1:14447; + server_name youri2paddress; + + # Comment to enable logs + access_log /dev/null; + error_log /dev/null; + + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/activity+json application/atom+xml; + + client_max_body_size 16m; + + location / { + + add_header X-XSS-Protection "1; mode=block"; + add_header X-Permitted-Cross-Domain-Policies none; + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header Referrer-Policy same-origin; + add_header X-Download-Options noopen; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + + proxy_pass http://localhost:4000; + + client_max_body_size 16m; + } + + location /proxy { + proxy_cache pleroma_media_cache; + proxy_cache_lock on; + proxy_ignore_client_abort on; + proxy_pass http://localhost:4000; + } +} +``` +reload Nginx: +``` +systemctl stop i2pd.service --no-block +systemctl start i2pd.service +``` +*Notice:* The stop command initiates a graceful shutdown process, i2pd stops after finishing to route transit tunnels (maximum 10 minutes). + +You should now be able to both access your instance using I2P and federate with other I2P instances! + +[^1]: [I2PD tools](https://github.com/purplei2p/i2pd-tools) to print information about a router info file or an I2P private key, generate an I2P private key, and generate vanity addresses. + +### Possible Issues + +Will be added when encountered. diff --git a/docs/configuration/mrf.md b/docs/configuration/mrf.md new file mode 100644 index 0000000..a31c26b --- /dev/null +++ b/docs/configuration/mrf.md @@ -0,0 +1,158 @@ +# Message Rewrite Facility + +The Message Rewrite Facility (MRF) is a subsystem that is implemented as a series of hooks that allows the administrator to rewrite or discard messages. + +Possible uses include: + +* marking incoming messages with media from a given account or instance as sensitive +* rejecting messages from a specific instance +* rejecting reports (flags) from a specific instance +* removing/unlisting messages from the public timelines +* removing media from messages +* sending only public messages to a specific instance + +The MRF provides user-configurable policies. The default policy is `NoOpPolicy`, which disables the MRF functionality. Pleroma also includes an easy to use policy called `SimplePolicy` which maps messages matching certain pre-defined criterion to actions built into the policy module. + +It is possible to use multiple, active MRF policies at the same time. + +## Quarantine Instances + +You have the ability to prevent from private / followers-only messages from federating with specific instances. Which means they will only get the public or unlisted messages from your instance. + +If, for example, you're using `MIX_ENV=prod` aka using production mode, you would open your configuration file located in `config/prod.secret.exs` and edit or add the option under your `:instance` config object. Then you would specify the instance within quotes. + +```elixir +config :pleroma, :instance, + [...] + quarantined_instances: ["instance.example", "other.example"] +``` + +## Using `SimplePolicy` + +`SimplePolicy` is capable of handling most common admin tasks. + +To use `SimplePolicy`, you must enable it. Do so by adding the following to your `:instance` config object, so that it looks like this: + +```elixir +config :pleroma, :mrf, + [...] + policies: Pleroma.Web.ActivityPub.MRF.SimplePolicy +``` + +Once `SimplePolicy` is enabled, you can configure various groups in the `:mrf_simple` config object. These groups are: + +* `reject`: Servers in this group will have their messages rejected. +* `accept`: If not empty, only messages from these instances will be accepted (whitelist federation). +* `media_nsfw`: Servers in this group will have the #nsfw tag and sensitive setting injected into incoming messages which contain media. +* `media_removal`: Servers in this group will have media stripped from incoming messages. +* `avatar_removal`: Avatars from these servers will be stripped from incoming messages. +* `banner_removal`: Banner images from these servers will be stripped from incoming messages. +* `report_removal`: Servers in this group will have their reports (flags) rejected. +* `federated_timeline_removal`: Servers in this group will have their messages unlisted from the public timelines by flipping the `to` and `cc` fields. +* `reject_deletes`: Deletion requests will be rejected from these servers. + +Servers should be configured as lists. + +### Example + +This example will enable `SimplePolicy`, block media from `illegalporn.biz`, mark media as NSFW from `porn.biz` and `porn.business`, reject messages from `spam.com`, remove messages from `spam.university` from the federated timeline and block reports (flags) from `whiny.whiner`. We also give a reason why the moderation was done: + +```elixir +config :pleroma, :mrf, + policies: [Pleroma.Web.ActivityPub.MRF.SimplePolicy] + +config :pleroma, :mrf_simple, + media_removal: [{"illegalporn.biz", "Media can contain illegal contant"}], + media_nsfw: [{"porn.biz", "unmarked nsfw media"}, {"porn.business", "A lot of unmarked nsfw media"}], + reject: [{"spam.com", "They keep spamming our users"}], + federated_timeline_removal: [{"spam.university", "Annoying low-quality posts who otherwise fill up TWKN"}], + report_removal: [{"whiny.whiner", "Keep spamming us with irrelevant reports"}] +``` + +### Use with Care + +The effects of MRF policies can be very drastic. It is important to use this functionality carefully. Always try to talk to an admin before writing an MRF policy concerning their instance. + +## Writing your own MRF Policy + +As discussed above, the MRF system is a modular system that supports pluggable policies. This means that an admin may write a custom MRF policy in Elixir or any other language that runs on the Erlang VM, by specifying the module name in the `policies` config setting. + +For example, here is a sample policy module which rewrites all messages to "new message content": + +```elixir +defmodule Pleroma.Web.ActivityPub.MRF.RewritePolicy do + @moduledoc "MRF policy which rewrites all Notes to have 'new message content'." + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + # Catch messages which contain Note objects with actual data to filter. + # Capture the object as `object`, the message content as `content` and the + # message itself as `message`. + @impl true + def filter( + %{"type" => "Create", "object" => %{"type" => "Note", "content" => content} = object} = + message + ) + when is_binary(content) do + # Subject / CW is stored as summary instead of `name` like other AS2 objects + # because of Mastodon doing it that way. + summary = object["summary"] + + # Message edits go here. + content = "new message content" + + # Assemble the mutated object. + object = + object + |> Map.put("content", content) + |> Map.put("summary", summary) + + # Assemble the mutated message. + message = Map.put(message, "object", object) + {:ok, message} + end + + # Let all other messages through without modifying them. + @impl true + def filter(message), do: {:ok, message} + + @impl true + def describe do + {:ok, %{mrf_sample: %{content: "new message content"}}} + end +end +``` + +If you save this file as `lib/pleroma/web/activity_pub/mrf/rewrite_policy.ex`, it will be included when you next rebuild Pleroma. You can enable it in the configuration like so: + +```elixir +config :pleroma, :mrf, + policies: [ + Pleroma.Web.ActivityPub.MRF.SimplePolicy, + Pleroma.Web.ActivityPub.MRF.RewritePolicy + ] +``` + +Please note that the Pleroma developers consider custom MRF policy modules to fall under the purview of the AGPL. As such, you are obligated to release the sources to your custom MRF policy modules upon request. + +### MRF policies descriptions + +If MRF policy depends on config, it can be added into MRF tab to adminFE by adding `config_description/0` method, which returns a map with a specific structure. See existing MRF's like `lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex` for examples. Note that more complex inputs, like tuples or maps, may need extra changes in the adminFE and just adding it to `config_description/0` may not be enough to get these inputs working from the adminFE. + +Example: + +```elixir +%{ + key: :mrf_activity_expiration, + related_policy: "Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy", + label: "MRF Activity Expiration Policy", + description: "Adds automatic expiration to all local activities", + children: [ + %{ + key: :days, + type: :integer, + description: "Default global expiration time for all local activities (in days)", + suggestions: [90, 365] + } + ] + } +``` diff --git a/docs/configuration/onion_federation.md b/docs/configuration/onion_federation.md new file mode 100644 index 0000000..3767321 --- /dev/null +++ b/docs/configuration/onion_federation.md @@ -0,0 +1,159 @@ +# Easy Onion Federation (Tor) +Tor can free people from the necessity of a domain, in addition to helping protect their privacy. As Pleroma's goal is to empower the people and let as many as possible host an instance with as little resources as possible, the ability to host an instance with a small, cheap computer like a RaspberryPi along with Tor, would be a great way to achieve that. +In addition, federating with such instances will also help furthering that goal. + +This is a guide to show you how it can be easily done. + +This guide assumes you already got Pleroma working, and that it's running on the default port 4000. +Currently only has an Nginx example. + +To install Tor on Debian / Ubuntu: +``` +apt -yq install tor +``` +If using an old server version (older than Debian Stretch or Ubuntu 18.04), install from backports or PPA. +I recommend using a newer server version instead. + +To have the newest, V3 onion addresses (which I recommend) in Debian, install Tor from backports. +If you do not have backports, uncomment the stretch-backports links at the end of `/etc/apt/sources.list`. +Then install: +``` +apt update +apt -t stretch-backports -yq install tor +``` +**WARNING:** Onion instances not using a Tor version supporting V3 addresses will not be able to federate with you. + +Create the hidden service for your Pleroma instance in `/etc/tor/torrc`: +``` +HiddenServiceDir /var/lib/tor/pleroma_hidden_service/ +HiddenServicePort 80 127.0.0.1:8099 +HiddenServiceVersion 3 # Remove if Tor version is below 0.3 ( tor --version ) +``` +Restart Tor to generate an adress: +``` +systemctl restart tor@default.service +``` +Get the address: +``` +cat /var/lib/tor/pleroma_hidden_service/hostname +``` + +# Federation + +Next, edit your Pleroma config. +If running in prod, cd to your Pleroma directory, edit `config/prod.secret.exs` +and append this line: +``` +config :pleroma, :http, proxy_url: {:socks5, :localhost, 9050} +``` +In your Pleroma directory, assuming you're running prod, +run the following: +``` +su pleroma +MIX_ENV=prod mix deps.get +MIX_ENV=prod mix ecto.migrate +exit +``` +restart Pleroma (if using systemd): +``` +systemctl restart pleroma +``` + +# Tor Instance Access + +Make your instance accessible using Tor. + +## Tor-only Instance +If creating a Tor-only instance, open `config/prod.secret.exs` and under "config :pleroma, Pleroma.Web.Endpoint," edit "https" and "port: 443" to the following: +``` + url: [host: "onionaddress", scheme: "http", port: 80], +``` +In addition to that, replace the existing nginx config's contents with the example below. + +## Existing Instance (Clearnet Instance) +If not a Tor-only instance, +add the nginx config below to your existing config at `/etc/nginx/sites-enabled/pleroma.nginx`. + +--- +For both cases, disable CSP in Pleroma's config (STS is disabled by default) so you can define those yourself separately from the clearnet (if your instance is also on the clearnet). +Copy the following into the `config/prod.secret.exs` in your Pleroma folder (/home/pleroma/pleroma/): +``` +config :pleroma, :http_security, + enabled: false +``` + +Use this as the Nginx config: +``` +proxy_cache_path /tmp/pleroma-media-cache levels=1:2 keys_zone=pleroma_media_cache:10m max_size=10g inactive=720m use_temp_path=off; +# The above already exists in a clearnet instance's config. +# If not, add it. + +server { + listen 127.0.0.1:8099; + server_name youronionaddress; + + # Comment to enable logs + access_log /dev/null; + error_log /dev/null; + + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/activity+json application/atom+xml; + + client_max_body_size 16m; + + location / { + + add_header X-XSS-Protection "1; mode=block"; + add_header X-Permitted-Cross-Domain-Policies none; + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header Referrer-Policy same-origin; + add_header X-Download-Options noopen; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + + proxy_pass http://localhost:4000; + + client_max_body_size 16m; + } + + location /proxy { + proxy_cache pleroma_media_cache; + proxy_cache_lock on; + proxy_ignore_client_abort on; + proxy_pass http://localhost:4000; + } +} +``` +reload Nginx: +``` +systemctl reload nginx +``` + +You should now be able to both access your instance using Tor and federate with other Tor instances! + +--- + +### Possible Issues + +* In Debian, make sure your hidden service folder `/var/lib/tor/pleroma_hidden_service/` and its contents, has debian-tor as both owner and group by using +``` +ls -la /var/lib/tor/ +``` +If it's not, run: +``` +chown -R debian-tor:debian-tor /var/lib/tor/pleroma_hidden_service/ +``` +* Make sure *only* the owner has *only* read and write permissions. +If not, run: +``` +chmod -R 600 /var/lib/tor/pleroma_hidden_service/ +``` +* If you have trouble logging in to the Mastodon Frontend when using Tor, use the Tor Browser Bundle. diff --git a/docs/configuration/optimizing_beam.md b/docs/configuration/optimizing_beam.md new file mode 100644 index 0000000..e336bd3 --- /dev/null +++ b/docs/configuration/optimizing_beam.md @@ -0,0 +1,66 @@ +# Optimizing the BEAM + +Pleroma is built upon the Erlang/OTP VM known as BEAM. The BEAM VM is highly optimized for latency, but this has drawbacks in environments without dedicated hardware. One of the tricks used by the BEAM VM is [busy waiting](https://en.wikipedia.org/wiki/Busy_waiting). This allows the application to pretend to be busy working so the OS kernel does not pause the application process and switch to another process waiting for the CPU to execute its workload. It does this by spinning for a period of time which inflates the apparent CPU usage of the application so it is immediately ready to execute another task. This can be observed with utilities like **top(1)** which will show consistently high CPU usage for the process. Switching between procesess is a rather expensive operation and also clears CPU caches further affecting latency and performance. The goal of busy waiting is to avoid this penalty. + +This strategy is very successful in making a performant and responsive application, but is not desirable on Virtual Machines or hardware with few CPU cores. Pleroma instances are often deployed on the same server as the required PostgreSQL database which can lead to situations where the Pleroma application is holding the CPU in a busy-wait loop and as a result the database cannot process requests in a timely manner. The fewer CPUs available, the more this problem is exacerbated. The latency is further amplified by the OS being installed on a Virtual Machine as the Hypervisor uses CPU time-slicing to pause the entire OS and switch between other tasks. + +More adventurous admins can be creative with CPU affinity (e.g., *taskset* for Linux and *cpuset* on FreeBSD) to pin processes to specific CPUs and eliminate much of this contention. The most important advice is to run as few processes as possible on your server to achieve the best performance. Even idle background processes can occasionally create [software interrupts](https://en.wikipedia.org/wiki/Interrupt) and take attention away from the executing process creating latency spikes and invalidation of the CPU caches as they must be cleared when switching between processes for security. + +Please only change these settings if you are experiencing issues or really know what you are doing. In general, there's no need to change these settings. + +## VPS Provider Recommendations + +### Good + +* Hetzner Cloud + +### Bad + +* AWS (known to use burst scheduling) + + +## Example configurations + +Tuning the BEAM requires you provide a config file normally called [vm.args](http://erlang.org/doc/man/erl.html#emulator-flags). If you are using systemd to manage the service you can modify the unit file as such: + +`ExecStart=/usr/bin/elixir --erl '-args_file /opt/pleroma/config/vm.args' -S /usr/bin/mix phx.server` + +Check your OS documentation to adopt a similar strategy on other platforms. + +### Virtual Machine and/or few CPU cores + +Disable the busy-waiting. This should generally only be done if you're on a platform that does burst scheduling, like AWS. + +**vm.args:** + +``` ++sbwt none ++sbwtdcpu none ++sbwtdio none +``` + +### Dedicated Hardware + +Enable more busy waiting, increase the internal maximum limit of BEAM processes and ports. You can use this if you run on dedicated hardware, but it is not necessary. + +**vm.args:** + +``` ++P 16777216 ++Q 16777216 ++K true ++A 128 ++sbt db ++sbwt very_long ++swt very_low ++sub true ++Mulmbcs 32767 ++Mumbcgs 1 ++Musmbcs 2047 +``` + +## Additional Reading + +* [WhatsApp: Scaling to Millions of Simultaneous Connections](https://www.erlang-factory.com/upload/presentations/558/efsf2012-whatsapp-scaling.pdf) +* [Preemptive Scheduling and Spinlocks](https://www.uio.no/studier/emner/matnat/ifi/nedlagte-emner/INF3150/h03/annet/slides/preemptive.pdf) +* [The Curious Case of BEAM CPU Usage](https://stressgrid.com/blog/beam_cpu_usage/) diff --git a/docs/configuration/postgresql.md b/docs/configuration/postgresql.md new file mode 100644 index 0000000..e251eb8 --- /dev/null +++ b/docs/configuration/postgresql.md @@ -0,0 +1,48 @@ +# Optimizing PostgreSQL performance + +Pleroma performance is largely dependent on performance of the underlying database. Better performance can be achieved by adjusting a few settings. + +## PGTune + +[PgTune](https://pgtune.leopard.in.ua) can be used to get recommended settings. Be sure to set "Number of Connections" to 20, otherwise it might produce settings hurtful to database performance. It is also recommended to not use "Network Storage" option. + +## Disable generic query plans + +When PostgreSQL receives a query, it decides on a strategy for searching the requested data, this is called a query plan. The query planner has two modes: generic and custom. Generic makes a plan for all queries of the same shape, ignoring the parameters, which is then cached and reused. Custom, on the contrary, generates a unique query plan based on query parameters. + +By default PostgreSQL has an algorithm to decide which mode is more efficient for particular query, however this algorithm has been observed to be wrong on some of the queries Pleroma sends, leading to serious performance loss. Therefore, it is recommended to disable generic mode. + + +Pleroma already avoids generic query plans by default, however the method it uses is not the most efficient because it needs to be compatible with all supported PostgreSQL versions. For PostgreSQL 12 and higher additional performance can be gained by adding the following to Pleroma configuration: +```elixir +config :pleroma, Pleroma.Repo, + prepare: :named, + parameters: [ + plan_cache_mode: "force_custom_plan" + ] +``` + +A more detailed explaination of the issue can be found at . + +## Example configurations + +Here are some configuration suggestions for PostgreSQL 10+. + +### 1GB RAM, 1 CPU +``` +shared_buffers = 256MB +effective_cache_size = 768MB +maintenance_work_mem = 64MB +work_mem = 13107kB +``` + +### 2GB RAM, 2 CPU +``` +shared_buffers = 512MB +effective_cache_size = 1536MB +maintenance_work_mem = 128MB +work_mem = 26214kB +max_worker_processes = 2 +max_parallel_workers_per_gather = 1 +max_parallel_workers = 2 +``` diff --git a/docs/configuration/search.md b/docs/configuration/search.md new file mode 100644 index 0000000..f131948 --- /dev/null +++ b/docs/configuration/search.md @@ -0,0 +1,123 @@ +# Configuring search + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Built-in search + +To use built-in search that has no external dependencies, set the search module to `Pleroma.Activity`: + +> config :pleroma, Pleroma.Search, module: Pleroma.Search.DatabaseSearch + +While it has no external dependencies, it has problems with performance and relevancy. + +## Meilisearch + +Note that it's quite a bit more memory hungry than PostgreSQL (around 4-5G for ~1.2 million +posts while idle and up to 7G while indexing initially). The disk usage for this additional index is also +around 4 gigabytes. Like [RUM](./cheatsheet.md#rum-indexing-for-full-text-search) indexes, it offers considerably +higher performance and ordering by timestamp in a reasonable amount of time. +Additionally, the search results seem to be more accurate. + +Due to high memory usage, it may be best to set it up on a different machine, if running pleroma on a low-resource +computer, and use private key authentication to secure the remote search instance. + +To use [meilisearch](https://www.meilisearch.com/), set the search module to `Pleroma.Search.Meilisearch`: + +> config :pleroma, Pleroma.Search, module: Pleroma.Search.Meilisearch + +You then need to set the address of the meilisearch instance, and optionally the private key for authentication. You might +also want to change the `initial_indexing_chunk_size` to be smaller if you're server is not very powerful, but not higher than `100_000`, +because meilisearch will refuse to process it if it's too big. However, in general you want this to be as big as possible, because meilisearch +indexes faster when it can process many posts in a single batch. + +> config :pleroma, Pleroma.Search.Meilisearch, +> url: "http://127.0.0.1:7700/", +> private_key: "private key", +> initial_indexing_chunk_size: 100_000 + +Information about setting up meilisearch can be found in the +[official documentation](https://docs.meilisearch.com/learn/getting_started/installation.html). +You probably want to start it with `MEILI_NO_ANALYTICS=true` environment variable to disable analytics. +At least version 0.25.0 is required, but you are strongly adviced to use at least 0.26.0, as it introduces +the `--enable-auto-batching` option which drastically improves performance. Without this option, the search +is hardly usable on a somewhat big instance. + +### Private key authentication (optional) + +To set the private key, use the `MEILI_MASTER_KEY` environment variable when starting. After setting the _master key_, +you have to get the _private key_, which is actually used for authentication. + +=== "OTP" + ```sh + ./bin/pleroma_ctl search.meilisearch show-keys + ``` + +=== "From Source" + ```sh + mix pleroma.search.meilisearch show-keys + ``` + +You will see a "Default Admin API Key", this is the key you actually put into your configuration file. + +### Initial indexing + +After setting up the configuration, you'll want to index all of your already existsing posts. Only public posts are indexed. You'll only +have to do it one time, but it might take a while, depending on the amount of posts your instance has seen. This is also a fairly RAM +consuming process for `meilisearch`, and it will take a lot of RAM when running if you have a lot of posts (seems to be around 5G for ~1.2 +million posts while idle and up to 7G while indexing initially, but your experience may be different). + +The sequence of actions is as follows: + +1. First, change the configuration to use `Pleroma.Search.Meilisearch` as the search backend +2. Restart your instance, at this point it can be used while the search indexing is running, though search won't return anything +3. Start the initial indexing process (as described below with `index`), + and wait until the task says it sent everything from the database to index +4. Wait until everything is actually indexed (by checking with `stats` as described below), + at this point you don't have to do anything, just wait a while. + +To start the initial indexing, run the `index` command: + +=== "OTP" + ```sh + ./bin/pleroma_ctl search.meilisearch index + ``` + +=== "From Source" + ```sh + mix pleroma.search.meilisearch index + ``` + +This will show you the total amount of posts to index, and then show you the amount of posts indexed currently, until the numbers eventually +become the same. The posts are indexed in big batches and meilisearch will take some time to actually index them, even after you have +inserted all the posts into it. Depending on the amount of posts, this may be as long as several hours. To get information about the status +of indexing and how many posts have actually been indexed, use the `stats` command: + +=== "OTP" + ```sh + ./bin/pleroma_ctl search.meilisearch stats + ``` + +=== "From Source" + ```sh + mix pleroma.search.meilisearch stats + ``` + +### Clearing the index + +In case you need to clear the index (for example, to re-index from scratch, if that needs to happen for some reason), you can +use the `clear` command: + +=== "OTP" + ```sh + ./bin/pleroma_ctl search.meilisearch clear + ``` + +=== "From Source" + ```sh + mix pleroma.search.meilisearch clear + ``` + +This will clear **all** the posts from the search index. Note, that deleted posts are also removed from index by the instance itself, so +there is no need to actually clear the whole index, unless you want **all** of it gone. That said, the index does not hold any information +that cannot be re-created from the database, it should also generally be a lot smaller than the size of your database. Still, the size +depends on the amount of text in posts. diff --git a/docs/configuration/static_dir.md b/docs/configuration/static_dir.md new file mode 100644 index 0000000..a294bb6 --- /dev/null +++ b/docs/configuration/static_dir.md @@ -0,0 +1,95 @@ +# Static Directory + +Static frontend files are shipped with pleroma. If you want to overwrite or update these without problems during upgrades, you can write your custom versions to the static directory. + +You can find the location of the static directory in the [configuration](../cheatsheet/#instance). + +=== "OTP" + + ```elixir + config :pleroma, :instance, + static_dir: "/var/lib/pleroma/static/" + ``` + +=== "From Source" + + ```elixir + config :pleroma, :instance, + static_dir: "instance/static/" + ``` + +Alternatively, you can overwrite this value in your configuration to use a different static instance directory. + +This document is written using `$static_dir` as the value of the `config :pleroma, :instance, static_dir` setting. + +If you use a From Source installation and want to manage your custom files in the git repository, you can remove the `instance/` entry from `.gitignore`. + +## robots.txt + +There's a mix tasks to [generate a new robot.txt](../../administration/CLI_tasks/robots_txt/). + +For more complex things, you can write your own robots.txt to `$static_dir/robots.txt`. + +E.g. if you want to block all crawlers except for [fediverse.network](https://fediverse.network/about) you can use + +``` +User-Agent: * +Disallow: / + +User-Agent: crawler-us-il-1.fediverse.network +Allow: / + +User-Agent: makhnovtchina.random.sh +Allow: / +``` + +## Thumbnail + +Add `$static_dir/instance/thumbnail.jpeg` with your selfie or other neat picture. It will be available on `http://your-domain.tld/instance/thumbnail.jpeg` and can be used by external applications. + +## Instance-specific panel + +Create and Edit your file at `$static_dir/instance/panel.html`. + +## Background + +You can change the background of your Pleroma instance by uploading it to `$static_dir/`, and then changing `background` in [your configuration](../cheatsheet/#frontend_configurations) accordingly. + +E.g. if you put `$static_dir/images/background.jpg` + +``` +config :pleroma, :frontend_configurations, + pleroma_fe: %{ + background: "/images/background.jpg" + } +``` + +## Logo + +!!! important + Note the extra `static` folder for the default logo.png location + +If you want to give a brand to your instance, You can change the logo of your instance by uploading it to the static directory `$static_dir/static/logo.png`. + +Alternatively, you can specify the path to your logo in [your configuration](../cheatsheet/#frontend_configurations). + +E.g. if you put `$static_dir/static/mylogo-file.png` + +``` +config :pleroma, :frontend_configurations, + pleroma_fe: %{ + logo: "/static/mylogo-file.png" + } +``` + +## Terms of Service + +!!! important + Note the extra `static` folder for the terms-of-service.html + +Terms of Service will be shown to all users on the registration page. It's the best place where to write down the rules for your instance. You can modify the rules by adding and changing `$static_dir/static/terms-of-service.html`. + + +## Styling rendered pages + +To overwrite the CSS stylesheet of the OAuth form and other static pages, you can upload your own CSS file to `instance/static/static.css`. This will completely replace the CSS used by those pages, so it might be a good idea to copy the one from `priv/static/instance/static.css` and make your changes. diff --git a/docs/configuration/storing_remote_media.md b/docs/configuration/storing_remote_media.md new file mode 100644 index 0000000..c01985d --- /dev/null +++ b/docs/configuration/storing_remote_media.md @@ -0,0 +1,38 @@ +# Storing Remote Media + +Pleroma does not store remote/federated media by default. The best way to achieve this is to change Nginx to keep its reverse proxy cache +for a year and to activate the `MediaProxyWarmingPolicy` MRF policy in Pleroma which will automatically fetch all media through the proxy +as soon as the post is received by your instance. + +## Nginx + +``` + proxy_cache_path /long/term/storage/path/pleroma-media-cache levels=1:2 + keys_zone=pleroma_media_cache:10m inactive=1y use_temp_path=off; + + location ~ ^/(media|proxy) { + proxy_cache pleroma_media_cache; + slice 1m; + proxy_cache_key $host$uri$is_args$args$slice_range; + proxy_set_header Range $slice_range; + proxy_http_version 1.1; + proxy_cache_valid 206 301 302 304 1h; + proxy_cache_valid 200 1y; + proxy_cache_use_stale error timeout invalid_header updating; + proxy_ignore_client_abort on; + proxy_buffering on; + chunked_transfer_encoding on; + proxy_ignore_headers Cache-Control Expires; + proxy_hide_header Cache-Control Expires; + proxy_pass http://127.0.0.1:4000; + } +``` + +## Pleroma + +Add to your `prod.secret.exs`: + +``` +config :pleroma, :mrf, + policies: [Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy] +``` diff --git a/docs/development/API/admin_api.md b/docs/development/API/admin_api.md new file mode 100644 index 0000000..7d31ee2 --- /dev/null +++ b/docs/development/API/admin_api.md @@ -0,0 +1,1753 @@ +# Admin API + +Authentication is required and the user must be an admin. + +The `/api/v1/pleroma/admin/*` path is backwards compatible with `/api/pleroma/admin/*` (`/api/pleroma/admin/*` will be deprecated in the future). + +## `GET /api/v1/pleroma/admin/users` + +### List users + +- Query Params: + - *optional* `query`: **string** search term (e.g. nickname, domain, nickname@domain) + - *optional* `filters`: **string** comma-separated string of filters: + - `local`: only local users + - `external`: only external users + - `active`: only active users + - `need_approval`: only unapproved users + - `unconfirmed`: only unconfirmed users + - `deactivated`: only deactivated users + - `is_admin`: users with admin role + - `is_moderator`: users with moderator role + - *optional* `page`: **integer** page number + - *optional* `page_size`: **integer** number of users per page (default is `50`) + - *optional* `tags`: **[string]** tags list + - *optional* `actor_types`: **[string]** actor type list (`Person`, `Service`, `Application`) + - *optional* `name`: **string** user display name + - *optional* `email`: **string** user email +- Example: `https://mypleroma.org/api/v1/pleroma/admin/users?query=john&filters=local,active&page=1&page_size=10&tags[]=some_tag&tags[]=another_tag&name=display_name&email=email@example.com` +- Response: + +```json +{ + "page_size": integer, + "count": integer, + "users": [ + { + "deactivated": bool, + "id": integer, + "nickname": string, + "roles": { + "admin": bool, + "moderator": bool + }, + "local": bool, + "tags": array, + "avatar": string, + "display_name": string, + "confirmation_pending": bool, + "approval_pending": bool, + "registration_reason": string, + }, + ... + ] +} +``` + +## DEPRECATED `DELETE /api/v1/pleroma/admin/users` + +### Remove a user + +- Params: + - `nickname` +- Response: User’s nickname + +## `DELETE /api/v1/pleroma/admin/users` + +### Remove a user + +- Params: + - `nicknames` +- Response: Array of user nicknames + +### Create a user + +- Method: `POST` +- Params: + `users`: [ + { + `nickname`, + `email`, + `password` + } + ] +- Response: User’s nickname + +## `POST /api/v1/pleroma/admin/users/follow` + +### Make a user follow another user + +- Params: + - `follower`: The nickname of the follower + - `followed`: The nickname of the followed +- Response: + - "ok" + +## `POST /api/v1/pleroma/admin/users/unfollow` + +### Make a user unfollow another user + +- Params: + - `follower`: The nickname of the follower + - `followed`: The nickname of the followed +- Response: + - "ok" + +## `PATCH /api/v1/pleroma/admin/users/:nickname/toggle_activation` + +### Toggle user activation + +- Params: + - `nickname` +- Response: User’s object + +```json +{ + "deactivated": bool, + "id": integer, + "nickname": string +} +``` + +## `PUT /api/v1/pleroma/admin/users/tag` + +### Tag a list of users + +- Params: + - `nicknames` (array) + - `tags` (array) + +## `DELETE /api/v1/pleroma/admin/users/tag` + +### Untag a list of users + +- Params: + - `nicknames` (array) + - `tags` (array) + +## `GET /api/v1/pleroma/admin/users/:nickname/permission_group` + +### Get user user permission groups membership + +- Params: none +- Response: + +```json +{ + "is_moderator": bool, + "is_admin": bool +} +``` + +## `GET /api/v1/pleroma/admin/users/:nickname/permission_group/:permission_group` + +Note: Available `:permission_group` is currently moderator and admin. 404 is returned when the permission group doesn’t exist. + +### Get user user permission groups membership per permission group + +- Params: none +- Response: + +```json +{ + "is_moderator": bool, + "is_admin": bool +} +``` + +## DEPRECATED `POST /api/v1/pleroma/admin/users/:nickname/permission_group/:permission_group` + +### Add user to permission group + +- Params: none +- Response: + - On failure: `{"error": "…"}` + - On success: JSON of the user + +## `POST /api/v1/pleroma/admin/users/permission_group/:permission_group` + +### Add users to permission group + +- Params: + - `nicknames`: nicknames array +- Response: + - On failure: `{"error": "…"}` + - On success: JSON of the user + +## DEPRECATED `DELETE /api/v1/pleroma/admin/users/:nickname/permission_group/:permission_group` + +## `DELETE /api/v1/pleroma/admin/users/:nickname/permission_group/:permission_group` + +### Remove user from permission group + +- Params: none +- Response: + - On failure: `{"error": "…"}` + - On success: JSON of the user +- Note: An admin cannot revoke their own admin status. + +## `DELETE /api/v1/pleroma/admin/users/permission_group/:permission_group` + +### Remove users from permission group + +- Params: + - `nicknames`: nicknames array +- Response: + - On failure: `{"error": "…"}` + - On success: JSON of the user +- Note: An admin cannot revoke their own admin status. + +## `PATCH /api/v1/pleroma/admin/users/activate` + +### Activate user + +- Params: + - `nicknames`: nicknames array +- Response: + +```json +{ + users: [ + { + // user object + } + ] +} +``` + +## `PATCH /api/v1/pleroma/admin/users/deactivate` + +### Deactivate user + +- Params: + - `nicknames`: nicknames array +- Response: + +```json +{ + users: [ + { + // user object + } + ] +} +``` + +## `PATCH /api/v1/pleroma/admin/users/approve` + +### Approve user + +- Params: + - `nicknames`: nicknames array +- Response: + +```json +{ + users: [ + { + // user object + } + ] +} +``` + +## `PATCH /api/v1/pleroma/admin/users/suggest` + +### Suggest a user + +Adds the user(s) to follower recommendations. + +- Params: + - `nicknames`: nicknames array +- Response: + +```json +{ + users: [ + { + // user object + } + ] +} +``` + +## `PATCH /api/v1/pleroma/admin/users/unsuggest` + +### Unsuggest a user + +Removes the user(s) from follower recommendations. + +- Params: + - `nicknames`: nicknames array +- Response: + +```json +{ + users: [ + { + // user object + } + ] +} +``` + +## `GET /api/v1/pleroma/admin/users/:nickname_or_id` + +### Retrive the details of a user + +- Params: + - `nickname` or `id` +- Response: + - On failure: `Not found` + - On success: JSON of the user + +## `GET /api/v1/pleroma/admin/users/:nickname_or_id/statuses` + +### Retrive user's latest statuses + +- Params: + - `nickname` or `id` + - *optional* `page_size`: number of statuses to return (default is `20`) + - *optional* `godmode`: `true`/`false` – allows to see private statuses + - *optional* `with_reblogs`: `true`/`false` – allows to see reblogs (default is false) +- Response: + - On failure: `Not found` + - On success: JSON, where: + - `total`: total count of the statuses for the user + - `activities`: list of the statuses for the user + +```json +{ + "total" : 1, + "activities": [ + // activities list + ] +} +``` + +## `GET /api/v1/pleroma/admin/instances/:instance/statuses` + +### Retrive instance's latest statuses + +- Params: + - `instance`: instance name + - *optional* `page_size`: number of statuses to return (default is `20`) + - *optional* `godmode`: `true`/`false` – allows to see private statuses + - *optional* `with_reblogs`: `true`/`false` – allows to see reblogs (default is false) +- Response: + - On failure: `Not found` + - On success: JSON, where: + - `total`: total count of the statuses for the instance + - `activities`: list of the statuses for the instance + +```json +{ + "total" : 1, + "activities": [ + // activities list + ] +} +``` + +## `DELETE /api/v1/pleroma/admin/instances/:instance` + +### Delete all users and activities from a remote instance + +Note: this will trigger a job to remove instance content in the background. +It may take some time. + +- Params: + - `instance`: remote instance host +- Response: + - The `instance` name as a string + +```json +"lain.com" +``` + +## `GET /api/v1/pleroma/admin/statuses` + +### Retrives all latest statuses + +- Params: + - *optional* `page_size`: number of statuses to return (default is `20`) + - *optional* `local_only`: excludes remote statuses + - *optional* `godmode`: `true`/`false` – allows to see private statuses + - *optional* `with_reblogs`: `true`/`false` – allows to see reblogs (default is false) +- Response: + - On failure: `Not found` + - On success: JSON array of user's latest statuses + +## `GET /api/v1/pleroma/admin/relay` + +### List Relays + +Params: none +Response: + +* On success: JSON array of relays + +```json +[ + {"actor": "https://example.com/relay", "followed_back": true}, + {"actor": "https://example2.com/relay", "followed_back": false} +] +``` + +## `POST /api/v1/pleroma/admin/relay` + +### Follow a Relay + +Params: + +* `relay_url` + +Response: + +* On success: relay json object + +```json +{"actor": "https://example.com/relay", "followed_back": true} +``` + +## `DELETE /api/v1/pleroma/admin/relay` + +### Unfollow a Relay + +- Params: + - `relay_url` + - *optional* `force`: forcefully unfollow a relay even when the relay is not available. (default is `false`) + +Response: + +* On success: URL of the unfollowed relay + +```json +{"https://example.com/relay"} +``` + +## `POST /api/v1/pleroma/admin/users/invite_token` + +### Create an account registration invite token + +- Params: + - *optional* `max_use` (integer) + - *optional* `expires_at` (date string e.g. "2019-04-07") +- Response: + +```json +{ + "id": integer, + "token": string, + "used": boolean, + "expires_at": date, + "uses": integer, + "max_use": integer, + "invite_type": string (possible values: `one_time`, `reusable`, `date_limited`, `reusable_date_limited`) +} +``` + +## `GET /api/v1/pleroma/admin/users/invites` + +### Get a list of generated invites + +- Params: none +- Response: + +```json +{ + + "invites": [ + { + "id": integer, + "token": string, + "used": boolean, + "expires_at": date, + "uses": integer, + "max_use": integer, + "invite_type": string (possible values: `one_time`, `reusable`, `date_limited`, `reusable_date_limited`) + }, + ... + ] +} +``` + +## `POST /api/v1/pleroma/admin/users/revoke_invite` + +### Revoke invite by token + +- Params: + - `token` +- Response: + +```json +{ + "id": integer, + "token": string, + "used": boolean, + "expires_at": date, + "uses": integer, + "max_use": integer, + "invite_type": string (possible values: `one_time`, `reusable`, `date_limited`, `reusable_date_limited`) + +} +``` + +## `POST /api/v1/pleroma/admin/users/email_invite` + +### Sends registration invite via email + +- Params: + - `email` + - `name`, optional + +- Response: + - On success: `204`, empty response + - On failure: + - 400 Bad Request, JSON: + + ```json + [ + { + "error": "Appropriate error message here" + } + ] + ``` + +## `GET /api/v1/pleroma/admin/users/:nickname/password_reset` + +### Get a password reset token for a given nickname + + +- Params: none +- Response: + +```json +{ + "token": "base64 reset token", + "link": "https://pleroma.social/api/v1/pleroma/password_reset/url-encoded-base64-token" +} +``` + +## `PATCH /api/v1/pleroma/admin/users/force_password_reset` + +### Force passord reset for a user with a given nickname + +- Params: + - `nicknames` +- Response: none (code `204`) + +## PUT `/api/v1/pleroma/admin/users/disable_mfa` + +### Disable mfa for user's account. + +- Params: + - `nickname` +- Response: User’s nickname + +## `GET /api/v1/pleroma/admin/users/:nickname/credentials` + +### Get the user's email, password, display and settings-related fields + +- Params: + - `nickname` + +- Response: + +```json +{ + "actor_type": "Person", + "allow_following_move": true, + "avatar": "https://pleroma.social/media/7e8e7508fd545ef580549b6881d80ec0ff2c81ed9ad37b9bdbbdf0e0d030159d.jpg", + "background": "https://pleroma.social/media/4de34c0bd10970d02cbdef8972bef0ebbf55f43cadc449554d4396156162fe9a.jpg", + "banner": "https://pleroma.social/media/8d92ba2bd244b613520abf557dd448adcd30f5587022813ee9dd068945986946.jpg", + "bio": "bio", + "default_scope": "public", + "discoverable": false, + "email": "user@example.com", + "fields": [ + { + "name": "example", + "value": "https://example.com" + } + ], + "hide_favorites": false, + "hide_followers": false, + "hide_followers_count": false, + "hide_follows": false, + "hide_follows_count": false, + "id": "9oouHaEEUR54hls968", + "locked": true, + "name": "user", + "no_rich_text": true, + "pleroma_settings_store": {}, + "raw_fields": [ + { + "id": 1, + "name": "example", + "value": "https://example.com" + }, + ], + "show_role": true, + "skip_thread_containment": false +} +``` + +## `PATCH /api/v1/pleroma/admin/users/:nickname/credentials` + +### Change the user's email, password, display and settings-related fields + +* Params: + * `email` + * `password` + * `name` + * `bio` + * `avatar` + * `locked` + * `no_rich_text` + * `default_scope` + * `banner` + * `hide_follows` + * `hide_followers` + * `hide_followers_count` + * `hide_follows_count` + * `hide_favorites` + * `allow_following_move` + * `background` + * `show_role` + * `skip_thread_containment` + * `fields` + * `is_discoverable` + * `actor_type` + +* Responses: + +Status: 200 + +```json +{"status": "success"} +``` + +Status: 400 + +```json +{"errors": + {"actor_type": "is invalid"}, + {"email": "has invalid format"}, + ... + } +``` + +Status: 404 + +```json +{"error": "Not found"} +``` + +## `GET /api/v1/pleroma/admin/reports` + +### Get a list of reports + +- Params: + - *optional* `state`: **string** the state of reports. Valid values are `open`, `closed` and `resolved` + - *optional* `limit`: **integer** the number of records to retrieve + - *optional* `page`: **integer** page number + - *optional* `page_size`: **integer** number of log entries per page (default is `50`) +- Response: + - On failure: 403 Forbidden error `{"error": "error_msg"}` when requested by anonymous or non-admin + - On success: JSON, returns a list of reports, where: + - `account`: the user who has been reported + - `actor`: the user who has sent the report + - `statuses`: list of statuses that have been included to the report + +```json +{ + "total" : 1, + "reports": [ + { + "account": { + "acct": "user", + "avatar": "https://pleroma.example.org/images/avi.png", + "avatar_static": "https://pleroma.example.org/images/avi.png", + "bot": false, + "created_at": "2019-04-23T17:32:04.000Z", + "display_name": "User", + "emojis": [], + "fields": [], + "followers_count": 1, + "following_count": 1, + "header": "https://pleroma.example.org/images/banner.png", + "header_static": "https://pleroma.example.org/images/banner.png", + "id": "9i6dAJqSGSKMzLG2Lo", + "locked": false, + "note": "", + "pleroma": { + "confirmation_pending": false, + "hide_favorites": true, + "hide_followers": false, + "hide_follows": false, + "is_admin": false, + "is_moderator": false, + "relationship": {}, + "tags": [] + }, + "source": { + "note": "", + "pleroma": {}, + "sensitive": false + }, + "tags": ["force_unlisted"], + "statuses_count": 3, + "url": "https://pleroma.example.org/users/user", + "username": "user" + }, + "actor": { + "acct": "lain", + "avatar": "https://pleroma.example.org/images/avi.png", + "avatar_static": "https://pleroma.example.org/images/avi.png", + "bot": false, + "created_at": "2019-03-28T17:36:03.000Z", + "display_name": "Roger Braun", + "emojis": [], + "fields": [], + "followers_count": 1, + "following_count": 1, + "header": "https://pleroma.example.org/images/banner.png", + "header_static": "https://pleroma.example.org/images/banner.png", + "id": "9hEkA5JsvAdlSrocam", + "locked": false, + "note": "", + "pleroma": { + "confirmation_pending": false, + "hide_favorites": false, + "hide_followers": false, + "hide_follows": false, + "is_admin": false, + "is_moderator": false, + "relationship": {}, + "tags": [] + }, + "source": { + "note": "", + "pleroma": {}, + "sensitive": false + }, + "tags": ["force_unlisted"], + "statuses_count": 1, + "url": "https://pleroma.example.org/users/lain", + "username": "lain" + }, + "content": "Please delete it", + "created_at": "2019-04-29T19:48:15.000Z", + "id": "9iJGOv1j8hxuw19bcm", + "state": "open", + "statuses": [ + { + "account": { ... }, + "application": { + "name": "Web", + "website": null + }, + "bookmarked": false, + "card": null, + "content": "@lain click on my link https://www.google.com/", + "created_at": "2019-04-23T19:15:47.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 0, + "id": "9i6mQ9uVrrOmOime8m", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [ + { + "acct": "lain", + "id": "9hEkA5JsvAdlSrocam", + "url": "https://pleroma.example.org/users/lain", + "username": "lain" + }, + { + "acct": "user", + "id": "9i6dAJqSGSKMzLG2Lo", + "url": "https://pleroma.example.org/users/user", + "username": "user" + } + ], + "muted": false, + "pinned": false, + "pleroma": { + "content": { + "text/plain": "@lain click on my link https://www.google.com/" + }, + "conversation_id": 28, + "in_reply_to_account_acct": null, + "local": true, + "spoiler_text": { + "text/plain": "" + } + }, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "uri": "https://pleroma.example.org/objects/8717b90f-8e09-4b58-97b0-e3305472b396", + "url": "https://pleroma.example.org/notice/9i6mQ9uVrrOmOime8m", + "visibility": "direct" + } + ] + } + ] +} +``` + +## `GET /api/v1/pleroma/admin/grouped_reports` + +### Get a list of reports, grouped by status + +- Params: none +- On success: JSON, returns a list of reports, where: + - `date`: date of the latest report + - `account`: the user who has been reported (see `/api/v1/pleroma/admin/reports` for reference) + - `status`: reported status (see `/api/v1/pleroma/admin/reports` for reference) + - `actors`: users who had reported this status (see `/api/v1/pleroma/admin/reports` for reference) + - `reports`: reports (see `/api/v1/pleroma/admin/reports` for reference) + +```json + "reports": [ + { + "date": "2019-10-07T12:31:39.615149Z", + "account": { ... }, + "status": { ... }, + "actors": [{ ... }, { ... }], + "reports": [{ ... }] + } + ] +``` + +## `GET /api/v1/pleroma/admin/reports/:id` + +### Get an individual report + +- Params: + - `id` +- Response: + - On failure: + - 403 Forbidden `{"error": "error_msg"}` + - 404 Not Found `"Not found"` + - On success: JSON, Report object (see above) + +## `PATCH /api/v1/pleroma/admin/reports` + +### Change the state of one or multiple reports + +- Params: + +```json + `reports`: [ + { + `id`, // required, report id + `state` // required, the new state. Valid values are `open`, `closed` and `resolved` + }, + ... + ] +``` + +- Response: + - On failure: + - 400 Bad Request, JSON: + + ```json + [ + { + `id`, // report id + `error` // error message + } + ] + ``` + + - On success: `204`, empty response + +## `POST /api/v1/pleroma/admin/reports/:id/notes` + +### Create report note + +- Params: + - `id`: required, report id + - `content`: required, the message +- Response: + - On failure: + - 400 Bad Request `"Invalid parameters"` when `status` is missing + - On success: `204`, empty response + +## `DELETE /api/v1/pleroma/admin/reports/:report_id/notes/:id` + +### Delete report note + +- Params: + - `report_id`: required, report id + - `id`: required, note id +- Response: + - On failure: + - 400 Bad Request `"Invalid parameters"` when `status` is missing + - On success: `204`, empty response + +## `GET /api/v1/pleroma/admin/statuses/:id` + +### Show status by id + +- Params: + - `id`: required, status id +- Response: + - On failure: + - 404 Not Found `"Not Found"` + - On success: JSON, Mastodon Status entity + +## `PUT /api/v1/pleroma/admin/statuses/:id` + +### Change the scope of an individual reported status + +- Params: + - `id` + - `sensitive`: optional, valid values are `true` or `false` + - `visibility`: optional, valid values are `public`, `private` and `unlisted` +- Response: + - On failure: + - 400 Bad Request `"Unsupported visibility"` + - 403 Forbidden `{"error": "error_msg"}` + - 404 Not Found `"Not found"` + - On success: JSON, Mastodon Status entity + +## `DELETE /api/v1/pleroma/admin/statuses/:id` + +### Delete an individual reported status + +- Params: + - `id` +- Response: + - On failure: + - 403 Forbidden `{"error": "error_msg"}` + - 404 Not Found `"Not found"` + - On success: 200 OK `{}` + +## `GET /api/v1/pleroma/admin/restart` + +### Restarts pleroma application + +**Only works when configuration from database is enabled.** + +- Params: none +- Response: + - On failure: + - 400 Bad Request `"To use this endpoint you need to enable configuration from database."` + +```json +{} +``` + +## `GET /api/v1/pleroma/admin/need_reboot` + +### Returns the flag whether the pleroma should be restarted + +- Params: none +- Response: + - `need_reboot` - boolean +```json +{ + "need_reboot": false +} +``` + +## `GET /api/v1/pleroma/admin/config` + +### Get list of merged default settings with saved in database. + +*If `need_reboot` is `true`, instance must be restarted, so reboot time settings can take effect.* + +**Only works when configuration from database is enabled.** + +- Params: + - `only_db`: true (*optional*, get only saved in database settings) +- Response: + - On failure: + - 400 Bad Request `"To use this endpoint you need to enable configuration from database."` + +```json +{ + "configs": [ + { + "group": ":pleroma", + "key": "Pleroma.Upload", + "value": [] + } + ], + "need_reboot": true +} +``` + +## `POST /api/v1/pleroma/admin/config` + +### Update config settings + +*If `need_reboot` is `true`, instance must be restarted, so reboot time settings can take effect.* + +**Only works when configuration from database is enabled.** + +Some modifications are necessary to save the config settings correctly: + +- strings which start with `Pleroma.`, `Phoenix.`, `Tesla.` or strings like `Oban`, `Ueberauth` will be converted to modules; +``` +"Pleroma.Upload" -> Pleroma.Upload +"Oban" -> Oban +``` +- strings starting with `:` will be converted to atoms; +``` +":pleroma" -> :pleroma +``` +- objects with `tuple` key and array value will be converted to tuples; +``` +{"tuple": ["string", "Pleroma.Upload", []]} -> {"string", Pleroma.Upload, []} +``` +- arrays with *tuple objects* will be converted to keywords; +``` +[{"tuple": [":key1", "value"]}, {"tuple": [":key2", "value"]}] -> [key1: "value", key2: "value"] +``` + +Most of the settings will be applied in `runtime`, this means that you don't need to restart the instance. But some settings are applied in `compile time` and require a reboot of the instance, such as: +- all settings inside these keys: + - `:hackney_pools` + - `:connections_pool` + - `:pools` + - `:chat` +- partially settings inside these keys: + - `:seconds_valid` in `Pleroma.Captcha` + - `:proxy_remote` in `Pleroma.Upload` + - `:upload_limit` in `:instance` + +- Params: + - `configs` - array of config objects + - config object params: + - `group` - string (**required**) + - `key` - string (**required**) + - `value` - string, [], {} or {"tuple": []} (**required**) + - `delete` - true (*optional*, if setting must be deleted) + - `subkeys` - array of strings (*optional*, only works when `delete=true` parameter is passed, otherwise will be ignored) + +*When a value have several nested settings, you can delete only some nested settings by passing a parameter `subkeys`, without deleting all settings by key.* +``` +[subkey: val1, subkey2: val2, subkey3: val3] \\ initial value +{"group": ":pleroma", "key": "some_key", "delete": true, "subkeys": [":subkey", ":subkey3"]} \\ passing json for deletion +[subkey2: val2] \\ value after deletion +``` + +*Most of the settings can be partially updated through merge old values with new values, except settings value of which is list or is not keyword.* + +Example of setting without keyword in value: +```elixir +config :tesla, :adapter, Tesla.Adapter.Hackney +``` + +List of settings which support only full update by key: +```elixir +@full_key_update [ + {:pleroma, :ecto_repos}, + {:mime, :types}, + {:cors_plug, [:max_age, :methods, :expose, :headers]}, + {:auto_linker, :opts}, + {:swarm, :node_blacklist}, + {:logger, :backends} + ] +``` + +List of settings which support only full update by subkey: +```elixir +@full_subkey_update [ + {:pleroma, :assets, :mascots}, + {:pleroma, :emoji, :groups}, + {:pleroma, :workers, :retries}, + {:pleroma, :mrf_subchain, :match_actor}, + {:pleroma, :mrf_keyword, :replace} + ] +``` + +*Settings without explicit key must be sent in separate config object params.* +```elixir +config :foo, + bar: :baz, + meta: [:data], + ... +``` +```json +{ + "configs": [ + {"group": ":foo", "key": ":bar", "value": ":baz"}, + {"group": ":foo", "key": ":meta", "value": [":data"]}, + ... + ] +} +``` +- Request: + +```json +{ + "configs": [ + { + "group": ":pleroma", + "key": "Pleroma.Upload", + "value": [ + {"tuple": [":uploader", "Pleroma.Uploaders.Local"]}, + {"tuple": [":filters", ["Pleroma.Upload.Filter.Dedupe"]]}, + {"tuple": [":link_name", true]}, + {"tuple": [":proxy_remote", false]}, + {"tuple": [":proxy_opts", [ + {"tuple": [":redirect_on_failure", false]}, + {"tuple": [":max_body_length", 1048576]}, + {"tuple": [":http", [ + {"tuple": [":follow_redirect", true]}, + {"tuple": [":pool", ":upload"]}, + ]]} + ] + ]}, + {"tuple": [":dispatch", { + "tuple": ["/api/v1/streaming", "Pleroma.Web.MastodonAPI.WebsocketHandler", []] + }]} + ] + } + ] +} +``` + +- Response: + - On failure: + - 400 Bad Request `"To use this endpoint you need to enable configuration from database."` +```json +{ + "configs": [ + { + "group": ":pleroma", + "key": "Pleroma.Upload", + "value": [...] + } + ], + "need_reboot": true +} +``` + +## ` GET /api/v1/pleroma/admin/config/descriptions` + +### Get JSON with config descriptions. +Loads json generated from `config/descriptions.exs`. + +- Params: none +- Response: + +```json +[{ + "group": ":pleroma", // string + "key": "ModuleName", // string + "type": "group", // string or list with possible values, + "description": "Upload general settings", // string + "children": [ + { + "key": ":uploader", // string or module name `Pleroma.Upload` + "type": "module", + "description": "Module which will be used for uploads", + "suggestions": ["module1", "module2"] + }, + { + "key": ":filters", + "type": ["list", "module"], + "description": "List of filter modules for uploads", + "suggestions": [ + "module1", "module2", "module3" + ] + } + ] +}] +``` + +## `GET /api/v1/pleroma/admin/moderation_log` + +### Get moderation log + +- Params: + - *optional* `page`: **integer** page number + - *optional* `page_size`: **integer** number of log entries per page (default is `50`) + - *optional* `start_date`: **datetime (ISO 8601)** filter logs by creation date, start from `start_date`. Accepts datetime in ISO 8601 format (YYYY-MM-DDThh:mm:ss), e.g. `2005-08-09T18:31:42` + - *optional* `end_date`: **datetime (ISO 8601)** filter logs by creation date, end by from `end_date`. Accepts datetime in ISO 8601 format (YYYY-MM-DDThh:mm:ss), e.g. 2005-08-09T18:31:42 + - *optional* `user_id`: **integer** filter logs by actor's id + - *optional* `search`: **string** search logs by the log message +- Response: + +```json +[ + { + "id": 1234, + "data": { + "actor": { + "id": 1, + "nickname": "lain" + }, + "action": "relay_follow" + }, + "time": 1502812026, // timestamp + "message": "[2017-08-15 15:47:06] @nick0 followed relay: https://example.org/relay" // log message + } +] +``` + +## `POST /api/v1/pleroma/admin/reload_emoji` + +### Reload the instance's custom emoji + +- Authentication: required +- Params: None +- Response: JSON, "ok" and 200 status + +## `PATCH /api/v1/pleroma/admin/users/confirm_email` + +### Confirm users' emails + +- Params: + - `nicknames` +- Response: Array of user nicknames + +## `PATCH /api/v1/pleroma/admin/users/resend_confirmation_email` + +### Resend confirmation email + +- Params: + - `nicknames` +- Response: Array of user nicknames + +## `GET /api/v1/pleroma/admin/stats` + +### Stats + +- Query Params: + - *optional* `instance`: **string** instance hostname (without protocol) to get stats for +- Example: `https://mypleroma.org/api/v1/pleroma/admin/stats?instance=lain.com` + +- Response: + +```json +{ + "status_visibility": { + "direct": 739, + "private": 9, + "public": 17, + "unlisted": 14 + } +} +``` + +## `GET /api/v1/pleroma/admin/oauth_app` + +### List OAuth app + +- Params: + - *optional* `name` + - *optional* `client_id` + - *optional* `page` + - *optional* `page_size` + - *optional* `trusted` + +- Response: + +```json +{ + "apps": [ + { + "id": 1, + "name": "App name", + "client_id": "yHoDSiWYp5mPV6AfsaVOWjdOyt5PhWRiafi6MRd1lSk", + "client_secret": "nLmis486Vqrv2o65eM9mLQx_m_4gH-Q6PcDpGIMl6FY", + "redirect_uri": "https://example.com/oauth-callback", + "website": "https://example.com", + "trusted": true + } + ], + "count": 17, + "page_size": 50 +} +``` + + +## `POST /api/v1/pleroma/admin/oauth_app` + +### Create OAuth App + +- Params: + - `name` + - `redirect_uris` + - `scopes` + - *optional* `website` + - *optional* `trusted` + +- Response: + +```json +{ + "id": 1, + "name": "App name", + "client_id": "yHoDSiWYp5mPV6AfsaVOWjdOyt5PhWRiafi6MRd1lSk", + "client_secret": "nLmis486Vqrv2o65eM9mLQx_m_4gH-Q6PcDpGIMl6FY", + "redirect_uri": "https://example.com/oauth-callback", + "website": "https://example.com", + "trusted": true +} +``` + +- On failure: +```json +{ + "redirect_uris": "can't be blank", + "name": "can't be blank" +} +``` + +## `PATCH /api/v1/pleroma/admin/oauth_app/:id` + +### Update OAuth App + +- Params: + - *optional* `name` + - *optional* `redirect_uris` + - *optional* `scopes` + - *optional* `website` + - *optional* `trusted` + +- Response: + +```json +{ + "id": 1, + "name": "App name", + "client_id": "yHoDSiWYp5mPV6AfsaVOWjdOyt5PhWRiafi6MRd1lSk", + "client_secret": "nLmis486Vqrv2o65eM9mLQx_m_4gH-Q6PcDpGIMl6FY", + "redirect_uri": "https://example.com/oauth-callback", + "website": "https://example.com", + "trusted": true +} +``` + +## `DELETE /api/v1/pleroma/admin/oauth_app/:id` + +### Delete OAuth App + +- Params: None + +- Response: + - On success: `204`, empty response + - On failure: + - 400 Bad Request `"Invalid parameters"` when `status` is missing + +## `GET /api/v1/pleroma/admin/media_proxy_caches` + +### Get a list of all banned MediaProxy URLs in Cachex + +- Authentication: required +- Params: +- *optional* `page`: **integer** page number +- *optional* `page_size`: **integer** number of log entries per page (default is `50`) +- *optional* `query`: **string** search term + +- Response: + +``` json +{ + "page_size": integer, + "count": integer, + "urls": [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] +} + +``` + +## `POST /api/v1/pleroma/admin/media_proxy_caches/delete` + +### Remove a banned MediaProxy URL from Cachex + +- Authentication: required +- Params: + - `urls` (array) + +- Response: + +``` json +{ } + +``` + +## `POST /api/v1/pleroma/admin/media_proxy_caches/purge` + +### Purge a MediaProxy URL + +- Authentication: required +- Params: + - `urls` (array) + - `ban` (boolean) + +- Response: + +``` json +{ } + +``` + +## GET /api/v1/pleroma/admin/users/:nickname/chats + +### List a user's chats + +- Params: None + +- Response: + +```json +[ + { + "sender": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "receiver": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "id" : "1", + "unread" : 2, + "last_message" : {...}, // The last message in that chat + "updated_at": "2020-04-21T15:11:46.000Z" + } +] +``` + +## GET /api/v1/pleroma/admin/chats/:chat_id + +### View a single chat + +- Params: None + +- Response: + +```json +{ + "sender": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "receiver": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "id" : "1", + "unread" : 2, + "last_message" : {...}, // The last message in that chat + "updated_at": "2020-04-21T15:11:46.000Z" +} +``` + +## GET /api/v1/pleroma/admin/chats/:chat_id/messages + +### List the messages in a chat + +- Params: `max_id`, `min_id` + +- Response: + +```json +[ + { + "account_id": "someflakeid", + "chat_id": "1", + "content": "Check this out :firefox:", + "created_at": "2020-04-21T15:11:46.000Z", + "emojis": [ + { + "shortcode": "firefox", + "static_url": "https://dontbulling.me/emoji/Firefox.gif", + "url": "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker": false + } + ], + "id": "13", + "unread": true + }, + { + "account_id": "someflakeid", + "chat_id": "1", + "content": "Whats' up?", + "created_at": "2020-04-21T15:06:45.000Z", + "emojis": [], + "id": "12", + "unread": false + } +] +``` + +## DELETE /api/v1/pleroma/admin/chats/:chat_id/messages/:message_id + +### Delete a single message + +- Params: None + +- Response: + +```json +{ + "account_id": "someflakeid", + "chat_id": "1", + "content": "Check this out :firefox:", + "created_at": "2020-04-21T15:11:46.000Z", + "emojis": [ + { + "shortcode": "firefox", + "static_url": "https://dontbulling.me/emoji/Firefox.gif", + "url": "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker": false + } + ], + "id": "13", + "unread": false +} +``` + +## `GET /api/v1/pleroma/admin/instance_document/:document_name` + +### Get an instance document + +- Authentication: required + +- Response: + +Returns the content of the document + +```html +

Instance panel

+``` + +## `PATCH /api/v1/pleroma/admin/instance_document/:document_name` +- Params: + - `file` (the file to be uploaded, using multipart form data.) + +### Update an instance document + +- Authentication: required + +- Response: + +``` json +{ + "url": "https://example.com/instance/panel.html" +} +``` + +## `DELETE /api/v1/pleroma/admin/instance_document/:document_name` + +### Delete an instance document + +- Response: + +``` json +{ + "url": "https://example.com/instance/panel.html" +} +``` + +## `GET /api/v1/pleroma/admin/frontends + +### List available frontends + +- Response: + +```json +[ + { + "build_url": "https://git.pleroma.social/pleroma/fedi-fe/-/jobs/artifacts/${ref}/download?job=build", + "git": "https://git.pleroma.social/pleroma/fedi-fe", + "installed": true, + "installed_refs": ["master"], + "name": "fedi-fe", + "ref": "master" + }, + { + "build_url": "https://git.pleroma.social/lambadalambda/kenoma/-/jobs/artifacts/${ref}/download?job=build", + "git": "https://git.pleroma.social/lambadalambda/kenoma", + "installed": false, + "installed_refs": [], + "name": "kenoma", + "ref": "master" + } +] +``` + +## `POST /api/v1/pleroma/admin/frontends/install` + +### Install a frontend + +- Params: + - `name`: frontend name, required + - `ref`: frontend ref + - `file`: path to a frontend zip file + - `build_url`: build URL + - `build_dir`: build directory + +- Response: + +```json +[ + { + "build_url": "https://git.pleroma.social/pleroma/fedi-fe/-/jobs/artifacts/${ref}/download?job=build", + "git": "https://git.pleroma.social/pleroma/fedi-fe", + "installed": true, + "name": "fedi-fe", + "ref": "master" + }, + { + "build_url": "https://git.pleroma.social/lambadalambda/kenoma/-/jobs/artifacts/${ref}/download?job=build", + "git": "https://git.pleroma.social/lambadalambda/kenoma", + "installed": false, + "name": "kenoma", + "ref": "master" + } +] +``` + +```json +{ + "error": "Could not install frontend" +} +``` + +## `GET /api/v1/pleroma/admin/announcements` + +### List announcements + +- Params: `offset`, `limit` + +- Response: JSON, list of announcements + +```json +[ + { + "id": "AHDp0GBdRn1EPN5HN2", + "content": "some content", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-03-09T02:13:05", + "reactions": [], + "statuses": [], + "tags": [], + "emojis": [], + "updated_at": "2022-03-09T02:13:05" + } +] +``` + +Note that this differs from the Mastodon API variant: Mastodon API only returns *active* announcements, while this returns all. + +## `GET /api/v1/pleroma/admin/announcements/:id` + +### Display one announcement + +- Response: JSON, one announcement + +```json +{ + "id": "AHDp0GBdRn1EPN5HN2", + "content": "some content", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-03-09T02:13:05", + "reactions": [], + "statuses": [], + "tags": [], + "emojis": [], + "updated_at": "2022-03-09T02:13:05" +} +``` + +## `POST /api/v1/pleroma/admin/announcements` + +### Create an announcement + +- Params: + - `content`: string, required, announcement content + - `starts_at`: datetime, optional, default to null, the time when the announcement will become active (displayed to users); if it is null, the announcement will be active immediately + - `ends_at`: datetime, optional, default to null, the time when the announcement will become inactive (no longer displayed to users); if it is null, the announcement will be active until an admin deletes it + - `all_day`: boolean, optional, default to false, tells the client whether to only display dates for `starts_at` and `ends_at` + +- Response: JSON, created announcement + +```json +{ + "id": "AHDp0GBdRn1EPN5HN2", + "content": "some content", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-03-09T02:13:05", + "reactions": [], + "statuses": [], + "tags": [], + "emojis": [], + "updated_at": "2022-03-09T02:13:05" +} +``` + +## `PATCH /api/v1/pleroma/admin/announcements/:id` + +### Change an announcement + +- Params: same as `POST /api/v1/pleroma/admin/announcements`, except no param is required. + +- Updates the announcement according to params. Missing params are kept as-is. + +- Response: JSON, updated announcement + +```json +{ + "id": "AHDp0GBdRn1EPN5HN2", + "content": "some content", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-03-09T02:13:05", + "reactions": [], + "statuses": [], + "tags": [], + "emojis": [], + "updated_at": "2022-03-09T02:13:05" +} +``` + +## `DELETE /api/v1/pleroma/admin/announcements/:id` + +### Delete an announcement + +- Response: JSON, empty object + +```json +{} +``` diff --git a/docs/development/API/chats.md b/docs/development/API/chats.md new file mode 100644 index 0000000..2d488f6 --- /dev/null +++ b/docs/development/API/chats.md @@ -0,0 +1,263 @@ +# Chats + +Chats are a way to represent an IM-style conversation between two actors. They are not the same as direct messages and they are not `Status`es, even though they have a lot in common. + +## Why Chats? + +There are no 'visibility levels' in ActivityPub, their definition is purely a Mastodon convention. Direct Messaging between users on the fediverse has mostly been modeled by using ActivityPub addressing following Mastodon conventions on normal `Note` objects. In this case, a 'direct message' would be a message that has no followers addressed and also does not address the special public actor, but just the recipients in the `to` field. It would still be a `Note` and is presented with other `Note`s as a `Status` in the API. + +This is an awkward setup for a few reasons: + +- As DMs generally still follow the usual `Status` conventions, it is easy to accidentally pull somebody into a DM thread by mentioning them. (e.g. "I hate @badguy so much") +- It is possible to go from a publicly addressed `Status` to a DM reply, back to public, then to a 'followers only' reply, and so on. This can be become very confusing, as it is unclear which user can see which part of the conversation. +- The standard `Status` format of implicit addressing also leads to rather ugly results if you try to display the messages as a chat, because all the recipients are always mentioned by name in the message. +- As direct messages are posted with the same api call (and usually same frontend component) as public messages, accidentally making a public message private or vice versa can happen easily. Client bugs can also lead to this, accidentally making private messages public. + +As a measure to improve this situation, the `Conversation` concept and related Pleroma extensions were introduced. While it made it possible to work around a few of the issues, many of the problems remained and it didn't see much adoption because it was too complicated to use correctly. + +## Chats explained +For this reasons, Chats are a new and different entity, both in the API as well as in ActivityPub. A quick overview: + +- Chats are meant to represent an instant message conversation between two actors. For now these are only 1-on-1 conversations, but the other actor can be a group in the future. +- Chat messages have the ActivityPub type `ChatMessage`. They are not `Note`s. Servers that don't understand them will just drop them. +- The only addressing allowed in `ChatMessage`s is one single ActivityPub actor in the `to` field. +- There's always only one Chat between two actors. If you start chatting with someone and later start a 'new' Chat, the old Chat will be continued. +- `ChatMessage`s are posted with a different api, making it very hard to accidentally send a message to the wrong person. +- `ChatMessage`s don't show up in the existing timelines. +- Chats can never go from private to public. They are always private between the two actors. + +## Caveats + +- Chats are NOT E2E encrypted (yet). Security is still the same as email. + +## API + +In general, the way to send a `ChatMessage` is to first create a `Chat`, then post a message to that `Chat`. `Group`s will later be supported by making them a sub-type of `Account`. + +This is the overview of using the API. The API is also documented via OpenAPI, so you can view it and play with it by pointing SwaggerUI or a similar OpenAPI tool to `https://yourinstance.tld/api/openapi`. + +### Creating or getting a chat. + +To create or get an existing Chat for a certain recipient (identified by Account ID) +you can call: + +`POST /api/v1/pleroma/chats/by-account-id/:account_id` + +The account id is the normal FlakeId of the user +``` +POST /api/v1/pleroma/chats/by-account-id/someflakeid +``` + +If you already have the id of a chat, you can also use + +``` +GET /api/v1/pleroma/chats/:id +``` + +There will only ever be ONE Chat for you and a given recipient, so this call +will return the same Chat if you already have one with that user. + +Returned data: + +```json +{ + "account": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "id" : "1", + "unread" : 2, + "last_message" : {...}, // The last message in that chat + "updated_at": "2020-04-21T15:11:46.000Z" +} +``` + +### Marking a chat as read + +To mark a number of messages in a chat up to a certain message as read, you can use + +`POST /api/v1/pleroma/chats/:id/read` + + +Parameters: +- last_read_id: Given this id, all chat messages until this one will be marked as read. Required. + + +Returned data: + +```json +{ + "account": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "id" : "1", + "unread" : 0, + "updated_at": "2020-04-21T15:11:46.000Z" +} +``` + +### Marking a single chat message as read + +To set the `unread` property of a message to `false` + +`POST /api/v1/pleroma/chats/:id/messages/:message_id/read` + +Returned data: + +The modified chat message + +### Getting a list of Chats + +`GET /api/v1/pleroma/chats` + +This will return a list of chats that you have been involved in, sorted by their +last update (so new chats will be at the top). + +Parameters: + +- with_muted: Include chats from muted users (boolean). + +Returned data: + +```json +[ + { + "account": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "id" : "1", + "unread" : 2, + "last_message" : {...}, // The last message in that chat + "updated_at": "2020-04-21T15:11:46.000Z" + } +] +``` + +The recipient of messages that are sent to this chat is given by their AP ID. +No pagination is implemented for now. + +### Getting the messages for a Chat + +For a given Chat id, you can get the associated messages with + +`GET /api/v1/pleroma/chats/:id/messages` + +This will return all messages, sorted by most recent to least recent. The usual +pagination options are implemented. + +Returned data: + +```json +[ + { + "account_id": "someflakeid", + "chat_id": "1", + "content": "Check this out :firefox:", + "created_at": "2020-04-21T15:11:46.000Z", + "emojis": [ + { + "shortcode": "firefox", + "static_url": "https://dontbulling.me/emoji/Firefox.gif", + "url": "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker": false + } + ], + "id": "13", + "unread": true + }, + { + "account_id": "someflakeid", + "chat_id": "1", + "content": "Whats' up?", + "created_at": "2020-04-21T15:06:45.000Z", + "emojis": [], + "id": "12", + "unread": false, + "idempotency_key": "75442486-0874-440c-9db1-a7006c25a31f" + } +] +``` + +- idempotency_key: The copy of the `idempotency-key` HTTP request header that can be used for optimistic message sending. Included only during the first few minutes after the message creation. + +### Posting a chat message + +Posting a chat message for given Chat id works like this: + +`POST /api/v1/pleroma/chats/:id/messages` + +Parameters: +- content: The text content of the message. Optional if media is attached. +- media_id: The id of an upload that will be attached to the message. + +Currently, no formatting beyond basic escaping and emoji is implemented. + +Returned data: + +```json +{ + "account_id": "someflakeid", + "chat_id": "1", + "content": "Check this out :firefox:", + "created_at": "2020-04-21T15:11:46.000Z", + "emojis": [ + { + "shortcode": "firefox", + "static_url": "https://dontbulling.me/emoji/Firefox.gif", + "url": "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker": false + } + ], + "id": "13", + "unread": false +} +``` + +### Deleting a chat + +Deleting a chat works like this: + +`DELETE /api/v1/pleroma/chats/:chat_id` + +Returned data is the deleted chat. + +### Deleting a chat message + +Deleting a chat message for given Chat id works like this: + +`DELETE /api/v1/pleroma/chats/:chat_id/messages/:message_id` + +Returned data is the deleted message. + +### Notifications + +There's a new `pleroma:chat_mention` notification, which has this form. It is not given out in the notifications endpoint by default, you need to explicitly request it with `include_types[]=pleroma:chat_mention`: + +```json +{ + "id": "someid", + "type": "pleroma:chat_mention", + "account": { ... } // User account of the sender, + "chat_message": { + "chat_id": "1", + "id": "10", + "content": "Hello", + "account_id": "someflakeid", + "unread": false + }, + "created_at": "somedate" +} +``` + +### Streaming + +There is an additional `user:pleroma_chat` stream. Incoming chat messages will make the current chat be sent to this `user` stream. The `event` of an incoming chat message is `pleroma:chat_update`. The payload is the updated chat with the incoming chat message in the `last_message` field. + +### Web Push + +If you want to receive push messages for this type, you'll need to add the `pleroma:chat_mention` type to your alerts in the push subscription. diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md new file mode 100644 index 0000000..27f4441 --- /dev/null +++ b/docs/development/API/differences_in_mastoapi_responses.md @@ -0,0 +1,505 @@ +# Differences in Mastodon API responses from vanilla Mastodon + +A Pleroma instance can be identified by " (compatible; Pleroma )" present in `version` field in response from `/api/v1/instance` + +## Flake IDs + +Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However, just like Mastodon's ids, they are lexically sortable strings + +## Timelines + +Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users. + +Adding the parameter `exclude_visibilities` to the timeline queries will exclude the statuses with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`), e.g., `exclude_visibilities[]=direct&exclude_visibilities[]=private`. + +Adding the parameter `reply_visibility` to the public and home timelines queries will filter replies. Possible values: without parameter (default) shows all replies, `following` - replies directed to you or users you follow, `self` - replies directed to you. + +Adding the parameter `instance=lain.com` to the public timeline will show only statuses originating from `lain.com` (or any remote instance). + +Home, public, hashtag & list timelines accept these parameters: + +- `only_media`: show only statuses with media attached +- `local`: show only local statuses +- `remote`: show only remote statuses + +## Statuses + +- `visibility`: has additional possible values `list` and `local` (for local-only statuses) + +Has these additional fields under the `pleroma` object: + +- `local`: true if the post was made on the local instance +- `conversation_id`: the ID of the AP context the status is associated with (if any) +- `direct_conversation_id`: the ID of the Mastodon direct message conversation the status is associated with (if any) +- `in_reply_to_account_acct`: the `acct` property of User entity for replied user (if any) +- `content`: a map consisting of alternate representations of the `content` property with the key being its mimetype. Currently, the only alternate representation supported is `text/plain` +- `spoiler_text`: a map consisting of alternate representations of the `spoiler_text` property with the key being its mimetype. Currently, the only alternate representation supported is `text/plain` +- `expires_at`: a datetime (iso8601) that states when the post will expire (be deleted automatically), or empty if the post won't expire +- `thread_muted`: true if the thread the post belongs to is muted +- `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "☕", count: 1, me: true}`. Contains no information about the reacting users, for that use the `/statuses/:id/reactions` endpoint. +- `parent_visible`: If the parent of this post is visible to the user or not. +- `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise. + +The `GET /api/v1/statuses/:id/source` endpoint additionally has the following attributes: + +- `content_type`: The content type of the status source. + +## Scheduled statuses + +Has these additional fields in `params`: + +- `expires_in`: the number of seconds the posted activity should expire in. + +## Media Attachments + +Has these additional fields under the `pleroma` object: + +- `mime_type`: mime type of the attachment. + +### Attachment cap + +Some apps operate under the assumption that no more than 4 attachments can be returned or uploaded. Pleroma however does not enforce any limits on attachment count neither when returning the status object nor when posting. + +### Limitations + +Pleroma does not process remote images and therefore cannot include fields such as `meta` and `blurhash`. It does not support focal points or aspect ratios. The frontend is expected to handle it. + +## Accounts + +The `id` parameter can also be the `nickname` of the user. This only works in these endpoints, not the deeper nested ones for following etc. + +- `/api/v1/accounts/:id` +- `/api/v1/accounts/:id/statuses` + +`/api/v1/accounts/:id/statuses` endpoint accepts these parameters: + +- `pinned`: include only pinned statuses +- `tagged`: with tag +- `only_media`: include only statuses with media attached +- `with_muted`: include statuses/reactions from muted accounts +- `exclude_reblogs`: exclude reblogs +- `exclude_replies`: exclude replies +- `exclude_visibilities`: exclude visibilities + +Endpoints which accept `with_relationships` parameter: + +- `/api/v1/accounts/:id` +- `/api/v1/accounts/:id/followers` +- `/api/v1/accounts/:id/following` +- `/api/v1/mutes` + +Has these additional fields under the `pleroma` object: + +- `ap_id`: nullable URL string, ActivityPub id of the user +- `background_image`: nullable URL string, background image of the user +- `tags`: Lists an array of tags for the user +- `relationship` (object): Includes fields as documented for Mastodon API https://docs.joinmastodon.org/entities/relationship/ +- `is_moderator`: boolean, nullable, true if user is a moderator +- `is_admin`: boolean, nullable, true if user is an admin +- `confirmation_pending`: boolean, true if a new user account is waiting on email confirmation to be activated +- `hide_favorites`: boolean, true when the user has hiding favorites enabled +- `hide_followers`: boolean, true when the user has follower hiding enabled +- `hide_follows`: boolean, true when the user has follow hiding enabled +- `hide_followers_count`: boolean, true when the user has follower stat hiding enabled +- `hide_follows_count`: boolean, true when the user has follow stat hiding enabled +- `settings_store`: A generic map of settings for frontends. Opaque to the backend. Only returned in `/api/v1/accounts/verify_credentials` and `/api/v1/accounts/update_credentials` +- `deactivated`: boolean, true when the user is deactivated +- `allow_following_move`: boolean, true when the user allows automatically follow moved following accounts +- `unread_conversation_count`: The count of unread conversations. Only returned to the account owner. +- `unread_notifications_count`: The count of unread notifications. Only returned to the account owner. +- `notification_settings`: object, can be absent. See `/api/v1/pleroma/notification_settings` for the parameters/keys returned. +- `accepts_chat_messages`: boolean, but can be null if we don't have that information about a user +- `favicon`: nullable URL string, Favicon image of the user's instance + +### Source + +Has these additional fields under the `pleroma` object: + +- `show_role`: boolean, nullable, true when the user wants his role (e.g admin, moderator) to be shown +- `no_rich_text` - boolean, nullable, true when html tags are stripped from all statuses requested from the API +- `discoverable`: boolean, true when the user allows external services (search bots) etc. to index / list the account (regardless of this setting, user will still appear in regular search results) +- `actor_type`: string, the type of this account. + +## Conversations + +Has an additional field under the `pleroma` object: + +- `recipients`: The list of the recipients of this Conversation. These will be addressed when replying to this conversation. + +## GET `/api/v1/conversations` + +Accepts additional parameters: + +- `recipients`: Only return conversations with the given recipients (a list of user ids). Usage example: `GET /api/v1/conversations?recipients[]=1&recipients[]=2` + +## Account Search + +Behavior has changed: + +- `/api/v1/accounts/search`: Does not require authentication + +## Search (global) + +Unlisted posts are available in search results, they are considered to be public posts that shouldn't be shown in local/federated timeline. + +## Notifications + +Has these additional fields under the `pleroma` object: + +- `is_seen`: true if the notification was read by the user + +### Move Notification + +The `type` value is `move`. Has an additional field: + +- `target`: new account + +### EmojiReact Notification + +The `type` value is `pleroma:emoji_reaction`. Has these fields: + +- `emoji`: The used emoji +- `account`: The account of the user who reacted +- `status`: The status that was reacted on + +### ChatMention Notification (not default) + +This notification has to be requested explicitly. + +The `type` value is `pleroma:chat_mention` + +- `account`: The account who sent the message +- `chat_message`: The chat message + +### Report Notification (not default) + +This notification has to be requested explicitly. + +The `type` value is `pleroma:report` + +- `account`: The account who reported +- `report`: The report + +## GET `/api/v1/notifications` + +Accepts additional parameters: + +- `exclude_visibilities`: will exclude the notifications for activities with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`). Usage example: `GET /api/v1/notifications?exclude_visibilities[]=direct&exclude_visibilities[]=private`. +- `include_types`: will include the notifications for activities with the given types. The parameter accepts an array of types (`mention`, `follow`, `reblog`, `favourite`, `move`, `pleroma:emoji_reaction`, `pleroma:chat_mention`, `pleroma:report`). Usage example: `GET /api/v1/notifications?include_types[]=mention&include_types[]=reblog`. + +## DELETE `/api/v1/notifications/destroy_multiple` + +An endpoint to delete multiple statuses by IDs. + +Required parameters: + +- `ids`: array of activity ids + +Usage example: `DELETE /api/v1/notifications/destroy_multiple/?ids[]=1&ids[]=2`. + +Returns on success: 200 OK `{}` + +## POST `/api/v1/statuses` + +Additional parameters can be added to the JSON body/Form data: + +- `preview`: boolean, if set to `true` the post won't be actually posted, but the status entity would still be rendered back. This could be useful for previewing rich text/custom emoji, for example. +- `content_type`: string, contain the MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint. +- `to`: A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for post visibility are not affected by this and will still apply. +- `visibility`: string, besides standard MastoAPI values (`direct`, `private`, `unlisted`, `local` or `public`) it can be used to address a List by setting it to `list:LIST_ID`. +- `expires_in`: The number of seconds the posted activity should expire in. When a posted activity expires it will be deleted from the server, and a delete request for it will be federated. This needs to be longer than an hour. +- `in_reply_to_conversation_id`: Will reply to a given conversation, addressing only the people who are part of the recipient set of that conversation. Sets the visibility to `direct`. + +## GET `/api/v1/statuses` + +An endpoint to get multiple statuses by IDs. + +Required parameters: + +- `ids`: array of activity ids + +Usage example: `GET /api/v1/statuses/?ids[]=1&ids[]=2`. + +Returns: array of Status. + +The maximum number of statuses is limited to 100 per request. + +## PATCH `/api/v1/accounts/update_credentials` + +Additional parameters can be added to the JSON body/Form data: + +- `no_rich_text` - if true, html tags are stripped from all statuses requested from the API +- `hide_followers` - if true, user's followers will be hidden +- `hide_follows` - if true, user's follows will be hidden +- `hide_followers_count` - if true, user's follower count will be hidden +- `hide_follows_count` - if true, user's follow count will be hidden +- `hide_favorites` - if true, user's favorites timeline will be hidden +- `show_role` - if true, user's role (e.g admin, moderator) will be exposed to anyone in the API +- `default_scope` - the scope returned under `privacy` key in Source subentity +- `pleroma_settings_store` - Opaque user settings to be saved on the backend. +- `skip_thread_containment` - if true, skip filtering out broken threads +- `allow_following_move` - if true, allows automatically follow moved following accounts +- `also_known_as` - array of ActivityPub IDs, needed for following move +- `pleroma_background_image` - sets the background image of the user. Can be set to "" (an empty string) to reset. +- `discoverable` - if true, external services (search bots) etc. are allowed to index / list the account (regardless of this setting, user will still appear in regular search results). +- `actor_type` - the type of this account. +- `accepts_chat_messages` - if false, this account will reject all chat messages. +- `language` - user's preferred language for receiving emails (digest, confirmation, etc.) + +All images (avatar, banner and background) can be reset to the default by sending an empty string ("") instead of a file. + +### Pleroma Settings Store + +Pleroma has mechanism that allows frontends to save blobs of json for each user on the backend. This can be used to save frontend-specific settings for a user that the backend does not need to know about. + +The parameter should have a form of `{frontend_name: {...}}`, with `frontend_name` identifying your type of client, e.g. `pleroma_fe`. It will overwrite everything under this property, but will not overwrite other frontend's settings. + +This information is returned in the `/api/v1/accounts/verify_credentials` endpoint. + +## Authentication + +*Pleroma supports refreshing tokens.* + +### POST `/oauth/token` + +You can obtain access tokens for a user in a few additional ways. + +#### Refreshing a token + +To obtain a new access token from a refresh token, pass `grant_type=refresh_token` with the following extra parameters: + +- `refresh_token`: The refresh token. + +#### Getting a token with a password + +To obtain a token from a user's password, pass `grant_type=password` with the following extra parameters: + +- `username`: Username to authenticate. +- `password`: The user's password. + +#### Response body + +Additional fields are returned in the response: + +- `id`: The primary key of this token in Pleroma's database. +- `me` (user tokens only): The ActivityPub ID of the user who owns the token. + +## Account Registration + +`POST /api/v1/accounts` + +Has these additional parameters (which are the same as in Pleroma-API): + +- `fullname`: optional +- `bio`: optional +- `captcha_solution`: optional, contains provider-specific captcha solution, +- `captcha_token`: optional, contains provider-specific captcha token +- `captcha_answer_data`: optional, contains provider-specific captcha data +- `token`: invite token required when the registrations aren't public. +- `language`: optional, user's preferred language for receiving emails (digest, confirmation, etc.), default to the language set in the `userLanguage` cookies or `Accept-Language` header. + +## Instance + +`GET /api/v1/instance` has additional fields + +- `max_toot_chars`: The maximum characters per post +- `chat_limit`: The maximum characters per chat message +- `description_limit`: The maximum characters per image description +- `poll_limits`: The limits of polls +- `upload_limit`: The maximum upload file size +- `avatar_upload_limit`: The same for avatars +- `background_upload_limit`: The same for backgrounds +- `banner_upload_limit`: The same for banners +- `background_image`: A background image that frontends can use +- `pleroma.metadata.features`: A list of supported features +- `pleroma.metadata.federation`: The federation restrictions of this instance +- `pleroma.metadata.fields_limits`: A list of values detailing the length and count limitation for various instance-configurable fields. +- `pleroma.metadata.post_formats`: A list of the allowed post format types +- `vapid_public_key`: The public key needed for push messages + +## Push Subscription + +`POST /api/v1/push/subscription` +`PUT /api/v1/push/subscription` + +Permits these additional alert types: + +- pleroma:chat_mention +- pleroma:emoji_reaction + +## Markers + +Has these additional fields under the `pleroma` object: + +- `unread_count`: contains number unread notifications + +## Streaming + +### Chats + +There is an additional `user:pleroma_chat` stream. Incoming chat messages will make the current chat be sent to this `user` stream. The `event` of an incoming chat message is `pleroma:chat_update`. The payload is the updated chat with the incoming chat message in the `last_message` field. + +### Remote timelines + +For viewing remote server timelines, there are `public:remote` and `public:remote:media` streams. Each of these accept a parameter like `?instance=lain.com`. + +### Follow relationships updates + +Pleroma streams follow relationships updates as `pleroma:follow_relationships_update` events to the `user` stream. + +The message payload consist of: + +- `state`: a relationship state, one of `follow_pending`, `follow_accept` or `follow_reject`. + +- `follower` and `following` maps with following fields: + - `id`: user ID + - `follower_count`: follower count + - `following_count`: following count + +### Authenticating via `sec-websocket-protocol` header + +Pleroma allows to authenticate via the `sec-websocket-protocol` header, for example, if your access token is `your-access-token`, you can authenticate using the following: + +``` +sec-websocket-protocol: your-access-token +``` + +### Authenticating after connection via `pleroma:authenticate` event + +Pleroma allows to authenticate after connection is established, via the `pleroma:authenticate` event. For example, if your access token is `your-access-token`, you can send the following after the connection is established: + +``` +{"type": "pleroma:authenticate", "token": "your-access-token"} +``` + +### Response to client-sent events + +Pleroma will respond to client-sent events that it recognizes. Supported event types are: + +- `subscribe` +- `unsubscribe` +- `pleroma:authenticate` + +The reply will be in the following format: + +``` +{ + "event": "pleroma:respond", + "payload": "{\"type\": \"\", \"result\": \"\", \"error\": \"\"}" +} +``` + +Result of the action can be either `success`, `ignored` or `error`. If it is `error`, the `error` property will contain the error code. Otherwise, the `error` property will not be present. Below are some examples: + +``` +{ + "event": "pleroma:respond", + "payload": "{\"type\": \"pleroma:authenticate\", \"result\": \"success\"}" +} + +{ + "event": "pleroma:respond", + "payload": "{\"type\": \"subscribe\", \"result\": \"ignored\"}" +} + +{ + "event": "pleroma:respond", + "payload": "{\"type\": \"unsubscribe\", \"result\": \"error\", \"error\": \"bad_topic\"}" +} +``` + +If the sent event is not of a type that Pleroma supports, it will not reply. + +### The `stream` attribute of a server-sent event + +Technically, this is in Mastodon, but its documentation does nothing to specify its format. + +This attribute appears on every event type except `pleroma:respond` and `delete`. It helps clients determine where they should display the new statuses. + +The value of the attribute is an array containing one or two elements. The first element is the type of the stream. The second is the identifier related to that specific stream, if applicable. + +For the following stream types, there is a second element in the array: + +- `list`: The second element is the id of the list, as a string. +- `hashtag`: The second element is the name of the hashtag. +- `public:remote:media` and `public:remote`: The second element is the domain of the corresponding instance. + +For all other stream types, there is no second element. + +Some examples of valid `stream` values: + +- `["list", "1"]`: List of id 1. +- `["hashtag", "mew"]`: The hashtag #mew. +- `["user:notifications"]`: Notifications for the current user. +- `["user"]`: Home timeline. +- `["public:remote", "mew.moe"]`: Public posts from the instance mew.moe . + +### The unified streaming endpoint + +If you do not specify a stream to connect to when requesting `/api/v1/streaming`, you will enter a connection that subscribes to no streams. After the connection is established, you can authenticate and then subscribe to different streams. + +### List of supported streams + +Below is a list of supported streams by Pleroma. To make a single-stream WebSocket connection, append the string specified in "Query style" to the streaming endpoint url. +To subscribe to a stream after the connection is established, merge the JSON object specified in "Subscribe style" with `{"type": "subscribe"}`. To unsubscribe, merge it with `{"type": "unsubscribe"}`. + +For example, to receive updates on the list 1, you can connect to `/api/v1/streaming/?stream=list&list=1`, or send + +``` +{"type": "subscribe", "stream": "list", "list": "1"} +``` + +upon establishing the websocket connection. + +To unsubscribe to list 1, send + +``` +{"type": "unsubscribe", "stream": "list", "list": "1"} +``` + +Note that if you specify a stream that requires a logged-in user in the query string (for example, `user` or `list`), you have to specify the access token when you are trying to establish the connection, i.e. in the query string or via the `sec-websocket-protocol` header. + +- `list` + - Query style: `?stream=list&list=` + - Subscribe style: `{"stream": "list", "list": ""}` +- `public`, `public:local`, `public:media`, `public:local:media`, `user`, `user:pleroma_chat`, `user:notifications`, `direct` + - Query style: `?stream=` + - Subscribe style: `{"stream": ""}` +- `hashtag` + - Query style: `?stream=hashtag&tag=` + - Subscribe style: `{"stream": "hashtag", "tag": ""}` +- `public:remote`, `public:remote:media` + - Query style: `?stream=&instance=` + - Subscribe style: `{"stream": "", "instance": ""}` + +## User muting and thread muting + +Both user muting and thread muting can be done for only a certain time by adding an `expires_in` parameter to the API calls and giving the expiration time in seconds. + +## Not implemented + +Pleroma is generally compatible with the Mastodon 2.7.2 API, but some newer features and non-essential features are omitted. These features usually return an HTTP 200 status code, but with an empty response. While they may be added in the future, they are considered low priority. + +### Suggestions + +*Added in Mastodon 2.4.3* + +- `GET /api/v1/suggestions`: Returns an empty array, `[]` + +### Trends + +*Added in Mastodon 3.0.0* + +- `GET /api/v1/trends`: Returns an empty array, `[]` + +### Identity proofs + +*Added in Mastodon 2.8.0* + +- `GET /api/v1/identity_proofs`: Returns an empty array, `[]` + +### Featured tags + +*Added in Mastodon 3.0.0* + +- `GET /api/v1/featured_tags`: Returns HTTP 404 diff --git a/docs/development/API/nodeinfo.md b/docs/development/API/nodeinfo.md new file mode 100644 index 0000000..c76567f --- /dev/null +++ b/docs/development/API/nodeinfo.md @@ -0,0 +1,345 @@ +# Nodeinfo + +See also [the Nodeinfo standard](https://nodeinfo.diaspora.software/). + +## `/.well-known/nodeinfo` +### The well-known path +* Method: `GET` +* Authentication: not required +* Params: none +* Response: JSON +* Example response: +```json +{ + "links":[ + { + "href":"https://example.com/nodeinfo/2.0.json", + "rel":"http://nodeinfo.diaspora.software/ns/schema/2.0" + }, + { + "href":"https://example.com/nodeinfo/2.1.json", + "rel":"http://nodeinfo.diaspora.software/ns/schema/2.1" + } + ] +} +``` + +## `/nodeinfo/2.0.json` +### Nodeinfo 2.0 +* Method: `GET` +* Authentication: not required +* Params: none +* Response: JSON +* Example response: +```json +{ + "metadata":{ + "accountActivationRequired":false, + "features":[ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + "chat", + "relay", + "pleroma_emoji_reactions", + "pleroma_chat_messages" + ], + "federation":{ + "enabled":true, + "exclusions":false, + "mrf_hashtag":{ + "federated_timeline_removal":[ + + ], + "reject":[ + + ], + "sensitive":[ + "nsfw" + ] + }, + "mrf_object_age":{ + "actions":[ + "delist", + "strip_followers" + ], + "threshold":604800 + }, + "mrf_policies":[ + "ObjectAgePolicy", + "TagPolicy", + "HashtagPolicy" + ], + "quarantined_instances":[ + + ] + }, + "fieldsLimits":{ + "maxFields":10, + "maxRemoteFields":20, + "nameLength":512, + "valueLength":2048 + }, + "invitesEnabled":false, + "mailerEnabled":false, + "nodeDescription":"Pleroma: An efficient and flexible fediverse server", + "nodeName":"Example", + "pollLimits":{ + "max_expiration":31536000, + "max_option_chars":200, + "max_options":20, + "min_expiration":0 + }, + "postFormats":[ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode" + ], + "private":false, + "restrictedNicknames":[ + ".well-known", + "~", + "about", + "activities", + "api", + "auth", + "check_password", + "dev", + "friend-requests", + "inbox", + "internal", + "main", + "media", + "nodeinfo", + "notice", + "oauth", + "objects", + "ostatus_subscribe", + "pleroma", + "proxy", + "push", + "registration", + "relay", + "settings", + "status", + "tag", + "user-search", + "user_exists", + "users", + "web", + "verify_credentials", + "update_credentials", + "relationships", + "search", + "confirmation_resend", + "mfa" + ], + "skipThreadContainment":true, + "staffAccounts":[ + "https://example.com/users/admin", + "https://example.com/users/staff" + ], + "suggestions":{ + "enabled":false + }, + "uploadLimits":{ + "avatar":2000000, + "background":4000000, + "banner":4000000, + "general":16000000 + } + }, + "openRegistrations":true, + "protocols":[ + "activitypub" + ], + "services":{ + "inbound":[ + + ], + "outbound":[ + + ] + }, + "software":{ + "name":"pleroma", + "version":"2.4.1" + }, + "usage":{ + "localPosts":27, + "users":{ + "activeHalfyear":129, + "activeMonth":70, + "total":235 + } + }, + "version":"2.0" +} +``` + +## `/nodeinfo/2.1.json` +### Nodeinfo 2.1 +* Method: `GET` +* Authentication: not required +* Params: none +* Response: JSON +* Example response: +```json +{ + "metadata":{ + "accountActivationRequired":false, + "features":[ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + "chat", + "relay", + "pleroma_emoji_reactions", + "pleroma_chat_messages" + ], + "federation":{ + "enabled":true, + "exclusions":false, + "mrf_hashtag":{ + "federated_timeline_removal":[ + + ], + "reject":[ + + ], + "sensitive":[ + "nsfw" + ] + }, + "mrf_object_age":{ + "actions":[ + "delist", + "strip_followers" + ], + "threshold":604800 + }, + "mrf_policies":[ + "ObjectAgePolicy", + "TagPolicy", + "HashtagPolicy" + ], + "quarantined_instances":[ + + ] + }, + "fieldsLimits":{ + "maxFields":10, + "maxRemoteFields":20, + "nameLength":512, + "valueLength":2048 + }, + "invitesEnabled":false, + "mailerEnabled":false, + "nodeDescription":"Pleroma: An efficient and flexible fediverse server", + "nodeName":"Example", + "pollLimits":{ + "max_expiration":31536000, + "max_option_chars":200, + "max_options":20, + "min_expiration":0 + }, + "postFormats":[ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode" + ], + "private":false, + "restrictedNicknames":[ + ".well-known", + "~", + "about", + "activities", + "api", + "auth", + "check_password", + "dev", + "friend-requests", + "inbox", + "internal", + "main", + "media", + "nodeinfo", + "notice", + "oauth", + "objects", + "ostatus_subscribe", + "pleroma", + "proxy", + "push", + "registration", + "relay", + "settings", + "status", + "tag", + "user-search", + "user_exists", + "users", + "web", + "verify_credentials", + "update_credentials", + "relationships", + "search", + "confirmation_resend", + "mfa" + ], + "skipThreadContainment":true, + "staffAccounts":[ + "https://example.com/users/admin", + "https://example.com/users/staff" + ], + "suggestions":{ + "enabled":false + }, + "uploadLimits":{ + "avatar":2000000, + "background":4000000, + "banner":4000000, + "general":16000000 + } + }, + "openRegistrations":true, + "protocols":[ + "activitypub" + ], + "services":{ + "inbound":[ + + ], + "outbound":[ + + ] + }, + "software":{ + "name":"pleroma", + "repository":"https://git.pleroma.social/pleroma/pleroma", + "version":"2.4.1" + }, + "usage":{ + "localPosts":27, + "users":{ + "activeHalfyear":129, + "activeMonth":70, + "total":235 + } + }, + "version":"2.1" +} +``` + diff --git a/docs/development/API/pleroma_api.md b/docs/development/API/pleroma_api.md new file mode 100644 index 0000000..a92c3c2 --- /dev/null +++ b/docs/development/API/pleroma_api.md @@ -0,0 +1,727 @@ +# Pleroma API + +Requests that require it can be authenticated with [an OAuth token](https://tools.ietf.org/html/rfc6749), the `_pleroma_key` cookie, or [HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization). + +Request parameters can be passed via [query strings](https://en.wikipedia.org/wiki/Query_string) or as [form data](https://www.w3.org/TR/html401/interact/forms.html). Files must be uploaded as `multipart/form-data`. + +The `/api/v1/pleroma/*` path is backwards compatible with `/api/pleroma/*` (`/api/pleroma/*` will be deprecated in the future). + +## `/api/v1/pleroma/emoji` +### Lists the custom emoji on that server. +* Method: `GET` +* Authentication: not required +* Params: none +* Response: JSON +* Example response: +```json +{ + "girlpower": { + "tags": [ + "Finmoji" + ], + "image_url": "/finmoji/128px/girlpower-128.png" + }, + "education": { + "tags": [ + "Finmoji" + ], + "image_url": "/finmoji/128px/education-128.png" + }, + "finnishlove": { + "tags": [ + "Finmoji" + ], + "image_url": "/finmoji/128px/finnishlove-128.png" + } +} +``` +* Note: Same data as Mastodon API’s `/api/v1/custom_emojis` but in a different format + +## `/api/pleroma/follow_import` +### Imports your follows, for example from a Mastodon CSV file. +* Method: `POST` +* Authentication: required +* Params: + * `list`: STRING or FILE containing a whitespace-separated list of accounts to follow +* Response: HTTP 200 on success, 500 on error +* Note: Users that can't be followed are silently skipped. + +## `/api/pleroma/blocks_import` +### Imports your blocks. +* Method: `POST` +* Authentication: required +* Params: + * `list`: STRING or FILE containing a whitespace-separated list of accounts to block +* Response: HTTP 200 on success, 500 on error + +## `/api/pleroma/mutes_import` +### Imports your mutes. +* Method: `POST` +* Authentication: required +* Params: + * `list`: STRING or FILE containing a whitespace-separated list of accounts to mute +* Response: HTTP 200 on success, 500 on error + +## `/api/v1/pleroma/captcha` +### Get a new captcha +* Method: `GET` +* Authentication: not required +* Params: none +* Response: Provider specific JSON, the only guaranteed parameter is `type` +* Example response: `{"type": "kocaptcha", "token": "whatever", "url": "https://captcha.kotobank.ch/endpoint", "seconds_valid": 300}` + +## `/api/pleroma/delete_account` +### Delete an account +* Method `POST` +* Authentication: required +* Params: + * `password`: user's password +* Response: JSON. Returns `{"status": "success"}` if the deletion was successful, `{"error": "[error message]"}` otherwise +* Example response: `{"error": "Invalid password."}` + +## `/api/pleroma/disable_account` +### Disable an account +* Method `POST` +* Authentication: required +* Params: + * `password`: user's password +* Response: JSON. Returns `{"status": "success"}` if the account was successfully disabled, `{"error": "[error message]"}` otherwise +* Example response: `{"error": "Invalid password."}` + +## `/api/pleroma/accounts/mfa` +#### Gets current MFA settings +* method: `GET` +* Authentication: required +* OAuth scope: `read:security` +* Response: JSON. Returns `{"settings": {"enabled": "false", "totp": false }}` +* Note: `enabled` is whether multi-factor auth is enabled for the user in general, while `totp` is one type of MFA. + +## `/api/pleroma/accounts/mfa/setup/totp` +#### Pre-setup the MFA/TOTP method +* method: `GET` +* Authentication: required +* OAuth scope: `write:security` +* Response: JSON. Returns `{"key": [secret_key], "provisioning_uri": "[qr code uri]" }` when successful, otherwise returns HTTP 422 `{"error": "error_msg"}` + +## `/api/pleroma/accounts/mfa/confirm/totp` +#### Confirms & enables MFA/TOTP support for user account. +* method: `POST` +* Authentication: required +* OAuth scope: `write:security` +* Params: + * `password`: user's password + * `code`: token from TOTP App +* Response: JSON. Returns `{}` if the enable was successful, HTTP 422 `{"error": "[error message]"}` otherwise + + +## `/api/pleroma/accounts/mfa/totp` +#### Disables MFA/TOTP method for user account. +* method: `DELETE` +* Authentication: required +* OAuth scope: `write:security` +* Params: + * `password`: user's password +* Response: JSON. Returns `{}` if the disable was successful, HTTP 422 `{"error": "[error message]"}` otherwise +* Example response: `{"error": "Invalid password."}` + +## `/api/pleroma/accounts/mfa/backup_codes` +#### Generstes backup codes MFA for user account. +* method: `GET` +* Authentication: required +* OAuth scope: `write:security` +* Response: JSON. Returns `{"codes": codes}`when successful, otherwise HTTP 422 `{"error": "[error message]"}` + +## `/api/v1/pleroma/admin/` +See [Admin-API](admin_api.md) + +## `/api/v1/pleroma/notifications/read` +### Mark notifications as read +* Method `POST` +* Authentication: required +* Params (mutually exclusive): + * `id`: a single notification id to read + * `max_id`: read all notifications up to this id +* Response: Notification entity/Array of Notification entities that were read. In case of `max_id`, only the first 80 read notifications will be returned. + +## `/api/v1/pleroma/accounts/:id/subscribe` +### Subscribe to receive notifications for all statuses posted by a user +* Method `POST` +* Authentication: required +* Params: + * `id`: account id to subscribe to +* Response: JSON, returns a mastodon relationship object on success, otherwise returns `{"error": "error_msg"}` +* Example response: +```json +{ + "id": "abcdefg", + "following": true, + "followed_by": false, + "blocking": false, + "muting": false, + "muting_notifications": false, + "subscribing": true, + "notifying": true, + "requested": false, + "domain_blocking": false, + "showing_reblogs": true, + "endorsed": false, + "note": "" +} +``` + +## `/api/v1/pleroma/accounts/:id/unsubscribe` +### Unsubscribe to stop receiving notifications from user statuses +* Method `POST` +* Authentication: required +* Params: + * `id`: account id to unsubscribe from +* Response: JSON, returns a mastodon relationship object on success, otherwise returns `{"error": "error_msg"}` +* Example response: +```json +{ + "id": "abcdefg", + "following": true, + "followed_by": false, + "blocking": false, + "muting": false, + "muting_notifications": false, + "subscribing": false, + "notifying": false, + "requested": false, + "domain_blocking": false, + "showing_reblogs": true, + "endorsed": false, + "note": "" +} +``` + +## `/api/v1/pleroma/accounts/:id/favourites` +### Returns favorites timeline of any user +* Method `GET` +* Authentication: not required +* Params: + * `id`: the id of the account for whom to return results + * `limit`: optional, the number of records to retrieve + * `since_id`: optional, returns results that are more recent than the specified id + * `max_id`: optional, returns results that are older than the specified id +* Response: JSON, returns a list of Mastodon Status entities on success, otherwise returns `{"error": "error_msg"}` +* Example response: +```json +[ + { + "account": { + "id": "9hptFmUF3ztxYh3Svg", + "url": "https://pleroma.example.org/users/nick2", + "username": "nick2", + ... + }, + "application": {"name": "Web", "website": null}, + "bookmarked": false, + "card": null, + "content": "This is :moominmamma: note 0", + "created_at": "2019-04-15T15:42:15.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 1, + "id": "9hptFmVJ02khbzYJaS", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": false, + "pleroma": { + "content": {"text/plain": "This is :moominmamma: note 0"}, + "conversation_id": 13679, + "local": true, + "spoiler_text": {"text/plain": "2hu"} + }, + "reblog": null, + "reblogged": false, + "reblogs_count": 0, + "replies_count": 0, + "sensitive": false, + "spoiler_text": "2hu", + "tags": [{"name": "2hu", "url": "/tag/2hu"}], + "uri": "https://pleroma.example.org/objects/198ed2a1-7912-4482-b559-244a0369e984", + "url": "https://pleroma.example.org/notice/9hptFmVJ02khbzYJaS", + "visibility": "public" + } +] +``` + +## `/api/v1/pleroma/accounts/update_*` +### Set and clear account avatar, banner, and background + +- PATCH `/api/v1/pleroma/accounts/update_avatar`: Set/clear user avatar image +- PATCH `/api/v1/pleroma/accounts/update_banner`: Set/clear user banner image +- PATCH `/api/v1/pleroma/accounts/update_background`: Set/clear user background image + +## `/api/v1/pleroma/accounts/confirmation_resend` +### Resend confirmation email +* Method `POST` +* Params: + * `email`: email of that needs to be verified +* Authentication: not required +* Response: 204 No Content + +## `/api/v1/pleroma/mascot` +### Gets user mascot image +* Method `GET` +* Authentication: required + +* Response: JSON. Returns a mastodon media attachment entity. +* Example response: +```json +{ + "id": "abcdefg", + "url": "https://pleroma.example.org/media/abcdefg.png", + "type": "image", + "pleroma": { + "mime_type": "image/png" + } +} +``` + +### Updates user mascot image +* Method `PUT` +* Authentication: required +* Params: + * `file`: Multipart image +* Response: JSON. Returns a mastodon media attachment entity + when successful, otherwise returns HTTP 415 `{"error": "error_msg"}` +* Example response: +```json +{ + "id": "abcdefg", + "url": "https://pleroma.example.org/media/abcdefg.png", + "type": "image", + "pleroma": { + "mime_type": "image/png" + } +} +``` +* Note: Behaves exactly the same as `POST /api/v1/upload`. + Can only accept images - any attempt to upload non-image files will be met with `HTTP 415 Unsupported Media Type`. + +## `/api/pleroma/notification_settings` +### Updates user notification settings +* Method `PUT` +* Authentication: required +* Params: + * `block_from_strangers`: BOOLEAN field, blocks notifications from accounts you do not follow + * `hide_notification_contents`: BOOLEAN field. When set to true, it removes the contents of a message from the push notification. +* Response: JSON. Returns `{"status": "success"}` if the update was successful, otherwise returns `{"error": "error_msg"}` + +## `/api/v1/pleroma/healthcheck` +### Healthcheck endpoint with additional system data. +* Method `GET` +* Authentication: not required +* Params: none +* Response: JSON, statuses (200 - healthy, 503 unhealthy). +* Example response: +```json +{ + "pool_size": 0, # database connection pool + "active": 0, # active processes + "idle": 0, # idle processes + "memory_used": 0.00, # Memory used + "healthy": true, # Instance state + "job_queue_stats": {} # Job queue stats +} +``` + +## `/api/pleroma/change_email` +### Change account email +* Method `POST` +* Authentication: required +* Params: + * `password`: user's password + * `email`: new email +* Response: JSON. Returns `{"status": "success"}` if the change was successful, `{"error": "[error message]"}` otherwise +* Note: Currently, Mastodon has no API for changing email. If they add it in future it might be incompatible with Pleroma. + +## `/api/pleroma/move_account` +### Move account +* Method `POST` +* Authentication: required +* Params: + * `password`: user's password + * `target_account`: the nickname of the target account (e.g. `foo@example.org`) +* Response: JSON. Returns `{"status": "success"}` if the change was successful, `{"error": "[error message]"}` otherwise +* Note: This endpoint emits a `Move` activity to all followers of the current account. Some remote servers will automatically unfollow the current account and follow the target account upon seeing this, but this depends on the remote server implementation and cannot be guaranteed. For local followers , they will automatically unfollow and follow if and only if they have set the `allow_following_move` preference ("Allow auto-follow when following account moves"). + +## `/api/pleroma/aliases` +### Get aliases of the current account +* Method `GET` +* Authentication: required +* Response: JSON. Returns `{"aliases": [alias, ...]}`, where `alias` is the nickname of an alias, e.g. `foo@example.org`. + +### Add alias to the current account +* Method `PUT` +* Authentication: required +* Params: + * `alias`: the nickname of the alias to add, e.g. `foo@example.org`. +* Response: JSON. Returns `{"status": "success"}` if the change was successful, `{"error": "[error message]"}` otherwise + +### Delete alias from the current account +* Method `DELETE` +* Authentication: required +* Params: + * `alias`: the nickname of the alias to delete, e.g. `foo@example.org`. +* Response: JSON. Returns `{"status": "success"}` if the change was successful, `{"error": "[error message]"}` otherwise + +# Pleroma Conversations + +Pleroma Conversations have the same general structure that Mastodon Conversations have. The behavior differs in the following ways when using these endpoints: + +1. Pleroma Conversations never add or remove recipients, unless explicitly changed by the user. +2. Pleroma Conversations statuses can be requested by Conversation id. +3. Pleroma Conversations can be replied to. + +Conversations have the additional field `recipients` under the `pleroma` key. This holds a list of all the accounts that will receive a message in this conversation. + +The status posting endpoint takes an additional parameter, `in_reply_to_conversation_id`, which, when set, will set the visiblity to direct and address only the people who are the recipients of that Conversation. + +⚠ Conversation IDs can be found in direct messages with the `pleroma.direct_conversation_id` key, do not confuse it with `pleroma.conversation_id`. + +## `GET /api/v1/pleroma/conversations/:id/statuses` +### Timeline for a given conversation +* Method `GET` +* Authentication: required +* Params: Like other timelines +* Response: JSON, statuses (200 - healthy, 503 unhealthy). + +## `GET /api/v1/pleroma/conversations/:id` +### The conversation with the given ID. +* Method `GET` +* Authentication: required +* Params: None +* Response: JSON, statuses (200 - healthy, 503 unhealthy). + +## `PATCH /api/v1/pleroma/conversations/:id` +### Update a conversation. Used to change the set of recipients. +* Method `PATCH` +* Authentication: required +* Params: + * `recipients`: A list of ids of users that should receive posts to this conversation. This will replace the current list of recipients, so submit the full list. The owner of owner of the conversation will always be part of the set of recipients, though. +* Response: JSON, statuses (200 - healthy, 503 unhealthy) + +## `POST /api/v1/pleroma/conversations/read` +### Marks all user's conversations as read. +* Method `POST` +* Authentication: required +* Params: None +* Response: JSON, returns a list of Mastodon Conversation entities that were marked as read (200 - healthy, 503 unhealthy). + +## `GET /api/v1/pleroma/emoji/pack?name=:name` + +### Get pack.json for the pack + +* Method `GET` +* Authentication: not required +* Params: + * `page`: page number for files (default 1) + * `page_size`: page size for files (default 30) +* Response: JSON, pack json with `files`, `files_count` and `pack` keys with 200 status or 404 if the pack does not exist. + +```json +{ + "files": {...}, + "files_count": 0, // emoji count in pack + "pack": {...} +} +``` + +## `POST /api/v1/pleroma/emoji/pack?name=:name` + +### Creates an empty pack + +* Method `POST` +* Authentication: required (admin) +* Params: + * `name`: pack name +* Response: JSON, "ok" and 200 status or 409 if the pack with that name already exists + +## `PATCH /api/v1/pleroma/emoji/pack?name=:name` + +### Updates (replaces) pack metadata + +* Method `PATCH` +* Authentication: required (admin) +* Params: + * `name`: pack name + * `metadata`: metadata to replace the old one + * `license`: Pack license + * `homepage`: Pack home page url + * `description`: Pack description + * `fallback-src`: Fallback url to download pack from + * `fallback-src-sha256`: SHA256 encoded for fallback pack archive + * `share-files`: is pack allowed for sharing (boolean) +* Response: JSON, updated "metadata" section of the pack and 200 status or 400 if there was a + problem with the new metadata (the error is specified in the "error" part of the response JSON) + +## `DELETE /api/v1/pleroma/emoji/pack?name=:name` + +### Delete a custom emoji pack + +* Method `DELETE` +* Authentication: required (admin) +* Params: + * `name`: pack name +* Response: JSON, "ok" and 200 status or 500 if there was an error deleting the pack + +## `GET /api/v1/pleroma/emoji/packs/import` + +### Imports packs from filesystem + +* Method `GET` +* Authentication: required (admin) +* Params: None +* Response: JSON, returns a list of imported packs. + +## `GET /api/v1/pleroma/emoji/packs/remote` + +### Make request to another instance for packs list + +* Method `GET` +* Authentication: required (admin) +* Params: + * `url`: url of the instance to get packs from + * `page`: page number for packs (default 1) + * `page_size`: page size for packs (default 50) +* Response: JSON with the pack list, hashmap with pack name and pack contents + +## `POST /api/v1/pleroma/emoji/packs/download` + +### Download pack from another instance + +* Method `POST` +* Authentication: required (admin) +* Params: + * `url`: url of the instance to download from + * `name`: pack to download from that instance + * `as`: (*optional*) name how to save pack +* Response: JSON, "ok" with 200 status if the pack was downloaded, or 500 if there were + errors downloading the pack + +## `POST /api/v1/pleroma/emoji/packs/files?name=:name` + +### Add new file to the pack + +* Method `POST` +* Authentication: required (admin) +* Params: + * `name`: pack name + * `file`: file needs to be uploaded with the multipart request or link to remote file. + * `shortcode`: (*optional*) shortcode for new emoji, must be unique for all emoji. If not sended, shortcode will be taken from original filename. + * `filename`: (*optional*) new emoji file name. If not specified will be taken from original filename. +* Response: JSON, list of files for updated pack (hashmap -> shortcode => filename) with status 200, either error status with error message. + +## `PATCH /api/v1/pleroma/emoji/packs/files?name=:name` + +### Update emoji file from pack + +* Method `PATCH` +* Authentication: required (admin) +* Params: + * `name`: pack name + * `shortcode`: emoji file shortcode + * `new_shortcode`: new emoji file shortcode + * `new_filename`: new filename for emoji file + * `force`: (*optional*) with true value to overwrite existing emoji with new shortcode +* Response: JSON, list with updated files for updated pack (hashmap -> shortcode => filename) with status 200, either error status with error message. + +## `DELETE /api/v1/pleroma/emoji/packs/files?name=:name` + +### Delete emoji file from pack + +* Method `DELETE` +* Authentication: required (admin) +* Params: + * `name`: pack name + * `shortcode`: emoji file shortcode +* Response: JSON, list with updated files for updated pack (hashmap -> shortcode => filename) with status 200, either error status with error message. + +## `GET /api/v1/pleroma/emoji/packs` + +### Lists local custom emoji packs + +* Method `GET` +* Authentication: not required +* Params: + * `page`: page number for packs (default 1) + * `page_size`: page size for packs (default 50) +* Response: `packs` key with JSON hashmap of pack name to pack contents and `count` key for count of packs. + +```json +{ + "packs": { + "pack_name": {...}, // pack contents + ... + }, + "count": 0 // packs count +} +``` + +## `GET /api/v1/pleroma/emoji/packs/archive?name=:name` + +### Requests a local pack archive from the instance + +* Method `GET` +* Authentication: not required +* Params: + * `name`: pack name +* Response: the archive of the pack with a 200 status code, 403 if the pack is not set as shared, + 404 if the pack does not exist + +## `GET /api/v1/pleroma/accounts/:id/scrobbles` +### Requests a list of current and recent Listen activities for an account +* Method `GET` +* Authentication: not required +* Params: None +* Response: An array of media metadata entities. +* Example response: +```json +[ + { + "account": {...}, + "id": "1234", + "title": "Some Title", + "artist": "Some Artist", + "album": "Some Album", + "length": 180000, + "created_at": "2019-09-28T12:40:45.000Z" + } +] +``` + +## `POST /api/v1/pleroma/scrobble` +### Creates a new Listen activity for an account +* Method `POST` +* Authentication: required +* Params: + * `title`: the title of the media playing + * `album`: the album of the media playing [optional] + * `artist`: the artist of the media playing [optional] + * `length`: the length of the media playing [optional] +* Response: the newly created media metadata entity representing the Listen activity + +# Emoji Reactions + +Emoji reactions work a lot like favourites do. They make it possible to react to a post with a single emoji character. To detect the presence of this feature, you can check `pleroma_emoji_reactions` entry in the features list of nodeinfo. + +## `PUT /api/v1/pleroma/statuses/:id/reactions/:emoji` +### React to a post with a unicode emoji +* Method: `PUT` +* Authentication: required +* Params: `emoji`: A unicode RGI emoji or a regional indicator +* Response: JSON, the status. + +## `DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji` +### Remove a reaction to a post with a unicode emoji +* Method: `DELETE` +* Authentication: required +* Params: `emoji`: A unicode RGI emoji or a regional indicator +* Response: JSON, the status. + +## `GET /api/v1/pleroma/statuses/:id/reactions` +### Get an object of emoji to account mappings with accounts that reacted to the post +* Method: `GET` +* Authentication: optional +* Params: None +* Response: JSON, a list of emoji/account list tuples, sorted by emoji insertion date, in ascending order, e.g, the first emoji in the list is the oldest. +* Example Response: +```json +[ + {"name": "😀", "count": 2, "me": true, "accounts": [{"id" => "xyz.."...}, {"id" => "zyx..."}]}, + {"name": "☕", "count": 1, "me": false, "accounts": [{"id" => "abc..."}]} +] +``` + +## `GET /api/v1/pleroma/statuses/:id/reactions/:emoji` +### Get an object of emoji to account mappings with accounts that reacted to the post for a specific emoji +* Method: `GET` +* Authentication: optional +* Params: None +* Response: JSON, a list of emoji/account list tuples +* Example Response: +```json +[ + {"name": "😀", "count": 2, "me": true, "accounts": [{"id" => "xyz.."...}, {"id" => "zyx..."}]} +] +``` + +## `POST /api/v1/pleroma/backups` +### Create a user backup archive + +* Method: `POST` +* Authentication: required +* Params: none +* Response: JSON +* Example response: + +```json +[{ + "content_type": "application/zip", + "file_size": 0, + "inserted_at": "2020-09-10T16:18:03.000Z", + "processed": false, + "url": "https://example.com/media/backups/archive-foobar-20200910T161803-QUhx6VYDRQ2wfV0SdA2Pfj_2CLM_ATUlw-D5l5TJf4Q.zip" +}] +``` + +## `GET /api/v1/pleroma/backups` +### Lists user backups + +* Method: `GET` +* Authentication: not required +* Params: none +* Response: JSON +* Example response: + +```json +[{ + "content_type": "application/zip", + "file_size": 55457, + "inserted_at": "2020-09-10T16:18:03.000Z", + "processed": true, + "url": "https://example.com/media/backups/archive-foobar-20200910T161803-QUhx6VYDRQ2wfV0SdA2Pfj_2CLM_ATUlw-D5l5TJf4Q.zip" +}] +``` + +## `GET /api/oauth_tokens` +### Retrieve a list of active sessions for the user +* Method: `GET` +* Authentication: required +* Params: none +* Response: JSON +* Example response: + +```json +[ + { + "app_name": "Pleroma FE", + "id": 9275, + "valid_until": "2121-11-24T15:51:08.234234" + }, + { + "app_name": "Patron", + "id": 8805, + "valid_until": "2121-10-26T18:09:59.857150" + }, + { + "app_name": "Soapbox FE", + "id": 9727, + "valid_until": "2121-12-25T16:52:39.692877" + } +] +``` + +## `DELETE /api/oauth_tokens/:id` +### Revoke a user session by its ID +* Method: `DELETE` +* Authentication: required +* Params: none +* Response: HTTP 200 on success, 500 on error diff --git a/docs/development/API/prometheus.md b/docs/development/API/prometheus.md new file mode 100644 index 0000000..a5158d9 --- /dev/null +++ b/docs/development/API/prometheus.md @@ -0,0 +1,44 @@ +# Prometheus Metrics + +Pleroma includes support for exporting metrics via the [prometheus_ex](https://github.com/deadtrickster/prometheus.ex) library. + +Config example: + +``` +config :prometheus, Pleroma.Web.Endpoint.MetricsExporter, + enabled: true, + auth: {:basic, "myusername", "mypassword"}, + ip_whitelist: ["127.0.0.1"], + path: "/api/pleroma/app_metrics", + format: :text +``` + +* `enabled` (Pleroma extension) enables the endpoint +* `ip_whitelist` (Pleroma extension) could be used to restrict access only to specified IPs +* `auth` sets the authentication (`false` for no auth; configurable to HTTP Basic Auth, see [prometheus-plugs](https://github.com/deadtrickster/prometheus-plugs#exporting) documentation) +* `format` sets the output format (`:text` or `:protobuf`) +* `path` sets the path to app metrics page + + +## `/api/pleroma/app_metrics` + +### Exports Prometheus application metrics + +* Method: `GET` +* Authentication: not required by default (see configuration options above) +* Params: none +* Response: text + +## Grafana + +### Config example + +The following is a config example to use with [Grafana](https://grafana.com) + +``` + - job_name: 'beam' + metrics_path: /api/pleroma/app_metrics + scheme: https + static_configs: + - targets: ['pleroma.soykaf.com'] +``` diff --git a/docs/development/ap_extensions.md b/docs/development/ap_extensions.md new file mode 100644 index 0000000..3d1caeb --- /dev/null +++ b/docs/development/ap_extensions.md @@ -0,0 +1,65 @@ +# AP Extensions +## Actor endpoints + +The following endpoints are additionally present into our actors. + +- `oauthRegistrationEndpoint` (`http://litepub.social/ns#oauthRegistrationEndpoint`) +- `uploadMedia` (`https://www.w3.org/ns/activitystreams#uploadMedia`) + +### oauthRegistrationEndpoint + +Points to MastodonAPI `/api/v1/apps` for now. + +See + +### uploadMedia + +Inspired by , it is part of the ActivityStreams namespace because it used to be part of the ActivityPub specification and got removed from it. + +Content-Type: multipart/form-data + +Parameters: +- (required) `file`: The file being uploaded +- (optionnal) `description`: A plain-text description of the media, for accessibility purposes. + +Response: HTTP 201 Created with the object into the body, no `Location` header provided as it doesn't have an `id` + +The object given in the reponse should then be inserted into an Object's `attachment` field. + +## ChatMessages + +`ChatMessage`s are the messages sent in 1-on-1 chats. They are similar to +`Note`s, but the addresing is done by having a single AP actor in the `to` +field. Addressing multiple actors is not allowed. These messages are always +private, there is no public version of them. They are created with a `Create` +activity. + +They are part of the `litepub` namespace as `http://litepub.social/ns#ChatMessage`. + +Example: + +```json +{ + "actor": "http://2hu.gensokyo/users/raymoo", + "id": "http://2hu.gensokyo/objects/1", + "object": { + "attributedTo": "http://2hu.gensokyo/users/raymoo", + "content": "You expected a cute girl? Too bad.", + "id": "http://2hu.gensokyo/objects/2", + "published": "2020-02-12T14:08:20Z", + "to": [ + "http://2hu.gensokyo/users/marisa" + ], + "type": "ChatMessage" + }, + "published": "2018-02-12T14:08:20Z", + "to": [ + "http://2hu.gensokyo/users/marisa" + ], + "type": "Create" +} +``` + +This setup does not prevent multi-user chats, but these will have to go through +a `Group`, which will be the recipient of the messages and then `Announce` them +to the users in the `Group`. diff --git a/docs/development/authentication_authorization.md b/docs/development/authentication_authorization.md new file mode 100644 index 0000000..183bfc2 --- /dev/null +++ b/docs/development/authentication_authorization.md @@ -0,0 +1,21 @@ +# Authentication & Authorization + +## OAuth token-based authentication & authorization + +* Pleroma supports hierarchical OAuth scopes, just like Mastodon but with added granularity of admin scopes. For a reference, see [Mastodon OAuth scopes](https://docs.joinmastodon.org/api/oauth-scopes/). + +* It is important to either define OAuth scope restrictions or explicitly mark OAuth scope check as skipped, for every controller action. To define scopes, call `plug(Pleroma.Web.Plugs.OAuthScopesPlug, %{scopes: [...]})`. To explicitly set OAuth scopes check skipped, call `plug(:skip_plug, Pleroma.Web.Plugs.OAuthScopesPlug )`. + +* In controllers, `use Pleroma.Web, :controller` will result in `action/2` (see `Pleroma.Web.controller/0` for definition) be called prior to actual controller action, and it'll perform security / privacy checks before passing control to actual controller action. + + For routes with `:authenticated_api` pipeline, authentication & authorization are expected, thus `OAuthScopesPlug` will be run unless explicitly skipped (also `EnsureAuthenticatedPlug` will be executed immediately before action even if there was an early run to give an early error, since `OAuthScopesPlug` supports `:proceed_unauthenticated` option, and other plugs may support similar options as well). + + For `:api` pipeline routes, it'll be verified whether `OAuthScopesPlug` was called or explicitly skipped, and if it was not then auth information will be dropped for request. Then `EnsurePublicOrAuthenticatedPlug` will be called to ensure that either the instance is not private or user is authenticated (unless explicitly skipped). Such automated checks help to prevent human errors and result in higher security / privacy for users. + +## Non-OAuth authentication + +* With non-OAuth authentication ([HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) or HTTP header- or params-provided auth), OAuth scopes check is _not_ performed for any action (since password is provided during the auth, requester is able to obtain a token with full permissions anyways); auth plugs invoke `Pleroma.Helpers.AuthHelper.skip_oauth(conn)` in this case. + +## Auth-related configuration, OAuth consumer mode etc. + +See `Authentication` section of [the configuration cheatsheet](../configuration/cheatsheet.md#authentication). diff --git a/docs/development/index.md b/docs/development/index.md new file mode 100644 index 0000000..01a6175 --- /dev/null +++ b/docs/development/index.md @@ -0,0 +1 @@ +This section contains notes and guidelines for developers. diff --git a/docs/development/setting_up_a_gitlab_runner.md b/docs/development/setting_up_a_gitlab_runner.md new file mode 100644 index 0000000..88beb82 --- /dev/null +++ b/docs/development/setting_up_a_gitlab_runner.md @@ -0,0 +1,9 @@ +# Setting up a Gitlab-runner + +When you push changes, a pipeline will start some automated jobs. These are done with so called [runners](https://docs.gitlab.com/runner/), services that run somewhere on a server and run these automated jobs. These jobs typically run tests and should pass. If not, you probably need to fix something. + +Generally, Pleroma provides a runner, so you don't need to set up your own. However, if for whatever reason you want to set up your own, here's some high level instructions. + +1. We use docker to run the jobs, so you should install that. For Debian, you need to allow non-free packages in the [source list](https://wiki.debian.org/SourcesList). Then you can install docker with `apt install docker-compose`. +2. You can [install](https://docs.gitlab.com/runner/install/index.html) and [configure](https://docs.gitlab.com/runner/register/index.html) a Gitlab-runner. It's probably easiest to install from the packages, but there are other options as well. +3. When registering the runner, you'll need some values. You can find them in the project under your own name. Choose "Settings", "CI/CD", and then expand "Runners". For executor you can choose "docker". For default image, you can use the image used in (although it shouldn't matter much). diff --git a/docs/development/setting_up_pleroma_dev.md b/docs/development/setting_up_pleroma_dev.md new file mode 100644 index 0000000..8da761d --- /dev/null +++ b/docs/development/setting_up_pleroma_dev.md @@ -0,0 +1,70 @@ +# Setting up a Pleroma development environment + +Pleroma requires some adjustments from the defaults for running the instance locally. The following should help you to get started. + +## Installing + +1. Install Pleroma as explained in [the docs](../installation/debian_based_en.md), with some exceptions: + * You can use your own fork of the repository and add pleroma as a remote `git remote add pleroma 'https://git.pleroma.social/pleroma/pleroma'` + * You can skip systemd and nginx and all that stuff + * No need to create a dedicated pleroma user, it's easier to just use your own user + * For the DB you can still choose a dedicated user, the mix tasks set it up for you so it's no extra work for you + * For domain you can use `localhost` + * instead of creating a `prod.secret.exs`, create `dev.secret.exs` + * No need to prefix with `MIX_ENV=prod`. We're using dev and that's the default MIX_ENV +2. Change the dev.secret.exs + * Change the scheme in `config :pleroma, Pleroma.Web.Endpoint` to http (see examples below) + * If you want to change other settings, you can do that too +3. You can now start the server `mix phx.server`. Once it's build and started, you can access the instance on `http://:` (e.g.http://localhost:4000 ) and should be able to do everything locally you normaly can. + +Example config to change the scheme to http. Change the port if you want to run on another port. +```elixir + config :pleroma, Pleroma.Web.Endpoint, + url: [host: "localhost", scheme: "http", port: 4000], +``` + +Example config to disable captcha. This makes it a bit easier to create test-users. +```elixir +config :pleroma, Pleroma.Captcha, + enabled: false +``` + +Example config to change the log level to info +```elixir +config :logger, :console, + # :debug :info :warning :error + level: :info +``` + +## Testing + +1. Create a `test.secret.exs` file with the content as shown below +2. Create the database user and test database. + 1. You can use the `config/setup_db.psql` as a template. Copy the file if you want and change the database name, user and password to the values for the test-database (e.g. 'pleroma_local_test' for database and user). Then run this file like you did during installation. + 2. The tests will try to create the Database, so we'll have to allow our test-database user to create databases, `sudo -Hu postgres psql -c "ALTER USER pleroma_local_test WITH CREATEDB;"` +3. Run the tests with `mix test`. The tests should succeed. + +Example content for the `test.secret.exs` file. Feel free to use another user, database name or password, just make sure the database is dedicated for the testing environment. +```elixir +# Pleroma test configuration + +# NOTE: This file should not be committed to a repo or otherwise made public +# without removing sensitive information. + +import Config + +config :pleroma, Pleroma.Repo, + username: "pleroma_local_test", + password: "mysuperduperpassword", + database: "pleroma_local_test", + hostname: "localhost" + +``` + +## Updating + +Update Pleroma as explained in [the docs](../administration/updating.md). Just make sure you pull from upstream and not from your own fork. + +## Working on multiple branches + +If you develop on a separate branch, it's possible you did migrations that aren't merged into another branch you're working on. If you have multiple things you're working on, it's probably best to set up multiple pleroma's each with their own database. If you finished with a branch and want to switch back to develop to start a new branch from there, you can drop the database and recreate the database (e.g. by using `config/setup_db.psql`). The commands to drop and recreate the database can be found in [the docs](../administration/backup.md). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..3799a00 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,19 @@ +# Introduction to Pleroma +## What is Pleroma? +Pleroma is a federated social networking platform, compatible with Mastodon and other ActivityPub implementations. It is free software licensed under the AGPLv3. +It actually consists of two components: a backend, named simply Pleroma, and a user-facing frontend, named Pleroma-FE. It also includes the Mastodon frontend, if that's your thing. +It's part of what we call the fediverse, a federated network of instances which speak common protocols and can communicate with each other. +One account on an instance is enough to talk to the entire fediverse! + +## How can I use it? + +Pleroma instances are already widely deployed, a list can be found at and . + +If you don't feel like joining an existing instance, but instead prefer to deploy your own instance, that's easy too! +Installation instructions can be found in the installation section of these docs. + +## I got an account, now what? +Great! Now you can explore the fediverse! Open the login page for your Pleroma instance (e.g. ) and login with your username and password. (If you don't have an account yet, click on Register) + +### Pleroma-FE +The default front-end used by Pleroma is Pleroma-FE. You can find more information on what it is and how to use it in the [Introduction to Pleroma-FE](../frontend). \ No newline at end of file diff --git a/docs/installation/alpine_linux_en.md b/docs/installation/alpine_linux_en.md new file mode 100644 index 0000000..7154bca --- /dev/null +++ b/docs/installation/alpine_linux_en.md @@ -0,0 +1,231 @@ +# Installing on Alpine Linux + +{! backend/installation/otp_vs_from_source_source.include !} + +## Installation + +This guide is a step-by-step installation guide for Alpine Linux. The instructions were verified against Alpine v3.10 standard image. You might miss additional dependencies if you use `netboot` instead. + +It assumes that you have administrative rights, either as root or a user with [sudo permissions](https://www.linode.com/docs/tools-reference/custom-kernels-distros/install-alpine-linux-on-your-linode/#configuration). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su -l -s $SHELL -c 'command'` instead. + +{! backend/installation/generic_dependencies.include !} + +### Prepare the system + +* The community repository must be enabled in `/etc/apk/repositories`. Depending on which version and mirror you use this looks like `http://alpine.42.fr/v3.10/community`. If you autogenerated the mirror during installation: + +```shell +awk 'NR==2' /etc/apk/repositories | sed 's/main/community/' | tee -a /etc/apk/repositories +``` + +* Then update the system, if not already done: + +```shell +sudo apk update +sudo apk upgrade +``` + +* Install some tools, which are needed later: + +```shell +sudo apk add git build-base cmake file-dev +``` + +### Install Elixir and Erlang + +* Install Erlang and Elixir: + +```shell +sudo apk add erlang erlang-runtime-tools erlang-xmerl elixir +``` + +* Install `erlang-eldap` if you want to enable ldap authenticator + +```shell +sudo apk add erlang-eldap +``` + +### Install PostgreSQL + +* Install Postgresql server: + +```shell +sudo apk add postgresql postgresql-contrib +``` + +* Initialize database: + +```shell +sudo /etc/init.d/postgresql start +``` + +* Enable and start postgresql server: + +```shell +sudo rc-update add postgresql +``` + +### Install media / graphics packages (optional, see [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md)) + +```shell +sudo apk add ffmpeg imagemagick exiftool +``` + +### Install PleromaBE + +* Add a new system user for the Pleroma service: + +```shell +sudo addgroup pleroma +sudo adduser -S -s /bin/false -h /opt/pleroma -H -G pleroma pleroma +``` + +**Note**: To execute a single command as the Pleroma system user, use `sudo -Hu pleroma command`. You can also switch to a shell by using `sudo -Hu pleroma $SHELL`. If you don’t have and want `sudo` on your system, you can use `su` as root user (UID 0) for a single command by using `su -l pleroma -s $SHELL -c 'command'` and `su -l pleroma -s $SHELL` for starting a shell. + +* Git clone the PleromaBE repository and make the Pleroma user the owner of the directory: + +```shell +sudo mkdir -p /opt/pleroma +sudo chown -R pleroma:pleroma /opt/pleroma +sudo -Hu pleroma git clone -b stable https://git.pleroma.social/pleroma/pleroma /opt/pleroma +``` + +* Change to the new directory: + +```shell +cd /opt/pleroma +``` + +* Install the dependencies for Pleroma and answer with `yes` if it asks you to install `Hex`: + +```shell +sudo -Hu pleroma mix deps.get +``` + +* Generate the configuration: `sudo -Hu pleroma MIX_ENV=prod mix pleroma.instance gen` + * Answer with `yes` if it asks you to install `rebar3`. + * This may take some time, because parts of pleroma get compiled first. + * After that it will ask you a few questions about your instance and generates a configuration file in `config/generated_config.exs`. + +* Check the configuration and if all looks right, rename it, so Pleroma will load it (`prod.secret.exs` for productive instance, `dev.secret.exs` for development instances): + +```shell +sudo -Hu pleroma mv config/{generated_config.exs,prod.secret.exs} +``` + +* The previous command creates also the file `config/setup_db.psql`, with which you can create the database: + +```shell +sudo -Hu postgres psql -f config/setup_db.psql +``` + +* Now run the database migration: + +```shell +sudo -Hu pleroma MIX_ENV=prod mix ecto.migrate +``` + +* Now you can start Pleroma already + +```shell +sudo -Hu pleroma MIX_ENV=prod mix phx.server +``` + +### Finalize installation + +If you want to open your newly installed instance to the world, you should run nginx or some other webserver/proxy in front of Pleroma and you should consider to create an OpenRC service file for Pleroma. + +#### Nginx + +* Install nginx, if not already done: + +```shell +sudo apk add nginx +``` + +* Setup your SSL cert, using your method of choice or certbot. If using certbot, first install it: + +```shell +sudo apk add certbot +``` + +and then set it up: + +```shell +sudo mkdir -p /var/lib/letsencrypt/ +sudo certbot certonly --email -d --standalone +``` + +If that doesn’t work, make sure, that nginx is not already running. If it still doesn’t work, try setting up nginx first (change ssl “on” to “off” and try again). + +* Copy the example nginx configuration to the nginx folder + +```shell +sudo cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/conf.d/pleroma.conf +``` + +* Before starting nginx edit the configuration and change it to your needs. You must change change `server_name` and the paths to the certificates. You can use `nano` (install with `apk add nano` if missing). + +``` +server { + server_name your.domain; + listen 80; + ... +} + +server { + server_name your.domain; + listen 443 ssl http2; + ... + ssl_trusted_certificate /etc/letsencrypt/live/your.domain/chain.pem; + ssl_certificate /etc/letsencrypt/live/your.domain/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your.domain/privkey.pem; + ... +} +``` +* (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + +* Enable and start nginx: + +```shell +sudo rc-update add nginx +sudo service nginx start +``` + +If you need to renew the certificate in the future, uncomment the relevant location block in the nginx config and run: + +```shell +sudo certbot certonly --email -d --webroot -w /var/lib/letsencrypt/ +``` + +#### OpenRC service + +* Copy example service file: + +```shell +sudo cp /opt/pleroma/installation/init.d/pleroma /etc/init.d/pleroma +``` + +* Make sure to start it during the boot + +```shell +sudo rc-update add pleroma +``` + +#### Create your first user + +If your instance is up and running, you can create your first user with administrative rights with the following task: + +```shell +sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new --admin +``` + +#### Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/arch_linux_en.md b/docs/installation/arch_linux_en.md new file mode 100644 index 0000000..f7d722e --- /dev/null +++ b/docs/installation/arch_linux_en.md @@ -0,0 +1,226 @@ +# Installing on Arch Linux + +{! backend/installation/otp_vs_from_source_source.include !} + +## Installation + +This guide will assume that you have administrative rights, either as root or a user with [sudo permissions](https://wiki.archlinux.org/index.php/Sudo). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su -s $SHELL -c 'command'` instead. + +### Required packages + +* `postgresql` +* `elixir` +* `git` +* `base-devel` +* `cmake` +* `file` + +#### Optional packages used in this guide + +* `nginx` (preferred, example configs for other reverse proxies can be found in the repo) +* `certbot` (or any other ACME client for Let’s Encrypt certificates) +* `ImageMagick` +* `ffmpeg` +* `exiftool` + +### Prepare the system + +* First update the system, if not already done: + +```shell +sudo pacman -Syu +``` + +* Install some of the above mentioned programs: + +```shell +sudo pacman -S git base-devel elixir cmake file +``` + +### Install PostgreSQL + +[Arch Wiki article](https://wiki.archlinux.org/index.php/PostgreSQL) + +* Install the `postgresql` package: + +```shell +sudo pacman -S postgresql +``` + +* Initialize the database cluster: + +```shell +sudo -iu postgres initdb -D /var/lib/postgres/data +``` + +* Start and enable the `postgresql.service` + +```shell +sudo systemctl enable --now postgresql.service +``` + +### Install media / graphics packages (optional, see [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md)) + +```shell +sudo pacman -S ffmpeg imagemagick perl-image-exiftool +``` + +### Install PleromaBE + +* Add a new system user for the Pleroma service: + +```shell +sudo useradd -r -s /bin/false -m -d /var/lib/pleroma -U pleroma +``` + +**Note**: To execute a single command as the Pleroma system user, use `sudo -Hu pleroma command`. You can also switch to a shell by using `sudo -Hu pleroma $SHELL`. If you don’t have and want `sudo` on your system, you can use `su` as root user (UID 0) for a single command by using `su -l pleroma -s $SHELL -c 'command'` and `su -l pleroma -s $SHELL` for starting a shell. + +* Git clone the PleromaBE repository and make the Pleroma user the owner of the directory: + +```shell +sudo mkdir -p /opt/pleroma +sudo chown -R pleroma:pleroma /opt/pleroma +sudo -Hu pleroma git clone -b stable https://git.pleroma.social/pleroma/pleroma /opt/pleroma +``` + +* Change to the new directory: + +```shell +cd /opt/pleroma +``` + +* Install the dependencies for Pleroma and answer with `yes` if it asks you to install `Hex`: + +```shell +sudo -Hu pleroma mix deps.get +``` + +* Generate the configuration: `sudo -Hu pleroma MIX_ENV=prod mix pleroma.instance gen` + * Answer with `yes` if it asks you to install `rebar3`. + * This may take some time, because parts of pleroma get compiled first. + * After that it will ask you a few questions about your instance and generates a configuration file in `config/generated_config.exs`. + +* Check the configuration and if all looks right, rename it, so Pleroma will load it (`prod.secret.exs` for productive instance, `dev.secret.exs` for development instances): + +```shell +sudo -Hu pleroma mv config/{generated_config.exs,prod.secret.exs} +``` + +* The previous command creates also the file `config/setup_db.psql`, with which you can create the database: + +```shell +sudo -Hu postgres psql -f config/setup_db.psql +``` + +* Now run the database migration: + +```shell +sudo -Hu pleroma MIX_ENV=prod mix ecto.migrate +``` + +* Now you can start Pleroma already + +```shell +sudo -Hu pleroma MIX_ENV=prod mix phx.server +``` + +### Finalize installation + +If you want to open your newly installed instance to the world, you should run nginx or some other webserver/proxy in front of Pleroma and you should consider to create a systemd service file for Pleroma. + +#### Nginx + +* Install nginx, if not already done: + +```shell +sudo pacman -S nginx +``` + +* Create directories for available and enabled sites: + +```shell +sudo mkdir -p /etc/nginx/sites-{available,enabled} +``` + +* Append the following line at the end of the `http` block in `/etc/nginx/nginx.conf`: + +```Nginx +include sites-enabled/*; +``` + +* Setup your SSL cert, using your method of choice or certbot. If using certbot, first install it: + +```shell +sudo pacman -S certbot certbot-nginx +``` + +and then set it up: + +```shell +sudo mkdir -p /var/lib/letsencrypt/ +sudo certbot certonly --email -d --standalone +``` + +If that doesn’t work, make sure, that nginx is not already running. If it still doesn’t work, try setting up nginx first (change ssl “on” to “off” and try again). + +--- + +* Copy the example nginx configuration and activate it: + +```shell +sudo cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/pleroma.nginx +sudo ln -s /etc/nginx/sites-available/pleroma.nginx /etc/nginx/sites-enabled/pleroma.nginx +``` + +* Before starting nginx edit the configuration and change it to your needs (e.g. change servername, change cert paths) + +* (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + +* Enable and start nginx: + +```shell +sudo systemctl enable --now nginx.service +``` + +If you need to renew the certificate in the future, uncomment the relevant location block in the nginx config and run: + +```shell +sudo certbot certonly --email -d --webroot -w /var/lib/letsencrypt/ +``` + +#### Other webserver/proxies + +You can find example configurations for them in `/opt/pleroma/installation/`. + +#### Systemd service + +* Copy example service file + +```shell +sudo cp /opt/pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service +``` + +* Edit the service file and make sure that all paths fit your installation +* Enable and start `pleroma.service`: + +```shell +sudo systemctl enable --now pleroma.service +``` + +#### Create your first user + +If your instance is up and running, you can create your first user with administrative rights with the following task: + +```shell +sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new --admin +``` + +#### Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md new file mode 100644 index 0000000..b61e4ad --- /dev/null +++ b/docs/installation/debian_based_en.md @@ -0,0 +1,189 @@ +# Installing on Debian Based Distributions + +{! backend/installation/otp_vs_from_source_source.include !} + +## Installation + +This guide will assume you are on Debian 12 (“bookworm”) or later. This guide should also work with Ubuntu 22.04 (“jammy”) and later. It also assumes that you have administrative rights, either as root or a user with [sudo permissions](https://www.digitalocean.com/community/tutorials/how-to-add-delete-and-grant-sudo-privileges-to-users-on-a-debian-vps). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su -s $SHELL -c 'command'` instead. + +{! backend/installation/generic_dependencies.include !} + +### Prepare the system + +* First update the system, if not already done: + +```shell +sudo apt update +sudo apt full-upgrade +``` + +* Install some of the above mentioned programs: + +```shell +sudo apt install git build-essential postgresql postgresql-contrib cmake libmagic-dev +``` + +### Install Elixir and Erlang + +* Install Elixir and Erlang (you might need to use backports or [asdf](https://github.com/asdf-vm/asdf) on old systems): + +```shell +sudo apt update +sudo apt install elixir erlang-dev erlang-nox +``` + + +### Optional packages: [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md) + +```shell +sudo apt install imagemagick ffmpeg libimage-exiftool-perl +``` + +### Install PleromaBE + +* Add a new system user for the Pleroma service: + +```shell +sudo useradd -r -s /bin/false -m -d /var/lib/pleroma -U pleroma +``` + +**Note**: To execute a single command as the Pleroma system user, use `sudo -Hu pleroma command`. You can also switch to a shell by using `sudo -Hu pleroma $SHELL`. If you don’t have and want `sudo` on your system, you can use `su` as root user (UID 0) for a single command by using `su -l pleroma -s $SHELL -c 'command'` and `su -l pleroma -s $SHELL` for starting a shell. + +* Git clone the PleromaBE repository and make the Pleroma user the owner of the directory: + +```shell +sudo mkdir -p /opt/pleroma +sudo chown -R pleroma:pleroma /opt/pleroma +sudo -Hu pleroma git clone -b stable https://git.pleroma.social/pleroma/pleroma /opt/pleroma +``` + +* Change to the new directory: + +```shell +cd /opt/pleroma +``` + +* Install the dependencies for Pleroma and answer with `yes` if it asks you to install `Hex`: + +```shell +sudo -Hu pleroma mix deps.get +``` + +* Generate the configuration: `sudo -Hu pleroma MIX_ENV=prod mix pleroma.instance gen` + * Answer with `yes` if it asks you to install `rebar3`. + * This may take some time, because parts of pleroma get compiled first. + * After that it will ask you a few questions about your instance and generates a configuration file in `config/generated_config.exs`. + +* Check the configuration and if all looks right, rename it, so Pleroma will load it (`prod.secret.exs` for productive instance, `dev.secret.exs` for development instances): + +```shell +sudo -Hu pleroma mv config/{generated_config.exs,prod.secret.exs} +``` + + +* The previous command creates also the file `config/setup_db.psql`, with which you can create the database: + +```shell +sudo -Hu postgres psql -f config/setup_db.psql +``` + +* Now run the database migration: + +```shell +sudo -Hu pleroma MIX_ENV=prod mix ecto.migrate +``` + +* Now you can start Pleroma already + +```shell +sudo -Hu pleroma MIX_ENV=prod mix phx.server +``` + +### Finalize installation + +If you want to open your newly installed instance to the world, you should run nginx or some other webserver/proxy in front of Pleroma and you should consider to create a systemd service file for Pleroma. + +#### Nginx + +* Install nginx, if not already done: + +```shell +sudo apt install nginx +``` + +* Setup your SSL cert, using your method of choice or certbot. If using certbot, first install it: + +```shell +sudo apt install certbot +``` + +and then set it up: + +```shell +sudo mkdir -p /var/lib/letsencrypt/ +sudo certbot certonly --email -d --standalone +``` + +If that doesn’t work, make sure, that nginx is not already running. If it still doesn’t work, try setting up nginx first (change ssl “on” to “off” and try again). + +--- + +* Copy the example nginx configuration and activate it: + +```shell +sudo cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/pleroma.nginx +sudo ln -s /etc/nginx/sites-available/pleroma.nginx /etc/nginx/sites-enabled/pleroma.nginx +``` + +* Before starting nginx edit the configuration and change it to your needs (e.g. change servername, change cert paths) + +* (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + +* Enable and start nginx: + +```shell +sudo systemctl enable --now nginx.service +``` + +If you need to renew the certificate in the future, uncomment the relevant location block in the nginx config and run: + +```shell +sudo certbot certonly --email -d --webroot -w /var/lib/letsencrypt/ +``` + +#### Other webserver/proxies + +You can find example configurations for them in `/opt/pleroma/installation/`. + +#### Systemd service + +* Copy example service file + +```shell +sudo cp /opt/pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service +``` + +* Edit the service file and make sure that all paths fit your installation +* Enable and start `pleroma.service`: + +```shell +sudo systemctl enable --now pleroma.service +``` + +#### Create your first user + +If your instance is up and running, you can create your first user with administrative rights with the following task: + +```shell +sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new --admin +``` + +#### Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md new file mode 100644 index 0000000..1424ad7 --- /dev/null +++ b/docs/installation/debian_based_jp.md @@ -0,0 +1,198 @@ +# Pleromaの入れ方 + +Note: This article is potentially outdated because at this time we may not have people who can speak this language well enough to update it. To see the up-to-date version, which may have significant differences or important caveats of the installation process, look up the English version. + +## 日本語訳について + +この記事は [Installing on Debian based distributions](Installing on Debian based distributions) の日本語訳です。何かがおかしいと思ったら、原文を見てください。 + +## インストール + +このガイドはDebian Bookwormを利用することを想定しています。Ubuntu 22.04でもおそらく動作します。また、ユーザはrootもしくはsudoにより管理者権限を持っていることを前提とします。もし、以下の操作をrootユーザで行う場合は、 `sudo` を無視してください。ただし、`sudo -Hu pleroma` のようにユーザを指定している場合には `su -s $SHELL -c 'command'` を代わりに使ってください。 + +### 必要なソフトウェア + +- PostgreSQL 9.6以上 (Ubuntu16.04では9.5しか提供されていないので,[](https://www.postgresql.org/download/linux/ubuntu/)こちらから新しいバージョンを入手してください) +- `postgresql-contrib` 9.6以上 (同上) +- Elixir 1.8 以上 ([Debianのリポジトリからインストールしないこと!!! ここからインストールすること!](https://elixir-lang.org/install.html#unix-and-unix-like)。または [asdf](https://github.com/asdf-vm/asdf) をpleromaユーザーでインストールしてください) +- `erlang-dev` +- `erlang-nox` +- `git` +- `build-essential` +- `cmake` +- `libmagic-dev` + +#### このガイドで利用している追加パッケージ + +- `nginx` (おすすめです。他のリバースプロキシを使う場合は、参考となる設定をこのリポジトリから探してください) +- `certbot` (または何らかのLet's Encrypt向けACMEクライアント) +- `ImageMagick` +- `ffmpeg` +- `exiftool` + +### システムを準備する + +* まずシステムをアップデートしてください。 +``` +sudo apt update +sudo apt full-upgrade +``` + +* 上記に挙げたパッケージをインストールしておきます。 +``` +sudo apt install git build-essential postgresql postgresql-contrib cmake ffmpeg imagemagick libmagic-dev +``` + +### ElixirとErlangをインストールします + +* Erlangのリポジトリをダウンロードおよびインストールします。 +``` +wget -P /tmp/ https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb +sudo dpkg -i /tmp/erlang-solutions_2.0_all.deb +``` + +* ElixirとErlangをインストールします、 +``` +sudo apt update +sudo apt install elixir erlang-dev erlang-nox +``` + +### オプションパッケージ: [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md) + +```shell +sudo apt install imagemagick ffmpeg libimage-exiftool-perl +``` + +### Pleroma BE (バックエンド) をインストールします + +* Pleroma用に新しいユーザーを作ります。 + +``` +sudo useradd -r -s /bin/false -m -d /var/lib/pleroma -U pleroma +``` + +**注意**: Pleromaユーザとして単発のコマンドを実行したい場合はは、`sudo -Hu pleroma command` を使ってください。シェルを使いたい場合は `sudo -Hu pleroma $SHELL`です。もし `sudo` を使わない場合は、rootユーザで `su -l pleroma -s $SHELL -c 'command'` とすることでコマンドを、`su -l pleroma -s $SHELL` とすることでシェルを開始できます。 + +* Gitリポジトリをクローンします。 +``` +sudo mkdir -p /opt/pleroma +sudo chown -R pleroma:pleroma /opt/pleroma +sudo -Hu pleroma git clone -b stable https://git.pleroma.social/pleroma/pleroma /opt/pleroma +``` + +* 新しいディレクトリに移動します。 +``` +cd /opt/pleroma +``` + +* Pleromaが依存するパッケージをインストールします。Hexをインストールしてもよいか聞かれたら、yesを入力してください。 +``` +sudo -Hu pleroma mix deps.get +``` + +* コンフィギュレーションを生成します。 +``` +sudo -Hu pleroma MIX_ENV=prod mix pleroma.instance gen +``` + * rebar3をインストールしてもよいか聞かれたら、yesを入力してください。 + * このときにpleromaの一部がコンパイルされるため、この処理には時間がかかります。 + * あなたのインスタンスについて、いくつかの質問されます。この質問により `config/generated_config.exs` という設定ファイルが生成されます。 + + +* コンフィギュレーションを確認して、もし問題なければ、ファイル名を変更してください。 +``` +sudo -Hu pleroma mv config/{generated_config.exs,prod.secret.exs} +``` + +* 先程のコマンドで、すでに `config/setup_db.psql` というファイルが作られています。このファイルをもとに、データベースを作成します。 +``` +sudo -Hu pleroma MIX_ENV=prod mix pleroma.instance gen +``` + +* そして、データベースのマイグレーションを実行します。 +``` +sudo -Hu pleroma MIX_ENV=prod mix ecto.migrate +``` + +* これでPleromaを起動できるようになりました。 +``` +sudo -Hu pleroma MIX_ENV=prod mix phx.server +``` + +### インストールの最終段階 + +あなたの新しいインスタンスを世界に向けて公開するには、nginx等のWebサーバやプロキシサーバをPleromaの前段に使用する必要があります。また、Pleroma のためにシステムサービスファイルを作成する必要があります。 + +#### Nginx + +* まだインストールしていないなら、nginxをインストールします。 +``` +sudo apt install nginx +``` + +* SSLをセットアップします。他の方法でもよいですが、ここではcertbotを説明します。 +certbotを使うならば、まずそれをインストールします。 +``` +sudo apt install certbot +``` +そしてセットアップします。 +``` +sudo mkdir -p /var/lib/letsencrypt/ +sudo certbot certonly --email -d --standalone +``` +もしうまくいかないときは、nginxが正しく動いていない可能性があります。先にnginxを設定してください。ssl "on" を "off" に変えてから再試行してください。 + +--- + +* nginxの設定ファイルサンプルをnginxフォルダーにコピーします。 +``` +sudo cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/pleroma.nginx +sudo ln -s /etc/nginx/sites-available/pleroma.nginx /etc/nginx/sites-enabled/pleroma.nginx +``` + +* nginxを起動する前に、設定ファイルを編集してください。例えば、サーバー名、証明書のパスなどを変更する必要があります。 +* nginxを再起動します。 +``` +sudo systemctl enable --now nginx.service +``` + +もし証明書を更新する必要が出てきた場合には、nginxの関連するlocationブロックのコメントアウトを外し、以下のコマンドを動かします。 + +``` +sudo certbot certonly --email -d --webroot -w /var/lib/letsencrypt/ +``` + +#### 他のWebサーバやプロキシ +これに関してはサンプルが `/opt/pleroma/installation/` にあるので、探してみてください。 + +#### Systemd サービス + +* サービスファイルのサンプルをコピーします。 +``` +sudo cp /opt/pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service +``` + +* サービスファイルを変更します。すべてのパスが正しいことを確認してください +* サービスを有効化し `pleroma.service` を開始してください +``` +sudo systemctl enable --now pleroma.service +``` + +#### 初期ユーザの作成 + +新たにインスタンスを作成したら、以下のコマンドにより管理者権限を持った初期ユーザを作成できます。 + +``` +sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new --admin +``` + +#### その他の設定とカスタマイズ + +{! backend/installation/further_reading.include !} + +## 質問ある? + +インストールについて質問がある、もしくは、うまくいかないときは、以下のところで質問できます。 + +* [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) +* **libera.chat** の **#pleroma** IRCチャンネル diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md new file mode 100644 index 0000000..50ed30d --- /dev/null +++ b/docs/installation/freebsd_en.md @@ -0,0 +1,222 @@ +# Installing on FreeBSD + +This document was written for FreeBSD 12.1, but should be work on future releases. + +{! backend/installation/generic_dependencies.include !} + +## Installing software used in this guide + +This assumes the target system has `pkg(8)`. + +``` +# pkg install elixir postgresql12-server postgresql12-client postgresql12-contrib git-lite sudo nginx gmake acme.sh cmake +``` + +Copy the rc.d scripts to the right directory: + +Setup the required services to automatically start at boot, using `sysrc(8)`. + +``` +# sysrc nginx_enable=YES +# sysrc postgresql_enable=YES +``` + +## Initialize postgres + +``` +# service postgresql initdb +# service postgresql start +``` + +### Install media / graphics packages (optional, see [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md)) + +```shell +# pkg install imagemagick ffmpeg p5-Image-ExifTool +``` + +## Configuring Pleroma + +Create a user for Pleroma: + +``` +# pw add user pleroma -m +# echo 'export LC_ALL="en_US.UTF-8"' >> /home/pleroma/.profile +# su -l pleroma +``` + +Clone the repository: + +``` +$ cd $HOME # Should be the same as /home/pleroma +$ git clone -b stable https://git.pleroma.social/pleroma/pleroma.git +``` + +Configure Pleroma. Note that you need a domain name at this point: + +``` +$ cd /home/pleroma/pleroma +$ mix deps.get # Enter "y" when asked to install Hex +$ MIX_ENV=prod mix pleroma.instance gen # You will be asked a few questions here. +$ cp config/generated_config.exs config/prod.secret.exs +``` + +Since Postgres is configured, we can now initialize the database. There should +now be a file in `config/setup_db.psql` that makes this easier. Edit it, and +*change the password* to a password of your choice. Make sure it is secure, since +it'll be protecting your database. As root, you can now initialize the database: + +``` +# cd /home/pleroma/pleroma +# sudo -Hu postgres -g postgres psql -f config/setup_db.psql +``` + +Postgres allows connections from all users without a password by default. To +fix this, edit `/var/db/postgres/data12/pg_hba.conf`. Change every `trust` to +`password`. + +Once this is done, restart Postgres with: +``` +# service postgresql restart +``` + +Run the database migrations. + +Back as the pleroma user, run the following to implement any database migrations. + +``` +# su -l pleroma +$ cd /home/pleroma/pleroma +$ MIX_ENV=prod mix ecto.migrate +``` + +You will need to do this whenever you update with `git pull`: + +## Configuring acme.sh + +We'll be using acme.sh in Stateless Mode for TLS certificate renewal. + +First, as root, allow the user `acme` to have access to the acme log file, as follows: + +``` +# touch /var/log/acme.sh.log +# chown acme:acme /var/log/acme.sh.log +# chmod 600 /var/log/acme.sh.log +``` + +Next, obtain your account fingerprint: + +``` +# sudo -Hu acme -g acme acme.sh --register-account +``` + +You need to add the following to your nginx configuration for the server +running on port 80: + +``` + location ~ ^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)$ { + default_type text/plain; + return 200 "$1.6fXAG9VyG0IahirPEU2ZerUtItW2DHzDzD9wZaEKpqd"; + } +``` + +Replace the string after after `$1.` with your fingerprint. + +Start nginx: + +``` +# service nginx start +``` + +It should now be possible to issue a cert (replace `example.com` +with your domain name): + +``` +# sudo -Hu acme -g acme acme.sh --issue -d example.com --stateless +``` + +Let's add auto-renewal to `/etc/crontab` +(replace `example.com` with your domain): + +``` +/usr/local/bin/sudo -Hu acme -g acme /usr/local/sbin/acme.sh -r -d example.com --stateless +``` + +### Configuring nginx + +FreeBSD's default nginx configuration does not contain an include directive, which is +typically used for multiple sites. Therefore, you will need to first create the required +directory as follows: + + +``` +# mkdir -p /usr/local/etc/nginx/sites-available +``` + +Next, add an `include` directive to `/usr/local/etc/nginx/nginx.conf`, within the `http {}` +block, as follows: + + +``` +http { +... + include /usr/local/etc/nginx/sites-available/*; +} +``` + +As root, copy `/home/pleroma/pleroma/installation/pleroma.nginx` to +`/usr/local/etc/nginx/sites-available/pleroma.nginx`. + +Edit the defaults of `/usr/local/etc/nginx/sites-available/pleroma.nginx`: + +* Change `ssl_trusted_certificate` to `/var/db/acme/certs/example.tld/example.tld.cer`. +* Change `ssl_certificate` to `/var/db/acme/certs/example.tld/fullchain.cer`. +* Change `ssl_certificate_key` to `/var/db/acme/certs/example.tld/example.tld.key`. +* Change all references of `example.tld` to your instance's domain name. + +#### (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + +## Creating a startup script for Pleroma + +Pleroma will need to compile when it initially starts, which typically takes a longer +period of time. Therefore, it is good practice to initially run pleroma from the +command-line before utilizing the rc.d script. That is done as follows: + +``` +# su -l pleroma +$ cd $HOME/pleroma +$ MIX_ENV=prod mix phx.server +``` + +Copy the startup script to the correct location and make sure it's executable: + +``` +# cp /home/pleroma/pleroma/installation/freebsd/rc.d/pleroma /usr/local/etc/rc.d/pleroma +# chmod +x /usr/local/etc/rc.d/pleroma +``` + +Update the `/etc/rc.conf` and start pleroma with the following commands: + +``` +# sysrc pleroma_enable=YES +# service pleroma start +``` + +#### Create your first user + +If your instance is up and running, you can create your first user with administrative rights with the following task: + +```shell +sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new --admin +``` +## Conclusion + +Restart nginx with `# service nginx restart` and you should be up and running. + +Make sure your time is in sync, or other instances will receive your posts with +incorrect timestamps. You should have ntpd running. + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/further_reading.include b/docs/installation/further_reading.include new file mode 100644 index 0000000..46752c7 --- /dev/null +++ b/docs/installation/further_reading.include @@ -0,0 +1,5 @@ +* [How Federation Works/Why is my Federated Timeline empty?](https://blog.soykaf.com/post/how-federation-works/) +* [Backup your instance](../administration/backup.md) +* [Updating your instance](../administration/updating.md) +* [Hardening your instance](../configuration/hardening.md) +* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) diff --git a/docs/installation/generic_dependencies.include b/docs/installation/generic_dependencies.include new file mode 100644 index 0000000..3365a36 --- /dev/null +++ b/docs/installation/generic_dependencies.include @@ -0,0 +1,16 @@ +## Required dependencies + +* PostgreSQL >=9.6 +* Elixir >=1.11.0 <1.15 +* Erlang OTP >=22.2.0 <26 +* git +* file / libmagic +* gcc or clang +* GNU make +* CMake + +## Optional dependencies + +* ImageMagick +* FFmpeg +* exiftool diff --git a/docs/installation/gentoo_en.md b/docs/installation/gentoo_en.md new file mode 100644 index 0000000..87128d6 --- /dev/null +++ b/docs/installation/gentoo_en.md @@ -0,0 +1,308 @@ +# Manual install on Gentoo GNU/Linux + +{! backend/installation/otp_vs_from_source.include !} + +This guide covers a manual from-source installation. To use the gentoo package, please check the [packaged installation guide for gentoo](./gentoo_otp_en.md). + +## Installation + +This guide will assume that you have administrative rights, either as root or a user with [sudo permissions](https://wiki.gentoo.org/wiki/Sudo). Lines that begin with `#` indicate that they should be run as the superuser. Lines using `$` should be run as the indicated user, e.g. `pleroma$` should be run as the `pleroma` user. + +{! backend/installation/generic_dependencies.include !} + +### Your make.conf, package.use, and USE flags + +The only specific USE flag you should need is the `uuid` flag for `dev-db/postgresql`. Add the following line to any new file in `/etc/portage/package.use`. If you would like a suggested name for the file, either `postgresql` or `pleroma` would do fine, depending on how you like to arrange your package.use flags. + +```text +dev-db/postgresql uuid +``` + +You could opt to add `USE="uuid"` to `/etc/portage/make.conf` if you'd rather set this as a global USE flags, but this flags does unrelated things in other packages, so keep that in mind if you elect to do so. + +Double check your compiler flags in `/etc/portage/make.conf`. If you require any special compilation flags or would like to set up remote builds, now is the time to do so. Be sure that your CFLAGS and MAKEOPTS make sense for the platform you are using. It is not recommended to use above `-O2` or risky optimization flags for a production server. + +### Installing a cron daemon + +Gentoo quite pointedly does not come with a cron daemon installed, and as such it is recommended you install one to automate certbot renewals and to allow other system administration tasks to be run automatically. Gentoo has [a whole wide world of cron options](https://wiki.gentoo.org/wiki/Cron) but if you just want A Cron That Works, `emerge --ask virtual/cron` will install the default cron implementation (probably cronie) which will work just fine. For the purpouses of this guide, we will be doing just that. + +### Required ebuilds + +* `dev-db/postgresql` +* `dev-lang/elixir` +* `dev-vcs/git` +* `dev-util/cmake` +* `sys-apps/file` + +#### Optional ebuilds used in this guide + +* `www-servers/nginx` (preferred, example configs for other reverse proxies can be found in the repo) +* `app-crypt/certbot` (or any other ACME client for Let’s Encrypt certificates) +* `app-crypt/certbot-nginx` (nginx certbot plugin that allows use of the all-powerful `--nginx` flag on certbot) +* `media-gfx/imagemagick` +* `media-video/ffmpeg` +* `media-libs/exiftool` + +### Prepare the system + +* First ensure that you have the latest copy of the portage ebuilds if you have not synced them yet: + +```shell + # emaint sync -a +``` + +* Emerge all required the required and suggested software in one go: + +```shell + # emerge --ask dev-db/postgresql dev-lang/elixir dev-vcs/git www-servers/nginx app-crypt/certbot app-crypt/certbot-nginx dev-util/cmake sys-apps/file +``` + +If you would not like to install the optional packages, remove them from this line. + +If you're running this from a low-powered virtual machine, it should work though it will take some time. There were no issues on a VPS with a single core and 1GB of RAM; if you are using an even more limited device and run into issues, you can try creating a swapfile or use a more powerful machine running Gentoo to [cross build](https://wiki.gentoo.org/wiki/Cross_build_environment). If you have a wait ahead of you, now would be a good time to take a break, strech a bit, refresh your beverage of choice and/or get a snack, and reply to Arch users' posts with "I use Gentoo btw" as we do. + +### Install PostgreSQL + +[Gentoo Wiki article](https://wiki.gentoo.org/wiki/PostgreSQL) as well as [PostgreSQL QuickStart](https://wiki.gentoo.org/wiki/PostgreSQL/QuickStart) might be worth a quick glance, as the way Gentoo handles postgres is slightly unusual, with built in capability to have two different databases running for testing and live or whatever other purpouse. While it is still straightforward to install, it does mean that the version numbers used in this guide might change for future updates, so keep an eye out for the output you get from `emerge` to ensure you are using the correct ones. + +* Install postgresql if you have not done so already: + +```shell + # emerge --ask dev-db/postgresql +``` + +Ensure that `/etc/conf.d/postgresql-11` has the encoding you want (it defaults to UTF8 which is probably what you want) and make any adjustments to the data directory if you find it necessary. Be sure to adjust the number at the end depending on what version of postgres you actually installed. + +* Initialize the database cluster + +The output from emerging postgresql should give you a command for initializing the postgres database. The default slot should be indicated in this command, ensure that it matches the command below. + +```shell + # emerge --config dev-db/postgresql:11 +``` + +* Start postgres and enable the system service + +```shell + # /etc/init.d/postgresql-11 start + # rc-update add postgresql-11 default + ``` + +### A note on licenses, the AGPL, and deployment procedures + +If you do not plan to make any modifications to your Pleroma instance, cloning directly from the main repo will get you what you need. However, if you plan on doing any contributions to upstream development, making changes or modifications to your instance, making custom themes, or want to play around--and let's be honest here, if you're using Gentoo that is most likely you--you will save yourself a lot of headache later if you take the time right now to fork the Pleroma repo and use that in the following section. + +Not only does this make it much easier to deploy changes you make, as you can commit and pull from upstream and all that good stuff from the comfort of your local machine then simply `git pull` on your instance server when you're ready to deploy, it also ensures you are compliant with the Affero General Public Licence that Pleroma is licenced under, which stipulates that all network services provided with modified AGPL code must publish their changes on a publicly available internet service and for free. It also makes it much easier to ask for help from and provide help to your fellow Pleroma admins if your public repo always reflects what you are running because it is part of your deployment procedure. + +### Install media / graphics packages (optional, see [`docs/installation/optional/media_graphics_packages.md`](docs/installation/optional/media_graphics_packages.md)) + +```shell +# emerge --ask media-video/ffmpeg media-gfx/imagemagick media-libs/exiftool +``` + +### Install PleromaBE + +* Add a new system user for the Pleroma service and set up default directories: + +Remove `,wheel` if you do not want this user to be able to use `sudo`, however note that being able to `sudo` as the `pleroma` user will make finishing the insallation and common maintenence tasks somewhat easier: + +```shell + # useradd -m -G users,wheel -s /bin/bash pleroma +``` + +Optional: If you are using sudo, review your sudo setup to ensure it works for you. The `/etc/sudoers` file has a lot of options and examples to help you, and [the Gentoo sudo guide](https://wiki.gentoo.org/wiki/Sudo) has more information. Finishing this installation will be somewhat easier if you have a way to sudo from the `pleroma` user, but it might be best to not allow that user to sudo during normal operation, and as such there will be a reminder at the end of this guide to double check if you would like to lock down the `pleroma` user after initial setup. + +**Note**: To execute a single command as the Pleroma system user, use `sudo -Hu pleroma command`. You can also switch to a shell by using `sudo -Hu pleroma $SHELL`. If you don't have or want `sudo` or would like to use the system as the `pleroma` user for instance maintenance tasks, you can simply use `su - pleroma` to switch to the `pleroma` user. + +* Git clone the PleromaBE repository and make the Pleroma user the owner of the directory: + +It is highly recommended you use your own fork for the `https://path/to/repo` part below, however if you foolishly decide to forego using your own fork, the primary repo `https://git.pleroma.social/pleroma/pleroma` will work here. + +```shell + pleroma$ cd ~ + pleroma$ git clone -b stable https://path/to/repo +``` + +* Change to the new directory: + +```shell +pleroma$ cd ~/pleroma +``` + +* Install the dependencies for Pleroma and answer with `yes` if it asks you to install `Hex`: + +```shell +pleroma$ mix deps.get +``` + +* Generate the configuration: + +```shell +pleroma$ MIX_ENV=prod mix pleroma.instance gen +``` + + * Answer with `yes` if it asks you to install `rebar3`. + + * This part precompiles some parts of Pleroma, so it might take a few moments + + * After that it will ask you a few questions about your instance and generates a configuration file in `config/generated_config.exs`. + + * Spend some time with `generated_config.exs` to ensure that everything is in order. If you plan on using an S3-compatible service to store your local media, that can be done here. You will likely mostly be using `prod.secret.exs` for a production instance, however if you would like to set up a development environment, make a copy to `dev.secret.exs` and adjust settings as needed as well. + +```shell +pleroma$ mv config/generated_config.exs config/prod.secret.exs +``` + +* The previous command creates also the file `config/setup_db.psql`, with which you can create the database. Ensure that it is using the correct database name on the `CREATE DATABASE` and the `\c` lines, then run the postgres script: + +```shell +pleroma$ sudo -Hu postgres psql -f config/setup_db.psql +``` + +* Now run the database migration: + +```shell +pleroma$ MIX_ENV=prod mix ecto.migrate +``` + +* Now you can start Pleroma already + +```shell +pleroma$ MIX_ENV=prod mix phx.server +``` + +It probably won't work over the public internet quite yet, however, as we still need to set up a web servere to proxy to the pleroma application, as well as configure SSL. + +### Finalize installation + +Assuming you want to open your newly installed federated social network to, well, the federation, you should run nginx or some other webserver/proxy in front of Pleroma. It is also a good idea to set up Pleroma to run as a system service. + +#### Nginx + +* Install nginx, if not already done: + +```shell + # emerge --ask www-servers/nginx +``` + +* Create directories for available and enabled sites: + +```shell + # mkdir -p /etc/nginx/sites-{available,enabled} +``` + +* Append the following line at the end of the `http` block in `/etc/nginx/nginx.conf`: + +```Nginx +include sites-enabled/*; +``` + +* Setup your SSL cert, using your method of choice or certbot. If using certbot, install it if you haven't already: + +```shell + # emerge --ask app-crypt/certbot app-crypt/certbot-nginx +``` + +and then set it up: + +```shell + # mkdir -p /var/lib/letsencrypt/ + # certbot certonly --email -d --standalone +``` + +If that doesn't work the first time, add `--dry-run` to further attempts to avoid being ratelimited as you identify the issue, and do not remove it until the dry run succeeds. If that doesn’t work, make sure, that nginx is not already running. If it still doesn’t work, try setting up nginx first (change ssl “on” to “off” and try again). Often the answer to issues with certbot is to use the `--nginx` flag once you have nginx up and running. + +If you are using any additional subdomains, such as for a media proxy, you can re-run the same command with the subdomain in question. When it comes time to renew later, you will not need to run multiple times for each domain, one renew will handle it. + +--- + +* Copy the example nginx configuration and activate it: + +```shell + # cp /home/pleroma/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/ + # ln -s /etc/nginx/sites-available/pleroma.nginx /etc/nginx/sites-enabled/pleroma.nginx +``` + +* Take some time to ensure that your nginx config is correct + +Replace all instances of `example.tld` with your instance's public URL. If for whatever reason you made changes to the port that your pleroma app runs on, be sure that is reflected in your configuration. + +Pay special attention to the line that begins with `ssl_ecdh_curve`. It is stongly advised to comment that line out so that OpenSSL will use its full capabilities, and it is also possible you are running OpenSSL 1.0.2 necessitating that you do this. + +* (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + +* Enable and start nginx: + +```shell + # rc-update add nginx default + # /etc/init.d/nginx start +``` + +If you are using certbot, it is HIGHLY recommend you set up a cron job that renews your certificate, and that you install the suggested `certbot-nginx` plugin. If you don't do these things, you only have yourself to blame when your instance breaks suddenly because you forgot about it. + +First, ensure that the command you will be installing into your crontab works. + +```shell + # /usr/bin/certbot renew --nginx +``` + +Assuming not much time has passed since you got certbot working a few steps ago, you should get a message for all domains you installed certificates for saying `Cert not yet due for renewal`. + +Now, run crontab as a superuser with `crontab -e` or `sudo crontab -e` as appropriate, and add the following line to your cron: + +```cron +0 0 1 * * /usr/bin/certbot renew --nginx +``` + +This will run certbot on the first of the month at midnight. If you'd rather run more frequently, it's not a bad idea, feel free to go for it. + +#### Other webserver/proxies + +If you would like to use other webservers or proxies, there are example configurations for some popular alternatives in `/home/pleroma/pleroma/installation/`. You can, of course, check out [the Gentoo wiki](https://wiki.gentoo.org) for more information on installing and configuring said alternatives. + +#### Create the uploads folder + +Even if you are using S3, Pleroma needs someplace to store media posted on your instance. If you are using the `/home/pleroma/pleroma` root folder suggested by this guide, simply: + +```shell + pleroma$ mkdir -p ~/pleroma/uploads + ``` + +#### init.d service + +* Copy example service file + +```shell + # cp /home/pleroma/pleroma/installation/init.d/pleroma /etc/init.d/ +``` + +* Be sure to take a look at this service file and make sure that all paths fit your installation + +* Enable and start `pleroma`: + +```shell + # rc-update add pleroma default + # /etc/init.d/pleroma start +``` + +#### Create your first user + +If your instance is up and running, you can create your first user with administrative rights with the following task: + +```shell +pleroma$ MIX_ENV=prod mix pleroma.user new --admin +``` + +#### Privilege cleanup + +If you opted to allow sudo for the `pleroma` user but would like to remove the ability for greater security, now might be a good time to edit `/etc/sudoers` and/or change the groups the `pleroma` user belongs to. Be sure to restart the pleroma service afterwards to ensure it picks up on the changes. + +#### Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/gentoo_otp_en.md b/docs/installation/gentoo_otp_en.md new file mode 100644 index 0000000..4fafc0c --- /dev/null +++ b/docs/installation/gentoo_otp_en.md @@ -0,0 +1,207 @@ +# Packaged install on Gentoo Linux + +{! backend/installation/otp_vs_from_source.include !} + +This guide covers installation via Gentoo provided packaging. A [manual installation guide for gentoo](./gentoo_en.md) is also available. + +## Installation + +This guide will assume that you have administrative rights, either as root or a user with [sudo permissions](https://wiki.gentoo.org/wiki/Sudo). Lines that begin with `#` indicate that they should be run as the superuser. Lines using `$` should be run as the indicated user, e.g. `pleroma$` should be run as the `pleroma` user. + +{! backend/installation/generic_dependencies.include !} + +### Installing a cron daemon + +Gentoo quite pointedly does not come with a cron daemon installed, and as such it is recommended you install one to automate certbot renewals and to allow other system administration tasks to be run automatically. Gentoo has [a whole wide world of cron options](https://wiki.gentoo.org/wiki/Cron) but if you just want A Cron That Works, `emerge --ask virtual/cron` will install the default cron implementation (probably cronie) which will work just fine. For the purpouses of this guide, we will be doing just that. + +### Required ebuilds + +* `www-apps/pleroma` + +#### Optional ebuilds used in this guide + +* `www-servers/nginx` (preferred, example configs for other reverse proxies can be found in the repo) +* `app-crypt/certbot` (or any other ACME client for Let’s Encrypt certificates) +* `app-crypt/certbot-nginx` (nginx certbot plugin that allows use of the all-powerful `--nginx` flag on certbot) +* `media-gfx/imagemagick` +* `media-video/ffmpeg` +* `media-libs/exiftool` + +### Prepare the system + +* If you haven't yet done so, add the [Gentoo User Repository (GURU)](https://wiki.gentoo.org/wiki/Project:GURU), where the `www-apps/pleroma` ebuild currently lives at: +```shell + # eselect repository enable guru +``` + +* Ensure that you have the latest copy of the Gentoo and GURU ebuilds if you have not synced them yet: + +```shell + # emaint sync -a +``` + + +* Emerge all required the required and suggested software in one go: + +```shell + # emerge --ask www-apps/pleroma www-servers/nginx app-crypt/certbot app-crypt/certbot-nginx +``` + +If you would not like to install the optional packages, remove them from this line. + +If you're running this from a low-powered virtual machine, it should work though it will take some time. There were no issues on a VPS with a single core and 1GB of RAM; if you are using an even more limited device and run into issues, you can try creating a swapfile or use a more powerful machine running Gentoo to [cross build](https://wiki.gentoo.org/wiki/Cross_build_environment). If you have a wait ahead of you, now would be a good time to take a break, strech a bit, refresh your beverage of choice and/or get a snack, and reply to Arch users' posts with "I use Gentoo btw" as we do. + +### Setup PostgreSQL + +[Gentoo Wiki article](https://wiki.gentoo.org/wiki/PostgreSQL) as well as [PostgreSQL QuickStart](https://wiki.gentoo.org/wiki/PostgreSQL/QuickStart) might be worth a quick glance, as the way Gentoo handles postgres is slightly unusual, with built in capability to have two different databases running for testing and live or whatever other purpouse. While it is still straightforward to install, it does mean that the version numbers used in this guide might change for future updates, so keep an eye out for the output you get from `emerge` to ensure you are using the correct ones. + +* Initialize the database cluster + +The output from emerging postgresql should give you a command for initializing the postgres database. The default slot should be indicated in this command, ensure that it matches the command below. + +```shell + # emerge --config dev-db/postgresql:11 +``` + +### Install media / graphics packages (optional) + +See [Optional software packages needed for specific functionality](optional/media_graphics_packages.md) for details. + +```shell +# emerge --ask media-video/ffmpeg media-gfx/imagemagick media-libs/exiftool +``` + +### Setup PleromaBE + +* Generate the configuration: + +```shell + # pleroma_ctl instance gen --output /etc/pleroma/config.exs --output-psql /tmp/setup_db.psql" +``` + +* Create the PostgreSQL database + +```shell + # sudo -u postgres -s $SHELL -lc "psql -f /tmp/setup_db.psql" +``` + +* Now run the database migration: + +```shell + # pleroma_ctl migrate +``` + +* Optional: If you have installed RUM indexes (`dev-db/rum`) you also need to run: +``` + # sudo -Hu pleroma "pleroma_ctl migrate --migrations-path priv/repo/optional_migrations/rum_indexing/" +``` + +* Now you can start Pleroma already and add it in the default runlevel + +```shell + # rc-service pleroma start + # rc-update add pleroma default +``` + +It probably won't work over the public internet quite yet, however, as we still need to set up a web server to proxy to the pleroma application, as well as configure SSL. + +### Finalize installation + +Assuming you want to open your newly installed federated social network to, well, the federation, you should run nginx or some other webserver/proxy in front of Pleroma. It is also a good idea to set up Pleroma to run as a system service. + +#### Nginx + +* Install nginx, if not already done: + +```shell + # emerge --ask www-servers/nginx +``` + +* Create directories for available and enabled sites: + +```shell + # mkdir -p /etc/nginx/sites-{available,enabled} +``` + +* Append the following line at the end of the `http` block in `/etc/nginx/nginx.conf`: + +```Nginx +include sites-enabled/*; +``` + +* Setup your SSL cert, using your method of choice or certbot. If using certbot, install it if you haven't already: + +```shell + # emerge --ask app-crypt/certbot app-crypt/certbot-nginx +``` + +and then set it up: + +```shell + # mkdir -p /var/lib/letsencrypt/ + # certbot certonly --email -d --standalone +``` + +If that doesn't work the first time, add `--dry-run` to further attempts to avoid being ratelimited as you identify the issue, and do not remove it until the dry run succeeds. If that doesn’t work, make sure, that nginx is not already running. If it still doesn’t work, try setting up nginx first (change ssl “on” to “off” and try again). Often the answer to issues with certbot is to use the `--nginx` flag once you have nginx up and running. + +If you are using any additional subdomains, such as for a media proxy, you can re-run the same command with the subdomain in question. When it comes time to renew later, you will not need to run multiple times for each domain, one renew will handle it. + +--- + +* Copy the example nginx configuration and activate it: + +```shell + # cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/ + # ln -s /etc/nginx/sites-available/pleroma.nginx /etc/nginx/sites-enabled/pleroma.nginx +``` + +* Take some time to ensure that your nginx config is correct + +Replace all instances of `example.tld` with your instance's public URL. If for whatever reason you made changes to the port that your pleroma app runs on, be sure that is reflected in your configuration. + +Pay special attention to the line that begins with `ssl_ecdh_curve`. It is stongly advised to comment that line out so that OpenSSL will use its full capabilities, and it is also possible you are running OpenSSL 1.0.2 necessitating that you do this. + +* Enable and start nginx: + +```shell + # rc-update add nginx default + # /etc/init.d/nginx start +``` + +If you are using certbot, it is HIGHLY recommend you set up a cron job that renews your certificate, and that you install the suggested `certbot-nginx` plugin. If you don't do these things, you only have yourself to blame when your instance breaks suddenly because you forgot about it. + +First, ensure that the command you will be installing into your crontab works. + +```shell + # /usr/bin/certbot renew --nginx +``` + +Assuming not much time has passed since you got certbot working a few steps ago, you should get a message for all domains you installed certificates for saying `Cert not yet due for renewal`. + +Now, run crontab as a superuser with `crontab -e` or `sudo crontab -e` as appropriate, and add the following line to your cron: + +```cron +0 0 1 * * /usr/bin/certbot renew --nginx +``` + +This will run certbot on the first of the month at midnight. If you'd rather run more frequently, it's not a bad idea, feel free to go for it. + +#### Other webserver/proxies + +If you would like to use other webservers or proxies, there are example configurations for some popular alternatives in `/opt/pleroma/installation/`. You can, of course, check out [the Gentoo wiki](https://wiki.gentoo.org) for more information on installing and configuring said alternatives. + +#### Create your first user + +If your instance is up and running, you can create your first user with administrative rights with the following task: + +```shell +pleroma$ pleroma_ctl user new --admin +``` + +#### Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/migrating_from_source_otp_en.md b/docs/installation/migrating_from_source_otp_en.md new file mode 100644 index 0000000..7988625 --- /dev/null +++ b/docs/installation/migrating_from_source_otp_en.md @@ -0,0 +1,143 @@ +# Switching a from-source install to OTP releases + +{! backend/installation/otp_vs_from_source.include !} + +In this guide we cover how you can migrate from a from source installation to one using OTP releases. + +## Pre-requisites +You will be running commands as root. If you aren't root already, please elevate your privileges by executing `sudo su`/`su`. + +The system needs to have `curl` and `unzip` installed for downloading and unpacking release builds. + +=== "Alpine" + ```sh + apk add curl unzip + ``` + +=== "Debian/Ubuntu" + ```sh + apt install curl unzip + ``` + +## Moving content out of the application directory +When using OTP releases the application directory changes with every version so it would be a bother to keep content there (and also dangerous unless `--no-rm` option is used when updating). Fortunately almost all paths in Pleroma are configurable, so it is possible to move them out of there. + +Pleroma should be stopped before proceeding. + +### Moving uploads/custom public files directory + +```sh +# Create uploads directory and set proper permissions (skip if using a remote uploader) +# Note: It does not have to be `/var/lib/pleroma/uploads`, you can configure it to be something else later +mkdir -p /var/lib/pleroma/uploads +chown -R pleroma /var/lib/pleroma + +# Create custom public files directory +# Note: It does not have to be `/var/lib/pleroma/static`, you can configure it to be something else later +mkdir -p /var/lib/pleroma/static +chown -R pleroma /var/lib/pleroma + +# If you use the local uploader with default settings your uploads should be located in `~pleroma/uploads` +mv ~pleroma/uploads/* /var/lib/pleroma/uploads + +# If you have created the custom public files directory with default settings it should be located in `~pleroma/instance/static` +mv ~pleroma/instance/static /var/lib/pleroma/static +``` + +### Moving emoji +Assuming you have all emojis in subdirectories of `priv/static/emoji` moving them can be done with +```sh +mkdir /var/lib/pleroma/static/emoji +ls -d ~pleroma/priv/static/emoji/*/ | xargs -i sh -c 'mv "{}" "/var/lib/pleroma/static/emoji/$(basename {})"' +``` + +But, if for some reason you have custom emojis in the root directory you should copy the whole directory instead. +```sh +mv ~pleroma/priv/static/emoji /var/lib/pleroma/static/emoji +``` +and then copy custom emojis to `/var/lib/pleroma/static/emoji/custom`. + +This is needed because storing custom emojis in the root directory is deprecated, but if you just move them to `/var/lib/pleroma/static/emoji/custom` it will break emoji urls on old posts. + +Note that globs have been replaced with `pack_extensions`, so if your emojis are not in png/gif you should [modify the default value](../configuration/cheatsheet.md#emoji). + +### Moving the config +```sh +# Create the config directory +# The default path for Pleroma config is /etc/pleroma/config.exs +# but it can be set via PLEROMA_CONFIG_PATH environment variable +mkdir -p /etc/pleroma + +# Move the config file +mv ~pleroma/config/prod.secret.exs /etc/pleroma/config.exs + +# Change `use Mix.Config` at the top to `import Config` +$EDITOR /etc/pleroma/config.exs +``` +## Installing the release +Before proceeding, get the flavour from [Detecting flavour](otp_en.md#detecting-flavour) section in OTP installation guide. +```sh +# Delete all files in pleroma user's directory +rm -r ~pleroma/* + +# Set the flavour environment variable to the string you got in Detecting flavour section. +# For example if the flavour is `amd64-musl` the command will be +export FLAVOUR="amd64-musl" + +# Clone the release build into a temporary directory and unpack it +# Replace `stable` with `unstable` if you want to run the unstable branch +sudo -Hu pleroma " +curl 'https://git.pleroma.social/api/v4/projects/2/jobs/artifacts/stable/download?job=$FLAVOUR' -o /tmp/pleroma.zip +unzip /tmp/pleroma.zip -d /tmp/ +" + +# Move the release to the home directory and delete temporary files +sudo -Hu pleroma " +mv /tmp/release/* ~pleroma/ +rmdir /tmp/release +rm /tmp/pleroma.zip +" + +# Start the instance to verify that everything is working as expected +sudo -Hu pleroma "./bin/pleroma daemon" + +# Wait for about 20 seconds and query the instance endpoint, if it shows your uri, name and email correctly, you are configured correctly +sleep 20 && curl http://localhost:4000/api/v1/instance + +# Stop the instance +sudo -Hu pleroma "./bin/pleroma stop" +``` + +## Setting up a system service +OTP releases have different service files than from-source installs so they need to be copied over again. + +**Warning:** The service files assume pleroma user's home directory is `/opt/pleroma`, please make sure all paths fit your installation. + +=== "Alpine" + ```sh + # Copy the service into a proper directory + cp -f ~pleroma/installation/init.d/pleroma /etc/init.d/pleroma + + # Start pleroma + rc-service pleroma start + ``` + +=== "Debian/Ubuntu" + ```sh + # Copy the service into a proper directory + cp ~pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service + + # Reload service files + systemctl daemon-reload + + # Reenable pleroma to start on boot + systemctl reenable pleroma + + # Start pleroma + systemctl start pleroma + ``` + +## Running mix tasks +Refer to [Running mix tasks](otp_en.md#running-mix-tasks) section from OTP release installation guide. +## Updating +Refer to [Updating](otp_en.md#updating) section from OTP release installation guide. diff --git a/docs/installation/netbsd_en.md b/docs/installation/netbsd_en.md new file mode 100644 index 0000000..2ade7df --- /dev/null +++ b/docs/installation/netbsd_en.md @@ -0,0 +1,215 @@ +# Installing on NetBSD + +{! backend/installation/generic_dependencies.include !} + +## Installing software used in this guide + +pkgin should have been installed by the NetBSD installer if you selected +the right options. If it isn't installed, install it using pkg_add. + +Note that `postgresql11-contrib` is needed for the Postgres extensions +Pleroma uses. + +The `mksh` shell is needed to run the Elixir `mix` script. + +`# pkgin install acmesh elixir git-base git-docs mksh nginx postgresql11-server postgresql11-client postgresql11-contrib sudo ffmpeg4 ImageMagick` + +You can also build these packages using pkgsrc: +``` +databases/postgresql11-contrib +databases/postgresql11-client +databases/postgresql11-server +devel/git-base +devel/git-docs +devel/cmake +lang/elixir +security/acmesh +security/sudo +shells/mksh +www/nginx +``` + +Copy the rc.d scripts to the right directory: + +``` +# cp /usr/pkg/share/examples/rc.d/nginx /usr/pkg/share/examples/rc.d/pgsql /etc/rc.d +``` + +Add nginx and Postgres to `/etc/rc.conf`: + +``` +nginx=YES +pgsql=YES +``` + +## Configuring postgres + +First, run `# /etc/rc.d/pgsql start`. Then, `$ sudo -Hu pgsql -g pgsql createdb`. + +### Install media / graphics packages (optional, see [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md)) + +`# pkgin install ImageMagick ffmpeg4 p5-Image-ExifTool` + +## Configuring Pleroma + +Create a user for Pleroma: + +``` +# groupadd pleroma +# useradd -d /home/pleroma -m -g pleroma -s /usr/pkg/bin/mksh pleroma +# echo 'export LC_ALL="en_GB.UTF-8"' >> /home/pleroma/.profile +# su -l pleroma -c $SHELL +``` + +Clone the repository: + +``` +$ cd /home/pleroma +$ git clone -b stable https://git.pleroma.social/pleroma/pleroma.git +``` + +Configure Pleroma. Note that you need a domain name at this point: + +``` +$ cd /home/pleroma/pleroma +$ mix deps.get +$ MIX_ENV=prod mix pleroma.instance gen # You will be asked a few questions here. +``` + +Since Postgres is configured, we can now initialize the database. There should +now be a file in `config/setup_db.psql` that makes this easier. Edit it, and +*change the password* to a password of your choice. Make sure it is secure, since +it'll be protecting your database. Now initialize the database: + +``` +$ sudo -Hu pgsql -g pgsql psql -f config/setup_db.psql +``` + +Postgres allows connections from all users without a password by default. To +fix this, edit `/usr/pkg/pgsql/data/pg_hba.conf`. Change every `trust` to +`password`. + +Once this is done, restart Postgres with `# /etc/rc.d/pgsql restart`. + +Run the database migrations. +You will need to do this whenever you update with `git pull`: + +``` +$ MIX_ENV=prod mix ecto.migrate +``` + +## Configuring nginx + +Install the example configuration file +`/home/pleroma/pleroma/installation/pleroma.nginx` to +`/usr/pkg/etc/nginx.conf`. + +Note that it will need to be wrapped in a `http {}` block. You should add +settings for the nginx daemon outside of the http block, for example: + +``` +user nginx nginx; +error_log /var/log/nginx/error.log; +worker_processes 4; + +events { +} +``` + +Edit the defaults: + +* Change `ssl_certificate` and `ssl_trusted_certificate` to +`/etc/nginx/tls/fullchain`. +* Change `ssl_certificate_key` to `/etc/nginx/tls/key`. +* Change `example.tld` to your instance's domain name. + +### (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + +## Configuring acme.sh + +We'll be using acme.sh in Stateless Mode for TLS certificate renewal. + +First, get your account fingerprint: + +``` +$ sudo -Hu nginx -g nginx acme.sh --register-account +``` + +You need to add the following to your nginx configuration for the server +running on port 80: + +``` + location ~ ^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)$ { + default_type text/plain; + return 200 "$1.6fXAG9VyG0IahirPEU2ZerUtItW2DHzDzD9wZaEKpqd"; + } +``` + +Replace the string after after `$1.` with your fingerprint. + +Start nginx: + +``` +# /etc/rc.d/nginx start +``` + +It should now be possible to issue a cert (replace `example.com` +with your domain name): + +``` +$ sudo -Hu nginx -g nginx acme.sh --issue -d example.com --stateless +``` + +Let's add auto-renewal to `/etc/daily.local` +(replace `example.com` with your domain): + +``` +/usr/pkg/bin/sudo -Hu nginx -g nginx \ + /usr/pkg/sbin/acme.sh -r \ + -d example.com \ + --cert-file /etc/nginx/tls/cert \ + --key-file /etc/nginx/tls/key \ + --ca-file /etc/nginx/tls/ca \ + --fullchain-file /etc/nginx/tls/fullchain \ + --stateless +``` + +## Creating a startup script for Pleroma + +Copy the startup script to the correct location and make sure it's executable: + +``` +# cp /home/pleroma/pleroma/installation/netbsd/rc.d/pleroma /etc/rc.d/pleroma +# chmod +x /etc/rc.d/pleroma +``` + +Add the following to `/etc/rc.conf`: + +``` +pleroma=YES +pleroma_home="/home/pleroma" +pleroma_user="pleroma" +``` + +Run `# /etc/rc.d/pleroma start` to start Pleroma. + +## Conclusion + +Restart nginx with `# /etc/rc.d/nginx restart` and you should be up and running. + +Make sure your time is in sync, or other instances will receive your posts with +incorrect timestamps. You should have ntpd running. + +## Instances running NetBSD + +* + +#### Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/nixos_en.md b/docs/installation/nixos_en.md new file mode 100644 index 0000000..f3c4988 --- /dev/null +++ b/docs/installation/nixos_en.md @@ -0,0 +1,15 @@ +# Installing on NixOS + +NixOS contains a source build package of pleroma and a NixOS module to install it. +For installation add this to your configuration.nix and add a config.exs next to it: +```nix + services.pleroma = { + enable = true; + configs = [ (lib.fileContents ./config.exs) ]; + secretConfigFile = "/var/lib/pleroma/secret.exs"; + }; +``` + +## Questions +The nix community uses matrix for communication: [#nix:nixos.org](https://matrix.to/#/#nix:nixos.org) + diff --git a/docs/installation/openbsd_en.md b/docs/installation/openbsd_en.md new file mode 100644 index 0000000..9e7e040 --- /dev/null +++ b/docs/installation/openbsd_en.md @@ -0,0 +1,263 @@ +# Installing on OpenBSD + +This guide describes the installation and configuration of pleroma (and the required software to run it) on a single OpenBSD 6.6 server. + +For any additional information regarding commands and configuration files mentioned here, check the man pages [online](https://man.openbsd.org/) or directly on your server with the man command. + +{! backend/installation/generic_dependencies.include !} + +### Preparing the system +#### Required software + +To install them, run the following command (with doas or as root): + +``` +pkg_add elixir gmake git postgresql-server postgresql-contrib cmake ffmpeg ImageMagick +``` + +Pleroma requires a reverse proxy, OpenBSD has relayd in base (and is used in this guide) and packages/ports are available for nginx (www/nginx) and apache (www/apache-httpd). Independently of the reverse proxy, [acme-client(1)](https://man.openbsd.org/acme-client) can be used to get a certificate from Let's Encrypt. + +#### Optional software + +Per [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md): + * ImageMagick + * ffmpeg + * exiftool + +To install the above: + +``` +pkg_add ImageMagick ffmpeg p5-Image-ExifTool +``` + +#### Creating the pleroma user +Pleroma will be run by a dedicated user, \_pleroma. Before creating it, insert the following lines in login.conf: +``` +pleroma:\ + :datasize-max=1536M:\ + :datasize-cur=1536M:\ + :openfiles-max=4096 +``` +This creates a "pleroma" login class and sets higher values than default for datasize and openfiles (see [login.conf(5)](https://man.openbsd.org/login.conf)), this is required to avoid having pleroma crash some time after starting. + +Create the \_pleroma user, assign it the pleroma login class and create its home directory (/home/\_pleroma/): `useradd -m -L pleroma _pleroma` + +#### Clone pleroma's directory +Enter a shell as the \_pleroma user. As root, run `su _pleroma -;cd`. Then clone the repository with `git clone -b stable https://git.pleroma.social/pleroma/pleroma.git`. Pleroma is now installed in /home/\_pleroma/pleroma/, it will be configured and started at the end of this guide. + +#### PostgreSQL +Start a shell as the \_postgresql user (as root run `su _postgresql -` then run the `initdb` command to initialize postgresql: +You will need to specify pgdata directory to the default (/var/postgresql/data) with the `-D ` and set the user to postgres with the `-U ` flag. This can be done as follows: + +``` +initdb -D /var/postgresql/data -U postgres +``` +If you are not using the default directory, you will have to update the `datadir` variable in the /etc/rc.d/postgresql script. + +When this is done, enable postgresql so that it starts on boot and start it. As root, run: +``` +rcctl enable postgresql +rcctl start postgresql +``` +To check that it started properly and didn't fail right after starting, you can run `ps aux | grep postgres`, there should be multiple lines of output. + +#### httpd +httpd will have three fuctions: + + * redirect requests trying to reach the instance over http to the https URL + * serve a robots.txt file + * get Let's Encrypt certificates, with acme-client + +Insert the following config in httpd.conf: +``` +# $OpenBSD: httpd.conf,v 1.17 2017/04/16 08:50:49 ajacoutot Exp $ + +ext_inet="" +ext_inet6="" + +server "default" { + listen on $ext_inet port 80 # Comment to disable listening on IPv4 + listen on $ext_inet6 port 80 # Comment to disable listening on IPv6 + listen on 127.0.0.1 port 80 # Do NOT comment this line + + log syslog + directory no index + + location "/.well-known/acme-challenge/*" { + root "/acme" + request strip 2 + } + + location "/robots.txt" { root "/htdocs/local/" } + location "/*" { block return 302 "https://$HTTP_HOST$REQUEST_URI" } +} + +types { +} +``` +Do not forget to change ** to your server's address(es). If httpd should only listen on one protocol family, comment one of the two first *listen* options. + +Create the /var/www/htdocs/local/ folder and write the content of your robots.txt in /var/www/htdocs/local/robots.txt. +Check the configuration with `httpd -n`, if it is OK enable and start httpd (as root): +``` +rcctl enable httpd +rcctl start httpd +``` + +#### acme-client +acme-client is used to get SSL/TLS certificates from Let's Encrypt. +Insert the following configuration in /etc/acme-client.conf: +``` +# +# $OpenBSD: acme-client.conf,v 1.4 2017/03/22 11:14:14 benno Exp $ +# + +authority letsencrypt- { + #agreement url "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf" + api url "https://acme-v02.api.letsencrypt.org/directory" + account key "/etc/acme/letsencrypt-privkey-.pem" +} + +domain { + domain key "/etc/ssl/private/.key" + domain certificate "/etc/ssl/.crt" + domain full chain certificate "/etc/ssl/.fullchain.pem" + sign with letsencrypt- + challengedir "/var/www/acme/" +} +``` +Replace ** by the domain name you'll use for your instance. As root, run `acme-client -n` to check the config, then `acme-client -ADv ` to create account and domain keys, and request a certificate for the first time. +Make acme-client run everyday by adding it in /etc/daily.local. As root, run the following command: `echo "acme-client " >> /etc/daily.local`. + +Relayd will look for certificates and keys based on the address it listens on (see next part), the easiest way to make them available to relayd is to create a link, as root run: +``` +ln -s /etc/ssl/.fullchain.pem /etc/ssl/.crt +ln -s /etc/ssl/private/.key /etc/ssl/private/.key +``` +This will have to be done for each IPv4 and IPv6 address relayd listens on. + +#### relayd +relayd will be used as the reverse proxy sitting in front of pleroma. +Insert the following configuration in /etc/relayd.conf: +``` +# $OpenBSD: relayd.conf,v 1.4 2018/03/23 09:55:06 claudio Exp $ + +ext_inet="" +ext_inet6="" + +table { 127.0.0.1 } +table { 127.0.0.1 } + +http protocol plerup { # Protocol for upstream pleroma server + #tcp { nodelay, sack, socket buffer 65536, backlog 128 } # Uncomment and adjust as you see fit + tls ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305" + tls ecdhe secp384r1 + + # Forward some paths to the local server (as pleroma won't respond to them as you might want) + pass request quick path "/robots.txt" forward to + + # Append a bunch of headers + match request header append "X-Forwarded-For" value "$REMOTE_ADDR" # This two header and the next one are not strictly required by pleroma but adding them won't hurt + match request header append "X-Forwarded-By" value "$SERVER_ADDR:$SERVER_PORT" + + match response header append "X-XSS-Protection" value "1; mode=block" + match response header append "X-Permitted-Cross-Domain-Policies" value "none" + match response header append "X-Frame-Options" value "DENY" + match response header append "X-Content-Type-Options" value "nosniff" + match response header append "Referrer-Policy" value "same-origin" + match response header append "X-Download-Options" value "noopen" + match response header append "Content-Security-Policy" value "default-src 'none'; base-uri 'self'; form-action 'self'; img-src 'self' data: https:; media-src 'self' https:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self' wss://CHANGEME.tld; upgrade-insecure-requests;" # Modify "CHANGEME.tld" and set your instance's domain here + match request header append "Connection" value "upgrade" + #match response header append "Strict-Transport-Security" value "max-age=31536000; includeSubDomains" # Uncomment this only after you get HTTPS working. + + # If you do not want remote frontends to be able to access your Pleroma backend server, comment these lines + match response header append "Access-Control-Allow-Origin" value "*" + match response header append "Access-Control-Allow-Methods" value "POST, PUT, DELETE, GET, PATCH, OPTIONS" + match response header append "Access-Control-Allow-Headers" value "Authorization, Content-Type, Idempotency-Key" + match response header append "Access-Control-Expose-Headers" value "Link, X-RateLimit-Reset, X-RateLimit-Limit, X-RateLimit-Remaining, X-Request-Id" + # Stop commenting lines here +} + +relay wwwtls { + listen on $ext_inet port https tls # Comment to disable listening on IPv4 + listen on $ext_inet6 port https tls # Comment to disable listening on IPv6 + + protocol plerup + + forward to port 4000 check http "/" code 200 + forward to port 80 check http "/robots.txt" code 200 +} +``` +Again, change ** to your server's address(es) and comment one of the two *listen* options if needed. Also change *wss://CHANGEME.tld* to *wss://*. +Check the configuration with `relayd -n`, if it is OK enable and start relayd (as root): +``` +rcctl enable relayd +rcctl start relayd +``` + +##### (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + +#### pf +Enabling and configuring pf is highly recommended. +In /etc/pf.conf, insert the following configuration: +``` +# Macros +if="" +authorized_ssh_clients="any" + +# Skip traffic on loopback interface +set skip on lo + +# Default behavior +set block-policy drop +block in log all +pass out quick + +# Security features +match in all scrub (no-df random-id) +block in log from urpf-failed + +# Rules +pass in quick on $if inet proto icmp to ($if) icmp-type { echoreq unreach paramprob trace } # ICMP +pass in quick on $if inet6 proto icmp6 to ($if) icmp6-type { echoreq unreach paramprob timex toobig } # ICMPv6 +pass in quick on $if proto tcp to ($if) port { http https } # relayd/httpd +pass in quick on $if proto tcp from $authorized_ssh_clients to ($if) port ssh +``` +Replace ** by your server's network interface name (which you can get with ifconfig). Consider replacing the content of the authorized\_ssh\_clients macro by, for exemple, your home IP address, to avoid SSH connection attempts from bots. + +Check pf's configuration by running `pfctl -nf /etc/pf.conf`, load it with `pfctl -f /etc/pf.conf` and enable pf at boot with `rcctl enable pf`. + +#### Configure and start pleroma +Enter a shell as \_pleroma (as root `su _pleroma -`) and enter pleroma's installation directory (`cd ~/pleroma/`). + +Then follow the main installation guide: + + * run `mix deps.get` + * run `MIX_ENV=prod mix pleroma.instance gen` and enter your instance's information when asked + * copy config/generated\_config.exs to config/prod.secret.exs. The default values should be sufficient but you should edit it and check that everything seems OK. + * exit your current shell back to a root one and run `psql -U postgres -f /home/_pleroma/pleroma/config/setup_db.psql` to setup the database. + * return to a \_pleroma shell into pleroma's installation directory (`su _pleroma -;cd ~/pleroma`) and run `MIX_ENV=prod mix ecto.migrate` + +As \_pleroma in /home/\_pleroma/pleroma, you can now run `LC_ALL=en_US.UTF-8 MIX_ENV=prod mix phx.server` to start your instance. +In another SSH session/tmux window, check that it is working properly by running `ftp -MVo - http://127.0.0.1:4000/api/v1/instance`, you should get json output. Double-check that *uri*'s value is your instance's domain name. + +##### Starting pleroma at boot +An rc script to automatically start pleroma at boot hasn't been written yet, it can be run in a tmux session (tmux is in base). + + +#### Create administrative user + +If your instance is up and running, you can create your first user with administrative rights with the following command as the \_pleroma user. +``` +LC_ALL=en_US.UTF-8 MIX_ENV=prod mix pleroma.user new --admin +``` + +#### Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/openbsd_fi.md b/docs/installation/openbsd_fi.md new file mode 100644 index 0000000..73aca3a --- /dev/null +++ b/docs/installation/openbsd_fi.md @@ -0,0 +1,123 @@ +# Pleroman asennus OpenBSD:llä + +Note: This article is potentially outdated because at this time we may not have people who can speak this language well enough to update it. To see the up-to-date version, which may have significant differences or important caveats of the installation process, look up the English version. + +Tarvitset: +* Oman domainin +* OpenBSD 6.3 -serverin +* Auttavan ymmärryksen unix-järjestelmistä + +Komennot, joiden edessä on '#', tulee ajaa käyttäjänä `root`. Tämä on +suositeltavaa tehdä komennon `doas` avulla, katso `doas (1)` ja `doas.conf (5)`. +Tästä eteenpäin oletuksena on, että domain "esimerkki.com" osoittaa +serverin IP-osoitteeseen. + +Jos asennuksen kanssa on ongelmia, IRC-kanava #pleroma Libera.chat tai +Matrix-kanava #pleroma:libera.chat ovat hyviä paikkoja löytää apua +(englanniksi), `/msg eal kukkuu` jos haluat välttämättä puhua härmää. + +Asenna tarvittava ohjelmisto: + +`# pkg_add git elixir gmake postgresql-server-10.3 postgresql-contrib-10.3 cmake ffmpeg ImageMagick` + +#### Optional software + +[`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md): + * ImageMagick + * ffmpeg + * exiftool + +Asenna tarvittava ohjelmisto: + +`# pkg_add ImageMagick ffmpeg p5-Image-ExifTool` + +Luo postgresql-tietokanta: + +`# su - _postgresql` + +`$ mkdir /var/postgresql/data` + +`$ initdb -D /var/postgresql/data -E UTF8` + +`$ createdb` + +Käynnistä tietokanta ja aseta se käynnistymään automaattisesti. + +`# rcctl start postgresql` + +`# rcctl enable postgresql` + +Luo käyttäjä pleromaa varten (kysyy muutaman kysymyksen): + +`# adduser pleroma` + +Vaihda pleroma-käyttäjään ja mene kotihakemistoosi: + +`# su - pleroma` + +Lataa pleroman lähdekoodi: + +`$ git clone -b stable https://git.pleroma.social/pleroma/pleroma.git` + +`$ cd pleroma` + +Asenna tarvittavat elixir-kirjastot: + +`$ mix deps.get` + +`$ mix deps.compile` + +Luo tarvittava konfiguraatio: + +`$ mix generate_config` + +`$ cp config/generated_config.exs config/prod.secret.exs` + +Aja luodut tietokantakomennot: + +`# su _postgres -c 'psql -f config/setup_db.psql'` + +`$ MIX_ENV=prod mix ecto.migrate` + +Käynnistä pleroma-prosessi: + +`$ MIX_ENV=prod mix compile` + +`$ MIX_ENV=prod mix phx.server` + +Tässä vaiheessa on hyvä tarkistaa että asetukset ovat oikein. Avaa selaimella, +curlilla tai vastaavalla työkalulla `esimerkki.com:4000/api/v1/instance` ja katso +että kohta "uri" on "https://esimerkki.com". + +Huom! Muista varmistaa että muuttuja MIX_ENV on "prod" mix-komentoja ajaessasi. +Mix lukee oikean konfiguraatiotiedoston sen mukaisesti. + +Ohessa enimmäkseen toimivaksi todettu rc.d-skripti pleroman käynnistämiseen. +Kirjoita se tiedostoon /etc/rc.d/pleroma. Tämän jälkeen aja +`# chmod +x /etc/rc.d/pleroma`, ja voit käynnistää pleroman komennolla +`# /etc/rc.d/pleroma start`. + +``` +#!/bin/ksh +#/etc/rc.d/pleroma + +daemon="cd /home/pleroma/pleroma;MIX_ENV=prod /usr/local/bin/elixir" +daemon_flags="--detached /usr/local/bin/mix phx.server" +daemon_user="pleroma" +rc_reload="NO" +rc_bg="YES" + +pexp="beam" + +. /etc/rc.d/rc.subr + +rc_cmd $1 +``` + +Tämän jälkeen tarvitset enää HTTP-serverin välittämään kutsut pleroma-prosessille. +Tiedostosta `install/pleroma.nginx` löytyy esimerkkikonfiguraatio, ja TLS-sertifikaatit +saat ilmaiseksi esimerkiksi [letsencryptiltä](https://certbot.eff.org/lets-encrypt/opbsd-nginx.html). +Nginx asentuu yksinkertaisesti komennolla `# pkg_add nginx`. + +Kun olet valmis, avaa https://esimerkki.com selaimessasi. Luo käyttäjä ja seuraa kiinnostavia +tyyppejä muilla palvelimilla! diff --git a/docs/installation/optional/media_graphics_packages.md b/docs/installation/optional/media_graphics_packages.md new file mode 100644 index 0000000..ad01d47 --- /dev/null +++ b/docs/installation/optional/media_graphics_packages.md @@ -0,0 +1,37 @@ +# Optional software packages needed for specific functionality + +For specific Pleroma functionality (which is disabled by default) some or all of the below packages are required: + +* `ImageMagic` +* `ffmpeg` +* `exiftool` + +Please refer to documentation in `docs/installation` on how to install them on specific OS. + +Note: the packages are not required with the current default settings of Pleroma. + +## `ImageMagick` + +`ImageMagick` is a set of tools to create, edit, compose, or convert bitmap images. + +It is required for the following Pleroma features: + +* `Pleroma.Upload.Filters.Mogrify`, `Pleroma.Upload.Filters.Mogrifun` upload filters (related config: `Plaroma.Upload/filters` in `config/config.exs`) +* Media preview proxy for still images (related config: `media_preview_proxy/enabled` in `config/config.exs`) + +## `ffmpeg` + +`ffmpeg` is software to record, convert and stream audio and video. + +It is required for the following Pleroma features: + +* Media preview proxy for videos (related config: `media_preview_proxy/enabled` in `config/config.exs`) + +## `exiftool` + +`exiftool` is media files metadata reader/writer. + +It is required for the following Pleroma features: + +* `Pleroma.Upload.Filters.Exiftool.StripLocation` upload filter (related config: `Plaroma.Upload/filters` in `config/config.exs`) +* `Pleroma.Upload.Filters.Exiftool.ReadDescription` upload filter (related config: `Plaroma.Upload/filters` in `config/config.exs`) diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md new file mode 100644 index 0000000..a69b2fe --- /dev/null +++ b/docs/installation/otp_en.md @@ -0,0 +1,312 @@ +# Installing on Linux using OTP releases + +{! backend/installation/otp_vs_from_source.include !} + +This guide covers a installation using OTP releases as built by the Pleroma project, it is meant as a fallback to distribution packages/recipes which are the preferred installation method. +To install Pleroma from source, please check out the corresponding guide for your distro. + +## Pre-requisites +* A machine you have root access to running Debian GNU/Linux or compatible (eg. Ubuntu), or Alpine on `x86_64`, `aarch64` or `armv7l` CPU. If you are not sure what you are running see [Detecting flavour section](#detecting-flavour) below +* A (sub)domain pointed to the machine + +You will be running commands as root. If you aren't root already, please elevate your privileges by executing `sudo -i`/`su`. + +Similarly to other binaries, OTP releases tend to be only compatible with the distro they are built on, as such this guide focuses only on Debian/Ubuntu and Alpine. + +### Detecting flavour + +Paste the following into the shell: +```sh +arch="$(uname -m)";if [ "$arch" = "x86_64" ];then arch="amd64";elif [ "$arch" = "armv7l" ];then arch="arm";elif [ "$arch" = "aarch64" ];then arch="arm64";else echo "Unsupported arch: $arch">&2;fi;if getconf GNU_LIBC_VERSION>/dev/null;then libc_postfix="";elif [ "$(ldd 2>&1|head -c 9)" = "musl libc" ];then libc_postfix="-musl";elif [ "$(find /lib/libc.musl*|wc -l)" ];then libc_postfix="-musl";else echo "Unsupported libc">&2;fi;echo "$arch$libc_postfix" +``` + +This should give your flavour string. If not this just means that we don't build releases for your platform, you can still try installing from source. + +### Installing the required packages + +Other than things bundled in the OTP release Pleroma depends on: + +* curl (to download the release build) +* unzip (needed to unpack release builds) +* ncurses (ERTS won't run without it) +* PostgreSQL (also utilizes extensions in postgresql-contrib) +* nginx (could be swapped with another reverse proxy but this guide covers only it) +* certbot (for Let's Encrypt certificates, could be swapped with another ACME client, but this guide covers only it) +* libmagic/file + +=== "Alpine" + ``` + awk 'NR==2' /etc/apk/repositories | sed 's/main/community/' | tee -a /etc/apk/repositories + apk update + apk add curl unzip ncurses postgresql postgresql-contrib nginx certbot file-dev + ``` + +=== "Debian/Ubuntu" + ``` + apt install curl unzip libncurses5 postgresql postgresql-contrib nginx certbot libmagic-dev + ``` + +### Installing optional packages + +Per [`docs/installation/optional/media_graphics_packages.md`](optional/media_graphics_packages.md): + * ImageMagick + * ffmpeg + * exiftool + +=== "Alpine" + ``` + apk update + apk add imagemagick ffmpeg exiftool + ``` + +=== "Debian/Ubuntu" + ``` + apt install imagemagick ffmpeg libimage-exiftool-perl + ``` + +## Setup +### Configuring PostgreSQL +#### (Optional) Installing RUM indexes + +!!! warning + It is recommended to use PostgreSQL v11 or newer. We have seen some minor issues with lower PostgreSQL versions. + +RUM indexes are an alternative indexing scheme that is not included in PostgreSQL by default. You can read more about them on the [Configuration page](../configuration/cheatsheet.md#rum-indexing-for-full-text-search). They are completely optional and most of the time are not worth it, especially if you are running a single user instance (unless you absolutely need ordered search results). + +=== "Alpine" + ``` + apk add git build-base postgresql-dev + git clone https://github.com/postgrespro/rum /tmp/rum + cd /tmp/rum + make USE_PGXS=1 + make USE_PGXS=1 install + cd + rm -r /tmp/rum + ``` + +=== "Debian/Ubuntu" + ``` + # Available only on Buster/19.04 + apt install postgresql-11-rum + ``` + +#### (Optional) Performance configuration +It is encouraged to check [Optimizing your PostgreSQL performance](../configuration/postgresql.md) document, for tips on PostgreSQL tuning. + +Restart PostgreSQL to apply configuration changes: + +=== "Alpine" + ``` + rc-service postgresql restart + ``` + +=== "Debian/Ubuntu" + ``` + systemctl restart postgresql + ``` + +### Installing Pleroma +```sh +# Create a Pleroma user +adduser --system --shell /bin/false --home /opt/pleroma pleroma + +# Set the flavour environment variable to the string you got in Detecting flavour section. +# For example if the flavour is `amd64-musl` the command will be +export FLAVOUR="amd64-musl" + +# Clone the release build into a temporary directory and unpack it +sudo -Hu pleroma " +curl 'https://git.pleroma.social/api/v4/projects/2/jobs/artifacts/stable/download?job=$FLAVOUR' -o /tmp/pleroma.zip +unzip /tmp/pleroma.zip -d /tmp/ +" + +# Move the release to the home directory and delete temporary files +sudo -Hu pleroma " +mv /tmp/release/* /opt/pleroma +rmdir /tmp/release +rm /tmp/pleroma.zip +" +# Create uploads directory and set proper permissions (skip if planning to use a remote uploader) +# Note: It does not have to be `/var/lib/pleroma/uploads`, the config generator will ask about the upload directory later + +mkdir -p /var/lib/pleroma/uploads +chown -R pleroma /var/lib/pleroma + +# Create custom public files directory (custom emojis, frontend bundle overrides, robots.txt, etc.) +# Note: It does not have to be `/var/lib/pleroma/static`, the config generator will ask about the custom public files directory later +mkdir -p /var/lib/pleroma/static +chown -R pleroma /var/lib/pleroma + +# Create a config directory +mkdir -p /etc/pleroma +chown -R pleroma /etc/pleroma + +# Run the config generator +sudo -Hu pleroma "./bin/pleroma_ctl instance gen --output /etc/pleroma/config.exs --output-psql /tmp/setup_db.psql" + +# Create the postgres database +sudo -u postgres -s $SHELL -lc "psql -f /tmp/setup_db.psql" + +# Create the database schema +sudo -Hu pleroma "./bin/pleroma_ctl migrate" + +# If you have installed RUM indexes uncommend and run +# sudo -Hu pleroma "./bin/pleroma_ctl migrate --migrations-path priv/repo/optional_migrations/rum_indexing/" + +# Start the instance to verify that everything is working as expected +sudo -Hu pleroma "./bin/pleroma daemon" + +# Wait for about 20 seconds and query the instance endpoint, if it shows your uri, name and email correctly, you are configured correctly +sleep 20 && curl http://localhost:4000/api/v1/instance + +# Stop the instance +sudo -Hu pleroma "./bin/pleroma stop" +``` + +### Setting up nginx and getting Let's Encrypt SSL certificaties + +#### Get a Let's Encrypt certificate +```sh +certbot certonly --standalone --preferred-challenges http -d yourinstance.tld +``` + +#### Copy Pleroma nginx configuration to the nginx folder + +The location of nginx configs is dependent on the distro + +=== "Alpine" + ``` + cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/conf.d/pleroma.conf + ``` + +=== "Debian/Ubuntu" + ``` + cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/pleroma.conf + ln -s /etc/nginx/sites-available/pleroma.conf /etc/nginx/sites-enabled/pleroma.conf + ``` + +If your distro does not have either of those you can append `include /etc/nginx/pleroma.conf` to the end of the http section in /etc/nginx/nginx.conf and +```sh +cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/pleroma.conf +``` + +#### Edit the nginx config +```sh +# Replace example.tld with your (sub)domain +$EDITOR path-to-nginx-config + +# Verify that the config is valid +nginx -t +``` +#### (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + +#### Start nginx + +=== "Alpine" + ``` + rc-service nginx start + ``` + +=== "Debian/Ubuntu" + ``` + systemctl start nginx + ``` + +At this point if you open your (sub)domain in a browser you should see a 502 error, that's because Pleroma is not started yet. + +### Setting up a system service + +=== "Alpine" + ``` + # Copy the service into a proper directory + cp /opt/pleroma/installation/init.d/pleroma /etc/init.d/pleroma + + # Start pleroma and enable it on boot + rc-service pleroma start + rc-update add pleroma + ``` + +=== "Debian/Ubuntu" + ``` + # Copy the service into a proper directory + cp /opt/pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service + + # Start pleroma and enable it on boot + systemctl start pleroma + systemctl enable pleroma + ``` + +If everything worked, you should see Pleroma-FE when visiting your domain. If that didn't happen, try reviewing the installation steps, starting Pleroma in the foreground and seeing if there are any errrors. + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC, you can also [file an issue on our Gitlab](https://git.pleroma.social/pleroma/pleroma-support/issues/new). + +## Post installation + +### Setting up auto-renew of the Let's Encrypt certificate +```sh +# Create the directory for webroot challenges +mkdir -p /var/lib/letsencrypt + +# Uncomment the webroot method +$EDITOR path-to-nginx-config + +# Verify that the config is valid +nginx -t +``` + +=== "Alpine" + ``` + # Restart nginx + rc-service nginx restart + + # Start the cron daemon and make it start on boot + rc-service crond start + rc-update add crond + + # Ensure the webroot menthod and post hook is working + certbot renew --cert-name yourinstance.tld --webroot -w /var/lib/letsencrypt/ --dry-run --post-hook 'rc-service nginx reload' + + # Add it to the daily cron + echo '#!/bin/sh + certbot renew --cert-name yourinstance.tld --webroot -w /var/lib/letsencrypt/ --post-hook "rc-service nginx reload" + ' > /etc/periodic/daily/renew-pleroma-cert + chmod +x /etc/periodic/daily/renew-pleroma-cert + + # If everything worked the output should contain /etc/cron.daily/renew-pleroma-cert + run-parts --test /etc/periodic/daily + ``` + +=== "Debian/Ubuntu" + ``` + # Restart nginx + systemctl restart nginx + + # Ensure the webroot menthod and post hook is working + certbot renew --cert-name yourinstance.tld --webroot -w /var/lib/letsencrypt/ --dry-run --post-hook 'systemctl reload nginx' + + # Add it to the daily cron + echo '#!/bin/sh + certbot renew --cert-name yourinstance.tld --webroot -w /var/lib/letsencrypt/ --post-hook "systemctl reload nginx" + ' > /etc/cron.daily/renew-pleroma-cert + chmod +x /etc/cron.daily/renew-pleroma-cert + + # If everything worked the output should contain /etc/cron.daily/renew-pleroma-cert + run-parts --test /etc/cron.daily + ``` + +## Create your first user and set as admin +```sh +cd /opt/pleroma +su pleroma -s $SHELL -lc "./bin/pleroma_ctl user new joeuser joeuser@sld.tld --admin" +``` +This will create an account withe the username of 'joeuser' with the email address of joeuser@sld.tld, and set that user's account as an admin. This will result in a link that you can paste into the browser, which logs you in and enables you to set the password. + +## Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC, you can also [file an issue on our Gitlab](https://git.pleroma.social/pleroma/pleroma-support/issues/new). diff --git a/docs/installation/otp_vs_from_source.include b/docs/installation/otp_vs_from_source.include new file mode 100644 index 0000000..6c78202 --- /dev/null +++ b/docs/installation/otp_vs_from_source.include @@ -0,0 +1,8 @@ +## Packaged (OTP) installation vs Manual (from-source) installations + +There is multiple ways to install Pleroma. +
+
Distro-provided packages
This is the recommended method, where you can get the strongest compatibility guarantees and the best dependency-management
+
Pleroma-provided OTP binaries
Intended as fallback for Alpine/Debian-compatible systems lacking a proper Pleroma package, they are heavier than proper distro packages as they also contain Erlang/Elixir and can break after system updates
+
Manual from-source installation
Needs build-dependencies to be installed and manual updates+rebuilds. Allows for easier source-customisations.
+
diff --git a/docs/installation/otp_vs_from_source_source.include b/docs/installation/otp_vs_from_source_source.include new file mode 100644 index 0000000..9f71342 --- /dev/null +++ b/docs/installation/otp_vs_from_source_source.include @@ -0,0 +1,3 @@ +{! backend/installation/otp_vs_from_source.include !} + +This guide covers a manual from-source installation. To install using OTP releases, please check for the presence of a distro package, failing that you can use [Pleroma-provided OTP binaries](./otp_en.md). diff --git a/docs/installation/yunohost_en.md b/docs/installation/yunohost_en.md new file mode 100644 index 0000000..4c34e85 --- /dev/null +++ b/docs/installation/yunohost_en.md @@ -0,0 +1,9 @@ +# Installing on Yunohost + +[YunoHost](https://yunohost.org) is a server operating system aimed at self-hosting. The YunoHost community maintains a package of Pleroma which allows you to install Pleroma on YunoHost. You can install it via the normal way through the admin web interface, or through the CLI. More information can be found at [the repo of the package](https://github.com/YunoHost-Apps/pleroma_ynh). + +## Questions + +Questions and problems related to the YunoHost parts can be done through the [regular YunoHost channels](https://yunohost.org/en/help). + +For questions about Pleroma, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/elixir_buildpack.config b/elixir_buildpack.config new file mode 100644 index 0000000..1102e71 --- /dev/null +++ b/elixir_buildpack.config @@ -0,0 +1,2 @@ +elixir_version=1.10.4 +erlang_version=22.3.4.1 diff --git a/installation/apache-cache-purge.sh.example b/installation/apache-cache-purge.sh.example new file mode 100755 index 0000000..7b42628 --- /dev/null +++ b/installation/apache-cache-purge.sh.example @@ -0,0 +1,36 @@ +#!/bin/sh + +# A simple shell script to delete a media from Apache's mod_disk_cache. +# You will likely need to setup a sudo rule like the following: +# +# Cmnd_Alias HTCACHECLEAN = /usr/local/sbin/htcacheclean +# pleroma ALL=HTCACHECLEAN, NOPASSWD: HTCACHECLEAN +# +# Please also ensure you have enabled: +# +# config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script, url_format: :htcacheclean +# +# which will correctly format the URLs passed to this script for the htcacheclean utility. +# + +SCRIPTNAME=${0##*/} + +# mod_disk_cache directory +CACHE_DIRECTORY="/tmp/pleroma-media-cache" + +## Removes an item via the htcacheclean utility +## $1 - the filename, can be a pattern . +## $2 - the cache directory. +purge_item() { + sudo htcacheclean -v -p "${2}" "${1}" +} # purge_item + +purge() { + for url in $@ + do + echo "$SCRIPTNAME delete \`$url\` from cache ($CACHE_DIRECTORY)" + purge_item "$url" $CACHE_DIRECTORY + done +} + +purge $@ diff --git a/installation/caddyfile-pleroma.example b/installation/caddyfile-pleroma.example new file mode 100644 index 0000000..cc7dda0 --- /dev/null +++ b/installation/caddyfile-pleroma.example @@ -0,0 +1,17 @@ +# default Caddyfile config for Pleroma +# +# Simple installation instructions: +# 1. Replace 'example.tld' with your instance's domain wherever it appears. +# 2. Copy this section into your Caddyfile and restart Caddy. + +example.tld { + log { + output file /var/log/caddy/pleroma.log + } + + encode gzip + + # this is explicitly IPv4 since Pleroma.Web.Endpoint binds on IPv4 only + # and `localhost.` resolves to [::0] on some systems: see issue #930 + reverse_proxy 127.0.0.1:4000 +} diff --git a/installation/freebsd/rc.d/pleroma b/installation/freebsd/rc.d/pleroma new file mode 100755 index 0000000..f62aef1 --- /dev/null +++ b/installation/freebsd/rc.d/pleroma @@ -0,0 +1,27 @@ +#!/bin/sh +# $FreeBSD$ +# PROVIDE: pleroma +# REQUIRE: DAEMON postgresql +# KEYWORD: shutdown + +# sudo -u pleroma MIX_ENV=prod elixir --erl \"-detached\" -S mix phx.server + +. /etc/rc.subr + +name=pleroma +rcvar=pleroma_enable + +desc="Pleroma Social Media Platform" + +load_rc_config ${name} + +: ${pleroma_user:=pleroma} +: ${pleroma_home:=$(getent passwd ${pleroma_user} | awk -F: '{print $6}')} +: ${pleroma_chdir:="${pleroma_home}/pleroma"} +: ${pleroma_env:="HOME=${pleroma_home} MIX_ENV=prod"} + +command=/usr/local/bin/elixir +command_args="--erl \"-detached\" -S /usr/local/bin/mix phx.server" +procname="*beam.smp" + +run_rc_command "$1" diff --git a/installation/init.d/pleroma b/installation/init.d/pleroma new file mode 100755 index 0000000..cb6635a --- /dev/null +++ b/installation/init.d/pleroma @@ -0,0 +1,46 @@ +#!/sbin/openrc-run +supervisor=supervise-daemon +command_user=pleroma:pleroma +command_background=1 +# Ask process to terminate within 30 seconds, otherwise kill it +retry="SIGTERM/30/SIGKILL/5" +pidfile="/var/run/pleroma.pid" +directory=/opt/pleroma +healthcheck_delay=60 +healthcheck_timer=30 +no_new_privs="yes" + +: ${pleroma_port:-4000} + +# Needs OpenRC >= 0.42 +#respawn_max=0 +#respawn_delay=5 + +# put pleroma_console=YES in /etc/conf.d/pleroma if you want to be able to +# connect to pleroma via an elixir console +if yesno "${pleroma_console}"; then + command=elixir + command_args="--name pleroma@127.0.0.1 --erl '-kernel inet_dist_listen_min 9001 inet_dist_listen_max 9001 inet_dist_use_interface {127,0,0,1}' -S mix phx.server" + + start_post() { + einfo "You can get a console by using this command as pleroma's user:" + einfo "iex --name console@127.0.0.1 --remsh pleroma@127.0.0.1" + } +else + command=/usr/bin/mix + command_args="phx.server" +fi + +export MIX_ENV=prod + +depend() { + need nginx postgresql +} + +healthcheck() { + # put pleroma_health=YES in /etc/conf.d/pleroma if you want healthchecking + # and make sure you have curl installed + yesno "$pleroma_health" || return 0 + + curl -q "localhost:${pleroma_port}/api/pleroma/healthcheck" +} diff --git a/installation/netbsd/rc.d/pleroma b/installation/netbsd/rc.d/pleroma new file mode 100755 index 0000000..1114668 --- /dev/null +++ b/installation/netbsd/rc.d/pleroma @@ -0,0 +1,57 @@ +#!/bin/sh +# PROVIDE: pleroma +# REQUIRE: DAEMON pgsql + +if [ -f /etc/rc.subr ]; then + . /etc/rc.subr +fi + +name="pleroma" +rcvar=${name} +command="/usr/pkg/bin/elixir" +command_args="--detached -S /usr/pkg/bin/mix phx.server" +start_precmd="ulimit -n unlimited" +pidfile="/dev/null" + +pleroma_chdir="${pleroma_home}/pleroma" +pleroma_env="HOME=${pleroma_home} MIX_ENV=prod" + +check_pidfile() +{ + pid=$(pgrep -U "${pleroma_user}" /bin/beam.smp$) + echo -n "${pid}" +} + +if [ -f /etc/rc.subr -a -d /etc/rc.d -a -f /etc/rc.d/DAEMON ]; then + # newer NetBSD + load_rc_config ${name} + run_rc_command "$1" +else + # ancient NetBSD, Solaris and illumos, Linux, etc... + cmd=${1:-start} + + case ${cmd} in + start) + echo "Starting ${name}." + ${start_cmd} + ;; + + stop) + echo "Stopping ${name}." + check_pidfile + ! [ -n ${pid} ] && kill ${pid} + ;; + + restart) + ( $0 stop ) + sleep 5 + $0 start + ;; + + *) + echo 1>&2 "Usage: $0 [start|stop|restart]" + exit 1 + ;; + esac + exit 0 +fi diff --git a/installation/nginx-cache-purge.sh.example b/installation/nginx-cache-purge.sh.example new file mode 100755 index 0000000..5f6cbb1 --- /dev/null +++ b/installation/nginx-cache-purge.sh.example @@ -0,0 +1,40 @@ +#!/bin/sh + +# A simple shell script to delete a media from the Nginx cache. + +SCRIPTNAME=${0##*/} + +# NGINX cache directory +CACHE_DIRECTORY="/tmp/pleroma-media-cache" + +## Return the files where the items are cached. +## $1 - the filename, can be a pattern . +## $2 - the cache directory. +## $3 - (optional) the number of parallel processes to run for grep. +get_cache_files() { + local max_parallel=${3-16} + find $2 -maxdepth 2 -type d | xargs -P $max_parallel -n 1 grep -E -Rl "^KEY:.*$1" | sort -u +} + +## Removes an item from the given cache zone. +## $1 - the filename, can be a pattern . +## $2 - the cache directory. +purge_item() { + for f in $(get_cache_files $1 $2); do + echo "found file: $f" + [ -f $f ] || continue + echo "Deleting $f from $2." + rm $f + done +} # purge_item + +purge() { + for url in "$@" + do + echo "$SCRIPTNAME delete \`$url\` from cache ($CACHE_DIRECTORY)" + purge_item $url $CACHE_DIRECTORY + done + +} + +purge $@ diff --git a/installation/nsfw-api.service b/installation/nsfw-api.service new file mode 100644 index 0000000..ec629df --- /dev/null +++ b/installation/nsfw-api.service @@ -0,0 +1,15 @@ +[Unit] +Description=NSFW API +After=docker.service +Requires=docker.service + +[Service] +TimeoutStartSec=0 +Restart=always +ExecStartPre=-/usr/bin/docker stop %n +ExecStartPre=-/usr/bin/docker rm %n +ExecStartPre=/usr/bin/docker pull eugencepoi/nsfw_api:latest +ExecStart=/usr/bin/docker run --rm -p 127.0.0.1:5000:5000/tcp --env PORT=5000 --name %n eugencepoi/nsfw_api:latest + +[Install] +WantedBy=multi-user.target diff --git a/installation/openbsd/httpd.conf b/installation/openbsd/httpd.conf new file mode 100644 index 0000000..82f4803 --- /dev/null +++ b/installation/openbsd/httpd.conf @@ -0,0 +1,36 @@ +# +# Default httpd.conf file for Pleroma on OpenBSD +# Simple installation instructions +# 1. Place file in /etc +# 2. Replace with your public IP address +# 3. If using IPv6, uncomment IPv6 lines and replace with your public IPv6 address +# 4. Check file using 'doas httpd -n' +# 5. Enable and start httpd: +# # doas rcctl enable httpd +# # doas rcctl start httpd +# + +ext_inet="" +#ext_inet6="" + +server "default" { + listen on $ext_inet port 80 # Comment to disable listening on IPv4 +# listen on $ext_inet6 port 80 # Comment to disable listening on IPv6 + listen on 127.0.0.1 port 80 # Do NOT comment this line + + log syslog + directory no index + + location "/.well-known/acme-challenge/*" { + root "/acme" + request strip 2 + } + + location "/robots.txt" { root "/htdocs/local/" } + location "/*" { block return 302 "https://$HTTP_HOST$REQUEST_URI" } +} + +types { + include "/usr/share/misc/mime.types" +} + diff --git a/installation/openbsd/rc.d/pleromad b/installation/openbsd/rc.d/pleromad new file mode 100755 index 0000000..19ac4bb --- /dev/null +++ b/installation/openbsd/rc.d/pleromad @@ -0,0 +1,34 @@ +#!/bin/ksh +# +# Default init file for Pleroma on OpenBSD +# +# Simple installation instructions: +# 1. Install Pleroma per wiki instructions +# 2. Place this pleromad file in /etc/rc.d +# 3. Enable and start Pleroma +# # doas rcctl enable pleromad +# # doas rcctl start pleromad +# + +daemon="/usr/local/bin/elixir" +daemon_flags="--detached -S /usr/local/bin/mix phx.server" +daemon_user="_pleroma" + +. /etc/rc.d/rc.subr + +rc_reload=NO +pexp="phx.server" + +rc_check() { + pgrep -q -U _pleroma -f "phx.server" +} + +rc_start() { + ${rcexec} "cd pleroma; ${daemon} ${daemon_flags}" +} + +rc_stop() { + pkill -q -U _pleroma -f "phx.server" +} + +rc_cmd $1 diff --git a/installation/openbsd/relayd.conf b/installation/openbsd/relayd.conf new file mode 100644 index 0000000..31c2c11 --- /dev/null +++ b/installation/openbsd/relayd.conf @@ -0,0 +1,44 @@ +# +# Default relayd.conf file for Pleroma on OpenBSD +# Simple installation instructions: +# 1. Place in /etc +# 2. Replace with your public IPv4 address +# 3. If using IPv6i, uncomment IPv6 lines and replace with your public IPv6 address +# 4. Check file using 'doas relayd -n' +# 5. Reload/start relayd +# # doas rcctl enable relayd +# # doas rcctl start relayd +# + +ext_inet="" +#ext_inet6="" + +table { 127.0.0.1 } +table { 127.0.0.1 } + +http protocol plerup { # Protocol for upstream pleroma server + #tcp { nodelay, sack, socket buffer 65536, backlog 128 } # Uncomment and adjust as you see fit + tls ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA0-POLY1305" + tls ecdhe secp384r1 + + # Forward some paths to the local server (as pleroma won't respond to them as you might want) + pass request quick path "/robots.txt" forward to + + # Append a bunch of headers + match request header append "X-Forwarded-For" value "$REMOTE_ADDR" # This two header and the next one are not strictl required by pleroma but adding them won't hurt + match request header append "X-Forwarded-By" value "$SERVER_ADDR:$SERVER_PORT" + + match request header append "Connection" value "upgrade" + +} + +relay wwwtls { + listen on $ext_inet port https tls # Comment to disable listening on IPv4 +# listen on $ext_inet6 port https tls # Comment to disable listening on IPv6 + + protocol plerup + + forward to port 4000 check http "/" code 200 + forward to port 80 check http "/robots.txt" code 200 +} + diff --git a/installation/pleroma-apache.conf b/installation/pleroma-apache.conf new file mode 100644 index 0000000..139abe9 --- /dev/null +++ b/installation/pleroma-apache.conf @@ -0,0 +1,84 @@ +# Sample Apache config for Pleroma +# +# Simple installation instructions: +# 1. Install your TLS certificate. We recommend using Let's Encrypt via Certbot +# 2. Replace 'example.tld' with your instance's domain. +# 3. This assumes a Debian-style Apache config. Copy this file to +# /etc/apache2/sites-available/ and then activate the site by running +# 'a2ensite pleroma-apache.conf', then restart Apache. +# +# Optional: enable disk-based caching for the media proxy +# For details, see https://git.pleroma.social/pleroma/pleroma/wikis/How%20to%20activate%20mediaproxy +# +# 1. Create a directory as shown below for the CacheRoot and make sure +# the Apache user can write to it. +# 2. Configure Apache's htcacheclean to clean the directory periodically. +# Your OS may provide a service you can enable to do this automatically. + +Define servername example.tld + + + LoadModule proxy_module libexec/apache24/mod_proxy.so + + + LoadModule proxy_http_module libexec/apache24/mod_proxy_http.so + + + LoadModule proxy_wstunnel_module libexec/apache24/mod_proxy_wstunnel.so + + + LoadModule rewrite_module libexec/apache24/mod_rewrite.so + + + LoadModule ssl_module libexec/apache24/mod_ssl.so + + + LoadModule cache_module libexec/apache24/mod_cache.so + + + LoadModule cache_disk_module libexec/apache24/mod_cache_disk.so + + +ServerName ${servername} +ServerTokens Prod + +# If you want Pleroma-specific logs +#ErrorLog /var/log/httpd-pleroma-error.log +#CustomLog /var/log/httpd-pleroma-access.log combined + + + RewriteEngine on + RewriteCond %{SERVER_NAME} =${servername} + RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] + + + + SSLEngine on + SSLCertificateFile /etc/letsencrypt/live/${servername}/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/${servername}/privkey.pem + # Make sure you have the certbot-apache module installed + Include /etc/letsencrypt/options-ssl-apache.conf + + # Uncomment the following to enable MediaProxy caching on disk + #CacheRoot /tmp/pleroma-media-cache/ + #CacheDirLevels 1 + #CacheDirLength 2 + #CacheEnable disk /proxy + #CacheLock on + #CacheHeader on + #CacheDetailHeader on + ## 16MB max filesize for caching, configure as desired + #CacheMaxFileSize 16000000 + #CacheDefaultExpire 86400 + + RewriteEngine On + RewriteCond %{HTTP:Connection} Upgrade [NC] + RewriteCond %{HTTP:Upgrade} websocket [NC] + RewriteRule /(.*) ws://127.0.0.1:4000/$1 [P,L] + + #ProxyRequests must be off or you open your server to abuse as an open proxy + ProxyRequests off + ProxyPass / http://127.0.0.1:4000/ + ProxyPassReverse / http://127.0.0.1:4000/ + ProxyPreserveHost On + diff --git a/installation/pleroma-mediaproxy.nginx b/installation/pleroma-mediaproxy.nginx new file mode 100644 index 0000000..6f2edf2 --- /dev/null +++ b/installation/pleroma-mediaproxy.nginx @@ -0,0 +1,97 @@ +# This file is for those who want to serve uploaded media and media proxy over +# another domain. This is STRONGLY RECOMMENDED. +# This is meant to be used ALONG WITH `pleroma.nginx`. + +# If this is a new instance, replace the `location ~ ^/(media|proxy)` section in +# `pleroma.nginx` with the following to completely disable access to media from the main domain: +# location ~ ^/(media|proxy) { +# return 404; +# } +# +# If you are configuring an existing instance to use another domain +# for media, you will want to keep redirecting all existing local media to the new domain +# so already-uploaded media will not break. +# Replace the `location ~ ^/(media|proxy)` section in `pleroma.nginx` with the following: +# +# location /media { +# return 301 https://some.other.domain$request_uri; +# } +# +# location /proxy { +# return 404; +# } + +server { + server_name some.other.domain; + + listen 80; + listen [::]:80; + + # Uncomment this if you need to use the 'webroot' method with certbot. Make sure + # that the directory exists and that it is accessible by the webserver. If you followed + # the guide, you already ran 'mkdir -p /var/lib/letsencrypt' to create the folder. + # You may need to load this file with the ssl server block commented out, run certbot + # to get the certificate, and then uncomment it. + # + # location ~ /\.well-known/acme-challenge { + # root /var/lib/letsencrypt/; + # } + location / { + return 301 https://$server_name$request_uri; + } +} + +server { + server_name some.other.domain; + + listen 443 ssl http2; + listen [::]:443 ssl http2; + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + ssl_session_tickets off; + + ssl_trusted_certificate /etc/letsencrypt/live/some.other.domain/chain.pem; + ssl_certificate /etc/letsencrypt/live/some.other.domain/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/some.other.domain/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; + ssl_prefer_server_ciphers off; + # In case of an old server with an OpenSSL version of 1.0.2 or below, + # leave only prime256v1 or comment out the following line. + ssl_ecdh_curve X25519:prime256v1:secp384r1:secp521r1; + ssl_stapling on; + ssl_stapling_verify on; + + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/activity+json application/atom+xml; + + # the nginx default is 1m, not enough for large media uploads + client_max_body_size 16m; + ignore_invalid_headers off; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + location / { return 404; } + + location ~ ^/(media|proxy) { + proxy_cache pleroma_media_cache; + slice 1m; + proxy_cache_key $host$uri$is_args$args$slice_range; + proxy_set_header Range $slice_range; + proxy_cache_valid 200 206 301 304 1h; + proxy_cache_lock on; + proxy_ignore_client_abort on; + proxy_buffering on; + chunked_transfer_encoding on; + proxy_pass http://phoenix; + } +} diff --git a/installation/pleroma-mongooseim.cfg b/installation/pleroma-mongooseim.cfg new file mode 100755 index 0000000..3ecba56 --- /dev/null +++ b/installation/pleroma-mongooseim.cfg @@ -0,0 +1,936 @@ +%%% +%%% ejabberd configuration file +%%% +%%%' + +%%% The parameters used in this configuration file are explained in more detail +%%% in the ejabberd Installation and Operation Guide. +%%% Please consult the Guide in case of doubts, it is included with +%%% your copy of ejabberd, and is also available online at +%%% http://www.process-one.net/en/ejabberd/docs/ + +%%% This configuration file contains Erlang terms. +%%% In case you want to understand the syntax, here are the concepts: +%%% +%%% - The character to comment a line is % +%%% +%%% - Each term ends in a dot, for example: +%%% override_global. +%%% +%%% - A tuple has a fixed definition, its elements are +%%% enclosed in {}, and separated with commas: +%%% {loglevel, 4}. +%%% +%%% - A list can have as many elements as you want, +%%% and is enclosed in [], for example: +%%% [http_poll, web_admin, tls] +%%% +%%% Pay attention that list elements are delimited with commas, +%%% but no comma is allowed after the last list element. This will +%%% give a syntax error unlike in more lenient languages (e.g. Python). +%%% +%%% - A keyword of ejabberd is a word in lowercase. +%%% Strings are enclosed in "" and can contain spaces, dots, ... +%%% {language, "en"}. +%%% {ldap_rootdn, "dc=example,dc=com"}. +%%% +%%% - This term includes a tuple, a keyword, a list, and two strings: +%%% {hosts, ["jabber.example.net", "im.example.com"]}. +%%% +%%% - This config is preprocessed during release generation by a tool which +%%% interprets double curly braces as substitution markers, so avoid this +%%% syntax in this file (though it's valid Erlang). +%%% +%%% So this is OK (though arguably looks quite ugly): +%%% { {s2s_addr, "example-host.net"}, {127,0,0,1} }. +%%% +%%% And I can't give an example of what's not OK exactly because +%%% of this rule. +%%% + + +%%%. ======================= +%%%' OVERRIDE STORED OPTIONS + +%% +%% Override the old values stored in the database. +%% + +%% +%% Override global options (shared by all ejabberd nodes in a cluster). +%% +%%override_global. + +%% +%% Override local options (specific for this particular ejabberd node). +%% +%%override_local. + +%% +%% Remove the Access Control Lists before new ones are added. +%% +%%override_acls. + + +%%%. ========= +%%%' DEBUGGING + +%% +%% loglevel: Verbosity of log files generated by ejabberd. +%% 0: No ejabberd log at all (not recommended) +%% 1: Critical +%% 2: Error +%% 3: Warning +%% 4: Info +%% 5: Debug +%% +{loglevel, 3}. + +%%%. ================ +%%%' SERVED HOSTNAMES + +%% +%% hosts: Domains served by ejabberd. +%% You can define one or several, for example: +%% {hosts, ["example.net", "example.com", "example.org"]}. +%% +{hosts, ["pleroma.soykaf.com"] }. + +%% +%% route_subdomains: Delegate subdomains to other XMPP servers. +%% For example, if this ejabberd serves example.org and you want +%% to allow communication with an XMPP server called im.example.org. +%% +%%{route_subdomains, s2s}. + + +%%%. =============== +%%%' LISTENING PORTS + +%% +%% listen: The ports ejabberd will listen on, which service each is handled +%% by and what options to start it with. +%% +{listen, + [ + %% BOSH and WS endpoints over HTTP + { 5280, ejabberd_cowboy, [ + {num_acceptors, 10}, + {transport_options, [{max_connections, 1024}]}, + {modules, [ + + {"_", "/http-bind", mod_bosh}, + {"_", "/ws-xmpp", mod_websockets, [{ejabberd_service, [ + {access, all}, + {shaper_rule, fast}, + {ip, {127, 0, 0, 1}}, + {password, "secret"}]} + %% Uncomment to enable connection dropping or/and server-side pings + %{timeout, 600000}, {ping_rate, 2000} + ]} + %% Uncomment to serve static files + %{"_", "/static/[...]", cowboy_static, + % {dir, "/var/www", [{mimetypes, cow_mimetypes, all}]} + %}, + + %% Example usage of mod_revproxy + + %% {"_", "/[...]", mod_revproxy, [{timeout, 5000}, + %% % time limit for upstream to respond + %% {body_length, 8000000}, + %% % maximum body size (may be infinity) + %% {custom_headers, [{<<"header">>,<<"value">>}]} + %% % list of extra headers that are send to upstream + %% ]} + + %% Example usage of mod_cowboy + + %% {"_", "/[...]", mod_cowboy, [{http, mod_revproxy, + %% [{timeout, 5000}, + %% % time limit for upstream to respond + %% {body_length, 8000000}, + %% % maximum body size (may be infinity) + %% {custom_headers, [{<<"header">>,<<"value">>}]} + %% % list of extra headers that are send to upstream + %% ]}, + %% {ws, xmpp, mod_websockets} + %% ]} + ]} + ]}, + + %% BOSH and WS endpoints over HTTPS + { 5285, ejabberd_cowboy, [ + {num_acceptors, 10}, + {transport_options, [{max_connections, 1024}]}, + {ssl, [{certfile, "priv/ssl/fullchain.pem"}, {keyfile, "priv/ssl/privkey.pem"}, {password, ""}]}, + {modules, [ + {"_", "/http-bind", mod_bosh}, + {"_", "/ws-xmpp", mod_websockets, [ + %% Uncomment to enable connection dropping or/and server-side pings + %{timeout, 600000}, {ping_rate, 60000} + ]} + %% Uncomment to serve static files + %{"_", "/static/[...]", cowboy_static, + % {dir, "/var/www", [{mimetypes, cow_mimetypes, all}]} + %}, + ]} + ]}, + + %% MongooseIM HTTP API it's important to start it on localhost + %% or some private interface only (not accessible from the outside) + %% At least start it on different port which will be hidden behind firewall + + { {8088, "127.0.0.1"} , ejabberd_cowboy, [ + {num_acceptors, 10}, + {transport_options, [{max_connections, 1024}]}, + {modules, [ + {"localhost", "/api", mongoose_api_admin, []} + ]} + ]}, + + { 8089 , ejabberd_cowboy, [ + {num_acceptors, 10}, + {transport_options, [{max_connections, 1024}]}, + {protocol_options, [{compress, true}]}, + {ssl, [{certfile, "priv/ssl/fullchain.pem"}, {keyfile, "priv/ssl/privkey.pem"}, {password, ""}]}, + {modules, [ + {"_", "/api/sse", lasse_handler, [mongoose_client_api_sse]}, + {"_", "/api/messages/[:with]", mongoose_client_api_messages, []}, + {"_", "/api/contacts/[:jid]", mongoose_client_api_contacts, []}, + {"_", "/api/rooms/[:id]", mongoose_client_api_rooms, []}, + {"_", "/api/rooms/[:id]/config", mongoose_client_api_rooms_config, []}, + {"_", "/api/rooms/:id/users/[:user]", mongoose_client_api_rooms_users, []}, + {"_", "/api/rooms/[:id]/messages", mongoose_client_api_rooms_messages, []} + ]} + ]}, + + %% Following HTTP API is deprected, the new one abouve should be used instead + + { {5288, "127.0.0.1"} , ejabberd_cowboy, [ + {num_acceptors, 10}, + {transport_options, [{max_connections, 1024}]}, + {modules, [ + {"localhost", "/api", mongoose_api, [{handlers, [mongoose_api_metrics, + mongoose_api_users]}]} + ]} + ]}, + + %% If you want dual stack, you have to clone this entire config stanza + %% and change the bind to "::" + { {5222, "0.0.0.0"}, ejabberd_c2s, [ + + %% + %% If TLS is compiled in and you installed a SSL + %% certificate, specify the full path to the + %% file and uncomment this line: + %% + {certfile, "priv/ssl/both.pem"}, starttls, + + %%{zlib, 10000}, + %% https://www.openssl.org/docs/apps/ciphers.html#CIPHER_STRINGS + %% {ciphers, "DEFAULT:!EXPORT:!LOW:!SSLv2"}, + {access, c2s}, + {shaper, c2s_shaper}, + {max_stanza_size, 65536}, + {protocol_options, ["no_sslv3"]} + + ]}, + + + + %% + %% To enable the old SSL connection method on port 5223: + %% + %%{5223, ejabberd_c2s, [ + %% {access, c2s}, + %% {shaper, c2s_shaper}, + %% {certfile, "/path/to/ssl.pem"}, tls, + %% {max_stanza_size, 65536} + %% ]}, + + %% If you want dual stack, you have to clone this entire config stanza + %% and change the bind to "::" + { {5269, "0.0.0.0"}, ejabberd_s2s_in, [ + {shaper, s2s_shaper}, + {max_stanza_size, 131072}, + {protocol_options, ["no_sslv3"]} + + ]} + + %% + %% ejabberd_service: Interact with external components (transports, ...) + %% + ,{8888, ejabberd_service, [ + {access, all}, + {shaper_rule, fast}, + {ip, {127, 0, 0, 1}}, + {password, "secret"} + ]} + + %% + %% ejabberd_stun: Handles STUN Binding requests + %% + %%{ {3478, udp}, ejabberd_stun, []} + + ]}. + +%% +%% s2s_use_starttls: Enable STARTTLS + Dialback for S2S connections. +%% Allowed values are: false optional required required_trusted +%% You must specify a certificate file. +%% +{s2s_use_starttls, optional}. +%% +%% s2s_certfile: Specify a certificate file. +%% +{s2s_certfile, "priv/ssl/both.pem"}. + +%% https://www.openssl.org/docs/apps/ciphers.html#CIPHER_STRINGS +%% {s2s_ciphers, "DEFAULT:!EXPORT:!LOW:!SSLv2"}. + +%% +%% domain_certfile: Specify a different certificate for each served hostname. +%% +%%{domain_certfile, "example.org", "/path/to/example_org.pem"}. +%%{domain_certfile, "example.com", "/path/to/example_com.pem"}. + +%% +%% S2S whitelist or blacklist +%% +%% Default s2s policy for undefined hosts. +%% +{s2s_default_policy, deny }. + +%% +%% Allow or deny communication with specific servers. +%% +%%{ {s2s_host, "goodhost.org"}, allow}. +%%{ {s2s_host, "badhost.org"}, deny}. + +{outgoing_s2s_port, 5269 }. + +%% +%% IP addresses predefined for specific hosts to skip DNS lookups. +%% Ports defined here take precedence over outgoing_s2s_port. +%% Examples: +%% +%% { {s2s_addr, "example-host.net"}, {127,0,0,1} }. +%% { {s2s_addr, "example-host.net"}, { {127,0,0,1}, 5269 } }. +%% { {s2s_addr, "example-host.net"}, { {127,0,0,1}, 5269 } }. + +%% +%% Outgoing S2S options +%% +%% Preferred address families (which to try first) and connect timeout +%% in milliseconds. +%% +%%{outgoing_s2s_options, [ipv4, ipv6], 10000}. +%% +%%%. ============== +%%%' SESSION BACKEND + +%%{sm_backend, {mnesia, []}}. + +%% Requires {redis, global, default, ..., ...} outgoing pool +%%{sm_backend, {redis, []}}. + +{sm_backend, {mnesia, []} }. + + +%%%. ============== +%%%' AUTHENTICATION + +%% Advertised SASL mechanisms +{sasl_mechanisms, [cyrsasl_plain]}. + +%% +%% auth_method: Method used to authenticate the users. +%% The default method is the internal. +%% If you want to use a different method, +%% comment this line and enable the correct ones. +%% +%% {auth_method, internal }. +{auth_method, http }. +{auth_opts, [ + {http, global, auth, [{workers, 50}], [{server, "https://pleroma.soykaf.com"}]}, + {password_format, plain} % default + %% {password_format, scram} + + %% {scram_iterations, 4096} % default + + %% + %% For auth_http: + %% {basic_auth, "user:password"} + %% {path_prefix, "/"} % default + %% auth_http requires {http, Host | global, auth, ..., ...} outgoing pool. + %% + %% For auth_external + %%{extauth_program, "/path/to/authentication/script"}. + %% + %% For auth_jwt + %% {jwt_secret_source, "/path/to/file"}, + %% {jwt_algorithm, "RS256"}, + %% {jwt_username_key, user} + %% For cyrsasl_external + %% {authenticate_with_cn, false} + {cyrsasl_external, standard} + ]}. + +%% +%% Authentication using external script +%% Make sure the script is executable by ejabberd. +%% +%%{auth_method, external}. + +%% +%% Authentication using RDBMS +%% Remember to setup a database in the next section. +%% +%%{auth_method, rdbms}. + +%% +%% Authentication using LDAP +%% +%%{auth_method, ldap}. +%% + +%% List of LDAP servers: +%%{ldap_servers, ["localhost"]}. +%% +%% Encryption of connection to LDAP servers: +%%{ldap_encrypt, none}. +%%{ldap_encrypt, tls}. +%% +%% Port to connect to on LDAP servers: +%%{ldap_port, 389}. +%%{ldap_port, 636}. +%% +%% LDAP manager: +%%{ldap_rootdn, "dc=example,dc=com"}. +%% +%% Password of LDAP manager: +%%{ldap_password, "******"}. +%% +%% Search base of LDAP directory: +%%{ldap_base, "dc=example,dc=com"}. +%% +%% LDAP attribute that holds user ID: +%%{ldap_uids, [{"mail", "%u@mail.example.org"}]}. +%% +%% LDAP filter: +%%{ldap_filter, "(objectClass=shadowAccount)"}. + +%% +%% Anonymous login support: +%% auth_method: anonymous +%% anonymous_protocol: sasl_anon | login_anon | both +%% allow_multiple_connections: true | false +%% +%%{host_config, "public.example.org", [{auth_method, anonymous}, +%% {allow_multiple_connections, false}, +%% {anonymous_protocol, sasl_anon}]}. +%% +%% To use both anonymous and internal authentication: +%% +%%{host_config, "public.example.org", [{auth_method, [internal, anonymous]}]}. + + +%%%. ============== +%%%' OUTGOING CONNECTIONS (e.g. DB) + +%% Here you may configure all outgoing connections used by MongooseIM, +%% e.g. to RDBMS (such as MySQL), Riak or external HTTP components. +%% Default MongooseIM configuration uses only Mnesia (non-Mnesia extensions are disabled), +%% so no options here are uncommented out of the box. +%% This section includes configuration examples; for comprehensive guide +%% please consult MongooseIM documentation, page "Outgoing connections": +%% - doc/advanced-configuration/outgoing-connections.md +%% - https://mongooseim.readthedocs.io/en/latest/advanced-configuration/outgoing-connections/ + + +{outgoing_pools, [ +% {riak, global, default, [{workers, 5}], [{address, "127.0.0.1"}, {port, 8087}]}, +% {elastic, global, default, [], [{host, "elastic.host.com"}, {port, 9042}]}, + {http, global, auth, [{workers, 50}], [{server, "https://pleroma.soykaf.com"}]} +% {cassandra, global, default, [{workers, 100}], [{servers, [{"server1", 9042}]}, {keyspace, "big_mongooseim"}]}, +% {rdbms, global, default, [{workers, 10}], [{server, {mysql, "server", 3306, "database", "username", "password"}}]} +]}. + +%% More examples that may be added to outgoing_pools list: +%% +%% == MySQL == +%% {rdbms, global, default, [{workers, 10}], +%% [{server, {mysql, "server", 3306, "database", "username", "password"}}, +%% {keepalive_interval, 10}]}, +%% keepalive_interval is optional + +%% == PostgreSQL == +%% {rdbms, global, default, [{workers, 10}], +%% [{server, {pgsql, "server", "port", "database", "username", "password"}}]}, + +%% == ODBC (MSSQL) == +%% {rdbms, global, default, [{workers, 10}], +%% [{server, "DSN=mongooseim;UID=mongooseim;PWD=mongooseim"}]}, + +%% == Elastic Search == +%% {elastic, global, default, [], [{host, "elastic.host.com"}, {port, 9042}]}, + +%% == Riak == +%% {riak, global, default, [{workers, 20}], [{address, "127.0.0.1"}, {port, 8087}]}, + +%% == HTTP == +%% {http, global, conn1, [{workers, 50}], [{server, "http://server:8080"}]}, + +%% == Cassandra == +%% {cassandra, global, default, [{workers, 100}], +%% [ +%% {servers, [ +%% {"cassandra_server1.example.com", 9042}, +%% {"cassandra_server2.example.com", 9042}, +%% {"cassandra_server3.example.com", 9042}, +%% {"cassandra_server4.example.com", 9042} +%% ]}, +%% {keyspace, "big_mongooseim"} +%% ]} + +%% == Extra options == +%% +%% If you use PostgreSQL, have a large database, and need a +%% faster but inexact replacement for "select count(*) from users" +%% +%%{pgsql_users_number_estimate, true}. +%% +%% rdbms_server_type specifies what database is used over the RDBMS layer +%% Can take values mssql, pgsql, mysql +%% In some cases (for example for MAM with pgsql) it is required to set proper value. +%% +%% {rdbms_server_type, pgsql}. + +%%%. =============== +%%%' TRAFFIC SHAPERS + +%% +%% The "normal" shaper limits traffic speed to 1000 B/s +%% +{shaper, normal, {maxrate, 1000}}. + +%% +%% The "fast" shaper limits traffic speed to 50000 B/s +%% +{shaper, fast, {maxrate, 50000}}. + +%% +%% This option specifies the maximum number of elements in the queue +%% of the FSM. Refer to the documentation for details. +%% +{max_fsm_queue, 1000}. + +%%%. ==================== +%%%' ACCESS CONTROL LISTS + +%% +%% The 'admin' ACL grants administrative privileges to XMPP accounts. +%% You can put here as many accounts as you want. +%% +%{acl, admin, {user, "alice", "localhost"}}. +%{acl, admin, {user, "a", "localhost"}}. + +%% +%% Blocked users +%% +%%{acl, blocked, {user, "baduser", "example.org"}}. +%%{acl, blocked, {user, "test"}}. + +%% +%% Local users: don't modify this line. +%% +{acl, local, {user_regexp, ""}}. + +%% +%% More examples of ACLs +%% +%%{acl, jabberorg, {server, "jabber.org"}}. +%%{acl, aleksey, {user, "aleksey", "jabber.ru"}}. +%%{acl, test, {user_regexp, "^test"}}. +%%{acl, test, {user_glob, "test*"}}. + +%% +%% Define specific ACLs in a virtual host. +%% +%%{host_config, "localhost", +%% [ +%% {acl, admin, {user, "bob-local", "localhost"}} +%% ] +%%}. + +%%%. ============ +%%%' ACCESS RULES + +%% Maximum number of simultaneous sessions allowed for a single user: +{access, max_user_sessions, [{10, all}]}. + +%% Maximum number of offline messages that users can have: +{access, max_user_offline_messages, [{5000, admin}, {100, all}]}. + +%% This rule allows access only for local users: +{access, local, [{allow, local}]}. + +%% Only non-blocked users can use c2s connections: +{access, c2s, [{deny, blocked}, + {allow, all}]}. + +%% For C2S connections, all users except admins use the "normal" shaper +{access, c2s_shaper, [{none, admin}, + {normal, all}]}. + +%% All S2S connections use the "fast" shaper +{access, s2s_shaper, [{fast, all}]}. + +%% Admins of this server are also admins of the MUC service: +{access, muc_admin, [{allow, admin}]}. + +%% Only accounts of the local ejabberd server can create rooms: +{access, muc_create, [{allow, local}]}. + +%% All users are allowed to use the MUC service: +{access, muc, [{allow, all}]}. + +%% In-band registration allows registration of any possible username. +%% To disable in-band registration, replace 'allow' with 'deny'. +{access, register, [{allow, all}]}. + +%% By default the frequency of account registrations from the same IP +%% is limited to 1 account every 10 minutes. To disable, specify: infinity +{registration_timeout, infinity}. + +%% Default settings for MAM. +%% To set non-standard value, replace 'default' with 'allow' or 'deny'. +%% Only user can access his/her archive by default. +%% An online user can read room's archive by default. +%% Only an owner can change settings and purge messages by default. +%% Empty list (i.e. `[]`) means `[{deny, all}]`. +{access, mam_set_prefs, [{default, all}]}. +{access, mam_get_prefs, [{default, all}]}. +{access, mam_lookup_messages, [{default, all}]}. +{access, mam_purge_single_message, [{default, all}]}. +{access, mam_purge_multiple_messages, [{default, all}]}. + +%% 1 command of the specified type per second. +{shaper, mam_shaper, {maxrate, 1}}. +%% This shaper is primeraly for Mnesia overload protection during stress testing. +%% The limit is 1000 operations of each type per second. +{shaper, mam_global_shaper, {maxrate, 1000}}. + +{access, mam_set_prefs_shaper, [{mam_shaper, all}]}. +{access, mam_get_prefs_shaper, [{mam_shaper, all}]}. +{access, mam_lookup_messages_shaper, [{mam_shaper, all}]}. +{access, mam_purge_single_message_shaper, [{mam_shaper, all}]}. +{access, mam_purge_multiple_messages_shaper, [{mam_shaper, all}]}. + +{access, mam_set_prefs_global_shaper, [{mam_global_shaper, all}]}. +{access, mam_get_prefs_global_shaper, [{mam_global_shaper, all}]}. +{access, mam_lookup_messages_global_shaper, [{mam_global_shaper, all}]}. +{access, mam_purge_single_message_global_shaper, [{mam_global_shaper, all}]}. +{access, mam_purge_multiple_messages_global_shaper, [{mam_global_shaper, all}]}. + +%% +%% Define specific Access Rules in a virtual host. +%% +%%{host_config, "localhost", +%% [ +%% {access, c2s, [{allow, admin}, {deny, all}]}, +%% {access, register, [{deny, all}]} +%% ] +%%}. + +%%%. ================ +%%%' DEFAULT LANGUAGE + +%% +%% language: Default language used for server messages. +%% +{language, "en"}. + +%% +%% Set a different default language in a virtual host. +%% +%%{host_config, "localhost", +%% [{language, "ru"}] +%%}. + +%%%. ================ +%%%' MISCELLANEOUS + +{all_metrics_are_global, false }. + +%%%. ======== +%%%' SERVICES + +%% Unlike modules, services are started per node and provide either features which are not +%% related to any particular host, or backend stuff which is used by modules. +%% This is handled by `mongoose_service` module. + +{services, + [ + {service_admin_extra, [{submods, [node, accounts, sessions, vcard, + roster, last, private, stanza, stats]}]} + ] +}. + +%%%. ======= +%%%' MODULES + +%% +%% Modules enabled in all mongooseim virtual hosts. +%% For list of possible modules options, check documentation. +%% +{modules, + [ + + %% The format for a single route is as follows: + %% {Host, Path, Method, Upstream} + %% + %% "_" can be used as wildcard for Host, Path and Method + %% Upstream can be either host (just http(s)://host:port) or uri + %% The difference is that host upstreams append whole path while + %% uri upstreams append only remainder that follows the matched Path + %% (this behaviour is similar to nginx's proxy_pass rules) + %% + %% Bindings can be used to match certain parts of host or path. + %% They will be later overlaid with parts of the upstream uri. + %% + %% {mod_revproxy, + %% [{routes, [{"www.erlang-solutions.com", "/admin", "_", + %% "https://www.erlang-solutions.com/"}, + %% {":var.com", "/:var", "_", "http://localhost:8080/"}, + %% {":domain.com", "/", "_", "http://localhost:8080/:domain"}] + %% }]}, + +% {mod_http_upload, [ + %% Set max file size in bytes. Defaults to 10 MB. + %% Disabled if value is `undefined`. +% {max_file_size, 1024}, + %% Use S3 storage backend +% {backend, s3}, + %% Set options for S3 backend +% {s3, [ +% {bucket_url, "http://s3-eu-west-1.amazonaws.com/konbucket2"}, +% {region, "eu-west-1"}, +% {access_key_id, "AKIAIAOAONIULXQGMOUA"}, +% {secret_access_key, "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"} +% ]} +% ]}, + + {mod_adhoc, []}, + + {mod_disco, [{users_can_see_hidden_services, false}]}, + {mod_commands, []}, + {mod_muc_commands, []}, + {mod_muc_light_commands, []}, + {mod_last, []}, + {mod_stream_management, [ + % default 100 + % size of a buffer of unacked messages + % {buffer_max, 100} + + % default 1 - server sends the ack request after each stanza + % {ack_freq, 1} + + % default: 600 seconds + % {resume_timeout, 600} + ]}, + %% {mod_muc_light, [{host, "muclight.@HOST@"}]}, + %% {mod_muc, [{host, "muc.@HOST@"}, + %% {access, muc}, + %% {access_create, muc_create} + %% ]}, + %% {mod_muc_log, [ + %% {outdir, "/tmp/muclogs"}, + %% {access_log, muc} + %% ]}, + {mod_offline, [{access_max_user_messages, max_user_offline_messages}]}, + {mod_privacy, []}, + {mod_blocking, []}, + {mod_private, []}, +% {mod_private, [{backend, mnesia}]}, +% {mod_private, [{backend, rdbms}]}, +% {mod_register, [ +% %% +% %% Set the minimum informational entropy for passwords. +% %% +% %%{password_strength, 32}, +% +% %% +% %% After successful registration, the user receives +% %% a message with this subject and body. +% %% +% {welcome_message, {""}}, +% +% %% +% %% When a user registers, send a notification to +% %% these XMPP accounts. +% %% +% +% +% %% +% %% Only clients in the server machine can register accounts +% %% +% {ip_access, [{allow, "127.0.0.0/8"}, +% {deny, "0.0.0.0/0"}]}, +% +% %% +% %% Local c2s or remote s2s users cannot register accounts +% %% +% %%{access_from, deny}, +% +% {access, register} +% ]}, + {mod_roster, []}, + {mod_sic, []}, + {mod_vcard, [%{matches, 1}, +%{search, true}, +%{ldap_search_operator, 'or'}, %% either 'or' or 'and' +%{ldap_binary_search_fields, [<<"PHOTO">>]}, +%% list of binary search fields (as in vcard after mapping) +{host, "vjud.@HOST@"} +]}, + {mod_bosh, []}, + {mod_carboncopy, []} + + %% + %% Message Archive Management (MAM, XEP-0313) for registered users and + %% Multi-User chats (MUCs). + %% + +% {mod_mam_meta, [ + %% Use RDBMS backend (default) +% {backend, rdbms}, + + %% Do not store user preferences (default) +% {user_prefs_store, false}, + %% Store user preferences in RDBMS +% {user_prefs_store, rdbms}, + %% Store user preferences in Mnesia (recommended). + %% The preferences store will be called each time, as a message is routed. + %% That is why Mnesia is better suited for this job. +% {user_prefs_store, mnesia}, + + %% Enables a pool of asynchronous writers. (default) + %% Messages will be grouped together based on archive id. +% {async_writer, true}, + + %% Cache information about users (default) +% {cache_users, true}, + + %% Enable archivization for private messages (default) +% {pm, [ + %% Top-level options can be overriden here if needed, for example: +% {async_writer, false} +% ]}, + + %% + %% Message Archive Management (MAM) for multi-user chats (MUC). + %% Enable XEP-0313 for "muc.@HOST@". + %% +% {muc, [ +% {host, "muc.@HOST@"} + %% As with pm, top-level options can be overriden for MUC archive +% ]}, +% + %% Do not use a element (by default stanzaid is used) +% no_stanzaid_element, +% ]}, + + + %% + %% MAM configuration examples + %% + + %% Only MUC, no user-defined preferences, good performance. +% {mod_mam_meta, [ +% {backend, rdbms}, +% {pm, false}, +% {muc, [ +% {host, "muc.@HOST@"} +% ]} +% ]}, + + %% Only archives for c2c messages, good performance. +% {mod_mam_meta, [ +% {backend, rdbms}, +% {pm, [ +% {user_prefs_store, mnesia} +% ]} +% ]}, + + %% Basic configuration for c2c messages, bad performance, easy to debug. +% {mod_mam_meta, [ +% {backend, rdbms}, +% {async_writer, false}, +% {cache_users, false} +% ]}, + + %% Cassandra archive for c2c and MUC conversations. + %% No custom settings supported (always archive). +% {mod_mam_meta, [ +% {backend, cassandra}, +% {user_prefs_store, cassandra}, +% {muc, [{host, "muc.@HOST@"}]} +% ]} + +% {mod_event_pusher, [ +% {backends, [ +% %% +% %% Configuration for Amazon SNS notifications. +% %% +% {sns, [ +% %% AWS credentials, region and host configuration +% {access_key_id, "AKIAJAZYHOIPY6A2PESA"}, +% {secret_access_key, "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"}, +% {region, "eu-west-1"}, +% {account_id, "251423380551"}, +% {region, "eu-west-1"}, +% {sns_host, "sns.eu-west-1.amazonaws.com"}, +% +% %% Messages from this MUC host will be sent to the SNS topic +% {muc_host, "muc.@HOST@"}, +% +% %% Plugin module for defining custom message attributes and user identification +% {plugin_module, mod_event_pusher_sns_defaults}, +% +% %% Topic name configurations. Removing a topic will disable this specific SNS notification +% {presence_updates_topic, "user_presence_updated-dev-1"}, %% For presence updates +% {pm_messages_topic, "user_message_sent-dev-1"}, %% For private chat messages +% {muc_messages_topic, "user_messagegroup_sent-dev-1"} %% For group chat messages +% +% %% Pool options +% {pool_size, 100}, %% Worker pool size for publishing notifications +% {publish_retry_count, 2}, %% Retry count in case of publish error +% {publish_retry_time_ms, 50} %% Base exponential backoff time (in ms) for publish errors +% ]} +% ]} + +]}. + + +%% +%% Enable modules with custom options in a specific virtual host +%% +%%{host_config, "localhost", +%% [{ {add, modules}, +%% [ +%% {mod_some_module, []} +%% ] +%% } +%% ]}. + +%%%. +%%%' + +%%% $Id$ + +%%% Local Variables: +%%% mode: erlang +%%% End: +%%% vim: set filetype=erlang tabstop=8 foldmarker=%%%',%%%. foldmethod=marker: +%%%. diff --git a/installation/pleroma.nginx b/installation/pleroma.nginx new file mode 100644 index 0000000..abf4243 --- /dev/null +++ b/installation/pleroma.nginx @@ -0,0 +1,97 @@ +# default nginx site config for Pleroma +# +# Simple installation instructions: +# 1. Install your TLS certificate, possibly using Let's Encrypt. +# 2. Replace 'example.tld' with your instance's domain wherever it appears. +# 3. Copy this file to /etc/nginx/sites-available/ and then add a symlink to it +# in /etc/nginx/sites-enabled/ and run 'nginx -s reload' or restart nginx. + +proxy_cache_path /tmp/pleroma-media-cache levels=1:2 keys_zone=pleroma_media_cache:10m max_size=10g + inactive=720m use_temp_path=off; + +# this is explicitly IPv4 since Pleroma.Web.Endpoint binds on IPv4 only +# and `localhost.` resolves to [::0] on some systems: see issue #930 +upstream phoenix { + server 127.0.0.1:4000 max_fails=5 fail_timeout=60s; +} + +server { + server_name example.tld; + + listen 80; + listen [::]:80; + + # Uncomment this if you need to use the 'webroot' method with certbot. Make sure + # that the directory exists and that it is accessible by the webserver. If you followed + # the guide, you already ran 'mkdir -p /var/lib/letsencrypt' to create the folder. + # You may need to load this file with the ssl server block commented out, run certbot + # to get the certificate, and then uncomment it. + # + # location ~ /\.well-known/acme-challenge { + # root /var/lib/letsencrypt/; + # } + location / { + return 301 https://$server_name$request_uri; + } +} + +# Enable SSL session caching for improved performance +ssl_session_cache shared:ssl_session_cache:10m; + +server { + server_name example.tld; + + listen 443 ssl http2; + listen [::]:443 ssl http2; + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + ssl_session_tickets off; + + ssl_trusted_certificate /etc/letsencrypt/live/example.tld/chain.pem; + ssl_certificate /etc/letsencrypt/live/example.tld/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.tld/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; + ssl_prefer_server_ciphers off; + # In case of an old server with an OpenSSL version of 1.0.2 or below, + # leave only prime256v1 or comment out the following line. + ssl_ecdh_curve X25519:prime256v1:secp384r1:secp521r1; + ssl_stapling on; + ssl_stapling_verify on; + + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/activity+json application/atom+xml; + + # the nginx default is 1m, not enough for large media uploads + client_max_body_size 16m; + ignore_invalid_headers off; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + location / { + proxy_pass http://phoenix; + } + + location ~ ^/(media|proxy) { + proxy_cache pleroma_media_cache; + slice 1m; + proxy_cache_key $host$uri$is_args$args$slice_range; + proxy_set_header Range $slice_range; + proxy_set_header Host $host; + proxy_cache_valid 200 206 301 304 1h; + proxy_cache_lock on; + proxy_ignore_client_abort on; + proxy_buffering on; + chunked_transfer_encoding on; + proxy_pass http://phoenix; + } +} diff --git a/installation/pleroma.service b/installation/pleroma.service new file mode 100644 index 0000000..811f1e1 --- /dev/null +++ b/installation/pleroma.service @@ -0,0 +1,37 @@ +[Unit] +Description=Pleroma social network +After=network.target postgresql.service + +[Service] +ExecReload=/bin/kill $MAINPID +KillMode=process +Restart=on-failure + +; Name of the user that runs the Pleroma service. +User=pleroma +; Declares that Pleroma runs in production mode. +Environment="MIX_ENV=prod" + +; Make sure that all paths fit your installation. +; Path to the home directory of the user running the Pleroma service. +Environment="HOME=/var/lib/pleroma" +; Path to the folder containing the Pleroma installation. +WorkingDirectory=/opt/pleroma +; Path to the Mix binary. +Environment="PATH=/var/lib/pleroma/.asdf/shims:/var/lib/pleroma/.asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +ExecStart=/var/lib/pleroma/.asdf/shims/mix phx.server + +; Some security directives. +; Use private /tmp and /var/tmp folders inside a new file system namespace, which are discarded after the process stops. +PrivateTmp=true +; The /home, /root, and /run/user folders can not be accessed by this service anymore. If your Pleroma user has its home folder in one of the restricted places, or use one of these folders as its working directory, you have to set this to false. +ProtectHome=true +; Mount /usr, /boot, and /etc as read-only for processes invoked by this service. +ProtectSystem=full +; Sets up a new /dev mount for the process and only adds API pseudo devices like /dev/null, /dev/zero or /dev/random but not physical devices. Disabled by default because it may not work on devices like the Raspberry Pi. +PrivateDevices=false +; Drops the sysadmin capability from the daemon. +CapabilityBoundingSet=~CAP_SYS_ADMIN + +[Install] +WantedBy=multi-user.target diff --git a/installation/pleroma.supervisord b/installation/pleroma.supervisord new file mode 100644 index 0000000..19efffd --- /dev/null +++ b/installation/pleroma.supervisord @@ -0,0 +1,21 @@ +; Assumes pleroma is installed in /home/pleroma/pleroma and running as the pleroma user +; Also assumes mix is in /usr/bin, this might differ on BSDs or niche Linux distros +; Logs into /home/pleroma/logs +[program:pleroma] +command=/usr/bin/mix phx.server +directory=/home/pleroma/pleroma +autostart=true +autorestart=true +user=pleroma +environment = + MIX_ENV=prod, + HOME=/home/pleroma, + USER=pleroma, + PATH="/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/home/pleroma/bin:%(ENV_PATH)s", + PWD=/home/pleroma/pleroma +stdout_logfile=/home/pleroma/logs/stdout.log +stdout_logfile_maxbytes=50MB +stdout_logfile_backups=10 +stderr_logfile=/home/pleroma/logs/stderr.log +stderr_logfile_maxbytes=50MB +stderr_logfile_backups=10 \ No newline at end of file diff --git a/installation/pleroma.vcl b/installation/pleroma.vcl new file mode 100644 index 0000000..4eb2f3c --- /dev/null +++ b/installation/pleroma.vcl @@ -0,0 +1,127 @@ +# 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; + +backend default { + .host = "127.0.0.1"; + .port = "4000"; +} + +# ACL for IPs that are allowed to PURGE data from the cache +acl purge { + "127.0.0.1"; +} + +sub vcl_recv { + # Redirect HTTP to HTTPS + 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"; + } + + # 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 (purge); + } +} + +sub vcl_backend_response { + # gzip text content + if (beresp.http.content-type ~ "(text|text/css|application/x-javascript|application/javascript)") { + set beresp.do_gzip = true; + } + + # Retry broken backend responses. + if (beresp.status == 503) { + set bereq.http.X-Varnish-Backend-503 = "1"; + return (retry); + } + + # Bypass cache for large files + # 50000000 ~ 50MB + if (std.integer(beresp.http.content-length, 0) > 50000000) { + set beresp.uncacheable = true; + return (deliver); + } + + # Don't cache objects that require authentication + if (beresp.http.Authorization && !beresp.http.Cache-Control ~ "public") { + set beresp.uncacheable = true; + return (deliver); + } + + # Allow serving cached content for 6h in case backend goes down + set beresp.grace = 6h; + + # Do not cache 5xx responses + if (beresp.status == 500 || beresp.status == 502 || beresp.status == 503 || beresp.status == 504) { + set beresp.uncacheable = true; + return (abandon); + } + + # Do not cache redirects and errors + if ((beresp.status >= 300) && (beresp.status < 500)) { + set beresp.uncacheable = true; + set beresp.ttl = 30s; + return (deliver); + } +} + +# The synthetic response for 301 redirects +sub vcl_synth { + if (resp.status == 750) { + set resp.status = 301; + set resp.http.Location = req.http.x-redir; + return (deliver); + } +} + +# Ensure WebSockets through the pipe do not close prematurely +sub vcl_pipe { + if (req.http.upgrade) { + set bereq.http.upgrade = req.http.upgrade; + set bereq.http.connection = req.http.connection; + } +} + +sub vcl_backend_fetch { + # Be more lenient for slow servers on the fediverse + if (bereq.url ~ "^/proxy/") { + set bereq.first_byte_timeout = 300s; + } + + 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. + unset bereq.http.X-Varnish-Backend-503; + } else { + if (bereq.http.X-Varnish-Backend-503) { + if (bereq.method != "POST" && + std.healthy(bereq.backend) && + bereq.retries <= 4) { + # Flush broken backend response flag & try again. + unset bereq.http.X-Varnish-Backend-503; + } else { + return (abandon); + } + } + } +} + +sub vcl_backend_error { + # Retry broken backend responses. + set bereq.http.X-Varnish-Backend-503 = "1"; + return (retry); +} diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex new file mode 100644 index 0000000..2976085 --- /dev/null +++ b/lib/mix/pleroma.ex @@ -0,0 +1,139 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Pleroma do + @apps [ + :restarter, + :ecto, + :ecto_sql, + :postgrex, + :db_connection, + :cachex, + :flake_id, + :swoosh, + :timex, + :fast_html, + :oban + ] + @cachex_children ["object", "user", "scrubber", "web_resp"] + @doc "Common functions to be reused in mix tasks" + def start_pleroma do + Pleroma.Config.Holder.save_default() + Pleroma.Config.Oban.warn() + Pleroma.Application.limiters_setup() + Application.put_env(:phoenix, :serve_endpoints, false, persistent: true) + + unless System.get_env("DEBUG") do + Logger.remove_backend(:console) + end + + adapter = Application.get_env(:tesla, :adapter) + + apps = + if adapter == Tesla.Adapter.Gun do + [:gun | @apps] + else + [:hackney | @apps] + end + + Enum.each(apps, &Application.ensure_all_started/1) + + oban_config = [ + crontab: [], + repo: Pleroma.Repo, + log: false, + queues: [], + plugins: [] + ] + + children = + [ + Pleroma.Repo, + Pleroma.Emoji, + {Pleroma.Config.TransferTask, false}, + Pleroma.Web.Endpoint, + {Oban, oban_config}, + {Majic.Pool, + [name: Pleroma.MajicPool, pool_size: Pleroma.Config.get([:majic_pool, :size], 2)]} + ] ++ + http_children(adapter) + + cachex_children = Enum.map(@cachex_children, &Pleroma.Application.build_cachex(&1, [])) + + Supervisor.start_link(children ++ cachex_children, + strategy: :one_for_one, + name: Pleroma.Supervisor + ) + + if Pleroma.Config.get(:env) not in [:test, :benchmark] do + pleroma_rebooted?() + end + end + + defp pleroma_rebooted? do + if Restarter.Pleroma.rebooted?() do + :ok + else + Process.sleep(10) + pleroma_rebooted?() + end + end + + def load_pleroma do + Application.load(:pleroma) + end + + def get_option(options, opt, prompt, defval \\ nil, defname \\ nil) do + Keyword.get(options, opt) || shell_prompt(prompt, defval, defname) + end + + def shell_prompt(prompt, defval \\ nil, defname \\ nil) do + prompt_message = "#{prompt} [#{defname || defval}] " + + input = + if mix_shell?(), + do: Mix.shell().prompt(prompt_message), + else: :io.get_line(prompt_message) + + case input do + "\n" -> + case defval do + nil -> + shell_prompt(prompt, defval, defname) + + defval -> + defval + end + + input -> + String.trim(input) + end + end + + def shell_info(message) do + if mix_shell?(), + do: Mix.shell().info(message), + else: IO.puts(message) + end + + def shell_error(message) do + if mix_shell?(), + do: Mix.shell().error(message), + else: IO.puts(:stderr, message) + end + + @doc "Performs a safe check whether `Mix.shell/0` is available (does not raise if Mix is not loaded)" + def mix_shell?, do: :erlang.function_exported(Mix, :shell, 0) + + def escape_sh_path(path) do + ~S(') <> String.replace(path, ~S('), ~S(\')) <> ~S(') + end + + defp http_children(Tesla.Adapter.Gun) do + Pleroma.Gun.ConnectionPool.children() ++ + [{Task, &Pleroma.HTTP.AdapterHelper.Gun.limiter_setup/0}] + end + + defp http_children(_), do: [] +end diff --git a/lib/mix/tasks/pleroma/app.ex b/lib/mix/tasks/pleroma/app.ex new file mode 100644 index 0000000..885d071 --- /dev/null +++ b/lib/mix/tasks/pleroma/app.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.App do + @moduledoc File.read!("docs/administration/CLI_tasks/oauth_app.md") + use Mix.Task + + import Mix.Pleroma + + @shortdoc "Creates trusted OAuth App" + + def run(["create" | options]) do + start_pleroma() + + {opts, _} = + OptionParser.parse!(options, + strict: [name: :string, redirect_uri: :string, scopes: :string], + aliases: [n: :name, r: :redirect_uri, s: :scopes] + ) + + scopes = + if opts[:scopes] do + String.split(opts[:scopes], ",") + else + ["read", "write", "follow", "push"] + end + + params = %{ + client_name: opts[:name], + redirect_uris: opts[:redirect_uri], + trusted: true, + scopes: scopes + } + + with {:ok, app} <- Pleroma.Web.OAuth.App.create(params) do + shell_info("#{app.client_name} successfully created:") + shell_info("App client_id: " <> app.client_id) + shell_info("App client_secret: " <> app.client_secret) + else + {:error, changeset} -> + shell_error("Creating failed:") + + Enum.each(Pleroma.Web.OAuth.App.errors(changeset), fn {key, error} -> + shell_error("#{key}: #{error}") + end) + end + end +end diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex new file mode 100644 index 0000000..3a2ea44 --- /dev/null +++ b/lib/mix/tasks/pleroma/config.ex @@ -0,0 +1,402 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Config do + use Mix.Task + + import Ecto.Query + import Mix.Pleroma + + alias Pleroma.ConfigDB + alias Pleroma.Repo + + @shortdoc "Manages the location of the config" + @moduledoc File.read!("docs/administration/CLI_tasks/config.md") + + def run(["migrate_to_db"]) do + check_configdb(fn -> + start_pleroma() + migrate_to_db() + end) + end + + def run(["migrate_from_db" | options]) do + check_configdb(fn -> + start_pleroma() + + {opts, _} = + OptionParser.parse!(options, + strict: [env: :string, delete: :boolean, path: :string], + aliases: [d: :delete] + ) + + migrate_from_db(opts) + end) + end + + def run(["dump"]) do + check_configdb(fn -> + start_pleroma() + + header = config_header() + + settings = + ConfigDB + |> Repo.all() + |> Enum.sort() + + unless settings == [] do + shell_info("#{header}") + + Enum.each(settings, &dump(&1)) + else + shell_error("No settings in ConfigDB.") + end + end) + end + + def run(["dump", group, key]) do + check_configdb(fn -> + start_pleroma() + + group = maybe_atomize(group) + key = maybe_atomize(key) + + group + |> ConfigDB.get_by_group_and_key(key) + |> dump() + end) + end + + def run(["dump", group]) do + check_configdb(fn -> + start_pleroma() + + group = maybe_atomize(group) + + dump_group(group) + end) + end + + def run(["groups"]) do + check_configdb(fn -> + start_pleroma() + + groups = + ConfigDB + |> distinct([c], true) + |> select([c], c.group) + |> Repo.all() + + if length(groups) > 0 do + shell_info("The following configuration groups are set in ConfigDB:\r\n") + groups |> Enum.each(fn x -> shell_info("- #{x}") end) + shell_info("\r\n") + end + end) + end + + def run(["reset", "--force"]) do + check_configdb(fn -> + start_pleroma() + truncatedb() + shell_info("The ConfigDB settings have been removed from the database.") + end) + end + + def run(["reset"]) do + check_configdb(fn -> + start_pleroma() + + shell_info("The following settings will be permanently removed:") + + ConfigDB + |> Repo.all() + |> Enum.sort() + |> Enum.each(&dump(&1)) + + shell_error("\nTHIS CANNOT BE UNDONE!") + + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + truncatedb() + + shell_info("The ConfigDB settings have been removed from the database.") + else + shell_error("No changes made.") + end + end) + end + + def run(["delete", "--force", group, key]) do + start_pleroma() + + group = maybe_atomize(group) + key = maybe_atomize(key) + + with true <- key_exists?(group, key) do + shell_info("The following settings will be removed from ConfigDB:\n") + + group + |> ConfigDB.get_by_group_and_key(key) + |> dump() + + delete_key(group, key) + else + _ -> + shell_error("No settings in ConfigDB for #{inspect(group)}, #{inspect(key)}. Aborting.") + end + end + + def run(["delete", "--force", group]) do + start_pleroma() + + group = maybe_atomize(group) + + with true <- group_exists?(group) do + shell_info("The following settings will be removed from ConfigDB:\n") + dump_group(group) + delete_group(group) + else + _ -> shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") + end + end + + def run(["delete", group, key]) do + start_pleroma() + + group = maybe_atomize(group) + key = maybe_atomize(key) + + with true <- key_exists?(group, key) do + shell_info("The following settings will be removed from ConfigDB:\n") + + group + |> ConfigDB.get_by_group_and_key(key) + |> dump() + + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + delete_key(group, key) + else + shell_error("No changes made.") + end + else + _ -> + shell_error("No settings in ConfigDB for #{inspect(group)}, #{inspect(key)}. Aborting.") + end + end + + def run(["delete", group]) do + start_pleroma() + + group = maybe_atomize(group) + + with true <- group_exists?(group) do + shell_info("The following settings will be removed from ConfigDB:\n") + dump_group(group) + + if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do + delete_group(group) + else + shell_error("No changes made.") + end + else + _ -> shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.") + end + end + + @spec migrate_to_db(Path.t() | nil) :: any() + def migrate_to_db(file_path \\ nil) do + with :ok <- Pleroma.Config.DeprecationWarnings.warn() do + config_file = + if file_path do + file_path + else + if Pleroma.Config.get(:release) do + Pleroma.Config.get(:config_path) + else + "config/#{Pleroma.Config.get(:env)}.secret.exs" + end + end + + do_migrate_to_db(config_file) + else + _ -> + shell_error("Migration is not allowed until all deprecation warnings have been resolved.") + end + end + + defp do_migrate_to_db(config_file) do + if File.exists?(config_file) do + shell_info("Migrating settings from file: #{Path.expand(config_file)}") + truncatedb() + + custom_config = + config_file + |> read_file() + |> elem(0) + + custom_config + |> Keyword.keys() + |> Enum.each(&create(&1, custom_config)) + else + shell_info("To migrate settings, you must define custom settings in #{config_file}.") + end + end + + defp create(group, settings) do + group + |> Pleroma.Config.Loader.filter_group(settings) + |> Enum.each(fn {key, value} -> + {:ok, _} = ConfigDB.update_or_create(%{group: group, key: key, value: value}) + + shell_info("Settings for key #{key} migrated.") + end) + + shell_info("Settings for group #{inspect(group)} migrated.") + end + + defp migrate_from_db(opts) do + env = opts[:env] || Pleroma.Config.get(:env) + + filename = "#{env}.exported_from_db.secret.exs" + + config_path = + cond do + opts[:path] -> + opts[:path] + + Pleroma.Config.get(:release) -> + :config_path + |> Pleroma.Config.get() + |> Path.dirname() + + true -> + "config" + end + |> Path.join(filename) + + with {:ok, file} <- File.open(config_path, [:write, :utf8]) do + write_config(file, config_path, opts) + shell_info("Database configuration settings have been exported to #{config_path}") + else + _ -> + shell_error("Impossible to save settings to this directory #{Path.dirname(config_path)}") + tmp_config_path = Path.join(System.tmp_dir!(), filename) + file = File.open!(tmp_config_path) + + shell_info( + "Saving database configuration settings to #{tmp_config_path}. Copy it to the #{Path.dirname(config_path)} manually." + ) + + write_config(file, tmp_config_path, opts) + end + end + + defp write_config(file, path, opts) do + IO.write(file, config_header()) + + ConfigDB + |> Repo.all() + |> Enum.each(&write_and_delete(&1, file, opts[:delete])) + + :ok = File.close(file) + System.cmd("mix", ["format", path]) + end + + defp config_header, do: "import Config\r\n\r\n" + defp read_file(config_file), do: Config.Reader.read_imports!(config_file) + + defp write_and_delete(config, file, delete?) do + config + |> write(file) + |> delete(delete?) + end + + defp write(config, file) do + value = inspect(config.value, limit: :infinity) + + IO.write(file, "config #{inspect(config.group)}, #{inspect(config.key)}, #{value}\r\n\r\n") + + config + end + + defp delete(config, true) do + {:ok, _} = Repo.delete(config) + + shell_info( + "config #{inspect(config.group)}, #{inspect(config.key)} was deleted from the ConfigDB." + ) + end + + defp delete(_config, _), do: :ok + + defp dump(%ConfigDB{} = config) do + value = inspect(config.value, limit: :infinity) + + shell_info("config #{inspect(config.group)}, #{inspect(config.key)}, #{value}\r\n\r\n") + end + + defp dump(_), do: :noop + + defp dump_group(group) when is_atom(group) do + group + |> ConfigDB.get_all_by_group() + |> Enum.each(&dump/1) + end + + defp group_exists?(group) do + group + |> ConfigDB.get_all_by_group() + |> Enum.any?() + end + + defp key_exists?(group, key) do + group + |> ConfigDB.get_by_group_and_key(key) + |> is_nil + |> Kernel.!() + end + + defp maybe_atomize(arg) when is_atom(arg), do: arg + + defp maybe_atomize(":" <> arg), do: maybe_atomize(arg) + + defp maybe_atomize(arg) when is_binary(arg) do + if ConfigDB.module_name?(arg) do + String.to_existing_atom("Elixir." <> arg) + else + String.to_atom(arg) + end + end + + defp check_configdb(callback) do + with true <- Pleroma.Config.get([:configurable_from_database]) do + callback.() + else + _ -> + shell_error( + "ConfigDB not enabled. Please check the value of :configurable_from_database in your configuration." + ) + end + end + + defp delete_key(group, key) do + check_configdb(fn -> + ConfigDB.delete(%{group: group, key: key}) + end) + end + + defp delete_group(group) do + check_configdb(fn -> + group + |> ConfigDB.get_all_by_group() + |> Enum.each(&ConfigDB.delete/1) + end) + end + + defp truncatedb do + Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") + Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") + end +end diff --git a/lib/mix/tasks/pleroma/count_statuses.ex b/lib/mix/tasks/pleroma/count_statuses.ex new file mode 100644 index 0000000..c5ab8b7 --- /dev/null +++ b/lib/mix/tasks/pleroma/count_statuses.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.CountStatuses do + @shortdoc "Re-counts statuses for all users" + + use Mix.Task + alias Pleroma.User + import Ecto.Query + + def run([]) do + Mix.Pleroma.start_pleroma() + + stream = + User + |> where(local: true) + |> Pleroma.Repo.stream() + + Pleroma.Repo.transaction(fn -> + Enum.each(stream, &User.update_note_count/1) + end) + + Mix.Pleroma.shell_info("Done") + end +end diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex new file mode 100644 index 0000000..ed560c1 --- /dev/null +++ b/lib/mix/tasks/pleroma/database.ex @@ -0,0 +1,260 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Database do + alias Pleroma.Conversation + alias Pleroma.Maintenance + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + + require Logger + require Pleroma.Constants + + import Ecto.Query + import Mix.Pleroma + + use Mix.Task + + @shortdoc "A collection of database related tasks" + @moduledoc File.read!("docs/administration/CLI_tasks/database.md") + + def run(["remove_embedded_objects" | args]) do + {options, [], []} = + OptionParser.parse( + args, + strict: [ + vacuum: :boolean + ] + ) + + start_pleroma() + Logger.info("Removing embedded objects") + + Repo.query!( + "update activities set data = safe_jsonb_set(data, '{object}'::text[], data->'object'->'id') where data->'object'->>'id' is not null;", + [], + timeout: :infinity + ) + + if Keyword.get(options, :vacuum) do + Maintenance.vacuum("full") + end + end + + def run(["bump_all_conversations"]) do + start_pleroma() + Conversation.bump_for_all_activities() + end + + def run(["update_users_following_followers_counts"]) do + start_pleroma() + + Repo.transaction( + fn -> + from(u in User, select: u) + |> Repo.stream() + |> Stream.each(&User.update_follower_count/1) + |> Stream.run() + end, + timeout: :infinity + ) + end + + def run(["prune_objects" | args]) do + {options, [], []} = + OptionParser.parse( + args, + strict: [ + vacuum: :boolean + ] + ) + + start_pleroma() + + deadline = Pleroma.Config.get([:instance, :remote_post_retention_days]) + + Logger.info("Pruning objects older than #{deadline} days") + + time_deadline = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(-(deadline * 86_400)) + + from(o in Object, + where: + fragment( + "?->'to' \\? ? OR ?->'cc' \\? ?", + o.data, + ^Pleroma.Constants.as_public(), + o.data, + ^Pleroma.Constants.as_public() + ), + where: o.inserted_at < ^time_deadline, + where: + fragment("split_part(?->>'actor', '/', 3) != ?", o.data, ^Pleroma.Web.Endpoint.host()) + ) + |> Repo.delete_all(timeout: :infinity) + + prune_hashtags_query = """ + DELETE FROM hashtags AS ht + WHERE NOT EXISTS ( + SELECT 1 FROM hashtags_objects hto + WHERE ht.id = hto.hashtag_id) + """ + + Repo.query(prune_hashtags_query) + + if Keyword.get(options, :vacuum) do + Maintenance.vacuum("full") + end + end + + def run(["fix_likes_collections"]) do + start_pleroma() + + from(object in Object, + where: fragment("(?)->>'likes' is not null", object.data), + select: %{id: object.id, likes: fragment("(?)->>'likes'", object.data)} + ) + |> Pleroma.Repo.chunk_stream(100, :batches) + |> Stream.each(fn objects -> + ids = + objects + |> Enum.filter(fn object -> object.likes |> Jason.decode!() |> is_map() end) + |> Enum.map(& &1.id) + + Object + |> where([object], object.id in ^ids) + |> update([object], + set: [ + data: + fragment( + "safe_jsonb_set(?, '{likes}', '[]'::jsonb, true)", + object.data + ) + ] + ) + |> Repo.update_all([], timeout: :infinity) + end) + |> Stream.run() + end + + def run(["vacuum", args]) do + start_pleroma() + + Maintenance.vacuum(args) + end + + def run(["ensure_expiration"]) do + start_pleroma() + days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365) + + Pleroma.Activity + |> join(:inner, [a], o in Object, + on: + fragment( + "(?->>'id') = associated_object_id((?))", + o.data, + a.data + ) + ) + |> where(local: true) + |> where([a], fragment("(? ->> 'type'::text) = 'Create'", a.data)) + |> where([_a, o], fragment("?->>'type' = 'Note'", o.data)) + |> Pleroma.Repo.chunk_stream(100, :batches) + |> Stream.each(fn activities -> + Enum.each(activities, fn activity -> + expires_at = + activity.inserted_at + |> DateTime.from_naive!("Etc/UTC") + |> Timex.shift(days: days) + + Pleroma.Workers.PurgeExpiredActivity.enqueue(%{ + activity_id: activity.id, + expires_at: expires_at + }) + end) + end) + |> Stream.run() + end + + def run(["set_text_search_config", tsconfig]) do + start_pleroma() + %{rows: [[tsc]]} = Ecto.Adapters.SQL.query!(Pleroma.Repo, "SHOW default_text_search_config;") + shell_info("Current default_text_search_config: #{tsc}") + + %{rows: [[db]]} = Ecto.Adapters.SQL.query!(Pleroma.Repo, "SELECT current_database();") + shell_info("Update default_text_search_config: #{tsconfig}") + + %{messages: msg} = + Ecto.Adapters.SQL.query!( + Pleroma.Repo, + "ALTER DATABASE #{db} SET default_text_search_config = '#{tsconfig}';" + ) + + # non-exist config will not raise excpetion but only give >0 messages + if length(msg) > 0 do + shell_info("Error: #{inspect(msg, pretty: true)}") + else + rum_enabled = Pleroma.Config.get([:database, :rum_enabled]) + shell_info("Recreate index, RUM: #{rum_enabled}") + + # Note SQL below needs to be kept up-to-date with latest GIN or RUM index definition in future + if rum_enabled do + Ecto.Adapters.SQL.query!( + Pleroma.Repo, + "CREATE OR REPLACE FUNCTION objects_fts_update() RETURNS trigger AS $$ BEGIN + new.fts_content := to_tsvector(new.data->>'content'); + RETURN new; + END + $$ LANGUAGE plpgsql", + [], + timeout: :infinity + ) + + shell_info("Refresh RUM index") + Ecto.Adapters.SQL.query!(Pleroma.Repo, "UPDATE objects SET updated_at = NOW();") + else + Ecto.Adapters.SQL.query!(Pleroma.Repo, "DROP INDEX IF EXISTS objects_fts;") + + Ecto.Adapters.SQL.query!( + Pleroma.Repo, + "CREATE INDEX CONCURRENTLY objects_fts ON objects USING gin(to_tsvector('#{tsconfig}', data->>'content')); ", + [], + timeout: :infinity + ) + end + + shell_info('Done.') + end + end + + # Rolls back a specific migration (leaving subsequent migrations applied). + # WARNING: imposes a risk of unrecoverable data loss — proceed at your own responsibility. + # Based on https://stackoverflow.com/a/53825840 + def run(["rollback", version]) do + prompt = "SEVERE WARNING: this operation may result in unrecoverable data loss. Continue?" + + if shell_prompt(prompt, "n") in ~w(Yn Y y) do + {_, result, _} = + Ecto.Migrator.with_repo(Pleroma.Repo, fn repo -> + version = String.to_integer(version) + re = ~r/^#{version}_.*\.exs/ + path = Ecto.Migrator.migrations_path(repo) + + with {_, "" <> file} <- {:find, Enum.find(File.ls!(path), &String.match?(&1, re))}, + {_, [{mod, _} | _]} <- {:compile, Code.compile_file(Path.join(path, file))}, + {_, :ok} <- {:rollback, Ecto.Migrator.down(repo, version, mod)} do + {:ok, "Reversed migration: #{file}"} + else + {:find, _} -> {:error, "No migration found with version prefix: #{version}"} + {:compile, e} -> {:error, "Problem compiling migration module: #{inspect(e)}"} + {:rollback, e} -> {:error, "Problem reversing migration: #{inspect(e)}"} + end + end) + + shell_info(inspect(result)) + end + end +end diff --git a/lib/mix/tasks/pleroma/digest.ex b/lib/mix/tasks/pleroma/digest.ex new file mode 100644 index 0000000..aea9c8a --- /dev/null +++ b/lib/mix/tasks/pleroma/digest.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Digest do + use Mix.Task + import Mix.Pleroma + + @shortdoc "Manages digest emails" + @moduledoc File.read!("docs/administration/CLI_tasks/digest.md") + + def run(["test", nickname | opts]) do + Mix.Pleroma.start_pleroma() + + user = Pleroma.User.get_by_nickname(nickname) + + last_digest_emailed_at = + with [date] <- opts, + {:ok, datetime} <- Timex.parse(date, "{YYYY}-{0M}-{0D}") do + datetime + else + _ -> user.inserted_at + end + + patched_user = %{user | last_digest_emailed_at: last_digest_emailed_at} + + with %Swoosh.Email{} = email <- Pleroma.Emails.UserEmail.digest_email(patched_user) do + {:ok, _} = Pleroma.Emails.Mailer.deliver(email) + + shell_info("Digest email have been sent to #{nickname} (#{user.email})") + else + _ -> + shell_info("Cound't find any mentions for #{nickname} since #{last_digest_emailed_at}") + end + end +end diff --git a/lib/mix/tasks/pleroma/docs.ex b/lib/mix/tasks/pleroma/docs.ex new file mode 100644 index 0000000..32c02a5 --- /dev/null +++ b/lib/mix/tasks/pleroma/docs.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Docs do + use Mix.Task + import Mix.Pleroma + + @shortdoc "Generates docs from descriptions.exs" + @moduledoc """ + Generates docs from `descriptions.exs`. + + Supports two formats: `markdown` and `json`. + + ## Generate Markdown docs + + `mix pleroma.docs` + + ## Generate JSON docs + + `mix pleroma.docs json` + """ + + def run(["json"]) do + do_run(Pleroma.Docs.JSON) + end + + def run(_) do + do_run(Pleroma.Docs.Markdown) + end + + defp do_run(implementation) do + start_pleroma() + + with descriptions <- Pleroma.Config.Loader.read("config/description.exs"), + {:ok, file_path} <- + Pleroma.Docs.Generator.process( + implementation, + descriptions[:pleroma][:config_description] + ) do + type = if implementation == Pleroma.Docs.Markdown, do: "Markdown", else: "JSON" + + Mix.shell().info([:green, "#{type} docs successfully generated to #{file_path}."]) + end + end +end diff --git a/lib/mix/tasks/pleroma/ecto.ex b/lib/mix/tasks/pleroma/ecto.ex new file mode 100644 index 0000000..8cf77d1 --- /dev/null +++ b/lib/mix/tasks/pleroma/ecto.ex @@ -0,0 +1,50 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Ecto do + @doc """ + Ensures the given repository's migrations path exists on the file system. + """ + @spec ensure_migrations_path(Ecto.Repo.t(), Keyword.t()) :: String.t() + def ensure_migrations_path(repo, opts) do + path = opts[:migrations_path] || Path.join(source_repo_priv(repo), "migrations") + + path = + case Path.type(path) do + :relative -> + Path.join(Application.app_dir(:pleroma), path) + + :absolute -> + path + end + + if not File.dir?(path) do + raise_missing_migrations(Path.relative_to_cwd(path), repo) + end + + path + end + + @doc """ + Returns the private repository path relative to the source. + """ + def source_repo_priv(repo) do + config = repo.config() + priv = config[:priv] || "priv/#{repo |> Module.split() |> List.last() |> Macro.underscore()}" + Path.join(Application.app_dir(:pleroma), priv) + end + + defp raise_missing_migrations(path, repo) do + raise(""" + Could not find migrations directory #{inspect(path)} + for repo #{inspect(repo)}. + This may be because you are in a new project and the + migration directory has not been created yet. Creating an + empty directory at the path above will fix this error. + If you expected existing migrations to be found, please + make sure your repository has been properly configured + and the configured path exists. + """) + end +end diff --git a/lib/mix/tasks/pleroma/ecto/migrate.ex b/lib/mix/tasks/pleroma/ecto/migrate.ex new file mode 100644 index 0000000..c45c930 --- /dev/null +++ b/lib/mix/tasks/pleroma/ecto/migrate.ex @@ -0,0 +1,67 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Ecto.Migrate do + use Mix.Task + import Mix.Pleroma + require Logger + + @shortdoc "Wrapper on `ecto.migrate` task." + + @aliases [ + n: :step, + v: :to + ] + + @switches [ + all: :boolean, + step: :integer, + to: :integer, + quiet: :boolean, + log_sql: :boolean, + strict_version_order: :boolean, + migrations_path: :string + ] + + @moduledoc """ + Changes `Logger` level to `:info` before start migration. + Changes level back when migration ends. + + ## Start migration + + mix pleroma.ecto.migrate [OPTIONS] + + Options: + - see https://hexdocs.pm/ecto/2.0.0/Mix.Tasks.Ecto.Migrate.html + """ + + @impl true + def run(args \\ []) do + load_pleroma() + {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) + + if Application.get_env(:pleroma, Pleroma.Repo)[:ssl] do + Application.ensure_all_started(:ssl) + end + + opts = + if opts[:to] || opts[:step] || opts[:all], + do: opts, + else: Keyword.put(opts, :all, true) + + opts = + if opts[:quiet], + do: Keyword.merge(opts, log: false, log_sql: false), + else: opts + + path = Mix.Tasks.Pleroma.Ecto.ensure_migrations_path(Pleroma.Repo, opts) + + level = Logger.level() + Logger.configure(level: :info) + + {:ok, _, _} = Ecto.Migrator.with_repo(Pleroma.Repo, &Ecto.Migrator.run(&1, path, :up, opts)) + + Logger.configure(level: level) + end +end diff --git a/lib/mix/tasks/pleroma/ecto/rollback.ex b/lib/mix/tasks/pleroma/ecto/rollback.ex new file mode 100644 index 0000000..3d78eae --- /dev/null +++ b/lib/mix/tasks/pleroma/ecto/rollback.ex @@ -0,0 +1,72 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Ecto.Rollback do + use Mix.Task + import Mix.Pleroma + require Logger + @shortdoc "Wrapper on `ecto.rollback` task" + + @aliases [ + n: :step, + v: :to + ] + + @switches [ + all: :boolean, + step: :integer, + to: :integer, + start: :boolean, + quiet: :boolean, + log_sql: :boolean, + migrations_path: :string, + env: :string + ] + + @moduledoc """ + Changes `Logger` level to `:info` before start rollback. + Changes level back when rollback ends. + + ## Start rollback + + mix pleroma.ecto.rollback + + Options: + - see https://hexdocs.pm/ecto/2.0.0/Mix.Tasks.Ecto.Rollback.html + """ + + @impl true + def run(args \\ []) do + load_pleroma() + {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) + + if Application.get_env(:pleroma, Pleroma.Repo)[:ssl] do + Application.ensure_all_started(:ssl) + end + + opts = + if opts[:to] || opts[:step] || opts[:all], + do: opts, + else: Keyword.put(opts, :step, 1) + + opts = + if opts[:quiet], + do: Keyword.merge(opts, log: false, log_sql: false), + else: opts + + path = Mix.Tasks.Pleroma.Ecto.ensure_migrations_path(Pleroma.Repo, opts) + + level = Logger.level() + Logger.configure(level: :info) + + if opts[:env] == "test" do + Logger.info("Rollback succesfully") + else + {:ok, _, _} = + Ecto.Migrator.with_repo(Pleroma.Repo, &Ecto.Migrator.run(&1, path, :down, opts)) + end + + Logger.configure(level: level) + end +end diff --git a/lib/mix/tasks/pleroma/email.ex b/lib/mix/tasks/pleroma/email.ex new file mode 100644 index 0000000..37272c1 --- /dev/null +++ b/lib/mix/tasks/pleroma/email.ex @@ -0,0 +1,44 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Email do + use Mix.Task + import Mix.Pleroma + + @shortdoc "Email administrative tasks" + @moduledoc File.read!("docs/administration/CLI_tasks/email.md") + + def run(["test" | args]) do + start_pleroma() + + {options, [], []} = + OptionParser.parse( + args, + strict: [ + to: :string + ] + ) + + email = Pleroma.Emails.AdminEmail.test_email(options[:to]) + {:ok, _} = Pleroma.Emails.Mailer.deliver(email) + + shell_info("Test email has been sent to #{inspect(email.to)} from #{inspect(email.from)}") + end + + def run(["resend_confirmation_emails"]) do + start_pleroma() + + shell_info("Sending emails to all unconfirmed users") + + Pleroma.User.Query.build(%{ + local: true, + is_active: true, + is_confirmed: false, + invisible: false + }) + |> Pleroma.Repo.chunk_stream(500) + |> Stream.each(&Pleroma.User.maybe_send_confirmation_email(&1)) + |> Stream.run() + end +end diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex new file mode 100644 index 0000000..537f071 --- /dev/null +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -0,0 +1,275 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Emoji do + use Mix.Task + import Mix.Pleroma + + @shortdoc "Manages emoji packs" + @moduledoc File.read!("docs/administration/CLI_tasks/emoji.md") + + def run(["ls-packs" | args]) do + start_pleroma() + + {options, [], []} = parse_global_opts(args) + + url_or_path = options[:manifest] || default_manifest() + manifest = fetch_and_decode!(url_or_path) + + Enum.each(manifest, fn {name, info} -> + to_print = [ + {"Name", name}, + {"Homepage", info["homepage"]}, + {"Description", info["description"]}, + {"License", info["license"]}, + {"Source", info["src"]} + ] + + for {param, value} <- to_print do + IO.puts(IO.ANSI.format([:bright, param, :normal, ": ", value])) + end + + # A newline + IO.puts("") + end) + end + + def run(["get-packs" | args]) do + start_pleroma() + + {options, pack_names, []} = parse_global_opts(args) + + url_or_path = options[:manifest] || default_manifest() + + manifest = fetch_and_decode!(url_or_path) + + for pack_name <- pack_names do + if Map.has_key?(manifest, pack_name) do + pack = manifest[pack_name] + src = pack["src"] + + IO.puts( + IO.ANSI.format([ + "Downloading ", + :bright, + pack_name, + :normal, + " from ", + :underline, + src + ]) + ) + + {:ok, binary_archive} = fetch(src) + archive_sha = :crypto.hash(:sha256, binary_archive) |> Base.encode16() + + sha_status_text = ["SHA256 of ", :bright, pack_name, :normal, " source file is ", :bright] + + if archive_sha == String.upcase(pack["src_sha256"]) do + IO.puts(IO.ANSI.format(sha_status_text ++ [:green, "OK"])) + else + IO.puts(IO.ANSI.format(sha_status_text ++ [:red, "BAD"])) + + raise "Bad SHA256 for #{pack_name}" + end + + # The location specified in files should be in the same directory + files_loc = + url_or_path + |> Path.dirname() + |> Path.join(pack["files"]) + + IO.puts( + IO.ANSI.format([ + "Fetching the file list for ", + :bright, + pack_name, + :normal, + " from ", + :underline, + files_loc + ]) + ) + + files = fetch_and_decode!(files_loc) + + IO.puts(IO.ANSI.format(["Unpacking ", :bright, pack_name])) + + pack_path = + Path.join([ + Pleroma.Config.get!([:instance, :static_dir]), + "emoji", + pack_name + ]) + + files_to_unzip = + Enum.map( + files, + fn {_, f} -> to_charlist(f) end + ) + + {:ok, _} = + :zip.unzip(binary_archive, + cwd: pack_path, + file_list: files_to_unzip + ) + + IO.puts(IO.ANSI.format(["Writing pack.json for ", :bright, pack_name])) + + pack_json = %{ + pack: %{ + "license" => pack["license"], + "homepage" => pack["homepage"], + "description" => pack["description"], + "fallback-src" => pack["src"], + "fallback-src-sha256" => pack["src_sha256"], + "share-files" => true + }, + files: files + } + + File.write!(Path.join(pack_path, "pack.json"), Jason.encode!(pack_json, pretty: true)) + else + IO.puts(IO.ANSI.format([:bright, :red, "No pack named \"#{pack_name}\" found"])) + end + end + end + + def run(["gen-pack" | args]) do + start_pleroma() + + {opts, [src], []} = + OptionParser.parse( + args, + strict: [ + name: :string, + license: :string, + homepage: :string, + description: :string, + files: :string, + extensions: :string + ] + ) + + proposed_name = Path.basename(src) |> Path.rootname() + name = get_option(opts, :name, "Pack name:", proposed_name) + license = get_option(opts, :license, "License:") + homepage = get_option(opts, :homepage, "Homepage:") + description = get_option(opts, :description, "Description:") + + proposed_files_name = "#{name}_files.json" + files_name = get_option(opts, :files, "Save file list to:", proposed_files_name) + + default_exts = [".png", ".gif"] + + custom_exts = + get_option( + opts, + :extensions, + "Emoji file extensions (separated with spaces):", + Enum.join(default_exts, " ") + ) + |> String.split(" ", trim: true) + + exts = + if MapSet.equal?(MapSet.new(default_exts), MapSet.new(custom_exts)) do + default_exts + else + custom_exts + end + + IO.puts("Using #{Enum.join(exts, " ")} extensions") + + IO.puts("Downloading the pack and generating SHA256") + + {:ok, %{body: binary_archive}} = Pleroma.HTTP.get(src) + archive_sha = :crypto.hash(:sha256, binary_archive) |> Base.encode16() + + IO.puts("SHA256 is #{archive_sha}") + + pack_json = %{ + name => %{ + license: license, + homepage: homepage, + description: description, + src: src, + src_sha256: archive_sha, + files: files_name + } + } + + tmp_pack_dir = Path.join(System.tmp_dir!(), "emoji-pack-#{name}") + + {:ok, _} = :zip.unzip(binary_archive, cwd: String.to_charlist(tmp_pack_dir)) + + emoji_map = Pleroma.Emoji.Loader.make_shortcode_to_file_map(tmp_pack_dir, exts) + + File.write!(files_name, Jason.encode!(emoji_map, pretty: true)) + + IO.puts(""" + + #{files_name} has been created and contains the list of all found emojis in the pack. + Please review the files in the pack and remove those not needed. + """) + + pack_file = "#{name}.json" + + if File.exists?(pack_file) do + existing_data = File.read!(pack_file) |> Jason.decode!() + + File.write!( + pack_file, + Jason.encode!( + Map.merge( + existing_data, + pack_json + ), + pretty: true + ) + ) + + IO.puts("#{pack_file} has been updated with the #{name} pack") + else + File.write!(pack_file, Jason.encode!(pack_json, pretty: true)) + + IO.puts("#{pack_file} has been created with the #{name} pack") + end + end + + def run(["reload"]) do + start_pleroma() + Pleroma.Emoji.reload() + IO.puts("Emoji packs have been reloaded.") + end + + defp fetch_and_decode!(from) do + with {:ok, json} <- fetch(from) do + Jason.decode!(json) + else + {:error, error} -> raise "#{from} cannot be fetched. Error: #{error} occur." + end + end + + defp fetch("http" <> _ = from) do + with {:ok, %{body: body}} <- Pleroma.HTTP.get(from) do + {:ok, body} + end + end + + defp fetch(path), do: File.read(path) + + defp parse_global_opts(args) do + OptionParser.parse( + args, + strict: [ + manifest: :string + ], + aliases: [ + m: :manifest + ] + ) + end + + defp default_manifest, do: Pleroma.Config.get!([:emoji, :default_manifest]) +end diff --git a/lib/mix/tasks/pleroma/frontend.ex b/lib/mix/tasks/pleroma/frontend.ex new file mode 100644 index 0000000..fce8e59 --- /dev/null +++ b/lib/mix/tasks/pleroma/frontend.ex @@ -0,0 +1,108 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Frontend do + use Mix.Task + + import Mix.Pleroma + + alias Pleroma.Frontend + + @shortdoc "Manages bundled Pleroma frontends" + + @moduledoc File.read!("docs/administration/CLI_tasks/frontend.md") + + def run(["install", "none" | _args]) do + shell_info("Skipping frontend installation because none was requested") + "none" + end + + def run(["install", name | args]) do + start_pleroma() + + {options, [], []} = + OptionParser.parse( + args, + strict: [ + ref: :string, + build_url: :string, + build_dir: :string, + file: :string, + admin: :boolean, + primary: :boolean + ] + ) + + shell_info("Installing frontend #{name}...") + + with %Frontend{} = fe <- + options + |> Keyword.put(:name, name) + |> opts_to_frontend() + |> Frontend.install() do + shell_info("Frontend #{fe.name} installed") + + if get_frontend_type(options) do + run(["enable", name] ++ args) + end + else + error -> + shell_error("Failed to install frontend") + exit(inspect(error)) + end + end + + def run(["enable", name | args]) do + start_pleroma() + + {options, [], []} = + OptionParser.parse( + args, + strict: [ + ref: :string, + build_url: :string, + build_dir: :string, + file: :string, + admin: :boolean, + primary: :boolean + ] + ) + + frontend_type = get_frontend_type(options) || :primary + + shell_info("Enabling frontend #{name}...") + + with %Frontend{} = fe <- + options + |> Keyword.put(:name, name) + |> opts_to_frontend() + |> Frontend.enable(frontend_type) do + shell_info("Frontend #{fe.name} enabled") + else + error -> + shell_error("Failed to enable frontend") + exit(inspect(error)) + end + end + + defp opts_to_frontend(opts) do + struct(Frontend, opts) + end + + defp get_frontend_type(opts) do + case Enum.into(opts, %{}) do + %{admin: true, primary: true} -> + raise "Invalid command. Only one frontend type may be selected." + + %{admin: true} -> + :admin + + %{primary: true} -> + :primary + + _ -> + nil + end + end +end diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex new file mode 100644 index 0000000..a9a5181 --- /dev/null +++ b/lib/mix/tasks/pleroma/instance.ex @@ -0,0 +1,300 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Instance do + use Mix.Task + import Mix.Pleroma + + alias Pleroma.Config + + @shortdoc "Manages Pleroma instance" + @moduledoc File.read!("docs/administration/CLI_tasks/instance.md") + + def run(["gen" | rest]) do + {options, [], []} = + OptionParser.parse( + rest, + strict: [ + force: :boolean, + output: :string, + output_psql: :string, + domain: :string, + instance_name: :string, + admin_email: :string, + notify_email: :string, + dbhost: :string, + dbname: :string, + dbuser: :string, + dbpass: :string, + rum: :string, + indexable: :string, + db_configurable: :string, + uploads_dir: :string, + static_dir: :string, + listen_ip: :string, + listen_port: :string, + strip_uploads_location: :string, + read_uploads_description: :string, + anonymize_uploads: :string, + dedupe_uploads: :string + ], + aliases: [ + o: :output, + f: :force + ] + ) + + paths = + [config_path, psql_path] = [ + Keyword.get(options, :output, "config/generated_config.exs"), + Keyword.get(options, :output_psql, "config/setup_db.psql") + ] + + will_overwrite = Enum.filter(paths, &File.exists?/1) + proceed? = Enum.empty?(will_overwrite) or Keyword.get(options, :force, false) + + if proceed? do + [domain, port | _] = + String.split( + get_option( + options, + :domain, + "What domain will your instance use? (e.g mysite.com)" + ), + ":" + ) ++ [443] + + name = + get_option( + options, + :instance_name, + "What is the name of your instance? (e.g. Our Wonderful Community)", + domain + ) + + email = + get_option( + options, + :admin_email, + "What is your admin email address? (this will be public)" + ) + + notify_email = + get_option( + options, + :notify_email, + "What email address do you want to use for sending email notifications?", + email + ) + + indexable = + get_option( + options, + :indexable, + "Do you want search engines to index your site? (y/n)", + "y" + ) === "y" + + db_configurable? = true + + dbhost = get_option(options, :dbhost, "Where will your database live?", "localhost") + + dbname = get_option(options, :dbname, "What shall we name your database?", "pleroma") + + dbuser = + get_option( + options, + :dbuser, + "What shall we name your database user?", + "pleroma" + ) + + dbpass = + get_option( + options, + :dbpass, + "What shall be your database password?", + :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64), + "autogenerated" + ) + + rum_enabled = false + + listen_port = + get_option( + options, + :listen_port, + "What port will the app listen to (leave it if you are using the default setup with nginx)?", + 4000 + ) + + listen_ip = + get_option( + options, + :listen_ip, + "What IP will the app listen to (leave it if you are using the default setup with nginx)?", + "127.0.0.1" + ) + + uploads_dir = + Keyword.get(options, :uploads_dir) || + Config.get([Pleroma.Uploaders.Local, :uploads]) + |> Path.expand() + + static_dir = + Keyword.get(options, :static_dir) || + Config.get([:instance, :static_dir]) + |> Path.expand() + + strip_uploads_location = false + + anonymize_uploads = false + + read_uploads_description = true + + dedupe_uploads = false + + Config.put([:instance, :static_dir], static_dir) + + secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64) + jwt_secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64) + signing_salt = :crypto.strong_rand_bytes(8) |> Base.encode64() |> binary_part(0, 8) + lv_signing_salt = :crypto.strong_rand_bytes(8) |> Base.encode64() |> binary_part(0, 8) + {web_push_public_key, web_push_private_key} = :crypto.generate_key(:ecdh, :prime256v1) + template_dir = Application.app_dir(:pleroma, "priv") <> "/templates" + + result_config = + EEx.eval_file( + template_dir <> "/sample_config.eex", + domain: domain, + port: port, + email: email, + notify_email: notify_email, + name: name, + dbhost: dbhost, + dbname: dbname, + dbuser: dbuser, + dbpass: dbpass, + secret: secret, + jwt_secret: jwt_secret, + signing_salt: signing_salt, + lv_signing_salt: lv_signing_salt, + web_push_public_key: Base.url_encode64(web_push_public_key, padding: false), + web_push_private_key: Base.url_encode64(web_push_private_key, padding: false), + db_configurable?: db_configurable?, + static_dir: static_dir, + uploads_dir: uploads_dir, + rum_enabled: rum_enabled, + listen_ip: listen_ip, + listen_port: listen_port, + upload_filters: + upload_filters(%{ + strip_location: strip_uploads_location, + read_description: read_uploads_description, + anonymize: anonymize_uploads, + dedupe: dedupe_uploads + }) + ) + + result_psql = + EEx.eval_file( + template_dir <> "/sample_psql.eex", + dbname: dbname, + dbuser: dbuser, + dbpass: dbpass, + rum_enabled: rum_enabled + ) + + config_dir = Path.dirname(config_path) + psql_dir = Path.dirname(psql_path) + + # Note: Distros requiring group read (0o750) on those directories should + # pre-create the directories. + [config_dir, psql_dir, static_dir, uploads_dir] + |> Enum.reject(&File.exists?/1) + |> Enum.each(fn dir -> + File.mkdir_p!(dir) + File.chmod!(dir, 0o700) + end) + + shell_info("Writing config to #{config_path}.") + + # Sadly no fchmod(2) equivalent in Elixir… + File.touch!(config_path) + File.chmod!(config_path, 0o640) + File.write(config_path, result_config) + shell_info("Writing the postgres script to #{psql_path}.") + File.write(psql_path, result_psql) + + write_robots_txt(static_dir, indexable, template_dir) + + shell_info( + "\n All files successfully written! Refer to the installation instructions for your platform for next steps." + ) + + if db_configurable? do + shell_info( + " Please transfer your config to the database after running database migrations. Refer to \"Transfering the config to/from the database\" section of the docs for more information." + ) + end + else + shell_error( + "The task would have overwritten the following files:\n" <> + Enum.map_join(will_overwrite, &"- #{&1}\n") <> "Rerun with `--force` to overwrite them." + ) + end + end + + defp write_robots_txt(static_dir, indexable, template_dir) do + robots_txt = + EEx.eval_file( + template_dir <> "/robots_txt.eex", + indexable: indexable + ) + + robots_txt_path = Path.join(static_dir, "robots.txt") + + if File.exists?(robots_txt_path) do + File.cp!(robots_txt_path, "#{robots_txt_path}.bak") + shell_info("Backing up existing robots.txt to #{robots_txt_path}.bak") + end + + File.write(robots_txt_path, robots_txt) + shell_info("Writing #{robots_txt_path}.") + end + + defp upload_filters(filters) when is_map(filters) do + enabled_filters = + if filters.strip_location do + [Pleroma.Upload.Filter.Exiftool.StripLocation] + else + [] + end + + enabled_filters = + if filters.read_description do + enabled_filters ++ [Pleroma.Upload.Filter.Exiftool.ReadDescription] + else + enabled_filters + end + + enabled_filters = + if filters.anonymize do + enabled_filters ++ [Pleroma.Upload.Filter.AnonymizeFilename] + else + enabled_filters + end + + enabled_filters = + if filters.dedupe do + enabled_filters ++ [Pleroma.Upload.Filter.Dedupe] + else + enabled_filters + end + + enabled_filters + end + + defp upload_filters(_), do: [] +end diff --git a/lib/mix/tasks/pleroma/notification_settings.ex b/lib/mix/tasks/pleroma/notification_settings.ex new file mode 100644 index 0000000..f0a7cc4 --- /dev/null +++ b/lib/mix/tasks/pleroma/notification_settings.ex @@ -0,0 +1,87 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.NotificationSettings do + @shortdoc "Enable&Disable privacy option for push notifications" + @moduledoc """ + Example: + + > mix pleroma.notification_settings --hide-notification-contents=false --nickname-users="parallel588" # set false only for parallel588 user + > mix pleroma.notification_settings --hide-notification-contents=true # set true for all users + + """ + + use Mix.Task + import Mix.Pleroma + import Ecto.Query + + def run(args) do + start_pleroma() + + {options, _, _} = + OptionParser.parse( + args, + strict: [ + hide_notification_contents: :boolean, + email_users: :string, + nickname_users: :string + ] + ) + + hide_notification_contents = Keyword.get(options, :hide_notification_contents) + + if not is_nil(hide_notification_contents) do + hide_notification_contents + |> build_query(options) + |> Pleroma.Repo.update_all([]) + end + + shell_info("Done") + end + + defp build_query(hide_notification_contents, options) do + query = + from(u in Pleroma.User, + update: [ + set: [ + notification_settings: + fragment( + "jsonb_set(notification_settings, '{hide_notification_contents}', ?)", + ^hide_notification_contents + ) + ] + ] + ) + + user_emails = + options + |> Keyword.get(:email_users, "") + |> String.split(",") + |> Enum.map(&String.trim(&1)) + |> Enum.reject(&(&1 == "")) + + query = + if length(user_emails) > 0 do + where(query, [u], u.email in ^user_emails) + else + query + end + + user_nicknames = + options + |> Keyword.get(:nickname_users, "") + |> String.split(",") + |> Enum.map(&String.trim(&1)) + |> Enum.reject(&(&1 == "")) + + query = + if length(user_nicknames) > 0 do + where(query, [u], u.nickname in ^user_nicknames) + else + query + end + + query + end +end diff --git a/lib/mix/tasks/pleroma/openapi_spec.ex b/lib/mix/tasks/pleroma/openapi_spec.ex new file mode 100644 index 0000000..1ea4684 --- /dev/null +++ b/lib/mix/tasks/pleroma/openapi_spec.ex @@ -0,0 +1,75 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.OpenapiSpec do + def run([path]) do + # Load Pleroma application to get version info + Application.load(:pleroma) + + spec_json = Pleroma.Web.ApiSpec.spec(server_specific: false) |> Jason.encode!() + # to get rid of the structs + spec_regened = spec_json |> Jason.decode!() + + check_specs!(spec_regened) + + File.write(path, spec_json) + end + + defp check_specs!(spec) do + with :ok <- check_specs(spec) do + :ok + else + {_, errors} -> + IO.puts(IO.ANSI.format([:red, :bright, "Spec check failed, errors:"])) + Enum.map(errors, &IO.puts/1) + + raise "Spec check failed" + end + end + + def check_specs(spec) do + errors = + spec["paths"] + |> Enum.flat_map(fn {path, %{} = endpoints} -> + Enum.map( + endpoints, + fn {method, endpoint} -> + with :ok <- check_endpoint(spec, endpoint) do + :ok + else + error -> + "#{endpoint["operationId"]} (#{method} #{path}): #{error}" + end + end + ) + |> Enum.reject(fn res -> res == :ok end) + end) + + if errors == [] do + :ok + else + {:error, errors} + end + end + + defp check_endpoint(spec, endpoint) do + valid_tags = available_tags(spec) + + with {_, [_ | _] = tags} <- {:tags, endpoint["tags"]}, + {_, []} <- {:unavailable, Enum.reject(tags, &(&1 in valid_tags))} do + :ok + else + {:tags, _} -> + "No tags specified" + + {:unavailable, tags} -> + "Tags #{inspect(tags)} not available. Please add it in \"x-tagGroups\" in Pleroma.Web.ApiSpec" + end + end + + defp available_tags(spec) do + spec["x-tagGroups"] + |> Enum.flat_map(fn %{"tags" => tags} -> tags end) + end +end diff --git a/lib/mix/tasks/pleroma/refresh_counter_cache.ex b/lib/mix/tasks/pleroma/refresh_counter_cache.ex new file mode 100644 index 0000000..ad37cd2 --- /dev/null +++ b/lib/mix/tasks/pleroma/refresh_counter_cache.ex @@ -0,0 +1,69 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.RefreshCounterCache do + @shortdoc "Refreshes counter cache" + + use Mix.Task + + alias Pleroma.Activity + alias Pleroma.CounterCache + alias Pleroma.Repo + + require Logger + import Ecto.Query + + def run([]) do + Mix.Pleroma.start_pleroma() + + instances = + Activity + |> distinct([a], true) + |> select([a], fragment("split_part(?, '/', 3)", a.actor)) + |> Repo.all() + + instances + |> Enum.with_index(1) + |> Enum.each(fn {instance, i} -> + counters = instance_counters(instance) + CounterCache.set(instance, counters) + + Mix.Pleroma.shell_info( + "[#{i}/#{length(instances)}] Setting #{instance} counters: #{inspect(counters)}" + ) + end) + + Mix.Pleroma.shell_info("Done") + end + + defp instance_counters(instance) do + counters = %{"public" => 0, "unlisted" => 0, "private" => 0, "direct" => 0} + + Activity + |> where([a], fragment("(? ->> 'type'::text) = 'Create'", a.data)) + |> where([a], fragment("split_part(?, '/', 3) = ?", a.actor, ^instance)) + |> select( + [a], + {fragment( + "activity_visibility(?, ?, ?)", + a.actor, + a.recipients, + a.data + ), count(a.id)} + ) + |> group_by( + [a], + fragment( + "activity_visibility(?, ?, ?)", + a.actor, + a.recipients, + a.data + ) + ) + |> Repo.all(timeout: :timer.minutes(30)) + |> Enum.reduce(counters, fn {visibility, count}, acc -> + Map.put(acc, visibility, count) + end) + end +end diff --git a/lib/mix/tasks/pleroma/relay.ex b/lib/mix/tasks/pleroma/relay.ex new file mode 100644 index 0000000..29cc666 --- /dev/null +++ b/lib/mix/tasks/pleroma/relay.ex @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Relay do + use Mix.Task + import Mix.Pleroma + alias Pleroma.Web.ActivityPub.Relay + + @shortdoc "Manages remote relays" + @moduledoc File.read!("docs/administration/CLI_tasks/relay.md") + + def run(["follow", target]) do + start_pleroma() + + with {:ok, _activity} <- Relay.follow(target) do + # put this task to sleep to allow the genserver to push out the messages + :timer.sleep(500) + else + {:error, e} -> shell_error("Error while following #{target}: #{inspect(e)}") + end + end + + def run(["unfollow", target | rest]) do + start_pleroma() + + {options, [], []} = + OptionParser.parse( + rest, + strict: [force: :boolean], + aliases: [f: :force] + ) + + force = Keyword.get(options, :force, false) + + with {:ok, _activity} <- Relay.unfollow(target, %{force: force}) do + # put this task to sleep to allow the genserver to push out the messages + :timer.sleep(500) + else + {:error, e} -> shell_error("Error while following #{target}: #{inspect(e)}") + end + end + + def run(["list"]) do + start_pleroma() + + with {:ok, list} <- Relay.list() do + Enum.each(list, &print_relay_url/1) + else + {:error, e} -> shell_error("Error while fetching relay subscription list: #{inspect(e)}") + end + end + + defp print_relay_url(%{followed_back: false} = relay) do + shell_info("#{relay.actor} - no Accept received (relay didn't follow back)") + end + + defp print_relay_url(relay), do: shell_info(relay.actor) +end diff --git a/lib/mix/tasks/pleroma/robots_txt.ex b/lib/mix/tasks/pleroma/robots_txt.ex new file mode 100644 index 0000000..5124c7c --- /dev/null +++ b/lib/mix/tasks/pleroma/robots_txt.ex @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.RobotsTxt do + use Mix.Task + + @shortdoc "Generate robots.txt" + @moduledoc """ + Generates robots.txt + + ## Overwrite robots.txt to disallow all + + mix pleroma.robots_txt disallow_all + + This will write a robots.txt that will hide all paths on your instance + from search engines and other robots that obey robots.txt + + """ + def run(["disallow_all"]) do + Mix.Pleroma.start_pleroma() + static_dir = Pleroma.Config.get([:instance, :static_dir], "instance/static/") + + if !File.exists?(static_dir) do + File.mkdir_p!(static_dir) + end + + robots_txt_path = Path.join(static_dir, "robots.txt") + robots_txt_content = "User-Agent: *\nDisallow: /\n" + + File.write!(robots_txt_path, robots_txt_content, [:write]) + end +end diff --git a/lib/mix/tasks/pleroma/search/meilisearch.ex b/lib/mix/tasks/pleroma/search/meilisearch.ex new file mode 100644 index 0000000..8379a0c --- /dev/null +++ b/lib/mix/tasks/pleroma/search/meilisearch.ex @@ -0,0 +1,145 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Search.Meilisearch do + require Pleroma.Constants + + import Mix.Pleroma + import Ecto.Query + + import Pleroma.Search.Meilisearch, + only: [meili_post: 2, meili_put: 2, meili_get: 1, meili_delete: 1] + + def run(["index"]) do + start_pleroma() + Pleroma.HTML.compile_scrubbers() + + meili_version = + ( + {:ok, result} = meili_get("/version") + + result["pkgVersion"] + ) + + # The ranking rule syntax was changed but nothing about that is mentioned in the changelog + if not Version.match?(meili_version, ">= 0.25.0") do + raise "Meilisearch <0.24.0 not supported" + end + + {:ok, _} = + meili_post( + "/indexes/objects/settings/ranking-rules", + [ + "published:desc", + "words", + "exactness", + "proximity", + "typo", + "attribute", + "sort" + ] + ) + + {:ok, _} = + meili_post( + "/indexes/objects/settings/searchable-attributes", + [ + "content" + ] + ) + + IO.puts("Created indices. Starting to insert posts.") + + chunk_size = Pleroma.Config.get([Pleroma.Search.Meilisearch, :initial_indexing_chunk_size]) + + Pleroma.Repo.transaction( + fn -> + query = + from(Pleroma.Object, + # Only index public and unlisted posts which are notes and have some text + where: + fragment("data->>'type' = 'Note'") and + (fragment("data->'to' \\? ?", ^Pleroma.Constants.as_public()) or + fragment("data->'cc' \\? ?", ^Pleroma.Constants.as_public())), + order_by: [desc: fragment("data->'published'")] + ) + + count = query |> Pleroma.Repo.aggregate(:count, :data) + IO.puts("Entries to index: #{count}") + + Pleroma.Repo.stream( + query, + timeout: :infinity + ) + |> Stream.map(&Pleroma.Search.Meilisearch.object_to_search_data/1) + |> Stream.filter(fn o -> not is_nil(o) end) + |> Stream.chunk_every(chunk_size) + |> Stream.transform(0, fn objects, acc -> + new_acc = acc + Enum.count(objects) + + # Reset to the beginning of the line and rewrite it + IO.write("\r") + IO.write("Indexed #{new_acc} entries") + + {[objects], new_acc} + end) + |> Stream.each(fn objects -> + result = + meili_put( + "/indexes/objects/documents", + objects + ) + + with {:ok, res} <- result do + if not Map.has_key?(res, "uid") do + IO.puts("\nFailed to index: #{inspect(result)}") + end + else + e -> IO.puts("\nFailed to index due to network error: #{inspect(e)}") + end + end) + |> Stream.run() + end, + timeout: :infinity + ) + + IO.write("\n") + end + + def run(["clear"]) do + start_pleroma() + + meili_delete("/indexes/objects/documents") + end + + def run(["show-keys", master_key]) do + start_pleroma() + + endpoint = Pleroma.Config.get([Pleroma.Search.Meilisearch, :url]) + + {:ok, result} = + Pleroma.HTTP.get( + Path.join(endpoint, "/keys"), + [{"Authorization", "Bearer #{master_key}"}] + ) + + decoded = Jason.decode!(result.body) + + if decoded["results"] do + Enum.each(decoded["results"], fn %{"description" => desc, "key" => key} -> + IO.puts("#{desc}: #{key}") + end) + else + IO.puts("Error fetching the keys, check the master key is correct: #{inspect(decoded)}") + end + end + + def run(["stats"]) do + start_pleroma() + + {:ok, result} = meili_get("/indexes/objects/stats") + IO.puts("Number of entries: #{result["numberOfDocuments"]}") + IO.puts("Indexing? #{result["isIndexing"]}") + end +end diff --git a/lib/mix/tasks/pleroma/uploads.ex b/lib/mix/tasks/pleroma/uploads.ex new file mode 100644 index 0000000..bf02912 --- /dev/null +++ b/lib/mix/tasks/pleroma/uploads.ex @@ -0,0 +1,100 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Uploads do + use Mix.Task + import Mix.Pleroma + alias Pleroma.Upload + alias Pleroma.Uploaders.Local + require Logger + + @log_every 50 + + @shortdoc "Migrates uploads from local to remote storage" + @moduledoc File.read!("docs/administration/CLI_tasks/uploads.md") + + def run(["migrate_local", target_uploader | args]) do + delete? = Enum.member?(args, "--delete") + start_pleroma() + local_path = Pleroma.Config.get!([Local, :uploads]) + uploader = Module.concat(Pleroma.Uploaders, target_uploader) + + unless Code.ensure_loaded?(uploader) do + raise("The uploader #{inspect(uploader)} is not an existing/loaded module.") + end + + target_enabled? = Pleroma.Config.get([Upload, :uploader]) == uploader + + unless target_enabled? do + Pleroma.Config.put([Upload, :uploader], uploader) + end + + shell_info("Migrating files from local #{local_path} to #{to_string(uploader)}") + + if delete? do + shell_info( + "Attention: uploaded files will be deleted, hope you have backups! (--delete ; cancel with ^C)" + ) + + :timer.sleep(:timer.seconds(5)) + end + + uploads = + File.ls!(local_path) + |> Enum.map(fn id -> + root_path = Path.join(local_path, id) + + cond do + File.dir?(root_path) -> + files = for file <- File.ls!(root_path), do: {id, file, Path.join([root_path, file])} + + case List.first(files) do + {id, file, path} -> + {%Pleroma.Upload{id: id, name: file, path: id <> "/" <> file, tempfile: path}, + root_path} + + _ -> + nil + end + + File.exists?(root_path) -> + file = Path.basename(id) + hash = Path.rootname(id) + {%Pleroma.Upload{id: hash, name: file, path: file, tempfile: root_path}, root_path} + + true -> + nil + end + end) + |> Enum.filter(& &1) + + total_count = length(uploads) + shell_info("Found #{total_count} uploads") + + uploads + |> Task.async_stream( + fn {upload, root_path} -> + case Upload.store(upload, uploader: uploader, filters: [], size_limit: nil) do + {:ok, _} -> + if delete?, do: File.rm_rf!(root_path) + Logger.debug("uploaded: #{inspect(upload.path)} #{inspect(upload)}") + :ok + + error -> + shell_error("failed to upload #{inspect(upload.path)}: #{inspect(error)}") + end + end, + timeout: 150_000 + ) + |> Stream.chunk_every(@log_every) + # credo:disable-for-next-line Credo.Check.Warning.UnusedEnumOperation + |> Enum.reduce(0, fn done, count -> + count = count + length(done) + shell_info("Uploaded #{count}/#{total_count} files") + count + end) + + shell_info("Done!") + end +end diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex new file mode 100644 index 0000000..67f51d4 --- /dev/null +++ b/lib/mix/tasks/pleroma/user.ex @@ -0,0 +1,490 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.User do + use Mix.Task + import Mix.Pleroma + alias Ecto.Changeset + alias Pleroma.User + alias Pleroma.UserInviteToken + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline + + @shortdoc "Manages Pleroma users" + @moduledoc File.read!("docs/administration/CLI_tasks/user.md") + + def run(["new", nickname, email | rest]) do + {options, [], []} = + OptionParser.parse( + rest, + strict: [ + name: :string, + bio: :string, + password: :string, + moderator: :boolean, + admin: :boolean, + assume_yes: :boolean + ], + aliases: [ + y: :assume_yes + ] + ) + + name = Keyword.get(options, :name, nickname) + bio = Keyword.get(options, :bio, "") + + {password, generated_password?} = + case Keyword.get(options, :password) do + nil -> + {:crypto.strong_rand_bytes(16) |> Base.encode64(), true} + + password -> + {password, false} + end + + moderator? = Keyword.get(options, :moderator, false) + admin? = Keyword.get(options, :admin, false) + assume_yes? = Keyword.get(options, :assume_yes, false) + + shell_info(""" + A user will be created with the following information: + - nickname: #{nickname} + - email: #{email} + - password: #{if(generated_password?, do: "[generated; a reset link will be created]", else: password)} + - name: #{name} + - bio: #{bio} + - moderator: #{if(moderator?, do: "true", else: "false")} + - admin: #{if(admin?, do: "true", else: "false")} + """) + + proceed? = assume_yes? or shell_prompt("Continue?", "n") in ~w(Yn Y y) + + if proceed? do + start_pleroma() + + params = %{ + nickname: nickname, + email: email, + password: password, + password_confirmation: password, + name: name, + bio: bio + } + + changeset = User.register_changeset(%User{}, params, confirmed: true, approved: true) + {:ok, _user} = User.register(changeset) + + shell_info("User #{nickname} created") + + if moderator? do + run(["set", nickname, "--moderator"]) + end + + if admin? do + run(["set", nickname, "--admin"]) + end + + if generated_password? do + run(["reset_password", nickname]) + end + else + shell_info("User will not be created.") + end + end + + def run(["rm", nickname]) do + start_pleroma() + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname), + {:ok, delete_data, _} <- Builder.delete(user, user.ap_id), + {:ok, _delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do + shell_info("User #{nickname} deleted.") + else + _ -> shell_error("No local user #{nickname}") + end + end + + def run(["reset_password", nickname]) do + start_pleroma() + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname), + {:ok, token} <- Pleroma.PasswordResetToken.create_token(user) do + shell_info("Generated password reset token for #{user.nickname}") + + url = + Pleroma.Web.Router.Helpers.reset_password_url(Pleroma.Web.Endpoint, :reset, token.token) + + IO.puts("URL: #{url}") + else + _ -> + shell_error("No local user #{nickname}") + end + end + + def run(["reset_mfa", nickname]) do + start_pleroma() + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname), + {:ok, _token} <- Pleroma.MFA.disable(user) do + shell_info("Multi-Factor Authentication disabled for #{user.nickname}") + else + _ -> + shell_error("No local user #{nickname}") + end + end + + def run(["activate", nickname]) do + start_pleroma() + + with %User{} = user <- User.get_cached_by_nickname(nickname), + false <- user.is_active do + User.set_activation(user, true) + :timer.sleep(500) + + shell_info("Successfully activated #{nickname}") + else + true -> + shell_info("User #{nickname} already activated") + + _ -> + shell_error("No user #{nickname}") + end + end + + def run(["deactivate", nickname]) do + start_pleroma() + + with %User{} = user <- User.get_cached_by_nickname(nickname), + true <- user.is_active do + User.set_activation(user, false) + :timer.sleep(500) + + user = User.get_cached_by_id(user.id) + + if Enum.empty?(Enum.filter(User.get_friends(user), & &1.local)) do + shell_info("Successfully deactivated #{nickname} and unsubscribed all local followers") + end + else + false -> + shell_info("User #{nickname} already deactivated") + + _ -> + shell_error("No user #{nickname}") + end + end + + def run(["deactivate_all_from_instance", instance]) do + start_pleroma() + + Pleroma.User.Query.build(%{nickname: "@#{instance}"}) + |> Pleroma.Repo.chunk_stream(500, :batches) + |> Stream.each(fn users -> + users + |> Enum.each(fn user -> + run(["deactivate", user.nickname]) + end) + end) + |> Stream.run() + end + + def run(["set", nickname | rest]) do + start_pleroma() + + {options, [], []} = + OptionParser.parse( + rest, + strict: [ + admin: :boolean, + confirmed: :boolean, + locked: :boolean, + moderator: :boolean + ] + ) + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + user = + case Keyword.get(options, :admin) do + nil -> user + value -> set_admin(user, value) + end + + user = + case Keyword.get(options, :confirmed) do + nil -> user + value -> set_confirmation(user, value) + end + + user = + case Keyword.get(options, :locked) do + nil -> user + value -> set_locked(user, value) + end + + _user = + case Keyword.get(options, :moderator) do + nil -> user + value -> set_moderator(user, value) + end + else + _ -> + shell_error("No local user #{nickname}") + end + end + + def run(["tag", nickname | tags]) do + start_pleroma() + + with %User{} = user <- User.get_cached_by_nickname(nickname) do + user = user |> User.tag(tags) + + shell_info("Tags of #{user.nickname}: #{inspect(user.tags)}") + else + _ -> + shell_error("Could not change user tags for #{nickname}") + end + end + + def run(["untag", nickname | tags]) do + start_pleroma() + + with %User{} = user <- User.get_cached_by_nickname(nickname) do + user = user |> User.untag(tags) + + shell_info("Tags of #{user.nickname}: #{inspect(user.tags)}") + else + _ -> + shell_error("Could not change user tags for #{nickname}") + end + end + + def run(["invite" | rest]) do + {options, [], []} = + OptionParser.parse(rest, + strict: [ + expires_at: :string, + max_use: :integer + ] + ) + + options = + options + |> Keyword.update(:expires_at, {:ok, nil}, fn + nil -> {:ok, nil} + val -> Date.from_iso8601(val) + end) + |> Enum.into(%{}) + + start_pleroma() + + with {:ok, val} <- options[:expires_at], + options = Map.put(options, :expires_at, val), + {:ok, invite} <- UserInviteToken.create_invite(options) do + shell_info("Generated user invite token " <> String.replace(invite.invite_type, "_", " ")) + + url = + Pleroma.Web.Router.Helpers.redirect_url( + Pleroma.Web.Endpoint, + :registration_page, + invite.token + ) + + IO.puts(url) + else + error -> + shell_error("Could not create invite token: #{inspect(error)}") + end + end + + def run(["invites"]) do + start_pleroma() + + shell_info("Invites list:") + + UserInviteToken.list_invites() + |> Enum.each(fn invite -> + expire_info = + with expires_at when not is_nil(expires_at) <- invite.expires_at do + " | Expires at: #{Date.to_string(expires_at)}" + end + + using_info = + with max_use when not is_nil(max_use) <- invite.max_use do + " | Max use: #{max_use} Left use: #{max_use - invite.uses}" + end + + shell_info( + "ID: #{invite.id} | Token: #{invite.token} | Token type: #{invite.invite_type} | Used: #{invite.used}#{expire_info}#{using_info}" + ) + end) + end + + def run(["revoke_invite", token]) do + start_pleroma() + + with {:ok, invite} <- UserInviteToken.find_by_token(token), + {:ok, _} <- UserInviteToken.update_invite(invite, %{used: true}) do + shell_info("Invite for token #{token} was revoked.") + else + _ -> shell_error("No invite found with token #{token}") + end + end + + def run(["delete_activities", nickname]) do + start_pleroma() + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + User.delete_user_activities(user) + shell_info("User #{nickname} statuses deleted.") + else + _ -> + shell_error("No local user #{nickname}") + end + end + + def run(["confirm", nickname]) do + start_pleroma() + + with %User{} = user <- User.get_cached_by_nickname(nickname) do + {:ok, user} = User.confirm(user) + + message = if !user.is_confirmed, do: "needs", else: "doesn't need" + + shell_info("#{nickname} #{message} confirmation.") + else + _ -> + shell_error("No local user #{nickname}") + end + end + + def run(["confirm_all"]) do + start_pleroma() + + Pleroma.User.Query.build(%{ + local: true, + is_active: true, + is_moderator: false, + is_admin: false, + invisible: false + }) + |> Pleroma.Repo.chunk_stream(500, :batches) + |> Stream.each(fn users -> + users + |> Enum.each(fn user -> User.set_confirmation(user, true) end) + end) + |> Stream.run() + end + + def run(["unconfirm_all"]) do + start_pleroma() + + Pleroma.User.Query.build(%{ + local: true, + is_active: true, + is_moderator: false, + is_admin: false, + invisible: false + }) + |> Pleroma.Repo.chunk_stream(500, :batches) + |> Stream.each(fn users -> + users + |> Enum.each(fn user -> User.set_confirmation(user, false) end) + end) + |> Stream.run() + end + + def run(["sign_out", nickname]) do + start_pleroma() + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + User.global_sign_out(user) + + shell_info("#{nickname} signed out from all apps.") + else + _ -> + shell_error("No local user #{nickname}") + end + end + + def run(["list"]) do + start_pleroma() + + Pleroma.User.Query.build(%{local: true}) + |> Pleroma.Repo.chunk_stream(500, :batches) + |> Stream.each(fn users -> + users + |> Enum.each(fn user -> + shell_info( + "#{user.nickname} moderator: #{user.is_moderator}, admin: #{user.is_admin}, locked: #{user.is_locked}, is_active: #{user.is_active}" + ) + end) + end) + |> Stream.run() + end + + def run(["fix_follow_state", local_user, remote_user]) do + start_pleroma() + + with {:local, %User{} = local} <- {:local, User.get_by_nickname(local_user)}, + {:remote, %User{} = remote} <- {:remote, User.get_by_nickname(remote_user)}, + {:follow_data, %{data: %{"state" => request_state}}} <- + {:follow_data, Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(local, remote)} do + calculated_state = User.following?(local, remote) + + shell_info( + "Request state is #{request_state}, vs calculated state of following=#{calculated_state}" + ) + + if calculated_state == false && request_state == "accept" do + shell_info("Discrepancy found, fixing") + Pleroma.Web.CommonAPI.reject_follow_request(local, remote) + shell_info("Relationship fixed") + else + shell_info("No discrepancy found") + end + else + {:local, _} -> + shell_error("No local user #{local_user}") + + {:remote, _} -> + shell_error("No remote user #{remote_user}") + + {:follow_data, _} -> + shell_error("No follow data for #{local_user} and #{remote_user}") + end + end + + defp set_moderator(user, value) do + {:ok, user} = + user + |> Changeset.change(%{is_moderator: value}) + |> User.update_and_set_cache() + + shell_info("Moderator status of #{user.nickname}: #{user.is_moderator}") + user + end + + defp set_admin(user, value) do + {:ok, user} = User.admin_api_update(user, %{is_admin: value}) + + shell_info("Admin status of #{user.nickname}: #{user.is_admin}") + user + end + + defp set_locked(user, value) do + {:ok, user} = + user + |> Changeset.change(%{is_locked: value}) + |> User.update_and_set_cache() + + shell_info("Locked status of #{user.nickname}: #{user.is_locked}") + user + end + + defp set_confirmation(user, value) do + {:ok, user} = User.set_confirmation(user, value) + + shell_info("Confirmation status of #{user.nickname}: #{user.is_confirmed}") + user + end +end diff --git a/lib/phoenix/transports/web_socket/raw.ex b/lib/phoenix/transports/web_socket/raw.ex new file mode 100644 index 0000000..cf4fda7 --- /dev/null +++ b/lib/phoenix/transports/web_socket/raw.ex @@ -0,0 +1,93 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Phoenix.Transports.WebSocket.Raw do + import Plug.Conn, + only: [ + fetch_query_params: 1, + send_resp: 3 + ] + + alias Phoenix.Socket.Transport + + def default_config do + [ + timeout: 60_000, + transport_log: false, + cowboy: Phoenix.Endpoint.CowboyWebSocket + ] + end + + def init(%Plug.Conn{method: "GET"} = conn, {endpoint, handler, transport}) do + {_, opts} = handler.__transport__(transport) + + conn = + conn + |> fetch_query_params + |> Transport.transport_log(opts[:transport_log]) + |> Transport.check_origin(handler, endpoint, opts) + + case conn do + %{halted: false} = conn -> + case handler.connect(%{ + endpoint: endpoint, + transport: transport, + options: [serializer: nil], + params: conn.params + }) do + {:ok, socket} -> + {:ok, conn, {__MODULE__, {socket, opts}}} + + :error -> + send_resp(conn, :forbidden, "") + {:error, conn} + end + + _ -> + {:error, conn} + end + end + + def init(conn, _) do + send_resp(conn, :bad_request, "") + {:error, conn} + end + + def ws_init({socket, config}) do + Process.flag(:trap_exit, true) + {:ok, %{socket: socket}, config[:timeout]} + end + + def ws_handle(op, data, state) do + state.socket.handler + |> apply(:handle, [op, data, state]) + |> case do + {op, data} -> + {:reply, {op, data}, state} + + {op, data, state} -> + {:reply, {op, data}, state} + + %{} = state -> + {:ok, state} + + _ -> + {:ok, state} + end + end + + def ws_info({_, _} = tuple, state) do + {:reply, tuple, state} + end + + def ws_info(_tuple, state), do: {:ok, state} + + def ws_close(state) do + ws_handle(:closed, :normal, state) + end + + def ws_terminate(reason, state) do + ws_handle(:closed, reason, state) + end +end diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex new file mode 100644 index 0000000..5261321 --- /dev/null +++ b/lib/pleroma/activity.ex @@ -0,0 +1,411 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Activity do + use Ecto.Schema + + alias Pleroma.Activity + alias Pleroma.Activity.Queries + alias Pleroma.Bookmark + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.ReportNote + alias Pleroma.ThreadMute + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + + import Ecto.Changeset + import Ecto.Query + + @type t :: %__MODULE__{} + @type actor :: String.t() + + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + + schema "activities" do + field(:data, :map) + field(:local, :boolean, default: true) + field(:actor, :string) + field(:recipients, {:array, :string}, default: []) + field(:thread_muted?, :boolean, virtual: true) + + # A field that can be used if you need to join some kind of other + # id to order / paginate this field by + field(:pagination_id, :string, virtual: true) + + # This is a fake relation, + # do not use outside of with_preloaded_user_actor/with_joined_user_actor + has_one(:user_actor, User, on_delete: :nothing, foreign_key: :id) + # This is a fake relation, do not use outside of with_preloaded_bookmark/get_bookmark + has_one(:bookmark, Bookmark) + # This is a fake relation, do not use outside of with_preloaded_report_notes + has_many(:report_notes, ReportNote) + has_many(:notifications, Notification, on_delete: :delete_all) + + # Attention: this is a fake relation, don't try to preload it blindly and expect it to work! + # The foreign key is embedded in a jsonb field. + # + # To use it, you probably want to do an inner join and a preload: + # + # ``` + # |> join(:inner, [activity], o in Object, + # on: fragment("(?->>'id') = associated_object_id((?))", + # o.data, activity.data, activity.data)) + # |> preload([activity, object], [object: object]) + # ``` + # + # As a convenience, Activity.with_preloaded_object() sets up an inner join and preload for the + # typical case. + has_one(:object, Object, on_delete: :nothing, foreign_key: :id) + + timestamps() + end + + def with_joined_object(query, join_type \\ :inner) do + join(query, join_type, [activity], o in Object, + on: + fragment( + "(?->>'id') = associated_object_id(?)", + o.data, + activity.data + ), + as: :object + ) + end + + def with_preloaded_object(query, join_type \\ :inner) do + query + |> has_named_binding?(:object) + |> if(do: query, else: with_joined_object(query, join_type)) + |> preload([activity, object: object], object: object) + end + + # Note: applies to fake activities (ActivityPub.Utils.get_notified_from_object/1 etc.) + def user_actor(%Activity{actor: nil}), do: nil + + def user_actor(%Activity{} = activity) do + with %User{} <- activity.user_actor do + activity.user_actor + else + _ -> User.get_cached_by_ap_id(activity.actor) + end + end + + def with_joined_user_actor(query, join_type \\ :inner) do + join(query, join_type, [activity], u in User, + on: u.ap_id == activity.actor, + as: :user_actor + ) + end + + def with_preloaded_user_actor(query, join_type \\ :inner) do + query + |> with_joined_user_actor(join_type) + |> preload([activity, user_actor: user_actor], user_actor: user_actor) + end + + def with_preloaded_bookmark(query, %User{} = user) do + from([a] in query, + left_join: b in Bookmark, + on: b.user_id == ^user.id and b.activity_id == a.id, + as: :bookmark, + preload: [bookmark: b] + ) + end + + def with_preloaded_bookmark(query, _), do: query + + def with_preloaded_report_notes(query) do + from([a] in query, + left_join: r in ReportNote, + on: a.id == r.activity_id, + as: :report_note, + preload: [report_notes: r] + ) + end + + def with_preloaded_report_notes(query, _), do: query + + def with_set_thread_muted_field(query, %User{} = user) do + from([a] in query, + left_join: tm in ThreadMute, + on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data), + as: :thread_mute, + select: %Activity{a | thread_muted?: not is_nil(tm.id)} + ) + end + + def with_set_thread_muted_field(query, _), do: query + + def get_by_ap_id(ap_id) do + ap_id + |> Queries.by_ap_id() + |> Repo.one() + end + + def get_bookmark(%Activity{} = activity, %User{} = user) do + if Ecto.assoc_loaded?(activity.bookmark) do + activity.bookmark + else + Bookmark.get(user.id, activity.id) + end + end + + def get_bookmark(_, _), do: nil + + def get_report(activity_id) do + opts = %{ + type: "Flag", + skip_preload: true, + preload_report_notes: true + } + + ActivityPub.fetch_activities_query([], opts) + |> where(id: ^activity_id) + |> Repo.one() + end + + def change(struct, params \\ %{}) do + struct + |> cast(params, [:data, :recipients]) + |> validate_required([:data]) + |> unique_constraint(:ap_id, name: :activities_unique_apid_index) + end + + def get_by_ap_id_with_object(ap_id) do + ap_id + |> Queries.by_ap_id() + |> with_preloaded_object(:left) + |> Repo.one() + end + + @doc """ + Gets activity by ID, doesn't load activities from deactivated actors by default. + """ + @spec get_by_id(String.t(), keyword()) :: t() | nil + def get_by_id(id, opts \\ [filter: [:restrict_deactivated]]), do: get_by_id_with_opts(id, opts) + + @spec get_by_id_with_user_actor(String.t()) :: t() | nil + def get_by_id_with_user_actor(id), do: get_by_id_with_opts(id, preload: [:user_actor]) + + @spec get_by_id_with_object(String.t()) :: t() | nil + def get_by_id_with_object(id), do: get_by_id_with_opts(id, preload: [:object]) + + defp get_by_id_with_opts(id, opts) do + if FlakeId.flake_id?(id) do + query = Queries.by_id(id) + + with_filters_query = + if is_list(opts[:filter]) do + Enum.reduce(opts[:filter], query, fn + {:type, type}, acc -> Queries.by_type(acc, type) + :restrict_deactivated, acc -> restrict_deactivated_users(acc) + _, acc -> acc + end) + else + query + end + + with_preloads_query = + if is_list(opts[:preload]) do + Enum.reduce(opts[:preload], with_filters_query, fn + :user_actor, acc -> with_preloaded_user_actor(acc) + :object, acc -> with_preloaded_object(acc) + _, acc -> acc + end) + else + with_filters_query + end + + Repo.one(with_preloads_query) + end + end + + def all_by_ids_with_object(ids) do + Activity + |> where([a], a.id in ^ids) + |> with_preloaded_object() + |> Repo.all() + end + + @doc """ + Accepts `ap_id` or list of `ap_id`. + Returns a query. + """ + @spec create_by_object_ap_id(String.t() | [String.t()]) :: Ecto.Queryable.t() + def create_by_object_ap_id(ap_id) do + ap_id + |> Queries.by_object_id() + |> Queries.by_type("Create") + end + + def get_all_create_by_object_ap_id(ap_id) do + ap_id + |> create_by_object_ap_id() + |> Repo.all() + end + + def get_create_by_object_ap_id(ap_id) when is_binary(ap_id) do + create_by_object_ap_id(ap_id) + |> restrict_deactivated_users() + |> Repo.one() + end + + def get_create_by_object_ap_id(_), do: nil + + @doc """ + Accepts `ap_id` or list of `ap_id`. + Returns a query. + """ + @spec create_by_object_ap_id_with_object(String.t() | [String.t()]) :: Ecto.Queryable.t() + def create_by_object_ap_id_with_object(ap_id) do + ap_id + |> create_by_object_ap_id() + |> with_preloaded_object() + end + + def get_create_by_object_ap_id_with_object(ap_id) when is_binary(ap_id) do + ap_id + |> create_by_object_ap_id_with_object() + |> Repo.one() + end + + def get_create_by_object_ap_id_with_object(_), do: nil + + @spec create_by_id_with_object(String.t()) :: t() | nil + def create_by_id_with_object(id) do + get_by_id_with_opts(id, preload: [:object], filter: [type: "Create"]) + end + + defp get_in_reply_to_activity_from_object(%Object{data: %{"inReplyTo" => ap_id}}) do + get_create_by_object_ap_id_with_object(ap_id) + end + + defp get_in_reply_to_activity_from_object(_), do: nil + + def get_in_reply_to_activity(%Activity{} = activity) do + get_in_reply_to_activity_from_object(Object.normalize(activity, fetch: false)) + end + + def normalize(%Activity{data: %{"id" => ap_id}}), do: get_by_ap_id_with_object(ap_id) + def normalize(%{"id" => ap_id}), do: get_by_ap_id_with_object(ap_id) + def normalize(ap_id) when is_binary(ap_id), do: get_by_ap_id_with_object(ap_id) + def normalize(_), do: nil + + def delete_all_by_object_ap_id(id) when is_binary(id) do + activities = + id + |> Queries.by_object_id() + |> Queries.exclude_type("Delete") + |> select([u], u) + |> Repo.delete_all(timeout: :infinity) + |> elem(1) + + activities + |> Enum.each(fn %{data: %{"id" => ap_id}} -> delete_all_by_object_ap_id(ap_id) end) + + activities + |> Enum.find(fn + %{data: %{"type" => "Create", "object" => ap_id}} when is_binary(ap_id) -> ap_id == id + %{data: %{"type" => "Create", "object" => %{"id" => ap_id}}} -> ap_id == id + _ -> nil + end) + |> purge_web_resp_cache() + end + + def delete_all_by_object_ap_id(_), do: nil + + defp purge_web_resp_cache(%Activity{data: %{"id" => id}} = activity) when is_binary(id) do + with %{path: path} <- URI.parse(id) do + @cachex.del(:web_resp_cache, path) + end + + activity + end + + defp purge_web_resp_cache(activity), do: activity + + def follow_accepted?( + %Activity{data: %{"type" => "Follow", "object" => followed_ap_id}} = activity + ) do + with %User{} = follower <- Activity.user_actor(activity), + %User{} = followed <- User.get_cached_by_ap_id(followed_ap_id) do + Pleroma.FollowingRelationship.following?(follower, followed) + else + _ -> false + end + end + + def follow_accepted?(_), do: false + + def all_by_actor_and_id(actor, status_ids \\ []) + def all_by_actor_and_id(_actor, []), do: [] + + def all_by_actor_and_id(actor, status_ids) do + Activity + |> where([s], s.id in ^status_ids) + |> where([s], s.actor == ^actor) + |> Repo.all() + end + + def follow_requests_for_actor(%User{ap_id: ap_id}) do + ap_id + |> Queries.by_object_id() + |> Queries.by_type("Follow") + |> where([a], fragment("? ->> 'state' = 'pending'", a.data)) + end + + def following_requests_for_actor(%User{ap_id: ap_id}) do + Queries.by_type("Follow") + |> where([a], fragment("?->>'state' = 'pending'", a.data)) + |> where([a], a.actor == ^ap_id) + |> Repo.all() + end + + def restrict_deactivated_users(query) do + query + |> join(:inner, [activity], user in User, + as: :user, + on: activity.actor == user.ap_id and user.is_active == true + ) + end + + defdelegate search(user, query, options \\ []), to: Pleroma.Search.DatabaseSearch + + def direct_conversation_id(activity, for_user) do + alias Pleroma.Conversation.Participation + + with %{data: %{"context" => context}} when is_binary(context) <- activity, + %Pleroma.Conversation{} = conversation <- Pleroma.Conversation.get_for_ap_id(context), + %Participation{id: participation_id} <- + Participation.for_user_and_conversation(for_user, conversation) do + participation_id + else + _ -> nil + end + end + + @spec get_by_object_ap_id_with_object(String.t()) :: t() | nil + def get_by_object_ap_id_with_object(ap_id) when is_binary(ap_id) do + ap_id + |> Queries.by_object_id() + |> with_preloaded_object() + |> first() + |> Repo.one() + end + + def get_by_object_ap_id_with_object(_), do: nil + + @spec add_by_params_query(String.t(), String.t(), String.t()) :: Ecto.Query.t() + def add_by_params_query(object_id, actor, target) do + object_id + |> Queries.by_object_id() + |> Queries.by_type("Add") + |> Queries.by_actor(actor) + |> where([a], fragment("?->>'target' = ?", a.data, ^target)) + end +end diff --git a/lib/pleroma/activity/html.ex b/lib/pleroma/activity/html.ex new file mode 100644 index 0000000..706b2d3 --- /dev/null +++ b/lib/pleroma/activity/html.ex @@ -0,0 +1,81 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Activity.HTML do + alias Pleroma.HTML + alias Pleroma.Object + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + + # We store a list of cache keys related to an activity in a + # separate cache, scrubber_management_cache. It has the same + # size as scrubber_cache (see application.ex). Every time we add + # a cache to scrubber_cache, we update scrubber_management_cache. + # + # The most recent write of a certain key in the management cache + # is the same as the most recent write of any record related to that + # key in the main cache. + # Assuming LRW ( https://hexdocs.pm/cachex/Cachex.Policy.LRW.html ), + # this means when the management cache is evicted by cachex, all + # related records in the main cache will also have been evicted. + + defp get_cache_keys_for(activity_id) do + with {:ok, list} when is_list(list) <- @cachex.get(:scrubber_management_cache, activity_id) do + list + else + _ -> [] + end + end + + defp add_cache_key_for(activity_id, additional_key) do + current = get_cache_keys_for(activity_id) + + unless additional_key in current do + @cachex.put(:scrubber_management_cache, activity_id, [additional_key | current]) + end + end + + def invalidate_cache_for(activity_id) do + keys = get_cache_keys_for(activity_id) + Enum.map(keys, &@cachex.del(:scrubber_cache, &1)) + @cachex.del(:scrubber_management_cache, activity_id) + end + + def get_cached_scrubbed_html_for_activity( + content, + scrubbers, + activity, + key \\ "", + callback \\ fn x -> x end + ) do + key = "#{key}#{generate_scrubber_signature(scrubbers)}|#{activity.id}" + + @cachex.fetch!(:scrubber_cache, key, fn _key -> + object = Object.normalize(activity, fetch: false) + + add_cache_key_for(activity.id, key) + HTML.ensure_scrubbed_html(content, scrubbers, object.data["fake"] || false, callback) + end) + end + + def get_cached_stripped_html_for_activity(content, activity, key) do + get_cached_scrubbed_html_for_activity( + content, + FastSanitize.Sanitizer.StripTags, + activity, + key, + &HtmlEntities.decode/1 + ) + end + + defp generate_scrubber_signature(scrubber) when is_atom(scrubber) do + generate_scrubber_signature([scrubber]) + end + + defp generate_scrubber_signature(scrubbers) do + Enum.reduce(scrubbers, "", fn scrubber, signature -> + "#{signature}#{to_string(scrubber)}" + end) + end +end diff --git a/lib/pleroma/activity/ir/topics.ex b/lib/pleroma/activity/ir/topics.ex new file mode 100644 index 0000000..8249cbe --- /dev/null +++ b/lib/pleroma/activity/ir/topics.ex @@ -0,0 +1,99 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Activity.Ir.Topics do + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Visibility + + def get_activity_topics(activity) do + activity + |> Object.normalize(fetch: false) + |> generate_topics(activity) + |> List.flatten() + end + + defp generate_topics(%{data: %{"type" => "ChatMessage"}}, %{data: %{"type" => "Delete"}}) do + ["user", "user:pleroma_chat"] + end + + defp generate_topics(%{data: %{"type" => "ChatMessage"}}, %{data: %{"type" => "Create"}}) do + [] + end + + defp generate_topics(%{data: %{"type" => "Answer"}}, _) do + [] + end + + defp generate_topics(object, activity) do + ["user", "list"] ++ visibility_tags(object, activity) + end + + defp visibility_tags(object, %{data: %{"type" => type}} = activity) when type != "Announce" do + case Visibility.get_visibility(activity) do + "public" -> + if activity.local do + ["public", "public:local"] + else + ["public"] + end + |> item_creation_tags(object, activity) + + "local" -> + ["public:local"] + |> item_creation_tags(object, activity) + + "direct" -> + ["direct"] + + _ -> + [] + end + end + + defp visibility_tags(_object, _activity) do + [] + end + + defp item_creation_tags(tags, object, %{data: %{"type" => "Create"}} = activity) do + tags ++ + remote_topics(activity) ++ hashtags_to_topics(object) ++ attachment_topics(object, activity) + end + + defp item_creation_tags(tags, _, _) do + tags + end + + defp hashtags_to_topics(object) do + object + |> Object.hashtags() + |> Enum.map(fn hashtag -> "hashtag:" <> hashtag end) + end + + defp remote_topics(%{local: true}), do: [] + + defp remote_topics(%{actor: actor}) when is_binary(actor), + do: ["public:remote:" <> URI.parse(actor).host] + + defp remote_topics(_), do: [] + + defp attachment_topics(%{data: %{"attachment" => []}}, _act), do: [] + + defp attachment_topics(_object, %{local: true} = activity) do + case Visibility.get_visibility(activity) do + "public" -> + ["public:media", "public:local:media"] + + "local" -> + ["public:local:media"] + + _ -> + [] + end + end + + defp attachment_topics(_object, %{actor: actor}) when is_binary(actor), + do: ["public:media", "public:remote:media:" <> URI.parse(actor).host] + + defp attachment_topics(_object, _act), do: ["public:media"] +end diff --git a/lib/pleroma/activity/queries.ex b/lib/pleroma/activity/queries.ex new file mode 100644 index 0000000..81c44ac --- /dev/null +++ b/lib/pleroma/activity/queries.ex @@ -0,0 +1,108 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Activity.Queries do + @moduledoc """ + Contains queries for Activity. + """ + + import Ecto.Query, only: [from: 2, where: 3] + + @type query :: Ecto.Queryable.t() | Activity.t() + + alias Pleroma.Activity + alias Pleroma.User + + @spec by_id(query(), String.t()) :: query() + def by_id(query \\ Activity, id) do + from(a in query, where: a.id == ^id) + end + + @spec by_ap_id(query, String.t()) :: query + def by_ap_id(query \\ Activity, ap_id) do + from( + activity in query, + where: fragment("(?)->>'id' = ?", activity.data, ^to_string(ap_id)) + ) + end + + @spec by_actor(query, String.t()) :: query + def by_actor(query \\ Activity, actor) do + from(a in query, where: a.actor == ^actor) + end + + @spec by_author(query, User.t()) :: query + def by_author(query \\ Activity, %User{ap_id: ap_id}) do + from(a in query, where: a.actor == ^ap_id) + end + + def find_by_object_ap_id(activities, object_ap_id) do + Enum.find( + activities, + &(object_ap_id in [is_map(&1.data["object"]) && &1.data["object"]["id"], &1.data["object"]]) + ) + end + + @spec by_object_id(query, String.t() | [String.t()]) :: query + def by_object_id(query \\ Activity, object_id) + + def by_object_id(query, object_ids) when is_list(object_ids) do + from( + activity in query, + where: + fragment( + "associated_object_id((?)) = ANY(?)", + activity.data, + ^object_ids + ) + ) + end + + def by_object_id(query, object_id) when is_binary(object_id) do + from(activity in query, + where: + fragment( + "associated_object_id((?)) = ?", + activity.data, + ^object_id + ) + ) + end + + @spec by_object_in_reply_to_id(query, String.t(), keyword()) :: query + def by_object_in_reply_to_id(query, in_reply_to_id, opts \\ []) do + query = + if opts[:skip_preloading] do + Activity.with_joined_object(query) + else + Activity.with_preloaded_object(query) + end + + where( + query, + [activity, object: o], + fragment("(?)->>'inReplyTo' = ?", o.data, ^to_string(in_reply_to_id)) + ) + end + + @spec by_type(query, String.t()) :: query + def by_type(query \\ Activity, activity_type) do + from( + activity in query, + where: fragment("(?)->>'type' = ?", activity.data, ^activity_type) + ) + end + + @spec exclude_type(query, String.t()) :: query + def exclude_type(query \\ Activity, activity_type) do + from( + activity in query, + where: fragment("(?)->>'type' != ?", activity.data, ^activity_type) + ) + end + + def exclude_authors(query \\ Activity, actors) do + from(activity in query, where: activity.actor not in ^actors) + end +end diff --git a/lib/pleroma/announcement.ex b/lib/pleroma/announcement.ex new file mode 100644 index 0000000..d97c5e7 --- /dev/null +++ b/lib/pleroma/announcement.ex @@ -0,0 +1,160 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Announcement do + use Ecto.Schema + + import Ecto.Changeset, only: [cast: 3, validate_required: 2] + import Ecto.Query + + alias Pleroma.AnnouncementReadRelationship + alias Pleroma.Repo + + @type t :: %__MODULE__{} + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + + schema "announcements" do + field(:data, :map) + field(:starts_at, :utc_datetime) + field(:ends_at, :utc_datetime) + field(:rendered, :map) + + timestamps(type: :utc_datetime) + end + + def change(struct, params \\ %{}) do + struct + |> cast(validate_params(struct, params), [:data, :starts_at, :ends_at, :rendered]) + |> validate_required([:data]) + end + + defp validate_params(struct, params) do + base_data = + %{ + "content" => "", + "all_day" => false + } + |> Map.merge((struct && struct.data) || %{}) + + merged_data = + Map.merge(base_data, params.data) + |> Map.take(["content", "all_day"]) + + params + |> Map.merge(%{data: merged_data}) + |> add_rendered_properties() + end + + def add_rendered_properties(params) do + {content_html, _, _} = + Pleroma.Web.CommonAPI.Utils.format_input(params.data["content"], "text/plain", + mentions_format: :full + ) + + rendered = %{ + "content" => content_html + } + + params + |> Map.put(:rendered, rendered) + end + + def add(params) do + changeset = change(%__MODULE__{}, params) + + Repo.insert(changeset) + end + + def update(announcement, params) do + changeset = change(announcement, params) + + Repo.update(changeset) + end + + def list_all do + __MODULE__ + |> Repo.all() + end + + def list_paginated(%{limit: limited_number, offset: offset_number}) do + __MODULE__ + |> limit(^limited_number) + |> offset(^offset_number) + |> Repo.all() + end + + def get_by_id(id) do + Repo.get_by(__MODULE__, id: id) + end + + def delete_by_id(id) do + with announcement when not is_nil(announcement) <- get_by_id(id), + {:ok, _} <- Repo.delete(announcement) do + :ok + else + _ -> + :error + end + end + + def read_by?(announcement, user) do + AnnouncementReadRelationship.exists?(user, announcement) + end + + def mark_read_by(announcement, user) do + AnnouncementReadRelationship.mark_read(user, announcement) + end + + def render_json(announcement, opts \\ []) do + extra_params = + case Keyword.fetch(opts, :for) do + {:ok, user} when not is_nil(user) -> + %{read: read_by?(announcement, user)} + + _ -> + %{} + end + + admin_extra_params = + case Keyword.fetch(opts, :admin) do + {:ok, true} -> + %{pleroma: %{raw_content: announcement.data["content"]}} + + _ -> + %{} + end + + base = %{ + id: announcement.id, + content: announcement.rendered["content"], + starts_at: announcement.starts_at, + ends_at: announcement.ends_at, + all_day: announcement.data["all_day"], + published_at: announcement.inserted_at, + updated_at: announcement.updated_at, + mentions: [], + statuses: [], + tags: [], + emojis: [], + reactions: [] + } + + base + |> Map.merge(extra_params) + |> Map.merge(admin_extra_params) + end + + # "visible" means: + # starts_at < time < ends_at + def list_all_visible_when(time) do + __MODULE__ + |> where([a], is_nil(a.starts_at) or a.starts_at < ^time) + |> where([a], is_nil(a.ends_at) or a.ends_at > ^time) + |> Repo.all() + end + + def list_all_visible do + list_all_visible_when(DateTime.now("Etc/UTC") |> elem(1)) + end +end diff --git a/lib/pleroma/announcement_read_relationship.ex b/lib/pleroma/announcement_read_relationship.ex new file mode 100644 index 0000000..9b64404 --- /dev/null +++ b/lib/pleroma/announcement_read_relationship.ex @@ -0,0 +1,55 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.AnnouncementReadRelationship do + use Ecto.Schema + + import Ecto.Changeset + + alias FlakeId.Ecto.CompatType + alias Pleroma.Announcement + alias Pleroma.Repo + alias Pleroma.User + + @type t :: %__MODULE__{} + + schema "announcement_read_relationships" do + belongs_to(:user, User, type: CompatType) + belongs_to(:announcement, Announcement, type: CompatType) + + timestamps(updated_at: false) + end + + def mark_read(user, announcement) do + %__MODULE__{} + |> cast(%{user_id: user.id, announcement_id: announcement.id}, [:user_id, :announcement_id]) + |> validate_required([:user_id, :announcement_id]) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:announcement_id) + |> unique_constraint([:user_id, :announcement_id]) + |> Repo.insert() + end + + def mark_unread(user, announcement) do + with relationship <- get(user, announcement), + {:exists, true} <- {:exists, not is_nil(relationship)}, + {:ok, _} <- Repo.delete(relationship) do + :ok + else + {:exists, false} -> + :ok + + _ -> + :error + end + end + + def get(user, announcement) do + Repo.get_by(__MODULE__, user_id: user.id, announcement_id: announcement.id) + end + + def exists?(user, announcement) do + not is_nil(get(user, announcement)) + end +end diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex new file mode 100644 index 0000000..cd545f9 --- /dev/null +++ b/lib/pleroma/application.ex @@ -0,0 +1,336 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Application do + use Application + + import Cachex.Spec + + alias Pleroma.Config + + require Logger + + @name Mix.Project.config()[:name] + @compat_name Mix.Project.config()[:compat_name] + @version Mix.Project.config()[:version] + @repository Mix.Project.config()[:source_url] + @mix_env Mix.env() + + def name, do: @name + def compat_name, do: @compat_name + def version, do: @version + def named_version, do: @name <> " " <> @version + def compat_version, do: @compat_name <> " " <> @version + def repository, do: @repository + + def user_agent do + if Process.whereis(Pleroma.Web.Endpoint) do + case Config.get([:http, :user_agent], :default) do + :default -> + info = "#{Pleroma.Web.Endpoint.url()} <#{Config.get([:instance, :email], "")}>" + compat_version() <> "; " <> info + + custom -> + custom + end + else + # fallback, if endpoint is not started yet + "Pleroma Data Loader" + end + end + + # See http://elixir-lang.org/docs/stable/elixir/Application.html + # for more information on OTP Applications + def start(_type, _args) do + # Scrubbers are compiled at runtime and therefore will cause a conflict + # every time the application is restarted, so we disable module + # conflicts at runtime + Code.compiler_options(ignore_module_conflict: true) + # Disable warnings_as_errors at runtime, it breaks Phoenix live reload + # due to protocol consolidation warnings + Code.compiler_options(warnings_as_errors: false) + Pleroma.Telemetry.Logger.attach() + Config.Holder.save_default() + Pleroma.HTML.compile_scrubbers() + Pleroma.Config.Oban.warn() + Config.DeprecationWarnings.warn() + Pleroma.Web.Plugs.HTTPSecurityPlug.warn_if_disabled() + Pleroma.ApplicationRequirements.verify!() + setup_instrumenters() + load_custom_modules() + Pleroma.Docs.JSON.compile() + limiters_setup() + + adapter = Application.get_env(:tesla, :adapter) + + if match?({Tesla.Adapter.Finch, _}, adapter) do + Logger.info("Starting Finch") + Finch.start_link(name: MyFinch) + end + + if adapter == Tesla.Adapter.Gun do + if version = Pleroma.OTPVersion.version() do + [major, minor] = + version + |> String.split(".") + |> Enum.map(&String.to_integer/1) + |> Enum.take(2) + + if (major == 22 and minor < 2) or major < 22 do + raise " + !!!OTP VERSION WARNING!!! + You are using gun adapter with OTP version #{version}, which doesn't support correct handling of unordered certificates chains. Please update your Erlang/OTP to at least 22.2. + " + end + else + raise " + !!!OTP VERSION WARNING!!! + To support correct handling of unordered certificates chains - OTP version must be > 22.2. + " + end + end + + # Define workers and child supervisors to be supervised + children = + [ + Pleroma.PromEx, + Pleroma.Repo, + Config.TransferTask, + Pleroma.Emoji, + Pleroma.Web.Plugs.RateLimiter.Supervisor, + {Task.Supervisor, name: Pleroma.TaskSupervisor} + ] ++ + cachex_children() ++ + http_children(adapter, @mix_env) ++ + [ + Pleroma.Stats, + Pleroma.JobQueueMonitor, + {Majic.Pool, [name: Pleroma.MajicPool, pool_size: Config.get([:majic_pool, :size], 2)]}, + {Oban, Config.get(Oban)}, + Pleroma.Web.Endpoint, + TzWorld.Backend.DetsWithIndexCache + ] ++ + task_children(@mix_env) ++ + dont_run_in_test(@mix_env) ++ + [Pleroma.Gopher.Server] + + # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html + # for other strategies and supported options + # If we have a lot of caches, default max_restarts can cause test + # resets to fail. + # Go for the default 3 unless we're in test + max_restarts = + if @mix_env == :test do + 100 + else + 3 + end + + opts = [strategy: :one_for_one, name: Pleroma.Supervisor, max_restarts: max_restarts] + result = Supervisor.start_link(children, opts) + + set_postgres_server_version() + + result + end + + defp set_postgres_server_version do + version = + with %{rows: [[version]]} <- Ecto.Adapters.SQL.query!(Pleroma.Repo, "show server_version"), + {num, _} <- Float.parse(version) do + num + else + e -> + Logger.warning( + "Could not get the postgres version: #{inspect(e)}.\nSetting the default value of 9.6" + ) + + 9.6 + end + + :persistent_term.put({Pleroma.Repo, :postgres_version}, version) + end + + def load_custom_modules do + dir = Config.get([:modules, :runtime_dir]) + + if dir && File.exists?(dir) do + dir + |> Pleroma.Utils.compile_dir() + |> case do + {:error, _errors, _warnings} -> + raise "Invalid custom modules" + + {:ok, modules, _warnings} -> + if @mix_env != :test do + Enum.each(modules, fn mod -> + Logger.info("Custom module loaded: #{inspect(mod)}") + end) + end + + :ok + end + end + end + + defp setup_instrumenters do + require Prometheus.Registry + + if Application.get_env(:prometheus, Pleroma.Repo.Instrumenter) do + :ok = + :telemetry.attach( + "prometheus-ecto", + [:pleroma, :repo, :query], + &Pleroma.Repo.Instrumenter.handle_event/4, + %{} + ) + + Pleroma.Repo.Instrumenter.setup() + end + + Pleroma.Web.Endpoint.MetricsExporter.setup() + Pleroma.Web.Endpoint.PipelineInstrumenter.setup() + + # Note: disabled until prometheus-phx is integrated into prometheus-phoenix: + # Pleroma.Web.Endpoint.Instrumenter.setup() + PrometheusPhx.setup() + end + + defp cachex_children do + [ + build_cachex("used_captcha", ttl_interval: seconds_valid_interval()), + build_cachex("user", default_ttl: 25_000, ttl_interval: 1000, limit: 2500), + build_cachex("object", default_ttl: 25_000, ttl_interval: 1000, limit: 2500), + build_cachex("rich_media", default_ttl: :timer.minutes(120), limit: 5000), + build_cachex("scrubber", limit: 2500), + build_cachex("scrubber_management", limit: 2500), + build_cachex("idempotency", expiration: idempotency_expiration(), limit: 2500), + build_cachex("web_resp", limit: 2500), + build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10), + build_cachex("failed_proxy_url", limit: 2500), + build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000), + build_cachex("chat_message_id_idempotency_key", + expiration: chat_message_id_idempotency_key_expiration(), + limit: 500_000 + ), + build_cachex("anti_duplication_mrf", limit: 5_000), + build_cachex("translations", default_ttl: :timer.hours(24), limit: 5_000), + build_cachex("rel_me", default_ttl: :timer.minutes(30), limit: 2_500), + build_cachex("host_meta", default_ttl: :timer.minutes(120), limit: 5000) + ] + end + + defp emoji_packs_expiration, + do: expiration(default: :timer.seconds(5 * 60), interval: :timer.seconds(60)) + + defp idempotency_expiration, + do: expiration(default: :timer.seconds(6 * 60 * 60), interval: :timer.seconds(60)) + + defp chat_message_id_idempotency_key_expiration, + do: expiration(default: :timer.minutes(2), interval: :timer.seconds(60)) + + defp seconds_valid_interval, + do: :timer.seconds(Config.get!([Pleroma.Captcha, :seconds_valid])) + + @spec build_cachex(String.t(), keyword()) :: map() + def build_cachex(type, opts), + do: %{ + id: String.to_atom("cachex_" <> type), + start: {Cachex, :start_link, [String.to_atom(type <> "_cache"), opts]}, + type: :worker + } + + defp dont_run_in_test(env) when env in [:test, :benchmark], do: [] + + defp dont_run_in_test(_) do + [ + {Registry, + [ + name: Pleroma.Web.Streamer.registry(), + keys: :duplicate, + partitions: System.schedulers_online() + ]} + ] ++ background_migrators() + end + + defp background_migrators do + [ + Pleroma.Migrators.HashtagsTableMigrator, + Pleroma.Migrators.ContextObjectsDeletionMigrator + ] + end + + defp task_children(:test) do + [ + %{ + id: :web_push_init, + start: {Task, :start_link, [&Pleroma.Web.Push.init/0]}, + restart: :temporary + } + ] + end + + defp task_children(_) do + [ + %{ + id: :web_push_init, + start: {Task, :start_link, [&Pleroma.Web.Push.init/0]}, + restart: :temporary + }, + %{ + id: :internal_fetch_init, + start: {Task, :start_link, [&Pleroma.Web.ActivityPub.InternalFetchActor.init/0]}, + restart: :temporary + } + ] + end + + # start hackney and gun pools in tests + defp http_children(_, :test) do + http_children(Tesla.Adapter.Hackney, nil) ++ http_children(Tesla.Adapter.Gun, nil) + end + + defp http_children(Tesla.Adapter.Hackney, _) do + pools = [:federation, :media] + + pools = + if Config.get([Pleroma.Upload, :proxy_remote]) do + [:upload | pools] + else + pools + end + + for pool <- pools do + options = Config.get([:hackney_pools, pool]) + :hackney_pool.child_spec(pool, options) + end + end + + defp http_children(Tesla.Adapter.Gun, _) do + Pleroma.Gun.ConnectionPool.children() ++ + [{Task, &Pleroma.HTTP.AdapterHelper.Gun.limiter_setup/0}] + end + + defp http_children(_, _), do: [] + + @spec limiters_setup() :: :ok + def limiters_setup do + config = Config.get(ConcurrentLimiter, []) + + [ + Pleroma.Web.RichMedia.Helpers, + Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy, + Pleroma.Search, + Pleroma.Webhook.Notify + ] + |> Enum.each(fn module -> + mod_config = Keyword.get(config, module, []) + + max_running = Keyword.get(mod_config, :max_running, 5) + max_waiting = Keyword.get(mod_config, :max_waiting, 5) + + ConcurrentLimiter.new(module, max_running, max_waiting) + end) + end +end diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex new file mode 100644 index 0000000..ee4188f --- /dev/null +++ b/lib/pleroma/application_requirements.ex @@ -0,0 +1,272 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ApplicationRequirements do + @moduledoc """ + The module represents the collection of validations to runs before start server. + """ + + defmodule VerifyError, do: defexception([:message]) + + alias Pleroma.Config + alias Pleroma.Helpers.MediaHelper + + import Ecto.Query + + require Logger + + @spec verify!() :: :ok | VerifyError.t() + def verify! do + :ok + |> check_system_commands!() + |> check_confirmation_accounts!() + |> check_migrations_applied!() + |> check_welcome_message_config!() + |> check_rum!() + |> check_repo_pool_size!() + |> handle_result() + end + + defp handle_result(:ok), do: :ok + defp handle_result({:error, message}), do: raise(VerifyError, message: message) + + defp check_welcome_message_config!(:ok) do + if Pleroma.Config.get([:welcome, :email, :enabled], false) and + not Pleroma.Emails.Mailer.enabled?() do + Logger.warning(""" + To send welcome emails, you need to enable the mailer. + Welcome emails will NOT be sent with the current config. + + Enable the mailer: + config :pleroma, Pleroma.Emails.Mailer, enabled: true + """) + end + + :ok + end + + defp check_welcome_message_config!(result), do: result + + # Checks account confirmation email + # + def check_confirmation_accounts!(:ok) do + if Pleroma.Config.get([:instance, :account_activation_required]) && + not Pleroma.Emails.Mailer.enabled?() do + Logger.warning(""" + Account activation is required, but the mailer is disabled. + Users will NOT be able to confirm their accounts with this config. + Either disable account activation or enable the mailer. + + Disable account activation: + config :pleroma, :instance, account_activation_required: false + + Enable the mailer: + config :pleroma, Pleroma.Emails.Mailer, enabled: true + """) + end + + :ok + end + + def check_confirmation_accounts!(result), do: result + + # Checks for pending migrations. + # + def check_migrations_applied!(:ok) do + unless Pleroma.Config.get( + [:i_am_aware_this_may_cause_data_loss, :disable_migration_check], + false + ) do + {_, res, _} = + Ecto.Migrator.with_repo(Pleroma.Repo, fn repo -> + down_migrations = + Ecto.Migrator.migrations(repo) + |> Enum.reject(fn + {:up, _, _} -> true + {:down, _, _} -> false + end) + + if length(down_migrations) > 0 do + down_migrations_text = + Enum.map(down_migrations, fn {:down, id, name} -> "- #{name} (#{id})\n" end) + + Logger.error( + "The following migrations were not applied:\n#{down_migrations_text}" <> + "If you want to start Pleroma anyway, set\n" <> + "config :pleroma, :i_am_aware_this_may_cause_data_loss, disable_migration_check: true" + ) + + {:error, "Unapplied Migrations detected"} + else + :ok + end + end) + + res + else + :ok + end + end + + def check_migrations_applied!(result), do: result + + # Checks for settings of RUM indexes. + # + defp check_rum!(:ok) do + {_, res, _} = + Ecto.Migrator.with_repo(Pleroma.Repo, fn repo -> + migrate = + from(o in "columns", + where: o.table_name == "objects", + where: o.column_name == "fts_content" + ) + |> repo.exists?(prefix: "information_schema") + + setting = Pleroma.Config.get([:database, :rum_enabled], false) + + do_check_rum!(setting, migrate) + end) + + res + end + + defp check_rum!(result), do: result + + defp do_check_rum!(setting, migrate) do + case {setting, migrate} do + {true, false} -> + Logger.error( + "Use `RUM` index is enabled, but were not applied migrations for it.\n" <> + "If you want to start Pleroma anyway, set\n" <> + "config :pleroma, :database, rum_enabled: false\n" <> + "Otherwise apply the following migrations:\n" <> + "`mix ecto.migrate --migrations-path priv/repo/optional_migrations/rum_indexing/`" + ) + + {:error, "Unapplied RUM Migrations detected"} + + {false, true} -> + Logger.error( + "Detected applied migrations to use `RUM` index, but `RUM` isn't enable in settings.\n" <> + "If you want to use `RUM`, set\n" <> + "config :pleroma, :database, rum_enabled: true\n" <> + "Otherwise roll `RUM` migrations back.\n" <> + "`mix ecto.rollback --migrations-path priv/repo/optional_migrations/rum_indexing/`" + ) + + {:error, "RUM Migrations detected"} + + _ -> + :ok + end + end + + defp check_system_commands!(:ok) do + filter_commands_statuses = [ + check_filter(Pleroma.Upload.Filter.Exiftool.StripLocation, "exiftool"), + check_filter(Pleroma.Upload.Filter.Exiftool.ReadDescription, "exiftool"), + check_filter(Pleroma.Upload.Filter.Mogrify, "mogrify"), + check_filter(Pleroma.Upload.Filter.Mogrifun, "mogrify"), + check_filter(Pleroma.Upload.Filter.AnalyzeMetadata, "mogrify"), + check_filter(Pleroma.Upload.Filter.AnalyzeMetadata, "convert"), + check_filter(Pleroma.Upload.Filter.AnalyzeMetadata, "ffprobe") + ] + + preview_proxy_commands_status = + if !Config.get([:media_preview_proxy, :enabled]) or + MediaHelper.missing_dependencies() == [] do + true + else + Logger.error( + "The following dependencies required by Media preview proxy " <> + "(which is currently enabled) are not installed: " <> + inspect(MediaHelper.missing_dependencies()) + ) + + false + end + + language_detector_commands_status = + if Pleroma.Language.LanguageDetector.missing_dependencies() == [] do + true + else + Logger.error( + "The following dependencies required by the currently enabled " <> + "language detection provider are not installed: " <> + inspect(Pleroma.Language.LanguageDetector.missing_dependencies()) + ) + + false + end + + translation_commands_status = + if Pleroma.Language.Translation.missing_dependencies() == [] do + true + else + Logger.error( + "The following dependencies required by the currently enabled " <> + "translation provider are not installed: " <> + inspect(Pleroma.Language.Translation.missing_dependencies()) + ) + + false + end + + if Enum.all?( + [ + preview_proxy_commands_status, + language_detector_commands_status, + translation_commands_status + | filter_commands_statuses + ], + & &1 + ) do + :ok + else + {:error, + "System commands missing. Check logs and see `docs/installation` for more details."} + end + end + + defp check_system_commands!(result), do: result + + defp check_repo_pool_size!(:ok) do + if Pleroma.Config.get([Pleroma.Repo, :pool_size], 10) != 10 and + not Pleroma.Config.get([:dangerzone, :override_repo_pool_size], false) do + Logger.error(""" + !!!CONFIG WARNING!!! + + The database pool size has been altered from the recommended value of 10. + + Please revert or ensure your database is tuned appropriately and then set + `config :pleroma, :dangerzone, override_repo_pool_size: true`. + + If you are experiencing database timeouts, please check the "Optimizing + your PostgreSQL performance" section in the documentation. If you still + encounter issues after that, please open an issue on the tracker. + """) + + {:error, "Repo.pool_size different than recommended value."} + else + :ok + end + end + + defp check_repo_pool_size!(result), do: result + + defp check_filter(filter, command_required) do + filters = Config.get([Pleroma.Upload, :filters]) + + if filter in filters and not Pleroma.Utils.command_available?(command_required) do + Logger.error( + "#{filter} is specified in list of Pleroma.Upload filters, but the " <> + "#{command_required} command is not found" + ) + + false + else + true + end + end +end diff --git a/lib/pleroma/bookmark.ex b/lib/pleroma/bookmark.ex new file mode 100644 index 0000000..187749e --- /dev/null +++ b/lib/pleroma/bookmark.ex @@ -0,0 +1,65 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Bookmark do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.Activity + alias Pleroma.Bookmark + alias Pleroma.Repo + alias Pleroma.User + + @type t :: %__MODULE__{} + + schema "bookmarks" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) + + timestamps() + end + + @spec create(FlakeId.Ecto.CompatType.t(), FlakeId.Ecto.CompatType.t()) :: + {:ok, Bookmark.t()} | {:error, Changeset.t()} + def create(user_id, activity_id) do + attrs = %{ + user_id: user_id, + activity_id: activity_id + } + + %Bookmark{} + |> cast(attrs, [:user_id, :activity_id]) + |> validate_required([:user_id, :activity_id]) + |> unique_constraint(:activity_id, name: :bookmarks_user_id_activity_id_index) + |> Repo.insert() + end + + @spec for_user_query(FlakeId.Ecto.CompatType.t()) :: Ecto.Query.t() + def for_user_query(user_id) do + Bookmark + |> where(user_id: ^user_id) + |> join(:inner, [b], activity in assoc(b, :activity)) + |> preload([b, a], activity: a) + end + + def get(user_id, activity_id) do + Bookmark + |> where(user_id: ^user_id) + |> where(activity_id: ^activity_id) + |> Repo.one() + end + + @spec destroy(FlakeId.Ecto.CompatType.t(), FlakeId.Ecto.CompatType.t()) :: + {:ok, Bookmark.t()} | {:error, Changeset.t()} + def destroy(user_id, activity_id) do + from(b in Bookmark, + where: b.user_id == ^user_id, + where: b.activity_id == ^activity_id + ) + |> Repo.one() + |> Repo.delete() + end +end diff --git a/lib/pleroma/caching.ex b/lib/pleroma/caching.ex new file mode 100644 index 0000000..8a139bc --- /dev/null +++ b/lib/pleroma/caching.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Caching do + @callback get!(Cachex.cache(), any()) :: any() + @callback get(Cachex.cache(), any()) :: {atom(), any()} + @callback put(Cachex.cache(), any(), any(), Keyword.t()) :: {Cachex.status(), boolean()} + @callback put(Cachex.cache(), any(), any()) :: {Cachex.status(), boolean()} + @callback fetch!(Cachex.cache(), any(), function() | nil) :: any() + # @callback del(Cachex.cache(), any(), Keyword.t()) :: {Cachex.status(), boolean()} + @callback del(Cachex.cache(), any()) :: {Cachex.status(), boolean()} + @callback stream!(Cachex.cache(), any()) :: Enumerable.t() + @callback expire(Cachex.cache(), binary(), number()) :: {Cachex.status(), boolean()} + @callback expire_at(Cachex.cache(), binary(), number()) :: {Cachex.status(), boolean()} + @callback exists?(Cachex.cache(), any()) :: {Cachex.status(), boolean()} + @callback execute!(Cachex.cache(), function()) :: any() + @callback get_and_update(Cachex.cache(), any(), function()) :: + {:commit | :ignore, any()} +end diff --git a/lib/pleroma/captcha.ex b/lib/pleroma/captcha.ex new file mode 100644 index 0000000..03910f1 --- /dev/null +++ b/lib/pleroma/captcha.ex @@ -0,0 +1,104 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Captcha do + alias Calendar.DateTime + alias Plug.Crypto.KeyGenerator + alias Plug.Crypto.MessageEncryptor + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + + @doc """ + Ask the configured captcha service for a new captcha + """ + def new do + if not enabled?() do + %{type: :none} + else + new_captcha = method().new() + + # This make salt a little different for two keys + {secret, sign_secret} = secret_pair(new_captcha[:token]) + + # Basically copy what Phoenix.Token does here, add the time to + # the actual data and make it a binary to then encrypt it + encrypted_captcha_answer = + %{ + at: DateTime.now_utc(), + answer_data: new_captcha[:answer_data] + } + |> :erlang.term_to_binary() + |> MessageEncryptor.encrypt(secret, sign_secret) + + # Replace the answer with the encrypted answer + %{new_captcha | answer_data: encrypted_captcha_answer} + end + end + + @doc """ + Ask the configured captcha service to validate the captcha + """ + def validate(token, captcha, answer_data) do + with {:ok, %{at: at, answer_data: answer_md5}} <- validate_answer_data(token, answer_data), + :ok <- validate_expiration(at), + :ok <- validate_usage(token), + :ok <- method().validate(token, captcha, answer_md5), + {:ok, _} <- mark_captcha_as_used(token) do + :ok + end + end + + def enabled?, do: Pleroma.Config.get([__MODULE__, :enabled], false) + + defp seconds_valid, do: Pleroma.Config.get!([__MODULE__, :seconds_valid]) + + defp secret_pair(token) do + secret_key_base = Pleroma.Config.get!([Pleroma.Web.Endpoint, :secret_key_base]) + secret = KeyGenerator.generate(secret_key_base, token <> "_encrypt") + sign_secret = KeyGenerator.generate(secret_key_base, token <> "_sign") + + {secret, sign_secret} + end + + defp validate_answer_data(token, answer_data) do + {secret, sign_secret} = secret_pair(token) + + with false <- is_nil(answer_data), + {:ok, data} <- MessageEncryptor.decrypt(answer_data, secret, sign_secret), + %{at: at, answer_data: answer_md5} <- :erlang.binary_to_term(data) do + {:ok, %{at: at, answer_data: answer_md5}} + else + _ -> {:error, :invalid_answer_data} + end + end + + defp validate_expiration(created_at) do + # If the time found is less than (current_time-seconds_valid) then the time has already passed + # Later we check that the time found is more than the presumed invalidatation time, that means + # that the data is still valid and the captcha can be checked + + valid_if_after = DateTime.subtract!(DateTime.now_utc(), seconds_valid()) + + if DateTime.before?(created_at, valid_if_after) do + {:error, :expired} + else + :ok + end + end + + defp validate_usage(token) do + if is_nil(@cachex.get!(:used_captcha_cache, token)) do + :ok + else + {:error, :already_used} + end + end + + defp mark_captcha_as_used(token) do + ttl = seconds_valid() |> :timer.seconds() + @cachex.put(:used_captcha_cache, token, true, ttl: ttl) + end + + defp method, do: Pleroma.Config.get!([__MODULE__, :method]) +end diff --git a/lib/pleroma/captcha/kocaptcha.ex b/lib/pleroma/captcha/kocaptcha.ex new file mode 100644 index 0000000..e786e28 --- /dev/null +++ b/lib/pleroma/captcha/kocaptcha.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Captcha.Kocaptcha do + alias Pleroma.Captcha.Service + @behaviour Service + + @impl Service + def new do + endpoint = Pleroma.Config.get!([__MODULE__, :endpoint]) + + case Pleroma.HTTP.get(endpoint <> "/new") do + {:error, _} -> + %{error: :kocaptcha_service_unavailable} + + {:ok, res} -> + json_resp = Jason.decode!(res.body) + + %{ + type: :kocaptcha, + token: json_resp["token"], + url: endpoint <> json_resp["url"], + answer_data: json_resp["md5"], + seconds_valid: Pleroma.Config.get([Pleroma.Captcha, :seconds_valid]) + } + end + end + + @impl Service + def validate(_token, captcha, answer_data) do + # Here the token is unsed, because the unencrypted captcha answer is just passed to method + if not is_nil(captcha) and + :crypto.hash(:md5, captcha) |> Base.encode16() == String.upcase(answer_data), + do: :ok, + else: {:error, :invalid} + end +end diff --git a/lib/pleroma/captcha/native.ex b/lib/pleroma/captcha/native.ex new file mode 100644 index 0000000..9ba0f30 --- /dev/null +++ b/lib/pleroma/captcha/native.ex @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Captcha.Native do + alias Pleroma.Captcha.Service + @behaviour Service + + @impl Service + def new do + case Captcha.get() do + :error -> + %{error: :captcha_error} + + {:ok, answer_data, img_binary} -> + %{ + type: :native, + token: token(), + url: "data:image/png;base64," <> Base.encode64(img_binary), + answer_data: answer_data, + seconds_valid: Pleroma.Config.get([Pleroma.Captcha, :seconds_valid]) + } + end + end + + @impl Service + def validate(_token, captcha, captcha) when not is_nil(captcha), do: :ok + def validate(_token, _captcha, _answer), do: {:error, :invalid} + + defp token do + 10 + |> :crypto.strong_rand_bytes() + |> Base.url_encode64(padding: false) + end +end diff --git a/lib/pleroma/captcha/service.ex b/lib/pleroma/captcha/service.ex new file mode 100644 index 0000000..7479760 --- /dev/null +++ b/lib/pleroma/captcha/service.ex @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Captcha.Service do + @doc """ + Request new captcha from a captcha service. + + Returns: + + Type/Name of the service, the token to identify the captcha, + the data of the answer and service-specific data to use the newly created captcha + """ + @callback new() :: %{ + type: atom(), + token: String.t(), + answer_data: any() + } + + @doc """ + Validated the provided captcha solution. + + Arguments: + * `token` the captcha is associated with + * `captcha` solution of the captcha to validate + * `answer_data` is the data needed to validate the answer (presumably encrypted) + + Returns: + + `true` if captcha is valid, `false` if not + """ + @callback validate( + token :: String.t(), + captcha :: String.t(), + answer_data :: any() + ) :: :ok | {:error, String.t()} +end diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex new file mode 100644 index 0000000..fe32ec0 --- /dev/null +++ b/lib/pleroma/chat.ex @@ -0,0 +1,97 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Chat do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.Chat + alias Pleroma.Repo + alias Pleroma.User + + @moduledoc """ + Chat keeps a reference to ChatMessage conversations between a user and an recipient. The recipient can be a user (for now) or a group (not implemented yet). + + It is a helper only, to make it easy to display a list of chats with other people, ordered by last bump. The actual messages are retrieved by querying the recipients of the ChatMessages. + """ + + @type t :: %__MODULE__{} + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + + schema "chats" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + field(:recipient, :string) + + timestamps() + end + + def changeset(struct, params) do + struct + |> cast(params, [:user_id, :recipient]) + |> validate_change(:recipient, fn + :recipient, recipient -> + case User.get_cached_by_ap_id(recipient) do + nil -> [recipient: "must be an existing user"] + _ -> [] + end + end) + |> validate_required([:user_id, :recipient]) + |> unique_constraint(:user_id, name: :chats_user_id_recipient_index) + end + + @spec get_by_user_and_id(User.t(), FlakeId.Ecto.CompatType.t()) :: + {:ok, t()} | {:error, :not_found} + def get_by_user_and_id(%User{id: user_id}, id) do + from(c in __MODULE__, + where: c.id == ^id, + where: c.user_id == ^user_id + ) + |> Repo.find_resource() + end + + @spec get_by_id(FlakeId.Ecto.CompatType.t()) :: t() | nil + def get_by_id(id) do + Repo.get(__MODULE__, id) + end + + @spec get(FlakeId.Ecto.CompatType.t(), String.t()) :: t() | nil + def get(user_id, recipient) do + Repo.get_by(__MODULE__, user_id: user_id, recipient: recipient) + end + + @spec get_or_create(FlakeId.Ecto.CompatType.t(), String.t()) :: + {:ok, t()} | {:error, Ecto.Changeset.t()} + def get_or_create(user_id, recipient) do + %__MODULE__{} + |> changeset(%{user_id: user_id, recipient: recipient}) + |> Repo.insert( + # Need to set something, otherwise we get nothing back at all + on_conflict: [set: [recipient: recipient]], + returning: true, + conflict_target: [:user_id, :recipient] + ) + end + + @spec bump_or_create(FlakeId.Ecto.CompatType.t(), String.t()) :: + {:ok, t()} | {:error, Ecto.Changeset.t()} + def bump_or_create(user_id, recipient) do + %__MODULE__{} + |> changeset(%{user_id: user_id, recipient: recipient}) + |> Repo.insert( + on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], + returning: true, + conflict_target: [:user_id, :recipient] + ) + end + + @spec for_user_query(FlakeId.Ecto.CompatType.t()) :: Ecto.Query.t() + def for_user_query(user_id) do + from(c in Chat, + where: c.user_id == ^user_id, + order_by: [desc: c.updated_at] + ) + end +end diff --git a/lib/pleroma/chat/message_reference.ex b/lib/pleroma/chat/message_reference.ex new file mode 100644 index 0000000..ea65a4a --- /dev/null +++ b/lib/pleroma/chat/message_reference.ex @@ -0,0 +1,117 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Chat.MessageReference do + @moduledoc """ + A reference that builds a relation between an AP chat message that a user can see and whether it has been seen + by them, or should be displayed to them. Used to build the chat view that is presented to the user. + """ + + use Ecto.Schema + + alias Pleroma.Chat + alias Pleroma.Object + alias Pleroma.Repo + + import Ecto.Changeset + import Ecto.Query + + @primary_key {:id, FlakeId.Ecto.Type, autogenerate: true} + + schema "chat_message_references" do + belongs_to(:object, Object) + belongs_to(:chat, Chat, type: FlakeId.Ecto.CompatType) + + field(:unread, :boolean, default: true) + + timestamps() + end + + def changeset(struct, params) do + struct + |> cast(params, [:object_id, :chat_id, :unread]) + |> validate_required([:object_id, :chat_id, :unread]) + end + + def get_by_id(id) do + __MODULE__ + |> Repo.get(id) + |> Repo.preload(:object) + end + + def delete(cm_ref) do + cm_ref + |> Repo.delete() + end + + def delete_for_object(%{id: object_id}) do + from(cr in __MODULE__, + where: cr.object_id == ^object_id + ) + |> Repo.delete_all() + end + + def for_chat_and_object(%{id: chat_id}, %{id: object_id}) do + __MODULE__ + |> Repo.get_by(chat_id: chat_id, object_id: object_id) + |> Repo.preload(:object) + end + + def for_chat_query(chat) do + from(cr in __MODULE__, + where: cr.chat_id == ^chat.id, + order_by: [desc: :id], + preload: [:object] + ) + end + + def last_message_for_chat(chat) do + chat + |> for_chat_query() + |> limit(1) + |> Repo.one() + end + + def create(chat, object, unread) do + params = %{ + chat_id: chat.id, + object_id: object.id, + unread: unread + } + + %__MODULE__{} + |> changeset(params) + |> Repo.insert() + end + + def unread_count_for_chat(chat) do + chat + |> for_chat_query() + |> where([cmr], cmr.unread == true) + |> Repo.aggregate(:count) + end + + def mark_as_read(cm_ref) do + cm_ref + |> changeset(%{unread: false}) + |> Repo.update() + end + + def set_all_seen_for_chat(chat, last_read_id \\ nil) do + query = + chat + |> for_chat_query() + |> exclude(:order_by) + |> exclude(:preload) + |> where([cmr], cmr.unread == true) + + if last_read_id do + query + |> where([cmr], cmr.id <= ^last_read_id) + else + query + end + |> Repo.update_all(set: [unread: false]) + end +end diff --git a/lib/pleroma/clippy.ex b/lib/pleroma/clippy.ex new file mode 100644 index 0000000..bcf23ca --- /dev/null +++ b/lib/pleroma/clippy.ex @@ -0,0 +1,156 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Clippy do + @moduledoc false + + # No software is complete until they have a Clippy implementation. + # A ballmer peak _may_ be required to change this module. + + def tip do + tips() + |> Enum.random() + |> puts() + end + + def tips do + host = Pleroma.Config.get([Pleroma.Web.Endpoint, :url, :host]) + + [ + "“πλήρωμα” is “pleroma” in greek", + "For an extended Pleroma Clippy Experience, use the “Redmond” themes in Pleroma FE settings", + "Staff accounts and MRF policies of Pleroma instances are disclosed on the NodeInfo endpoints for easy transparency!\n +- https://catgirl.science/misc/nodeinfo.lua?#{host} +- https://fediverse.network/#{host}/federation", + "Pleroma can federate to the Dark Web!\n +- Tor: https://git.pleroma.social/pleroma/pleroma/wikis/Easy%20Onion%20Federation%20(Tor) +- i2p: https://git.pleroma.social/pleroma/pleroma/wikis/I2p%20federation", + "Lists of Pleroma instances:\n\n- http://distsn.org/pleroma-instances.html\n- https://fediverse.network/pleroma\n- https://the-federation.info/pleroma", + "Pleroma uses the LitePub protocol - https://litepub.social", + "To receive more federated posts, subscribe to relays!\n +- How-to: https://git.pleroma.social/pleroma/pleroma/wikis/Admin%20tasks#relay-managment +- Relays: https://fediverse.network/activityrelay" + ] + end + + @spec puts(String.t() | [[IO.ANSI.ansicode() | String.t(), ...], ...]) :: nil + def puts(text_or_lines) do + import IO.ANSI + + lines = + if is_binary(text_or_lines) do + String.split(text_or_lines, ~r/\n/) + else + text_or_lines + end + + longest_line_size = + lines + |> Enum.map(&charlist_count_text/1) + |> Enum.sort(&>=/2) + |> List.first() + + pad_text = longest_line_size + + pad = + for(_ <- 1..pad_text, do: "_") + |> Enum.join("") + + pad_spaces = + for(_ <- 1..pad_text, do: " ") + |> Enum.join("") + + spaces = " " + + pre_lines = [ + " / \\#{spaces} _#{pad}___", + " | |#{spaces} / #{pad_spaces} \\" + ] + + for l <- pre_lines do + IO.puts(l) + end + + clippy_lines = [ + " #{bright()}@ @#{reset()}#{spaces} ", + " || ||#{spaces}", + " || || <--", + " |\\_/| ", + " \\___/ " + ] + + noclippy_line = " " + + env = %{ + max_size: pad_text, + pad: pad, + pad_spaces: pad_spaces, + spaces: spaces, + pre_lines: pre_lines, + noclippy_line: noclippy_line + } + + # surrond one/five line clippy with blank lines around to not fuck up the layout + # + # yes this fix sucks but it's good enough, have you ever seen a release of windows + # without some butched features anyway? + lines = + if length(lines) == 1 or length(lines) == 5 do + [""] ++ lines ++ [""] + else + lines + end + + clippy_line(lines, clippy_lines, env) + rescue + e -> + IO.puts("(Clippy crashed, sorry: #{inspect(e)})") + IO.puts(text_or_lines) + end + + defp clippy_line([line | lines], [prefix | clippy_lines], env) do + IO.puts([prefix <> "| ", rpad_line(line, env.max_size)]) + clippy_line(lines, clippy_lines, env) + end + + # more text lines but clippy's complete + defp clippy_line([line | lines], [], env) do + IO.puts([env.noclippy_line, "| ", rpad_line(line, env.max_size)]) + + if lines == [] do + IO.puts(env.noclippy_line <> "\\_#{env.pad}___/") + end + + clippy_line(lines, [], env) + end + + # no more text lines but clippy's not complete + defp clippy_line([], [clippy | clippy_lines], env) do + if env.pad do + IO.puts(clippy <> "\\_#{env.pad}___/") + clippy_line([], clippy_lines, %{env | pad: nil}) + else + IO.puts(clippy) + clippy_line([], clippy_lines, env) + end + end + + defp clippy_line(_, _, _) do + end + + defp rpad_line(line, max) do + pad = max - (charlist_count_text(line) - 2) + pads = Enum.join(for(_ <- 1..pad, do: " ")) + [IO.ANSI.format(line), pads <> " |"] + end + + defp charlist_count_text(line) do + if is_list(line) do + text = Enum.join(Enum.filter(line, &is_binary/1)) + String.length(text) + else + String.length(line) + end + end +end diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex new file mode 100644 index 0000000..cf1453c --- /dev/null +++ b/lib/pleroma/config.ex @@ -0,0 +1,106 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config do + @behaviour Pleroma.Config.Getting + defmodule Error do + defexception [:message] + end + + @impl true + def get(key), do: get(key, nil) + + @impl true + def get([key], default), do: get(key, default) + + @impl true + def get([_ | _] = path, default) do + case fetch(path) do + {:ok, value} -> value + :error -> default + end + end + + @impl true + def get(key, default) do + Application.get_env(:pleroma, key, default) + end + + def get!(key) do + value = get(key, nil) + + if value == nil do + raise(Error, message: "Missing configuration value: #{inspect(key)}") + else + value + end + end + + def fetch(key) when is_atom(key), do: fetch([key]) + + def fetch([root_key | keys]) do + Enum.reduce_while(keys, Application.fetch_env(:pleroma, root_key), fn + key, {:ok, config} when is_map(config) or is_list(config) -> + case Access.fetch(config, key) do + :error -> + {:halt, :error} + + value -> + {:cont, value} + end + + _key, _config -> + {:halt, :error} + end) + end + + def put([key], value), do: put(key, value) + + def put([parent_key | keys], value) do + parent = + Application.get_env(:pleroma, parent_key, []) + |> put_in(keys, value) + + Application.put_env(:pleroma, parent_key, parent) + end + + def put(key, value) do + Application.put_env(:pleroma, key, value) + end + + def delete([key]), do: delete(key) + + def delete([parent_key | keys] = path) do + with {:ok, _} <- fetch(path) do + {_, parent} = + parent_key + |> get() + |> get_and_update_in(keys, fn _ -> :pop end) + + Application.put_env(:pleroma, parent_key, parent) + end + end + + def delete(key) do + Application.delete_env(:pleroma, key) + end + + def restrict_unauthenticated_access?(resource, kind) do + setting = get([:restrict_unauthenticated, resource, kind]) + + if setting in [nil, :if_instance_is_private] do + !get!([:instance, :public]) + else + setting + end + end + + def oauth_consumer_strategies, do: get([:auth, :oauth_consumer_strategies], []) + + def oauth_consumer_enabled?, do: oauth_consumer_strategies() != [] + + def feature_enabled?(feature_name) do + get([:features, feature_name]) not in [nil, false, :disabled, :auto] + end +end diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex new file mode 100644 index 0000000..240e884 --- /dev/null +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -0,0 +1,394 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.DeprecationWarnings do + alias Pleroma.Config + + require Logger + alias Pleroma.Config + + @type config_namespace() :: atom() | [atom()] + @type config_map() :: {config_namespace(), config_namespace(), String.t()} + + @mrf_config_map [ + {[:instance, :rewrite_policy], [:mrf, :policies], + "\n* `config :pleroma, :instance, rewrite_policy` is now `config :pleroma, :mrf, policies`"}, + {[:instance, :mrf_transparency], [:mrf, :transparency], + "\n* `config :pleroma, :instance, mrf_transparency` is now `config :pleroma, :mrf, transparency`"}, + {[:instance, :mrf_transparency_exclusions], [:mrf, :transparency_exclusions], + "\n* `config :pleroma, :instance, mrf_transparency_exclusions` is now `config :pleroma, :mrf, transparency_exclusions`"} + ] + + def check_exiftool_filter do + filters = Config.get([Pleroma.Upload]) |> Keyword.get(:filters, []) + + if Pleroma.Upload.Filter.Exiftool in filters do + Logger.warning(""" + !!!DEPRECATION WARNING!!! + Your config is using Exiftool as a filter instead of Exiftool.StripLocation. This should work for now, but you are advised to change to the new configuration to prevent possible issues later: + + ``` + config :pleroma, Pleroma.Upload, + filters: [Pleroma.Upload.Filter.Exiftool] + ``` + + Is now + + + ``` + config :pleroma, Pleroma.Upload, + filters: [Pleroma.Upload.Filter.Exiftool.StripLocation] + ``` + """) + + new_config = + filters + |> Enum.map(fn + Pleroma.Upload.Filter.Exiftool -> Pleroma.Upload.Filter.Exiftool.StripLocation + filter -> filter + end) + + Config.put([Pleroma.Upload, :filters], new_config) + + :error + else + :ok + end + end + + def check_simple_policy_tuples do + has_strings = + Config.get([:mrf_simple]) + |> Enum.any?(fn {_, v} -> Enum.any?(v, &is_binary/1) end) + + if has_strings do + Logger.warning(""" + !!!DEPRECATION WARNING!!! + Your config is using strings in the SimplePolicy configuration instead of tuples. They should work for now, but you are advised to change to the new configuration to prevent possible issues later: + + ``` + config :pleroma, :mrf_simple, + media_removal: ["instance.tld"], + media_nsfw: ["instance.tld"], + federated_timeline_removal: ["instance.tld"], + report_removal: ["instance.tld"], + reject: ["instance.tld"], + followers_only: ["instance.tld"], + accept: ["instance.tld"], + avatar_removal: ["instance.tld"], + banner_removal: ["instance.tld"], + reject_deletes: ["instance.tld"] + ``` + + Is now + + + ``` + config :pleroma, :mrf_simple, + media_removal: [{"instance.tld", "Reason for media removal"}], + media_nsfw: [{"instance.tld", "Reason for media nsfw"}], + federated_timeline_removal: [{"instance.tld", "Reason for federated timeline removal"}], + report_removal: [{"instance.tld", "Reason for report removal"}], + reject: [{"instance.tld", "Reason for reject"}], + followers_only: [{"instance.tld", "Reason for followers only"}], + accept: [{"instance.tld", "Reason for accept"}], + avatar_removal: [{"instance.tld", "Reason for avatar removal"}], + banner_removal: [{"instance.tld", "Reason for banner removal"}], + reject_deletes: [{"instance.tld", "Reason for reject deletes"}] + ``` + """) + + new_config = + Config.get([:mrf_simple]) + |> Enum.map(fn {k, v} -> + {k, + Enum.map(v, fn + {instance, reason} -> {instance, reason} + instance -> {instance, ""} + end)} + end) + + Config.put([:mrf_simple], new_config) + + :error + else + :ok + end + end + + def check_quarantined_instances_tuples do + has_strings = Config.get([:instance, :quarantined_instances]) |> Enum.any?(&is_binary/1) + + if has_strings do + Logger.warning(""" + !!!DEPRECATION WARNING!!! + Your config is using strings in the quarantined_instances configuration instead of tuples. They should work for now, but you are advised to change to the new configuration to prevent possible issues later: + + ``` + config :pleroma, :instance, + quarantined_instances: ["instance.tld"] + ``` + + Is now + + + ``` + config :pleroma, :instance, + quarantined_instances: [{"instance.tld", "Reason for quarantine"}] + ``` + """) + + new_config = + Config.get([:instance, :quarantined_instances]) + |> Enum.map(fn + {instance, reason} -> {instance, reason} + instance -> {instance, ""} + end) + + Config.put([:instance, :quarantined_instances], new_config) + + :error + else + :ok + end + end + + def check_transparency_exclusions_tuples do + has_strings = Config.get([:mrf, :transparency_exclusions]) |> Enum.any?(&is_binary/1) + + if has_strings do + Logger.warning(""" + !!!DEPRECATION WARNING!!! + Your config is using strings in the transparency_exclusions configuration instead of tuples. They should work for now, but you are advised to change to the new configuration to prevent possible issues later: + + ``` + config :pleroma, :mrf, + transparency_exclusions: ["instance.tld"] + ``` + + Is now + + + ``` + config :pleroma, :mrf, + transparency_exclusions: [{"instance.tld", "Reason to exlude transparency"}] + ``` + """) + + new_config = + Config.get([:mrf, :transparency_exclusions]) + |> Enum.map(fn + {instance, reason} -> {instance, reason} + instance -> {instance, ""} + end) + + Config.put([:mrf, :transparency_exclusions], new_config) + + :error + else + :ok + end + end + + def check_hellthread_threshold do + if Config.get([:mrf_hellthread, :threshold]) do + Logger.warning(""" + !!!DEPRECATION WARNING!!! + You are using the old configuration mechanism for the hellthread filter. Please check config.md. + """) + + :error + else + :ok + end + end + + def warn do + [ + check_hellthread_threshold(), + check_old_mrf_config(), + check_media_proxy_whitelist_config(), + check_welcome_message_config(), + check_gun_pool_options(), + check_activity_expiration_config(), + check_remote_ip_plug_name(), + check_uploders_s3_public_endpoint(), + check_quarantined_instances_tuples(), + check_transparency_exclusions_tuples(), + check_simple_policy_tuples(), + check_exiftool_filter() + ] + |> Enum.reduce(:ok, fn + :ok, :ok -> :ok + _, _ -> :error + end) + end + + def check_welcome_message_config do + instance_config = Pleroma.Config.get([:instance]) + + use_old_config = + Keyword.has_key?(instance_config, :welcome_user_nickname) or + Keyword.has_key?(instance_config, :welcome_message) + + if use_old_config do + Logger.error(""" + !!!DEPRECATION WARNING!!! + Your config is using the old namespace for Welcome messages configuration. You need to convert to the new namespace. e.g., + \n* `config :pleroma, :instance, welcome_user_nickname` and `config :pleroma, :instance, welcome_message` are now equal to: + \n* `config :pleroma, :welcome, direct_message: [enabled: true, sender_nickname: "NICKNAME", message: "Your welcome message"]`" + """) + + :error + else + :ok + end + end + + def check_old_mrf_config do + warning_preface = """ + !!!DEPRECATION WARNING!!! + Your config is using old namespaces for MRF configuration. They should work for now, but you are advised to change to new namespaces to prevent possible issues later: + """ + + move_namespace_and_warn(@mrf_config_map, warning_preface) + end + + @spec move_namespace_and_warn([config_map()], String.t()) :: :ok | nil + def move_namespace_and_warn(config_map, warning_preface) do + warning = + Enum.reduce(config_map, "", fn + {old, new, err_msg}, acc -> + old_config = Config.get(old) + + if old_config do + Config.put(new, old_config) + acc <> err_msg + else + acc + end + end) + + if warning == "" do + :ok + else + Logger.warning(warning_preface <> warning) + :error + end + end + + @spec check_media_proxy_whitelist_config() :: :ok | nil + def check_media_proxy_whitelist_config do + whitelist = Config.get([:media_proxy, :whitelist]) + + if Enum.any?(whitelist, &(not String.starts_with?(&1, "http"))) do + Logger.warning(""" + !!!DEPRECATION WARNING!!! + Your config is using old format (only domain) for MediaProxy whitelist option. Setting should work for now, but you are advised to change format to scheme with port to prevent possible issues later. + """) + + :error + else + :ok + end + end + + def check_gun_pool_options do + pool_config = Config.get(:connections_pool) + + if timeout = pool_config[:await_up_timeout] do + Logger.warning(""" + !!!DEPRECATION WARNING!!! + Your config is using old setting `config :pleroma, :connections_pool, await_up_timeout`. Please change to `config :pleroma, :connections_pool, connect_timeout` to ensure compatibility with future releases. + """) + + Config.put(:connections_pool, Keyword.put_new(pool_config, :connect_timeout, timeout)) + end + + pools_configs = Config.get(:pools) + + warning_preface = """ + !!!DEPRECATION WARNING!!! + Your config is using old setting name `timeout` instead of `recv_timeout` in pool settings. The setting will not take effect until updated. + """ + + updated_config = + Enum.reduce(pools_configs, [], fn {pool_name, config}, acc -> + if timeout = config[:timeout] do + Keyword.put(acc, pool_name, Keyword.put_new(config, :recv_timeout, timeout)) + else + acc + end + end) + + if updated_config != [] do + pool_warnings = + updated_config + |> Keyword.keys() + |> Enum.map(fn pool_name -> + "\n* `:timeout` options in #{pool_name} pool is now `:recv_timeout`" + end) + + Logger.warning(Enum.join([warning_preface | pool_warnings])) + + Config.put(:pools, updated_config) + :error + else + :ok + end + end + + @spec check_activity_expiration_config() :: :ok | nil + def check_activity_expiration_config do + warning_preface = """ + !!!DEPRECATION WARNING!!! + Your config is using old namespace for activity expiration configuration. Setting should work for now, but you are advised to change to new namespace to prevent possible issues later: + """ + + move_namespace_and_warn( + [ + {Pleroma.ActivityExpiration, Pleroma.Workers.PurgeExpiredActivity, + "\n* `config :pleroma, Pleroma.ActivityExpiration` is now `config :pleroma, Pleroma.Workers.PurgeExpiredActivity`"} + ], + warning_preface + ) + end + + @spec check_remote_ip_plug_name() :: :ok | nil + def check_remote_ip_plug_name do + warning_preface = """ + !!!DEPRECATION WARNING!!! + Your config is using old namespace for RemoteIp Plug. Setting should work for now, but you are advised to change to new namespace to prevent possible issues later: + """ + + move_namespace_and_warn( + [ + {Pleroma.Plugs.RemoteIp, Pleroma.Web.Plugs.RemoteIp, + "\n* `config :pleroma, Pleroma.Plugs.RemoteIp` is now `config :pleroma, Pleroma.Web.Plugs.RemoteIp`"} + ], + warning_preface + ) + end + + @spec check_uploders_s3_public_endpoint() :: :ok | nil + def check_uploders_s3_public_endpoint do + s3_config = Pleroma.Config.get([Pleroma.Uploaders.S3]) + + use_old_config = Keyword.has_key?(s3_config, :public_endpoint) + + if use_old_config do + Logger.error(""" + !!!DEPRECATION WARNING!!! + Your config is using the old setting for controlling the URL of media uploaded to your S3 bucket.\n + Please make the following change at your earliest convenience.\n + \n* `config :pleroma, Pleroma.Uploaders.S3, public_endpoint` is now equal to: + \n* `config :pleroma, Pleroma.Upload, base_url` + """) + + :error + else + :ok + end + end +end diff --git a/lib/pleroma/config/getting.ex b/lib/pleroma/config/getting.ex new file mode 100644 index 0000000..ec93fd0 --- /dev/null +++ b/lib/pleroma/config/getting.ex @@ -0,0 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.Getting do + @callback get(any()) :: any() + @callback get(any(), any()) :: any() + + def get(key), do: get(key, nil) + def get(key, default), do: impl().get(key, default) + + def impl do + Application.get_env(:pleroma, :config_impl, Pleroma.Config) + end +end diff --git a/lib/pleroma/config/helpers.ex b/lib/pleroma/config/helpers.ex new file mode 100644 index 0000000..973a75c --- /dev/null +++ b/lib/pleroma/config/helpers.ex @@ -0,0 +1,17 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.Helpers do + alias Pleroma.Config + + def instance_name, do: Config.get([:instance, :name]) + + defp instance_notify_email do + Config.get([:instance, :notify_email]) || Config.get([:instance, :email]) + end + + def sender do + {instance_name(), instance_notify_email()} + end +end diff --git a/lib/pleroma/config/holder.ex b/lib/pleroma/config/holder.ex new file mode 100644 index 0000000..7822bde --- /dev/null +++ b/lib/pleroma/config/holder.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.Holder do + @config Pleroma.Config.Loader.default_config() + + @spec save_default() :: :ok + def save_default do + default_config = + if System.get_env("RELEASE_NAME") do + Pleroma.Config.Loader.merge(@config, release_defaults()) + else + @config + end + + Pleroma.Config.put(:default_config, default_config) + end + + @spec default_config() :: keyword() + def default_config, do: get_default() + + @spec default_config(atom()) :: keyword() + def default_config(group), do: Keyword.get(get_default(), group) + + @spec default_config(atom(), atom()) :: keyword() + def default_config(group, key), do: get_in(get_default(), [group, key]) + + defp get_default, do: Pleroma.Config.get(:default_config) + + @spec release_defaults() :: keyword() + def release_defaults do + [ + pleroma: [ + {:instance, [static_dir: "/var/lib/pleroma/static"]}, + {Pleroma.Uploaders.Local, [uploads: "/var/lib/pleroma/uploads"]}, + {:modules, [runtime_dir: "/var/lib/pleroma/modules"]}, + {:release, true} + ] + ] + end +end diff --git a/lib/pleroma/config/loader.ex b/lib/pleroma/config/loader.ex new file mode 100644 index 0000000..bd85ecc --- /dev/null +++ b/lib/pleroma/config/loader.ex @@ -0,0 +1,50 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.Loader do + # These modules are only being used as keys here (for equality check), + # so it's okay to use `Module.concat/1` to have the compiler ignore them. + @reject_keys [ + Module.concat(["Pleroma.Repo"]), + Module.concat(["Pleroma.Web.Endpoint"]), + :env, + :configurable_from_database, + :database, + :swarm + ] + + @reject_groups [ + :postgrex, + :tesla + ] + + @reader Config.Reader + + @spec read(Path.t()) :: keyword() + def read(path), do: @reader.read!(path) + + @spec merge(keyword(), keyword()) :: keyword() + def merge(c1, c2), do: @reader.merge(c1, c2) + + @spec default_config() :: keyword() + def default_config do + "config/config.exs" + |> read() + |> filter() + end + + defp filter(configs) do + configs + |> Keyword.keys() + |> Enum.reduce([], &Keyword.put(&2, &1, filter_group(&1, configs))) + end + + @spec filter_group(atom(), keyword()) :: keyword() + def filter_group(group, configs) do + Enum.reject(configs[group], fn {key, _v} -> + key in @reject_keys or group in @reject_groups or + (group == :phoenix and key == :serve_endpoints) + end) + end +end diff --git a/lib/pleroma/config/oban.ex b/lib/pleroma/config/oban.ex new file mode 100644 index 0000000..836f0c1 --- /dev/null +++ b/lib/pleroma/config/oban.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.Oban do + require Logger + + def warn do + oban_config = Pleroma.Config.get(Oban) + + crontab = + [ + Pleroma.Workers.Cron.StatsWorker, + Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker, + Pleroma.Workers.Cron.ClearOauthTokenWorker + ] + |> Enum.reduce(oban_config[:crontab], fn removed_worker, acc -> + with acc when is_list(acc) <- acc, + setting when is_tuple(setting) <- + Enum.find(acc, fn {_, worker} -> worker == removed_worker end) do + """ + !!!OBAN CONFIG WARNING!!! + You are using old workers in Oban crontab settings, which were removed. + Please, remove setting from crontab in your config file (prod.secret.exs): #{inspect(setting)} + """ + |> Logger.warning() + + List.delete(acc, setting) + else + _ -> acc + end + end) + + Pleroma.Config.put(Oban, Keyword.put(oban_config, :crontab, crontab)) + end +end diff --git a/lib/pleroma/config/release_runtime_provider.ex b/lib/pleroma/config/release_runtime_provider.ex new file mode 100644 index 0000000..9ec0f97 --- /dev/null +++ b/lib/pleroma/config/release_runtime_provider.ex @@ -0,0 +1,70 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.ReleaseRuntimeProvider do + @moduledoc """ + Imports runtime config and `{env}.exported_from_db.secret.exs` for releases. + """ + @behaviour Config.Provider + + @impl true + def init(opts), do: opts + + @impl true + def load(config, opts) do + with_defaults = Config.Reader.merge(config, Pleroma.Config.Holder.release_defaults()) + + config_path = + opts[:config_path] || System.get_env("PLEROMA_CONFIG_PATH") || "/etc/pleroma/config.exs" + + with_runtime_config = + if File.exists?(config_path) do + # + %File.Stat{mode: mode} = File.lstat!(config_path) + + if Bitwise.band(mode, 0o007) > 0 do + raise "Configuration at #{config_path} has world-permissions, execute the following: chmod o= #{config_path}" + end + + if Bitwise.band(mode, 0o020) > 0 do + raise "Configuration at #{config_path} has group-wise write permissions, execute the following: chmod g-w #{config_path}" + end + + # Note: Elixir doesn't provides a getuid(2) + # so cannot forbid group-read only when config is owned by us + + runtime_config = Config.Reader.read!(config_path) + + with_defaults + |> Config.Reader.merge(pleroma: [config_path: config_path]) + |> Config.Reader.merge(runtime_config) + else + warning = [ + IO.ANSI.red(), + IO.ANSI.bright(), + "!!! Config path is not declared! Please ensure it exists and that PLEROMA_CONFIG_PATH is unset or points to an existing file", + IO.ANSI.reset() + ] + + IO.puts(warning) + with_defaults + end + + exported_config_path = + opts[:exported_config_path] || + config_path + |> Path.dirname() + |> Path.join("#{Pleroma.Config.get(:env)}.exported_from_db.secret.exs") + + with_exported = + if File.exists?(exported_config_path) do + exported_config = Config.Reader.read!(exported_config_path) + Config.Reader.merge(with_runtime_config, exported_config) + else + with_runtime_config + end + + with_exported + end +end diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex new file mode 100644 index 0000000..e8fe8a8 --- /dev/null +++ b/lib/pleroma/config/transfer_task.ex @@ -0,0 +1,195 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.TransferTask do + use Task + + alias Pleroma.Config + alias Pleroma.ConfigDB + alias Pleroma.Repo + + require Logger + + @type env() :: :test | :benchmark | :dev | :prod + + defp reboot_time_keys, + do: [ + {:pleroma, :hackney_pools}, + {:pleroma, Oban}, + {:pleroma, :rate_limit}, + {:pleroma, :markup}, + {:pleroma, :streamer}, + {:pleroma, :pools}, + {:pleroma, :connections_pool} + ] + + defp reboot_time_subkeys, + do: [ + {:pleroma, Pleroma.Captcha, [:seconds_valid]}, + {:pleroma, Pleroma.Upload, [:proxy_remote]}, + {:pleroma, :instance, [:upload_limit]}, + {:pleroma, :gopher, [:enabled]} + ] + + def start_link(restart_pleroma? \\ true) do + load_and_update_env([], restart_pleroma?) + if Config.get(:env) == :test, do: Ecto.Adapters.SQL.Sandbox.checkin(Repo) + :ignore + end + + @spec load_and_update_env([ConfigDB.t()], boolean()) :: :ok + def load_and_update_env(deleted_settings \\ [], restart_pleroma? \\ true) do + with {_, true} <- {:configurable, Config.get(:configurable_from_database)} do + # We need to restart applications for loaded settings take effect + + {logger, other} = + (Repo.all(ConfigDB) ++ deleted_settings) + |> Enum.map(&merge_with_default/1) + |> Enum.split_with(fn {group, _, _, _} -> group in [:logger] end) + + logger + |> Enum.sort() + |> Enum.each(&configure/1) + + started_applications = Application.started_applications() + + # TODO: some problem with prometheus after restart! + reject = [nil, :prometheus, :postgrex] + + reject = + if restart_pleroma? do + reject + else + [:pleroma | reject] + end + + other + |> Enum.map(&update/1) + |> Enum.uniq() + |> Enum.reject(&(&1 in reject)) + |> maybe_set_pleroma_last() + |> Enum.each(&restart(started_applications, &1, Config.get(:env))) + + :ok + else + {:configurable, false} -> Restarter.Pleroma.rebooted() + end + end + + defp maybe_set_pleroma_last(apps) do + # to be ensured that pleroma will be restarted last + if :pleroma in apps do + apps + |> List.delete(:pleroma) + |> List.insert_at(-1, :pleroma) + else + Restarter.Pleroma.rebooted() + apps + end + end + + defp merge_with_default(%{group: group, key: key, value: value} = setting) do + default = Config.Holder.default_config(group, key) + + merged = + cond do + Ecto.get_meta(setting, :state) == :deleted -> default + can_be_merged?(default, value) -> ConfigDB.merge_group(group, key, default, value) + true -> value + end + + {group, key, value, merged} + end + + # change logger configuration in runtime, without restart + defp configure({_, :backends, _, merged}) do + # removing current backends + Enum.each(Application.get_env(:logger, :backends), &Logger.remove_backend/1) + + Enum.each(merged, &Logger.add_backend/1) + + :ok = update_env(:logger, :backends, merged) + end + + defp configure({_, key, _, merged}) when key in [:console, :ex_syslogger] do + merged = + if key == :console do + put_in(merged[:format], merged[:format] <> "\n") + else + merged + end + + backend = + if key == :ex_syslogger, + do: {ExSyslogger, :ex_syslogger}, + else: key + + Logger.configure_backend(backend, merged) + :ok = update_env(:logger, key, merged) + end + + defp configure({_, key, _, merged}) do + Logger.configure([{key, merged}]) + :ok = update_env(:logger, key, merged) + end + + defp update({group, key, value, merged}) do + try do + :ok = update_env(group, key, merged) + + if group != :pleroma or pleroma_need_restart?(group, key, value), do: group + rescue + error -> + error_msg = + "updating env causes error, group: #{inspect(group)}, key: #{inspect(key)}, value: #{inspect(value)} error: #{inspect(error)}" + + Logger.warning(error_msg) + + nil + end + end + + defp update_env(group, key, nil), do: Application.delete_env(group, key) + defp update_env(group, key, value), do: Application.put_env(group, key, value) + + @spec pleroma_need_restart?(atom(), atom(), any()) :: boolean() + def pleroma_need_restart?(group, key, value) do + group_and_key_need_reboot?(group, key) or group_and_subkey_need_reboot?(group, key, value) + end + + defp group_and_key_need_reboot?(group, key) do + Enum.any?(reboot_time_keys(), fn {g, k} -> g == group and k == key end) + end + + defp group_and_subkey_need_reboot?(group, key, value) do + Keyword.keyword?(value) and + Enum.any?(reboot_time_subkeys(), fn {g, k, subkeys} -> + g == group and k == key and + Enum.any?(Keyword.keys(value), &(&1 in subkeys)) + end) + end + + defp restart(_, :pleroma, env), do: Restarter.Pleroma.restart_after_boot(env) + + defp restart(started_applications, app, _) do + with {^app, _, _} <- List.keyfind(started_applications, app, 0), + :ok <- Application.stop(app) do + :ok = Application.start(app) + else + nil -> + Logger.warning("#{app} is not started.") + + error -> + error + |> inspect() + |> Logger.warning() + end + end + + defp can_be_merged?(val1, val2) when is_list(val1) and is_list(val2) do + Keyword.keyword?(val1) and Keyword.keyword?(val2) + end + + defp can_be_merged?(_val1, _val2), do: false +end diff --git a/lib/pleroma/config_db.ex b/lib/pleroma/config_db.ex new file mode 100644 index 0000000..38c6084 --- /dev/null +++ b/lib/pleroma/config_db.ex @@ -0,0 +1,391 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ConfigDB do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query, only: [select: 3, from: 2] + import Pleroma.Web.Gettext + + alias __MODULE__ + alias Pleroma.Repo + + @type t :: %__MODULE__{} + + @full_subkey_update [ + {:pleroma, :assets, :mascots}, + {:pleroma, :emoji, :groups}, + {:pleroma, :workers, :retries}, + {:pleroma, :mrf_subchain, :match_actor}, + {:pleroma, :mrf_keyword, :replace} + ] + + schema "config" do + field(:key, Pleroma.EctoType.Config.Atom) + field(:group, Pleroma.EctoType.Config.Atom) + field(:value, Pleroma.EctoType.Config.BinaryValue) + field(:db, {:array, :string}, virtual: true, default: []) + + timestamps() + end + + @spec get_all_as_keyword() :: keyword() + def get_all_as_keyword do + ConfigDB + |> select([c], {c.group, c.key, c.value}) + |> Repo.all() + |> Enum.reduce([], fn {group, key, value}, acc -> + Keyword.update(acc, group, [{key, value}], &Keyword.merge(&1, [{key, value}])) + end) + end + + @spec get_all_by_group(atom() | String.t()) :: [t()] + def get_all_by_group(group) do + from(c in ConfigDB, where: c.group == ^group) |> Repo.all() + end + + @spec get_by_group_and_key(atom() | String.t(), atom() | String.t()) :: t() | nil + def get_by_group_and_key(group, key) do + get_by_params(%{group: group, key: key}) + end + + @spec get_by_params(map()) :: ConfigDB.t() | nil + def get_by_params(%{group: _, key: _} = params), do: Repo.get_by(ConfigDB, params) + + @spec changeset(ConfigDB.t(), map()) :: Changeset.t() + def changeset(config, params \\ %{}) do + config + |> cast(params, [:key, :group, :value]) + |> validate_required([:key, :group, :value]) + |> unique_constraint(:key, name: :config_group_key_index) + end + + defp create(params) do + %ConfigDB{} + |> changeset(params) + |> Repo.insert() + end + + defp update(%ConfigDB{} = config, %{value: value}) do + config + |> changeset(%{value: value}) + |> Repo.update() + end + + @spec get_db_keys(keyword(), any()) :: [String.t()] + def get_db_keys(value, key) do + keys = + if Keyword.keyword?(value) do + Keyword.keys(value) + else + [key] + end + + Enum.map(keys, &to_json_types(&1)) + end + + @spec merge_group(atom(), atom(), keyword(), keyword()) :: keyword() + def merge_group(group, key, old_value, new_value) do + new_keys = to_mapset(new_value) + + intersect_keys = old_value |> to_mapset() |> MapSet.intersection(new_keys) |> MapSet.to_list() + + merged_value = ConfigDB.merge(old_value, new_value) + + @full_subkey_update + |> Enum.map(fn + {g, k, subkey} when g == group and k == key -> + if subkey in intersect_keys, do: subkey, else: [] + + _ -> + [] + end) + |> List.flatten() + |> Enum.reduce(merged_value, &Keyword.put(&2, &1, new_value[&1])) + end + + defp to_mapset(keyword) do + keyword + |> Keyword.keys() + |> MapSet.new() + end + + @spec sub_key_full_update?(atom(), atom(), [Keyword.key()]) :: boolean() + def sub_key_full_update?(group, key, subkeys) do + Enum.any?(@full_subkey_update, fn {g, k, subkey} -> + g == group and k == key and subkey in subkeys + end) + end + + @spec merge(keyword(), keyword()) :: keyword() + def merge(config1, config2) when is_list(config1) and is_list(config2) do + Keyword.merge(config1, config2, fn _, app1, app2 -> + if Keyword.keyword?(app1) and Keyword.keyword?(app2) do + Keyword.merge(app1, app2, &deep_merge/3) + else + app2 + end + end) + end + + defp deep_merge(_key, value1, value2) do + if Keyword.keyword?(value1) and Keyword.keyword?(value2) do + Keyword.merge(value1, value2, &deep_merge/3) + else + value2 + end + end + + @spec update_or_create(map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} + def update_or_create(params) do + params = Map.put(params, :value, to_elixir_types(params[:value])) + search_opts = Map.take(params, [:group, :key]) + + with %ConfigDB{} = config <- ConfigDB.get_by_params(search_opts), + {_, true, config} <- {:partial_update, can_be_partially_updated?(config), config}, + {_, true, config} <- + {:can_be_merged, is_list(params[:value]) and is_list(config.value), config} do + new_value = merge_group(config.group, config.key, config.value, params[:value]) + update(config, %{value: new_value}) + else + {reason, false, config} when reason in [:partial_update, :can_be_merged] -> + update(config, params) + + nil -> + create(params) + end + end + + defp can_be_partially_updated?(%ConfigDB{} = config), do: not only_full_update?(config) + + defp only_full_update?(%ConfigDB{group: group, key: key}) do + full_key_update = [ + {:pleroma, :ecto_repos}, + {:mime, :types}, + {:cors_plug, [:max_age, :methods, :expose, :headers]}, + {:swarm, :node_blacklist}, + {:logger, :backends} + ] + + Enum.any?(full_key_update, fn + {s_group, s_key} -> + group == s_group and ((is_list(s_key) and key in s_key) or key == s_key) + end) + end + + @spec delete(ConfigDB.t() | map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} + def delete(%ConfigDB{} = config), do: Repo.delete(config) + + def delete(params) do + search_opts = Map.delete(params, :subkeys) + + with %ConfigDB{} = config <- ConfigDB.get_by_params(search_opts), + {config, sub_keys} when is_list(sub_keys) <- {config, params[:subkeys]}, + keys <- Enum.map(sub_keys, &string_to_elixir_types(&1)), + {_, config, new_value} when new_value != [] <- + {:partial_remove, config, Keyword.drop(config.value, keys)} do + update(config, %{value: new_value}) + else + {:partial_remove, config, []} -> + Repo.delete(config) + + {config, nil} -> + Repo.delete(config) + + nil -> + err = + dgettext("errors", "Config with params %{params} not found", params: inspect(params)) + + {:error, err} + end + end + + @spec to_json_types(term()) :: map() | list() | boolean() | String.t() + def to_json_types(entity) when is_list(entity) do + Enum.map(entity, &to_json_types/1) + end + + def to_json_types(%Regex{} = entity), do: inspect(entity) + + def to_json_types(entity) when is_map(entity) do + Map.new(entity, fn {k, v} -> {to_json_types(k), to_json_types(v)} end) + end + + def to_json_types({:args, args}) when is_list(args) do + arguments = + Enum.map(args, fn + arg when is_tuple(arg) -> inspect(arg) + arg -> to_json_types(arg) + end) + + %{"tuple" => [":args", arguments]} + end + + def to_json_types({:proxy_url, {type, :localhost, port}}) do + %{"tuple" => [":proxy_url", %{"tuple" => [to_json_types(type), "localhost", port]}]} + end + + def to_json_types({:proxy_url, {type, host, port}}) when is_tuple(host) do + ip = + host + |> :inet_parse.ntoa() + |> to_string() + + %{ + "tuple" => [ + ":proxy_url", + %{"tuple" => [to_json_types(type), ip, port]} + ] + } + end + + def to_json_types({:proxy_url, {type, host, port}}) do + %{ + "tuple" => [ + ":proxy_url", + %{"tuple" => [to_json_types(type), to_string(host), port]} + ] + } + end + + def to_json_types({:partial_chain, entity}), + do: %{"tuple" => [":partial_chain", inspect(entity)]} + + def to_json_types(entity) when is_tuple(entity) do + value = + entity + |> Tuple.to_list() + |> to_json_types() + + %{"tuple" => value} + end + + def to_json_types(entity) when is_binary(entity), do: entity + + def to_json_types(entity) when is_boolean(entity) or is_number(entity) or is_nil(entity) do + entity + end + + def to_json_types(entity) when entity in [:"tlsv1.1", :"tlsv1.2", :"tlsv1.3"] do + ":#{entity}" + end + + def to_json_types(entity) when is_atom(entity) or is_function(entity), do: inspect(entity) + + @spec to_elixir_types(boolean() | String.t() | map() | list()) :: term() + def to_elixir_types(%{"tuple" => [":args", args]}) when is_list(args) do + arguments = + Enum.map(args, fn arg -> + if String.contains?(arg, ["{", "}"]) do + {elem, []} = Code.eval_string(arg) + elem + else + to_elixir_types(arg) + end + end) + + {:args, arguments} + end + + def to_elixir_types(%{"tuple" => [":proxy_url", %{"tuple" => [type, host, port]}]}) do + {:proxy_url, {string_to_elixir_types(type), parse_host(host), port}} + end + + def to_elixir_types(%{"tuple" => [":partial_chain", entity]}) do + {partial_chain, []} = + entity + |> String.replace(~r/[^\w|^{:,[|^,|^[|^\]^}|^\/|^\.|^"]^\s/, "") + |> Code.eval_string() + + {:partial_chain, partial_chain} + end + + def to_elixir_types(%{"tuple" => entity}) do + Enum.reduce(entity, {}, &Tuple.append(&2, to_elixir_types(&1))) + end + + def to_elixir_types(entity) when is_map(entity) do + Map.new(entity, fn {k, v} -> {to_elixir_types(k), to_elixir_types(v)} end) + end + + def to_elixir_types(entity) when is_list(entity) do + Enum.map(entity, &to_elixir_types/1) + end + + def to_elixir_types(entity) when is_binary(entity) do + entity + |> String.trim() + |> string_to_elixir_types() + end + + def to_elixir_types(entity), do: entity + + @spec string_to_elixir_types(String.t()) :: + atom() | Regex.t() | module() | String.t() | no_return() + def string_to_elixir_types("~r" <> _pattern = regex) do + pattern = + ~r/^~r(?'delimiter'[\/|"'([{<]{1})(?'pattern'.+)[\/|"')\]}>]{1}(?'modifier'[uismxfU]*)/u + + delimiters = ["/", "|", "\"", "'", {"(", ")"}, {"[", "]"}, {"{", "}"}, {"<", ">"}] + + with %{"modifier" => modifier, "pattern" => pattern, "delimiter" => regex_delimiter} <- + Regex.named_captures(pattern, regex), + {:ok, {leading, closing}} <- find_valid_delimiter(delimiters, pattern, regex_delimiter), + {result, _} <- Code.eval_string("~r#{leading}#{pattern}#{closing}#{modifier}") do + result + end + end + + def string_to_elixir_types(":" <> atom), do: String.to_atom(atom) + + def string_to_elixir_types(value) do + if module_name?(value) do + String.to_existing_atom("Elixir." <> value) + else + value + end + end + + defp parse_host("localhost"), do: :localhost + + defp parse_host(host) do + charlist = to_charlist(host) + + case :inet.parse_address(charlist) do + {:error, :einval} -> + charlist + + {:ok, ip} -> + ip + end + end + + defp find_valid_delimiter([], _string, _) do + raise(ArgumentError, message: "valid delimiter for Regex expression not found") + end + + defp find_valid_delimiter([{leading, closing} = delimiter | others], pattern, regex_delimiter) + when is_tuple(delimiter) do + if String.contains?(pattern, closing) do + find_valid_delimiter(others, pattern, regex_delimiter) + else + {:ok, {leading, closing}} + end + end + + defp find_valid_delimiter([delimiter | others], pattern, regex_delimiter) do + if String.contains?(pattern, delimiter) do + find_valid_delimiter(others, pattern, regex_delimiter) + else + {:ok, {delimiter, delimiter}} + end + end + + @spec module_name?(String.t()) :: boolean() + def module_name?(string) do + Regex.match?(~r/^(Pleroma|Phoenix|Tesla|Ueberauth|Swoosh)\./, string) or + string in ["Oban", "Ueberauth", "ExSyslogger", "ConcurrentLimiter"] + end +end diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex new file mode 100644 index 0000000..826b9fb --- /dev/null +++ b/lib/pleroma/constants.ex @@ -0,0 +1,116 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Constants do + use Const + + const(as_public, do: "https://www.w3.org/ns/activitystreams#Public") + + const(object_internal_fields, + do: [ + "reactions", + "reaction_count", + "likes", + "like_count", + "announcements", + "announcement_count", + "emoji", + "context_id", + "deleted_activity_id", + "pleroma_internal", + "generator", + "assigned_account", + "rules", + "content_type", + "language", + "participations", + "participation_count", + "participation_request_count", + "location_id" + ] + ) + + const(static_only_files, + do: + ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css) + ) + + const(status_updatable_fields, + do: [ + "source", + "tag", + "updated", + "emoji", + "content", + "summary", + "sensitive", + "attachment", + "generator", + "language", + "startTime", + "endTime", + "location", + "location_id", + "location_provider", + "name" + ] + ) + + const(status_object_types, + do: [ + "Note", + "Question", + "Audio", + "Video", + "Event", + "Article", + "Page" + ] + ) + + const(updatable_object_types, + do: [ + "Note", + "Question", + "Audio", + "Video", + "Event", + "Article", + "Page" + ] + ) + + const(actor_types, + do: [ + "Application", + "Group", + "Organization", + "Person", + "Service" + ] + ) + + # basic regex, just there to weed out potential mistakes + # https://datatracker.ietf.org/doc/html/rfc2045#section-5.1 + const(mime_regex, + do: ~r/^[^[:cntrl:] ()<>@,;:\\"\/\[\]?=]+\/[^[:cntrl:] ()<>@,;:\\"\/\[\]?=]+(; .*)?$/ + ) + + const(upload_object_types, do: ["Document", "Image"]) + + const(activity_json_canonical_mime_type, + do: "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" + ) + + const(activity_json_mime_types, + do: [ + "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", + "application/activity+json" + ] + ) + + const(public_streams, + do: ["public", "public:local", "public:media", "public:local:media"] + ) +end diff --git a/lib/pleroma/conversation.ex b/lib/pleroma/conversation.ex new file mode 100644 index 0000000..42028aa --- /dev/null +++ b/lib/pleroma/conversation.ex @@ -0,0 +1,107 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Conversation do + alias Pleroma.Conversation.Participation + alias Pleroma.Conversation.Participation.RecipientShip + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + use Ecto.Schema + import Ecto.Changeset + + schema "conversations" do + # This is the context ap id. + field(:ap_id, :string) + has_many(:participations, Participation) + has_many(:users, through: [:participations, :user]) + + timestamps() + end + + def creation_cng(struct, params) do + struct + |> cast(params, [:ap_id]) + |> validate_required([:ap_id]) + |> unique_constraint(:ap_id) + end + + def create_for_ap_id(ap_id) do + %__MODULE__{} + |> creation_cng(%{ap_id: ap_id}) + |> Repo.insert( + on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], + returning: true, + conflict_target: :ap_id + ) + end + + def get_for_ap_id(ap_id) do + Repo.get_by(__MODULE__, ap_id: ap_id) + end + + def maybe_create_recipientships(participation, activity) do + participation = Repo.preload(participation, :recipients) + + if Enum.empty?(participation.recipients) do + recipients = User.get_all_by_ap_id(activity.recipients) + RecipientShip.create(recipients, participation) + end + end + + @doc """ + This will + 1. Create a conversation if there isn't one already + 2. Create a participation for all the people involved who don't have one already + 3. Bump all relevant participations to 'unread' + """ + def create_or_bump_for(activity, opts \\ []) do + with true <- Pleroma.Web.ActivityPub.Visibility.is_direct?(activity), + "Create" <- activity.data["type"], + %Object{} = object <- Object.normalize(activity, fetch: false), + true <- object.data["type"] in ["Note", "Question"], + ap_id when is_binary(ap_id) and byte_size(ap_id) > 0 <- object.data["context"], + {:ok, conversation} <- create_for_ap_id(ap_id) do + users = User.get_users_from_set(activity.recipients, local_only: false) + + participations = + Enum.map(users, fn user -> + invisible_conversation = Enum.any?(users, &User.blocks?(user, &1)) + + opts = Keyword.put(opts, :invisible_conversation, invisible_conversation) + + {:ok, participation} = + Participation.create_for_user_and_conversation(user, conversation, opts) + + maybe_create_recipientships(participation, activity) + participation + end) + + {:ok, + %{ + conversation + | participations: participations + }} + else + e -> {:error, e} + end + end + + @doc """ + This is only meant to be run by a mix task. It creates conversations/participations for all direct messages in the database. + """ + def bump_for_all_activities do + stream = + Pleroma.Web.ActivityPub.ActivityPub.fetch_direct_messages_query() + |> Repo.stream() + + Repo.transaction( + fn -> + stream + |> Enum.each(fn a -> create_or_bump_for(a, read: true) end) + end, + timeout: :infinity + ) + end +end diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex new file mode 100644 index 0000000..4ed93e5 --- /dev/null +++ b/lib/pleroma/conversation/participation.ex @@ -0,0 +1,227 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Conversation.Participation do + use Ecto.Schema + alias Pleroma.Conversation + alias Pleroma.Conversation.Participation.RecipientShip + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + import Ecto.Changeset + import Ecto.Query + + schema "conversation_participations" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:conversation, Conversation) + field(:read, :boolean, default: false) + field(:last_activity_id, FlakeId.Ecto.CompatType, virtual: true) + + has_many(:recipient_ships, RecipientShip) + has_many(:recipients, through: [:recipient_ships, :user]) + + timestamps() + end + + def creation_cng(struct, params) do + struct + |> cast(params, [:user_id, :conversation_id, :read]) + |> validate_required([:user_id, :conversation_id]) + end + + def create_for_user_and_conversation(user, conversation, opts \\ []) do + read = !!opts[:read] + invisible_conversation = !!opts[:invisible_conversation] + + update_on_conflict = + if(invisible_conversation, do: [], else: [read: read]) + |> Keyword.put(:updated_at, NaiveDateTime.utc_now()) + + %__MODULE__{} + |> creation_cng(%{ + user_id: user.id, + conversation_id: conversation.id, + read: invisible_conversation || read + }) + |> Repo.insert( + on_conflict: [set: update_on_conflict], + returning: true, + conflict_target: [:user_id, :conversation_id] + ) + end + + def read_cng(struct, params) do + struct + |> cast(params, [:read]) + |> validate_required([:read]) + end + + def mark_as_read(%User{} = user, %Conversation{} = conversation) do + with %__MODULE__{} = participation <- for_user_and_conversation(user, conversation) do + mark_as_read(participation) + end + end + + def mark_as_read(%__MODULE__{} = participation) do + participation + |> change(read: true) + |> Repo.update() + end + + def mark_all_as_read(%User{local: true} = user, %User{} = target_user) do + target_conversation_ids = + __MODULE__ + |> where([p], p.user_id == ^target_user.id) + |> select([p], p.conversation_id) + |> Repo.all() + + __MODULE__ + |> where([p], p.user_id == ^user.id) + |> where([p], p.conversation_id in ^target_conversation_ids) + |> update([p], set: [read: true]) + |> Repo.update_all([]) + + {:ok, user, []} + end + + def mark_all_as_read(%User{} = user, %User{}), do: {:ok, user, []} + + def mark_all_as_read(%User{} = user) do + {_, participations} = + __MODULE__ + |> where([p], p.user_id == ^user.id) + |> where([p], not p.read) + |> update([p], set: [read: true]) + |> select([p], p) + |> Repo.update_all([]) + + {:ok, user, participations} + end + + def mark_as_unread(participation) do + participation + |> read_cng(%{read: false}) + |> Repo.update() + end + + def for_user(user, params \\ %{}) do + from(p in __MODULE__, + where: p.user_id == ^user.id, + order_by: [desc: p.updated_at], + preload: [conversation: [:users]] + ) + |> restrict_recipients(user, params) + |> Pleroma.Pagination.fetch_paginated(params) + end + + def restrict_recipients(query, user, %{recipients: user_ids}) do + user_binary_ids = + [user.id | user_ids] + |> Enum.uniq() + |> User.binary_id() + + conversation_subquery = + __MODULE__ + |> group_by([p], p.conversation_id) + |> having( + [p], + count(p.user_id) == ^length(user_binary_ids) and + fragment("array_agg(?) @> ?", p.user_id, ^user_binary_ids) + ) + |> select([p], %{id: p.conversation_id}) + + query + |> join(:inner, [p], c in subquery(conversation_subquery), on: p.conversation_id == c.id) + end + + def restrict_recipients(query, _, _), do: query + + def for_user_and_conversation(user, conversation) do + from(p in __MODULE__, + where: p.user_id == ^user.id, + where: p.conversation_id == ^conversation.id + ) + |> Repo.one() + end + + def for_user_with_last_activity_id(user, params \\ %{}) do + for_user(user, params) + |> Enum.map(fn participation -> + activity_id = + ActivityPub.fetch_latest_direct_activity_id_for_context( + participation.conversation.ap_id, + %{ + user: user, + blocking_user: user + } + ) + + %{ + participation + | last_activity_id: activity_id + } + end) + |> Enum.reject(&is_nil(&1.last_activity_id)) + end + + def get(_, _ \\ []) + def get(nil, _), do: nil + + def get(id, params) do + query = + if preload = params[:preload] do + from(p in __MODULE__, + preload: ^preload + ) + else + __MODULE__ + end + + Repo.get(query, id) + end + + def set_recipients(participation, user_ids) do + user_ids = + [participation.user_id | user_ids] + |> Enum.uniq() + + Repo.transaction(fn -> + query = + from(r in RecipientShip, + where: r.participation_id == ^participation.id + ) + + Repo.delete_all(query) + + users = + from(u in User, + where: u.id in ^user_ids + ) + |> Repo.all() + + RecipientShip.create(users, participation) + :ok + end) + + {:ok, Repo.preload(participation, :recipients, force: true)} + end + + @spec unread_count(User.t()) :: integer() + def unread_count(%User{id: user_id}) do + from(q in __MODULE__, where: q.user_id == ^user_id and q.read == false) + |> Repo.aggregate(:count, :id) + end + + def unread_conversation_count_for_user(user) do + from(p in __MODULE__, + where: p.user_id == ^user.id, + where: not p.read, + select: %{count: count(p.id)} + ) + end + + def delete(%__MODULE__{} = participation) do + Repo.delete(participation) + end +end diff --git a/lib/pleroma/conversation/participation/recipient_ship.ex b/lib/pleroma/conversation/participation/recipient_ship.ex new file mode 100644 index 0000000..d9a0fdf --- /dev/null +++ b/lib/pleroma/conversation/participation/recipient_ship.ex @@ -0,0 +1,34 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Conversation.Participation.RecipientShip do + use Ecto.Schema + + alias Pleroma.Conversation.Participation + alias Pleroma.Repo + alias Pleroma.User + + import Ecto.Changeset + + schema "conversation_participation_recipient_ships" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:participation, Participation) + end + + def creation_cng(struct, params) do + struct + |> cast(params, [:user_id, :participation_id]) + |> validate_required([:user_id, :participation_id]) + end + + def create(%User{} = user, participation), do: create([user], participation) + + def create(users, participation) do + Enum.each(users, fn user -> + %__MODULE__{} + |> creation_cng(%{user_id: user.id, participation_id: participation.id}) + |> Repo.insert!() + end) + end +end diff --git a/lib/pleroma/counter_cache.ex b/lib/pleroma/counter_cache.ex new file mode 100644 index 0000000..d2b14bd --- /dev/null +++ b/lib/pleroma/counter_cache.ex @@ -0,0 +1,79 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.CounterCache do + alias Pleroma.CounterCache + alias Pleroma.Repo + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + + schema "counter_cache" do + field(:instance, :string) + field(:public, :integer) + field(:unlisted, :integer) + field(:private, :integer) + field(:direct, :integer) + end + + def changeset(struct, params) do + struct + |> cast(params, [:instance, :public, :unlisted, :private, :direct]) + |> validate_required([:instance]) + |> unique_constraint(:instance) + end + + def get_by_instance(instance) do + CounterCache + |> select([c], %{ + "public" => c.public, + "unlisted" => c.unlisted, + "private" => c.private, + "direct" => c.direct + }) + |> where([c], c.instance == ^instance) + |> Repo.one() + |> case do + nil -> %{"public" => 0, "unlisted" => 0, "private" => 0, "direct" => 0} + val -> val + end + end + + def get_sum do + CounterCache + |> select([c], %{ + "public" => type(sum(c.public), :integer), + "unlisted" => type(sum(c.unlisted), :integer), + "private" => type(sum(c.private), :integer), + "direct" => type(sum(c.direct), :integer) + }) + |> Repo.one() + end + + def set(instance, values) do + params = + Enum.reduce( + ["public", "private", "unlisted", "direct"], + %{"instance" => instance}, + fn param, acc -> + Map.put_new(acc, param, Map.get(values, param, 0)) + end + ) + + %CounterCache{} + |> changeset(params) + |> Repo.insert( + on_conflict: [ + set: [ + public: params["public"], + private: params["private"], + unlisted: params["unlisted"], + direct: params["direct"] + ] + ], + returning: true, + conflict_target: :instance + ) + end +end diff --git a/lib/pleroma/data_migration.ex b/lib/pleroma/data_migration.ex new file mode 100644 index 0000000..8451678 --- /dev/null +++ b/lib/pleroma/data_migration.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.DataMigration do + use Ecto.Schema + + alias Pleroma.DataMigration + alias Pleroma.DataMigration.State + alias Pleroma.Repo + + import Ecto.Changeset + import Ecto.Query + + schema "data_migrations" do + field(:name, :string) + field(:state, State, default: :pending) + field(:feature_lock, :boolean, default: false) + field(:params, :map, default: %{}) + field(:data, :map, default: %{}) + + timestamps() + end + + def changeset(data_migration, params \\ %{}) do + data_migration + |> cast(params, [:name, :state, :feature_lock, :params, :data]) + |> validate_required([:name]) + |> unique_constraint(:name) + end + + def update_one_by_id(id, params \\ %{}) do + with {1, _} <- + from(dm in DataMigration, where: dm.id == ^id) + |> Repo.update_all(set: params) do + :ok + end + end + + def get_by_name(name) do + Repo.get_by(DataMigration, name: name) + end + + def populate_hashtags_table, do: get_by_name("populate_hashtags_table") + def delete_context_objects, do: get_by_name("delete_context_objects") +end diff --git a/lib/pleroma/delivery.ex b/lib/pleroma/delivery.ex new file mode 100644 index 0000000..5d1853b --- /dev/null +++ b/lib/pleroma/delivery.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Delivery do + use Ecto.Schema + + alias Pleroma.Delivery + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + + import Ecto.Changeset + import Ecto.Query + + schema "deliveries" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:object, Object) + end + + def changeset(delivery, params \\ %{}) do + delivery + |> cast(params, [:user_id, :object_id]) + |> validate_required([:user_id, :object_id]) + |> foreign_key_constraint(:object_id) + |> foreign_key_constraint(:user_id) + |> unique_constraint(:user_id, name: :deliveries_user_id_object_id_index) + end + + def create(object_id, user_id) do + %Delivery{} + |> changeset(%{user_id: user_id, object_id: object_id}) + |> Repo.insert(on_conflict: :nothing) + end + + def get(object_id, user_id) do + from(d in Delivery, where: d.user_id == ^user_id and d.object_id == ^object_id) + |> Repo.one() + end + + # A hack because user delete activities have a fake id for whatever reason + # TODO: Get rid of this + def delete_all_by_object_id("pleroma:fake_object_id"), do: {0, []} + + def delete_all_by_object_id(object_id) do + from(d in Delivery, where: d.object_id == ^object_id) + |> Repo.delete_all() + end +end diff --git a/lib/pleroma/docs/generator.ex b/lib/pleroma/docs/generator.ex new file mode 100644 index 0000000..6508f19 --- /dev/null +++ b/lib/pleroma/docs/generator.ex @@ -0,0 +1,136 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Docs.Generator do + @callback process(keyword()) :: {:ok, String.t()} + + @spec process(module(), keyword()) :: {:ok, String.t()} + def process(implementation, descriptions) do + implementation.process(descriptions) + end + + @spec list_behaviour_implementations(behaviour :: module()) :: [module()] + def list_behaviour_implementations(behaviour) do + :code.all_loaded() + |> Enum.filter(fn {module, _} -> + # This shouldn't be needed as all modules are expected to have module_info/1, + # but in test enviroments some transient modules `:elixir_compiler_XX` + # are loaded for some reason (where XX is a random integer). + if function_exported?(module, :module_info, 1) do + module.module_info(:attributes) + |> Keyword.get_values(:behaviour) + |> List.flatten() + |> Enum.member?(behaviour) + end + end) + |> Enum.map(fn {module, _} -> module end) + end + + @doc """ + Converts: + - atoms to strings with leading `:` + - module names to strings, without leading `Elixir.` + - add humanized labels to `keys` if label is not defined, e.g. `:instance` -> `Instance` + """ + @spec convert_to_strings([map()]) :: [map()] + def convert_to_strings(descriptions) do + Enum.map(descriptions, &format_entity(&1)) + end + + defp format_entity(entity) do + entity + |> format_key() + |> Map.put(:group, atom_to_string(entity[:group])) + |> format_children() + end + + defp format_key(%{key: key} = entity) do + entity + |> Map.put(:key, atom_to_string(key)) + |> Map.put(:label, entity[:label] || humanize(key)) + end + + defp format_key(%{group: group} = entity) do + Map.put(entity, :label, entity[:label] || humanize(group)) + end + + defp format_key(entity), do: entity + + defp format_children(%{children: children} = entity) do + Map.put(entity, :children, Enum.map(children, &format_child(&1))) + end + + defp format_children(entity), do: entity + + defp format_child(%{suggestions: suggestions} = entity) do + entity + |> Map.put(:suggestions, format_suggestions(suggestions)) + |> format_key() + |> format_group() + |> format_children() + end + + defp format_child(entity) do + entity + |> format_key() + |> format_group() + |> format_children() + end + + defp format_group(%{group: group} = entity) do + Map.put(entity, :group, format_suggestion(group)) + end + + defp format_group(entity), do: entity + + defp atom_to_string(entity) when is_binary(entity), do: entity + + defp atom_to_string(entity) when is_atom(entity), do: inspect(entity) + + defp humanize(entity) do + string = inspect(entity) + + if String.starts_with?(string, ":"), + do: Phoenix.Naming.humanize(entity), + else: string + end + + defp format_suggestions({:list_behaviour_implementations, behaviour}) do + behaviour + |> list_behaviour_implementations() + |> format_suggestions() + end + + defp format_suggestions([]), do: [] + + defp format_suggestions([suggestion | tail]) do + [format_suggestion(suggestion) | format_suggestions(tail)] + end + + defp format_suggestion(entity) when is_atom(entity) do + atom_to_string(entity) + end + + defp format_suggestion([head | tail] = entity) when is_list(entity) do + [format_suggestion(head) | format_suggestions(tail)] + end + + defp format_suggestion(entity) when is_tuple(entity) do + format_suggestions(Tuple.to_list(entity)) |> List.to_tuple() + end + + defp format_suggestion(entity), do: entity +end + +defimpl Jason.Encoder, for: Tuple do + def encode(tuple, opts), do: Jason.Encode.list(Tuple.to_list(tuple), opts) +end + +defimpl Jason.Encoder, for: [Regex, Function] do + def encode(term, opts), do: Jason.Encode.string(inspect(term), opts) +end + +defimpl String.Chars, for: Regex do + def to_string(term), do: inspect(term) +end diff --git a/lib/pleroma/docs/json.ex b/lib/pleroma/docs/json.ex new file mode 100644 index 0000000..05f46f3 --- /dev/null +++ b/lib/pleroma/docs/json.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Docs.JSON do + @behaviour Pleroma.Docs.Generator + @external_resource "config/description.exs" + @raw_config Pleroma.Config.Loader.read("config/description.exs") + @raw_descriptions @raw_config[:pleroma][:config_description] + @term __MODULE__.Compiled + + @spec compile :: :ok + def compile do + descriptions = + Pleroma.Web.ActivityPub.MRF.config_descriptions() + |> Enum.reduce(@raw_descriptions, fn description, acc -> [description | acc] end) + + :persistent_term.put(@term, Pleroma.Docs.Generator.convert_to_strings(descriptions)) + end + + @spec compiled_descriptions :: Map.t() + def compiled_descriptions do + :persistent_term.get(@term) + end + + @spec process(keyword()) :: {:ok, String.t()} + def process(descriptions) do + with path <- "docs/generated_config.json", + {:ok, file} <- File.open(path, [:write, :utf8]), + formatted_descriptions <- + Pleroma.Docs.Generator.convert_to_strings(descriptions), + json <- Jason.encode!(formatted_descriptions), + :ok <- IO.write(file, json), + :ok <- File.close(file) do + {:ok, path} + end + end +end diff --git a/lib/pleroma/docs/markdown.ex b/lib/pleroma/docs/markdown.ex new file mode 100644 index 0000000..949388e --- /dev/null +++ b/lib/pleroma/docs/markdown.ex @@ -0,0 +1,97 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Docs.Markdown do + @behaviour Pleroma.Docs.Generator + + @spec process(keyword()) :: {:ok, String.t()} + def process(descriptions) do + config_path = "docs/generated_config.md" + {:ok, file} = File.open(config_path, [:utf8, :write]) + IO.write(file, "# Generated configuration\n") + IO.write(file, "Date of generation: #{Date.utc_today()}\n\n") + + IO.write( + file, + "This file describe the configuration, it is recommended to edit the relevant `*.secret.exs` file instead of the others founds in the ``config`` directory.\n\n" <> + "If you run Pleroma with ``MIX_ENV=prod`` the file is ``prod.secret.exs``, otherwise it is ``dev.secret.exs``.\n\n" + ) + + for group <- descriptions do + if is_nil(group[:key]) do + IO.write(file, "## #{inspect(group[:group])}\n") + else + IO.write(file, "## #{inspect(group[:key])}\n") + end + + IO.write(file, "#{group[:description]}\n") + + for child <- group[:children] || [] do + print_child_header(file, child) + + print_suggestions(file, child[:suggestions]) + + if child[:children] do + for subchild <- child[:children] do + print_child_header(file, subchild) + + print_suggestions(file, subchild[:suggestions]) + end + end + end + + IO.write(file, "\n") + end + + :ok = File.close(file) + {:ok, config_path} + end + + defp print_child_header(file, %{key: key, type: type, description: description} = _child) do + IO.write( + file, + "- `#{inspect(key)}` (`#{inspect(type)}`): #{description} \n" + ) + end + + defp print_child_header(file, %{key: key, type: type} = _child) do + IO.write(file, "- `#{inspect(key)}` (`#{inspect(type)}`) \n") + end + + defp print_suggestion(file, suggestion) when is_list(suggestion) do + IO.write(file, " `#{inspect(suggestion)}`\n") + end + + defp print_suggestion(file, suggestion) when is_function(suggestion) do + IO.write(file, " `#{inspect(suggestion.())}`\n") + end + + defp print_suggestion(file, suggestion, as_list \\ false) do + list_mark = if as_list, do: "- ", else: "" + IO.write(file, " #{list_mark}`#{inspect(suggestion)}`\n") + end + + defp print_suggestions(file, {:list_behaviour_implementations, behaviour}) do + suggestions = Pleroma.Docs.Generator.list_behaviour_implementations(behaviour) + print_suggestions(file, suggestions) + end + + defp print_suggestions(_file, nil), do: nil + + defp print_suggestions(_file, ""), do: nil + + defp print_suggestions(file, suggestions) do + if length(suggestions) > 1 do + IO.write(file, "Suggestions:\n") + + for suggestion <- suggestions do + print_suggestion(file, suggestion, true) + end + else + IO.write(file, " Suggestion: ") + + print_suggestion(file, List.first(suggestions)) + end + end +end diff --git a/lib/pleroma/docs/translator.ex b/lib/pleroma/docs/translator.ex new file mode 100644 index 0000000..13e33c8 --- /dev/null +++ b/lib/pleroma/docs/translator.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Docs.Translator do + require Pleroma.Docs.Translator.Compiler + require Pleroma.Web.Gettext + + @before_compile Pleroma.Docs.Translator.Compiler +end diff --git a/lib/pleroma/docs/translator/compiler.ex b/lib/pleroma/docs/translator/compiler.ex new file mode 100644 index 0000000..5d27d9f --- /dev/null +++ b/lib/pleroma/docs/translator/compiler.ex @@ -0,0 +1,119 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Docs.Translator.Compiler do + @external_resource "config/description.exs" + @raw_config Pleroma.Config.Loader.read("config/description.exs") + @raw_descriptions @raw_config[:pleroma][:config_description] + + defmacro __before_compile__(_env) do + strings = + __MODULE__.descriptions() + |> __MODULE__.extract_strings() + + quote do + def placeholder do + unquote do + Enum.map( + strings, + fn {path, type, string} -> + ctxt = msgctxt_for(path, type) + + quote do + Pleroma.Web.Gettext.dpgettext_noop( + "config_descriptions", + unquote(ctxt), + unquote(string) + ) + end + end + ) + end + end + end + end + + def descriptions do + Pleroma.Web.ActivityPub.MRF.config_descriptions() + |> Enum.reduce(@raw_descriptions, fn description, acc -> [description | acc] end) + |> Pleroma.Docs.Generator.convert_to_strings() + end + + def extract_strings(descriptions) do + descriptions + |> Enum.reduce(%{strings: [], path: []}, &process_item/2) + |> Map.get(:strings) + end + + defp process_item(entity, acc) do + current_level = + acc + |> process_desc(entity) + |> process_label(entity) + + process_children(entity, current_level) + end + + defp process_desc(acc, %{description: desc} = item) do + %{ + strings: [{acc.path ++ [key_for(item)], "description", desc} | acc.strings], + path: acc.path + } + end + + defp process_desc(acc, _) do + acc + end + + defp process_label(acc, %{label: label} = item) do + %{ + strings: [{acc.path ++ [key_for(item)], "label", label} | acc.strings], + path: acc.path + } + end + + defp process_label(acc, _) do + acc + end + + defp process_children(%{children: children} = item, acc) do + current_level = Map.put(acc, :path, acc.path ++ [key_for(item)]) + + children + |> Enum.reduce(current_level, &process_item/2) + |> Map.put(:path, acc.path) + end + + defp process_children(_, acc) do + acc + end + + def msgctxt_for(path, type) do + "config #{type} at #{Enum.join(path, " > ")}" + end + + defp convert_group({_, group}) do + group + end + + defp convert_group(group) do + group + end + + def key_for(%{group: group, key: key}) do + "#{convert_group(group)}-#{key}" + end + + def key_for(%{group: group}) do + convert_group(group) + end + + def key_for(%{key: key}) do + key + end + + def key_for(_) do + nil + end +end diff --git a/lib/pleroma/ecto_enums.ex b/lib/pleroma/ecto_enums.ex new file mode 100644 index 0000000..b346b39 --- /dev/null +++ b/lib/pleroma/ecto_enums.ex @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +import EctoEnum + +defenum(Pleroma.UserRelationship.Type, + block: 1, + mute: 2, + reblog_mute: 3, + notification_mute: 4, + inverse_subscription: 5, + suggestion_dismiss: 6, + endorsement: 7 +) + +defenum(Pleroma.FollowingRelationship.State, + follow_pending: 1, + follow_accept: 2, + follow_reject: 3 +) + +defenum(Pleroma.DataMigration.State, + pending: 1, + running: 2, + complete: 3, + failed: 4, + manual: 5 +) + +defenum(Pleroma.User.Backup.State, + pending: 1, + running: 2, + complete: 3, + failed: 4, + invalid: 5 +) diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/bare_uri.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/bare_uri.ex new file mode 100644 index 0000000..1038296 --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/bare_uri.ex @@ -0,0 +1,23 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.BareUri do + use Ecto.Type + + def type, do: :string + + def cast(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{scheme: nil} -> :error + %URI{} -> {:ok, uri} + _ -> :error + end + end + + def cast(_), do: :error + + def dump(data), do: {:ok, data} + + def load(data), do: {:ok, data} +end diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/date_time.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/date_time.ex new file mode 100644 index 0000000..b0258e8 --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/date_time.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.DateTime do + @moduledoc """ + The AP standard defines the date fields in AP as xsd:DateTime. Elixir's + DateTime can't parse this, but it can parse the related iso8601. This + module punches the date until it looks like iso8601 and normalizes to + it. + + DateTimes without a timezone offset are treated as UTC. + + Reference: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-published + """ + use Ecto.Type + + def type, do: :string + + def cast(datetime) when is_binary(datetime) do + with {:ok, datetime, _} <- DateTime.from_iso8601(datetime) do + {:ok, DateTime.to_iso8601(datetime)} + else + {:error, :missing_offset} -> cast("#{datetime}Z") + _e -> :error + end + end + + def cast(_), do: :error + + def dump(data) do + {:ok, data} + end + + def load(data) do + {:ok, data} + end +end diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/emoji.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/emoji.ex new file mode 100644 index 0000000..e0e4449 --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/emoji.ex @@ -0,0 +1,34 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.Emoji do + use Ecto.Type + + def type, do: :map + + def cast(data) when is_map(data) do + has_invalid_emoji? = + Enum.find(data, fn + {name, uri} when is_binary(name) and is_binary(uri) -> + # based on ObjectValidators.Uri.cast() + case URI.parse(uri) do + %URI{host: nil} -> true + %URI{host: ""} -> true + %URI{scheme: scheme} when scheme in ["https", "http"] -> false + _ -> true + end + + {_name, _uri} -> + true + end) + + if has_invalid_emoji?, do: :error, else: {:ok, data} + end + + def cast(_data), do: :error + + def dump(data), do: {:ok, data} + + def load(data), do: {:ok, data} +end diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/mime.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/mime.ex new file mode 100644 index 0000000..31d5157 --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/mime.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.MIME do + use Ecto.Type + + require Pleroma.Constants + + def type, do: :string + + def cast(mime) when is_binary(mime) do + if mime =~ Pleroma.Constants.mime_regex() do + {:ok, mime} + else + {:ok, "application/octet-stream"} + end + end + + def cast(_), do: :error + + def dump(data), do: {:ok, data} + + def load(data), do: {:ok, data} +end diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/object_id.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/object_id.ex new file mode 100644 index 0000000..663dc0d --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/object_id.ex @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.ObjectID do + use Ecto.Type + + def type, do: :string + + def cast(object) when is_binary(object) do + # Host has to be present and scheme has to be an http scheme (for now) + case URI.parse(object) do + %URI{host: nil} -> :error + %URI{host: ""} -> :error + %URI{scheme: scheme} when scheme in ["https", "http"] -> {:ok, object} + _ -> :error + end + end + + def cast(%{"id" => object}), do: cast(object) + + def cast(_), do: :error + + def dump(data), do: {:ok, data} + + def load(data), do: {:ok, data} +end diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex new file mode 100644 index 0000000..447d536 --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex @@ -0,0 +1,52 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.Recipients do + use Ecto.Type + + alias Pleroma.EctoType.ActivityPub.ObjectValidators.ObjectID + + def type, do: {:array, ObjectID} + + def cast(object) when is_binary(object) do + cast([object]) + end + + def cast(object) when is_map(object) do + case ObjectID.cast(object) do + {:ok, data} -> {:ok, [data]} + _ -> :error + end + end + + def cast(data) when is_list(data) do + data = + data + |> Enum.reduce_while([], fn element, list -> + case ObjectID.cast(element) do + {:ok, id} -> + {:cont, [id | list]} + + _ -> + {:cont, list} + end + end) + |> Enum.sort() + |> Enum.uniq() + + {:ok, data} + end + + def cast(data) do + {:error, data} + end + + def dump(data) do + {:ok, data} + end + + def load(data) do + {:ok, data} + end +end diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/safe_text.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/safe_text.ex new file mode 100644 index 0000000..95bd3ba --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/safe_text.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.SafeText do + use Ecto.Type + + alias Pleroma.HTML + + def type, do: :string + + def cast(str) when is_binary(str) do + {:ok, HTML.filter_tags(str)} + end + + def cast(_), do: :error + + def dump(data) do + {:ok, data} + end + + def load(data) do + {:ok, data} + end +end diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/uri.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/uri.ex new file mode 100644 index 0000000..b8e5c9d --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/uri.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.Uri do + use Ecto.Type + + def type, do: :string + + def cast(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{host: nil} -> :error + %URI{host: ""} -> :error + %URI{scheme: scheme} when scheme in ["https", "http"] -> {:ok, uri} + _ -> :error + end + end + + def cast(_), do: :error + + def dump(data), do: {:ok, data} + + def load(data), do: {:ok, data} +end diff --git a/lib/pleroma/ecto_type/config/atom.ex b/lib/pleroma/ecto_type/config/atom.ex new file mode 100644 index 0000000..c44d655 --- /dev/null +++ b/lib/pleroma/ecto_type/config/atom.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.Config.Atom do + use Ecto.Type + + def type, do: :atom + + def cast(key) when is_atom(key) do + {:ok, key} + end + + def cast(key) when is_binary(key) do + {:ok, Pleroma.ConfigDB.string_to_elixir_types(key)} + end + + def cast(_), do: :error + + def load(key) do + {:ok, Pleroma.ConfigDB.string_to_elixir_types(key)} + end + + def dump(key) when is_atom(key), do: {:ok, inspect(key)} + def dump(_), do: :error +end diff --git a/lib/pleroma/ecto_type/config/binary_value.ex b/lib/pleroma/ecto_type/config/binary_value.ex new file mode 100644 index 0000000..4aad0cf --- /dev/null +++ b/lib/pleroma/ecto_type/config/binary_value.ex @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.Config.BinaryValue do + use Ecto.Type + + def type, do: :term + + def cast(value) when is_binary(value) do + if String.valid?(value) do + {:ok, value} + else + {:ok, :erlang.binary_to_term(value)} + end + end + + def cast(value), do: {:ok, value} + + def load(value) when is_binary(value) do + {:ok, :erlang.binary_to_term(value)} + end + + def dump(value) do + {:ok, :erlang.term_to_binary(value)} + end +end diff --git a/lib/pleroma/ecto_type/string_id.ex b/lib/pleroma/ecto_type/string_id.ex new file mode 100644 index 0000000..a3df5c0 --- /dev/null +++ b/lib/pleroma/ecto_type/string_id.ex @@ -0,0 +1,23 @@ +defmodule Pleroma.EctoType.StringId do + @moduledoc """ + Stores the value as a number in the database, but represents it as a string. + """ + use Ecto.Type + + def type, do: :id + + def cast(value) do + {:ok, to_string(value)} + end + + def load(value) do + {:ok, to_string(value)} + end + + def dump(value) do + case Integer.parse(value) do + {n, _} -> {:ok, n} + _ -> {:error, value} + end + end +end diff --git a/lib/pleroma/emails/admin_email.ex b/lib/pleroma/emails/admin_email.ex new file mode 100644 index 0000000..372e552 --- /dev/null +++ b/lib/pleroma/emails/admin_email.ex @@ -0,0 +1,99 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emails.AdminEmail do + @moduledoc "Admin emails" + + import Swoosh.Email + + alias Pleroma.Config + alias Pleroma.HTML + alias Pleroma.Web.Router.Helpers + + defp instance_config, do: Config.get(:instance) + defp instance_name, do: instance_config()[:name] + + defp instance_notify_email do + Keyword.get(instance_config(), :notify_email, instance_config()[:email]) + end + + def test_email(mail_to \\ nil) do + html_body = """ +

Instance Test Email

+

A test email was requested. Hello. :)

+ """ + + new() + |> to(mail_to || Config.get([:instance, :email])) + |> from({instance_name(), instance_notify_email()}) + |> subject("Instance Test Email") + |> html_body(html_body) + end + + def report(to, reporter, account, statuses, comment) do + comment_html = + if comment do + "

Comment: #{comment}" + else + "" + end + + statuses_html = + if is_list(statuses) && length(statuses) > 0 do + statuses_list_html = + statuses + |> Enum.map(fn + %{id: id} -> + status_url = Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, id) + "

  • #{status_url}
  • " + + %{"id" => id} when is_binary(id) -> + "
  • #{id}
  • " + + id when is_binary(id) -> + "
  • #{id}
  • " + end) + |> Enum.join("\n") + + """ +

    Statuses: +

      + #{statuses_list_html} +
    +

    + """ + else + "" + end + + html_body = """ +

    Reported by: #{reporter.nickname}

    +

    Reported Account: #{account.nickname}

    + #{comment_html} + #{statuses_html} +

    + View Reports in AdminFE + """ + + new() + |> to({to.name, to.email}) + |> from({instance_name(), instance_notify_email()}) + |> subject("#{instance_name()} Report") + |> html_body(html_body) + end + + def new_unapproved_registration(to, account) do + html_body = """ +

    New account for review: @#{account.nickname}

    +
    #{HTML.strip_tags(account.registration_reason)}
    + Visit AdminFE + """ + + new() + |> to({to.name, to.email}) + |> from({instance_name(), instance_notify_email()}) + |> subject("New account up for review on #{instance_name()} (@#{account.nickname})") + |> html_body(html_body) + end +end diff --git a/lib/pleroma/emails/mailer.ex b/lib/pleroma/emails/mailer.ex new file mode 100644 index 0000000..1014421 --- /dev/null +++ b/lib/pleroma/emails/mailer.ex @@ -0,0 +1,71 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emails.Mailer do + @moduledoc """ + Defines the Pleroma mailer. + + The module contains functions to delivery email using Swoosh.Mailer. + """ + + alias Pleroma.Workers.MailerWorker + alias Swoosh.DeliveryError + + @otp_app :pleroma + @mailer_config [otp: :pleroma] + + @spec enabled?() :: boolean() + def enabled?, do: Pleroma.Config.get([__MODULE__, :enabled]) + + @doc "add email to queue" + def deliver_async(email, config \\ []) do + encoded_email = + email + |> :erlang.term_to_binary() + |> Base.encode64() + + MailerWorker.enqueue("email", %{"encoded_email" => encoded_email, "config" => config}) + end + + @doc "callback to perform send email from queue" + def perform(:deliver_async, email, config), do: deliver(email, config) + + @spec deliver(Swoosh.Email.t(), Keyword.t()) :: {:ok, term} | {:error, term} + def deliver(email, config \\ []) + + def deliver(email, config) do + # temporary hackney fix until hackney max_connections bug is fixed + # https://git.pleroma.social/pleroma/pleroma/-/issues/2101 + email = + Swoosh.Email.put_private(email, :hackney_options, ssl_options: [versions: [:"tlsv1.2"]]) + + case enabled?() do + true -> Swoosh.Mailer.deliver(email, parse_config(config)) + false -> {:error, :deliveries_disabled} + end + end + + @spec deliver!(Swoosh.Email.t(), Keyword.t()) :: term | no_return + def deliver!(email, config \\ []) + + def deliver!(email, config) do + case deliver(email, config) do + {:ok, result} -> result + {:error, reason} -> raise DeliveryError, reason: reason + end + end + + @on_load :validate_dependency + + @doc false + def validate_dependency do + parse_config([]) + |> Keyword.get(:adapter) + |> Swoosh.Mailer.validate_dependency() + end + + defp parse_config(config) do + Swoosh.Mailer.parse_config(@otp_app, __MODULE__, @mailer_config, config) + end +end diff --git a/lib/pleroma/emails/new_users_digest_email.ex b/lib/pleroma/emails/new_users_digest_email.ex new file mode 100644 index 0000000..a9e57c7 --- /dev/null +++ b/lib/pleroma/emails/new_users_digest_email.ex @@ -0,0 +1,34 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emails.NewUsersDigestEmail do + use Phoenix.Swoosh, view: Pleroma.Web.EmailView, layout: {Pleroma.Web.LayoutView, :email_styled} + + defp instance_notify_email do + Pleroma.Config.get([:instance, :notify_email]) || Pleroma.Config.get([:instance, :email]) + end + + def new_users(to, users_and_statuses) do + instance_name = Pleroma.Config.get([:instance, :name]) + styling = Pleroma.Config.get([Pleroma.Emails.UserEmail, :styling]) + + logo_url = + Pleroma.Helpers.UriHelper.maybe_add_base( + Pleroma.Config.get([:frontend_configurations, :pleroma_fe, :logo]), + Pleroma.Web.Endpoint.url() + ) + + new() + |> to({to.name, to.email}) + |> from({instance_name, instance_notify_email()}) + |> subject("#{instance_name} New Users") + |> render_body("new_users_digest.html", %{ + title: "New Users", + users_and_statuses: users_and_statuses, + instance: instance_name, + styling: styling, + logo_url: logo_url + }) + end +end diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex new file mode 100644 index 0000000..95b9637 --- /dev/null +++ b/lib/pleroma/emails/user_email.ex @@ -0,0 +1,393 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emails.UserEmail do + @moduledoc "User emails" + + require Pleroma.Web.Gettext + + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Gettext + alias Pleroma.Web.Router + + import Swoosh.Email + import Phoenix.Swoosh, except: [render_body: 3] + import Pleroma.Config.Helpers, only: [instance_name: 0, sender: 0] + + def render_body(email, template, assigns \\ %{}) do + email + |> put_new_layout({Pleroma.Web.LayoutView, :email}) + |> put_new_view(Pleroma.Web.EmailView) + |> Phoenix.Swoosh.render_body(template, assigns) + end + + defp recipient(email, nil), do: email + defp recipient(email, name), do: {name, email} + defp recipient(%User{} = user), do: recipient(user.email, user.name) + + @spec welcome(User.t(), map()) :: Swoosh.Email.t() + def welcome(user, opts \\ %{}) do + Gettext.with_locale_or_default user.language do + new() + |> to(recipient(user)) + |> from(Map.get(opts, :sender, sender())) + |> subject( + Map.get( + opts, + :subject, + Gettext.dpgettext( + "static_pages", + "welcome email subject", + "Welcome to %{instance_name}!", + instance_name: instance_name() + ) + ) + ) + |> html_body( + Map.get( + opts, + :html, + Gettext.dpgettext( + "static_pages", + "welcome email html body", + "Welcome to %{instance_name}!", + instance_name: instance_name() + ) + ) + ) + |> text_body( + Map.get( + opts, + :text, + Gettext.dpgettext( + "static_pages", + "welcome email text body", + "Welcome to %{instance_name}!", + instance_name: instance_name() + ) + ) + ) + end + end + + def password_reset_email(user, token) when is_binary(token) do + Gettext.with_locale_or_default user.language do + password_reset_url = Router.Helpers.reset_password_url(Endpoint, :reset, token) + + html_body = + Gettext.dpgettext( + "static_pages", + "password reset email body", + """ +

    Reset your password at %{instance_name}

    +

    Someone has requested password change for your account at %{instance_name}.

    +

    If it was you, visit the following link to proceed: reset password.

    +

    If it was someone else, nothing to worry about: your data is secure and your password has not been changed.

    + """, + instance_name: instance_name(), + password_reset_url: password_reset_url + ) + + new() + |> to(recipient(user)) + |> from(sender()) + |> subject( + Gettext.dpgettext("static_pages", "password reset email subject", "Password reset") + ) + |> html_body(html_body) + end + end + + def user_invitation_email( + user, + %Pleroma.UserInviteToken{} = user_invite_token, + to_email, + to_name \\ nil + ) do + Gettext.with_locale_or_default user.language do + registration_url = + Router.Helpers.redirect_url( + Endpoint, + :registration_page, + user_invite_token.token + ) + + html_body = + Gettext.dpgettext( + "static_pages", + "user invitation email body", + """ +

    You are invited to %{instance_name}

    +

    %{inviter_name} invites you to join %{instance_name}, an instance of Pleroma federated social networking platform.

    +

    Click the following link to register: accept invitation.

    + """, + instance_name: instance_name(), + inviter_name: user.name, + registration_url: registration_url + ) + + new() + |> to(recipient(to_email, to_name)) + |> from(sender()) + |> subject( + Gettext.dpgettext( + "static_pages", + "user invitation email subject", + "Invitation to %{instance_name}", + instance_name: instance_name() + ) + ) + |> html_body(html_body) + end + end + + def account_confirmation_email(user) do + Gettext.with_locale_or_default user.language do + confirmation_url = + Router.Helpers.confirm_email_url( + Endpoint, + :confirm_email, + user.id, + to_string(user.confirmation_token) + ) + + html_body = + Gettext.dpgettext( + "static_pages", + "confirmation email body", + """ +

    Thank you for registering on %{instance_name}

    +

    Email confirmation is required to activate the account.

    +

    Please click the following link to activate your account.

    + """, + instance_name: instance_name(), + confirmation_url: confirmation_url + ) + + new() + |> to(recipient(user)) + |> from(sender()) + |> subject( + Gettext.dpgettext( + "static_pages", + "confirmation email subject", + "%{instance_name} account confirmation", + instance_name: instance_name() + ) + ) + |> html_body(html_body) + end + end + + def approval_pending_email(user) do + Gettext.with_locale_or_default user.language do + html_body = + Gettext.dpgettext( + "static_pages", + "approval pending email body", + """ +

    Awaiting Approval

    +

    Your account at %{instance_name} is being reviewed by staff. You will receive another email once your account is approved.

    + """, + instance_name: instance_name() + ) + + new() + |> to(recipient(user)) + |> from(sender()) + |> subject( + Gettext.dpgettext( + "static_pages", + "approval pending email subject", + "Your account is awaiting approval" + ) + ) + |> html_body(html_body) + end + end + + def successful_registration_email(user) do + Gettext.with_locale_or_default user.language do + html_body = + Gettext.dpgettext( + "static_pages", + "successful registration email body", + """ +

    Hello @%{nickname},

    +

    Your account at %{instance_name} has been registered successfully.

    +

    No further action is required to activate your account.

    + """, + nickname: user.nickname, + instance_name: instance_name() + ) + + new() + |> to(recipient(user)) + |> from(sender()) + |> subject( + Gettext.dpgettext( + "static_pages", + "successful registration email subject", + "Account registered on %{instance_name}", + instance_name: instance_name() + ) + ) + |> html_body(html_body) + end + end + + @doc """ + Email used in digest email notifications + Includes Mentions and New Followers data + If there are no mentions (even when new followers exist), the function will return nil + """ + @spec digest_email(User.t()) :: Swoosh.Email.t() | nil + def digest_email(user) do + Gettext.with_locale_or_default user.language do + notifications = Pleroma.Notification.for_user_since(user, user.last_digest_emailed_at) + + mentions = + notifications + |> Enum.filter(&(&1.activity.data["type"] == "Create")) + |> Enum.map(fn notification -> + object = Pleroma.Object.normalize(notification.activity, fetch: false) + + if not is_nil(object) do + object = update_in(object.data["content"], &format_links/1) + + %{ + data: notification, + object: object, + from: User.get_by_ap_id(notification.activity.actor) + } + end + end) + |> Enum.filter(& &1) + + followers = + notifications + |> Enum.filter(&(&1.activity.data["type"] == "Follow")) + |> Enum.map(fn notification -> + from = User.get_by_ap_id(notification.activity.actor) + + if not is_nil(from) do + %{ + data: notification, + object: Pleroma.Object.normalize(notification.activity, fetch: false), + from: User.get_by_ap_id(notification.activity.actor) + } + end + end) + |> Enum.filter(& &1) + + unless Enum.empty?(mentions) do + styling = Config.get([__MODULE__, :styling]) + logo = Config.get([__MODULE__, :logo]) + + html_data = %{ + instance: instance_name(), + user: user, + mentions: mentions, + followers: followers, + unsubscribe_link: unsubscribe_url(user, "digest"), + styling: styling + } + + logo_path = + if is_nil(logo) do + Path.join(:code.priv_dir(:pleroma), "static/static/logo.svg") + else + Path.join(Config.get([:instance, :static_dir]), logo) + end + + new() + |> to(recipient(user)) + |> from(sender()) + |> subject( + Gettext.dpgettext( + "static_pages", + "digest email subject", + "Your digest from %{instance_name}", + instance_name: instance_name() + ) + ) + |> put_layout(false) + |> render_body("digest.html", html_data) + |> attachment(Swoosh.Attachment.new(logo_path, filename: "logo.svg", type: :inline)) + end + end + end + + defp format_links(str) do + re = ~r//iU + %{link_color: color} = Config.get([__MODULE__, :styling]) + + Regex.replace(re, str, fn link -> + String.replace(link, " user.id, "act" => %{"unsubscribe" => notifications_type}, "exp" => false} + |> Pleroma.JWT.generate_and_sign!() + |> Base.encode64() + + Router.Helpers.subscription_url(Endpoint, :unsubscribe, token) + end + + def backup_is_ready_email(backup, admin_user_id \\ nil) do + %{user: user} = Pleroma.Repo.preload(backup, :user) + + Gettext.with_locale_or_default user.language do + download_url = Pleroma.Web.PleromaAPI.BackupView.download_url(backup) + + html_body = + if is_nil(admin_user_id) do + Gettext.dpgettext( + "static_pages", + "account archive email body - self-requested", + """ +

    You requested a full backup of your Pleroma account. It's ready for download:

    +

    %{download_url}

    + """, + download_url: download_url + ) + else + admin = Pleroma.Repo.get(User, admin_user_id) + + Gettext.dpgettext( + "static_pages", + "account archive email body - admin requested", + """ +

    Admin @%{admin_nickname} requested a full backup of your Pleroma account. It's ready for download:

    +

    %{download_url}

    + """, + admin_nickname: admin.nickname, + download_url: download_url + ) + end + + new() + |> to(recipient(user)) + |> from(sender()) + |> subject( + Gettext.dpgettext( + "static_pages", + "account archive email subject", + "Your account archive is ready" + ) + ) + |> html_body(html_body) + end + end +end diff --git a/lib/pleroma/emoji-test.txt b/lib/pleroma/emoji-test.txt new file mode 100644 index 0000000..87d093d --- /dev/null +++ b/lib/pleroma/emoji-test.txt @@ -0,0 +1,5024 @@ +# emoji-test.txt +# Date: 2022-08-12, 20:24:39 GMT +# © 2022 Unicode®, Inc. +# Unicode and the Unicode Logo are registered trademarks of Unicode, Inc. in the U.S. and other countries. +# For terms of use, see https://www.unicode.org/terms_of_use.html +# +# Emoji Keyboard/Display Test Data for UTS #51 +# Version: 15.0 +# +# For documentation and usage, see https://www.unicode.org/reports/tr51 +# +# This file provides data for testing which emoji forms should be in keyboards and which should also be displayed/processed. +# Format: code points; status # emoji name +# Code points — list of one or more hex code points, separated by spaces +# Status +# component — an Emoji_Component, +# excluding Regional_Indicators, ASCII, and non-Emoji. +# fully-qualified — a fully-qualified emoji (see ED-18 in UTS #51), +# excluding Emoji_Component +# minimally-qualified — a minimally-qualified emoji (see ED-18a in UTS #51) +# unqualified — a unqualified emoji (See ED-19 in UTS #51) +# Notes: +# • This includes the emoji components that need emoji presentation (skin tone and hair) +# when isolated, but omits the components that need not have an emoji +# presentation when isolated. +# • The RGI set is covered by the listed fully-qualified emoji. +# • The listed minimally-qualified and unqualified cover all cases where an +# element of the RGI set is missing one or more emoji presentation selectors. +# • The file is in CLDR order, not codepoint order. This is recommended (but not required!) for keyboard palettes. +# • The groups and subgroups are illustrative. See the Emoji Order chart for more information. + + +# group: Smileys & Emotion + +# subgroup: face-smiling +1F600 ; fully-qualified # 😀 E1.0 grinning face +1F603 ; fully-qualified # 😃 E0.6 grinning face with big eyes +1F604 ; fully-qualified # 😄 E0.6 grinning face with smiling eyes +1F601 ; fully-qualified # 😁 E0.6 beaming face with smiling eyes +1F606 ; fully-qualified # 😆 E0.6 grinning squinting face +1F605 ; fully-qualified # 😅 E0.6 grinning face with sweat +1F923 ; fully-qualified # 🤣 E3.0 rolling on the floor laughing +1F602 ; fully-qualified # 😂 E0.6 face with tears of joy +1F642 ; fully-qualified # 🙂 E1.0 slightly smiling face +1F643 ; fully-qualified # 🙃 E1.0 upside-down face +1FAE0 ; fully-qualified # 🫠 E14.0 melting face +1F609 ; fully-qualified # 😉 E0.6 winking face +1F60A ; fully-qualified # 😊 E0.6 smiling face with smiling eyes +1F607 ; fully-qualified # 😇 E1.0 smiling face with halo + +# subgroup: face-affection +1F970 ; fully-qualified # 🥰 E11.0 smiling face with hearts +1F60D ; fully-qualified # 😍 E0.6 smiling face with heart-eyes +1F929 ; fully-qualified # 🤩 E5.0 star-struck +1F618 ; fully-qualified # 😘 E0.6 face blowing a kiss +1F617 ; fully-qualified # 😗 E1.0 kissing face +263A FE0F ; fully-qualified # ☺️ E0.6 smiling face +263A ; unqualified # ☺ E0.6 smiling face +1F61A ; fully-qualified # 😚 E0.6 kissing face with closed eyes +1F619 ; fully-qualified # 😙 E1.0 kissing face with smiling eyes +1F972 ; fully-qualified # 🥲 E13.0 smiling face with tear + +# subgroup: face-tongue +1F60B ; fully-qualified # 😋 E0.6 face savoring food +1F61B ; fully-qualified # 😛 E1.0 face with tongue +1F61C ; fully-qualified # 😜 E0.6 winking face with tongue +1F92A ; fully-qualified # 🤪 E5.0 zany face +1F61D ; fully-qualified # 😝 E0.6 squinting face with tongue +1F911 ; fully-qualified # 🤑 E1.0 money-mouth face + +# subgroup: face-hand +1F917 ; fully-qualified # 🤗 E1.0 smiling face with open hands +1F92D ; fully-qualified # 🤭 E5.0 face with hand over mouth +1FAE2 ; fully-qualified # 🫢 E14.0 face with open eyes and hand over mouth +1FAE3 ; fully-qualified # 🫣 E14.0 face with peeking eye +1F92B ; fully-qualified # 🤫 E5.0 shushing face +1F914 ; fully-qualified # 🤔 E1.0 thinking face +1FAE1 ; fully-qualified # 🫡 E14.0 saluting face + +# subgroup: face-neutral-skeptical +1F910 ; fully-qualified # 🤐 E1.0 zipper-mouth face +1F928 ; fully-qualified # 🤨 E5.0 face with raised eyebrow +1F610 ; fully-qualified # 😐 E0.7 neutral face +1F611 ; fully-qualified # 😑 E1.0 expressionless face +1F636 ; fully-qualified # 😶 E1.0 face without mouth +1FAE5 ; fully-qualified # 🫥 E14.0 dotted line face +1F636 200D 1F32B FE0F ; fully-qualified # 😶‍🌫️ E13.1 face in clouds +1F636 200D 1F32B ; minimally-qualified # 😶‍🌫 E13.1 face in clouds +1F60F ; fully-qualified # 😏 E0.6 smirking face +1F612 ; fully-qualified # 😒 E0.6 unamused face +1F644 ; fully-qualified # 🙄 E1.0 face with rolling eyes +1F62C ; fully-qualified # 😬 E1.0 grimacing face +1F62E 200D 1F4A8 ; fully-qualified # 😮‍💨 E13.1 face exhaling +1F925 ; fully-qualified # 🤥 E3.0 lying face +1FAE8 ; fully-qualified # 🫨 E15.0 shaking face + +# subgroup: face-sleepy +1F60C ; fully-qualified # 😌 E0.6 relieved face +1F614 ; fully-qualified # 😔 E0.6 pensive face +1F62A ; fully-qualified # 😪 E0.6 sleepy face +1F924 ; fully-qualified # 🤤 E3.0 drooling face +1F634 ; fully-qualified # 😴 E1.0 sleeping face + +# subgroup: face-unwell +1F637 ; fully-qualified # 😷 E0.6 face with medical mask +1F912 ; fully-qualified # 🤒 E1.0 face with thermometer +1F915 ; fully-qualified # 🤕 E1.0 face with head-bandage +1F922 ; fully-qualified # 🤢 E3.0 nauseated face +1F92E ; fully-qualified # 🤮 E5.0 face vomiting +1F927 ; fully-qualified # 🤧 E3.0 sneezing face +1F975 ; fully-qualified # 🥵 E11.0 hot face +1F976 ; fully-qualified # 🥶 E11.0 cold face +1F974 ; fully-qualified # 🥴 E11.0 woozy face +1F635 ; fully-qualified # 😵 E0.6 face with crossed-out eyes +1F635 200D 1F4AB ; fully-qualified # 😵‍💫 E13.1 face with spiral eyes +1F92F ; fully-qualified # 🤯 E5.0 exploding head + +# subgroup: face-hat +1F920 ; fully-qualified # 🤠 E3.0 cowboy hat face +1F973 ; fully-qualified # 🥳 E11.0 partying face +1F978 ; fully-qualified # 🥸 E13.0 disguised face + +# subgroup: face-glasses +1F60E ; fully-qualified # 😎 E1.0 smiling face with sunglasses +1F913 ; fully-qualified # 🤓 E1.0 nerd face +1F9D0 ; fully-qualified # 🧐 E5.0 face with monocle + +# subgroup: face-concerned +1F615 ; fully-qualified # 😕 E1.0 confused face +1FAE4 ; fully-qualified # 🫤 E14.0 face with diagonal mouth +1F61F ; fully-qualified # 😟 E1.0 worried face +1F641 ; fully-qualified # 🙁 E1.0 slightly frowning face +2639 FE0F ; fully-qualified # ☹️ E0.7 frowning face +2639 ; unqualified # ☹ E0.7 frowning face +1F62E ; fully-qualified # 😮 E1.0 face with open mouth +1F62F ; fully-qualified # 😯 E1.0 hushed face +1F632 ; fully-qualified # 😲 E0.6 astonished face +1F633 ; fully-qualified # 😳 E0.6 flushed face +1F97A ; fully-qualified # 🥺 E11.0 pleading face +1F979 ; fully-qualified # 🥹 E14.0 face holding back tears +1F626 ; fully-qualified # 😦 E1.0 frowning face with open mouth +1F627 ; fully-qualified # 😧 E1.0 anguished face +1F628 ; fully-qualified # 😨 E0.6 fearful face +1F630 ; fully-qualified # 😰 E0.6 anxious face with sweat +1F625 ; fully-qualified # 😥 E0.6 sad but relieved face +1F622 ; fully-qualified # 😢 E0.6 crying face +1F62D ; fully-qualified # 😭 E0.6 loudly crying face +1F631 ; fully-qualified # 😱 E0.6 face screaming in fear +1F616 ; fully-qualified # 😖 E0.6 confounded face +1F623 ; fully-qualified # 😣 E0.6 persevering face +1F61E ; fully-qualified # 😞 E0.6 disappointed face +1F613 ; fully-qualified # 😓 E0.6 downcast face with sweat +1F629 ; fully-qualified # 😩 E0.6 weary face +1F62B ; fully-qualified # 😫 E0.6 tired face +1F971 ; fully-qualified # 🥱 E12.0 yawning face + +# subgroup: face-negative +1F624 ; fully-qualified # 😤 E0.6 face with steam from nose +1F621 ; fully-qualified # 😡 E0.6 enraged face +1F620 ; fully-qualified # 😠 E0.6 angry face +1F92C ; fully-qualified # 🤬 E5.0 face with symbols on mouth +1F608 ; fully-qualified # 😈 E1.0 smiling face with horns +1F47F ; fully-qualified # 👿 E0.6 angry face with horns +1F480 ; fully-qualified # 💀 E0.6 skull +2620 FE0F ; fully-qualified # ☠️ E1.0 skull and crossbones +2620 ; unqualified # ☠ E1.0 skull and crossbones + +# subgroup: face-costume +1F4A9 ; fully-qualified # 💩 E0.6 pile of poo +1F921 ; fully-qualified # 🤡 E3.0 clown face +1F479 ; fully-qualified # 👹 E0.6 ogre +1F47A ; fully-qualified # 👺 E0.6 goblin +1F47B ; fully-qualified # 👻 E0.6 ghost +1F47D ; fully-qualified # 👽 E0.6 alien +1F47E ; fully-qualified # 👾 E0.6 alien monster +1F916 ; fully-qualified # 🤖 E1.0 robot + +# subgroup: cat-face +1F63A ; fully-qualified # 😺 E0.6 grinning cat +1F638 ; fully-qualified # 😸 E0.6 grinning cat with smiling eyes +1F639 ; fully-qualified # 😹 E0.6 cat with tears of joy +1F63B ; fully-qualified # 😻 E0.6 smiling cat with heart-eyes +1F63C ; fully-qualified # 😼 E0.6 cat with wry smile +1F63D ; fully-qualified # 😽 E0.6 kissing cat +1F640 ; fully-qualified # 🙀 E0.6 weary cat +1F63F ; fully-qualified # 😿 E0.6 crying cat +1F63E ; fully-qualified # 😾 E0.6 pouting cat + +# subgroup: monkey-face +1F648 ; fully-qualified # 🙈 E0.6 see-no-evil monkey +1F649 ; fully-qualified # 🙉 E0.6 hear-no-evil monkey +1F64A ; fully-qualified # 🙊 E0.6 speak-no-evil monkey + +# subgroup: heart +1F48C ; fully-qualified # 💌 E0.6 love letter +1F498 ; fully-qualified # 💘 E0.6 heart with arrow +1F49D ; fully-qualified # 💝 E0.6 heart with ribbon +1F496 ; fully-qualified # 💖 E0.6 sparkling heart +1F497 ; fully-qualified # 💗 E0.6 growing heart +1F493 ; fully-qualified # 💓 E0.6 beating heart +1F49E ; fully-qualified # 💞 E0.6 revolving hearts +1F495 ; fully-qualified # 💕 E0.6 two hearts +1F49F ; fully-qualified # 💟 E0.6 heart decoration +2763 FE0F ; fully-qualified # ❣️ E1.0 heart exclamation +2763 ; unqualified # ❣ E1.0 heart exclamation +1F494 ; fully-qualified # 💔 E0.6 broken heart +2764 FE0F 200D 1F525 ; fully-qualified # ❤️‍🔥 E13.1 heart on fire +2764 200D 1F525 ; unqualified # ❤‍🔥 E13.1 heart on fire +2764 FE0F 200D 1FA79 ; fully-qualified # ❤️‍🩹 E13.1 mending heart +2764 200D 1FA79 ; unqualified # ❤‍🩹 E13.1 mending heart +2764 FE0F ; fully-qualified # ❤️ E0.6 red heart +2764 ; unqualified # ❤ E0.6 red heart +1FA77 ; fully-qualified # 🩷 E15.0 pink heart +1F9E1 ; fully-qualified # 🧡 E5.0 orange heart +1F49B ; fully-qualified # 💛 E0.6 yellow heart +1F49A ; fully-qualified # 💚 E0.6 green heart +1F499 ; fully-qualified # 💙 E0.6 blue heart +1FA75 ; fully-qualified # 🩵 E15.0 light blue heart +1F49C ; fully-qualified # 💜 E0.6 purple heart +1F90E ; fully-qualified # 🤎 E12.0 brown heart +1F5A4 ; fully-qualified # 🖤 E3.0 black heart +1FA76 ; fully-qualified # 🩶 E15.0 grey heart +1F90D ; fully-qualified # 🤍 E12.0 white heart + +# subgroup: emotion +1F48B ; fully-qualified # 💋 E0.6 kiss mark +1F4AF ; fully-qualified # 💯 E0.6 hundred points +1F4A2 ; fully-qualified # 💢 E0.6 anger symbol +1F4A5 ; fully-qualified # 💥 E0.6 collision +1F4AB ; fully-qualified # 💫 E0.6 dizzy +1F4A6 ; fully-qualified # 💦 E0.6 sweat droplets +1F4A8 ; fully-qualified # 💨 E0.6 dashing away +1F573 FE0F ; fully-qualified # 🕳️ E0.7 hole +1F573 ; unqualified # 🕳 E0.7 hole +1F4AC ; fully-qualified # 💬 E0.6 speech balloon +1F441 FE0F 200D 1F5E8 FE0F ; fully-qualified # 👁️‍🗨️ E2.0 eye in speech bubble +1F441 200D 1F5E8 FE0F ; unqualified # 👁‍🗨️ E2.0 eye in speech bubble +1F441 FE0F 200D 1F5E8 ; minimally-qualified # 👁️‍🗨 E2.0 eye in speech bubble +1F441 200D 1F5E8 ; unqualified # 👁‍🗨 E2.0 eye in speech bubble +1F5E8 FE0F ; fully-qualified # 🗨️ E2.0 left speech bubble +1F5E8 ; unqualified # 🗨 E2.0 left speech bubble +1F5EF FE0F ; fully-qualified # 🗯️ E0.7 right anger bubble +1F5EF ; unqualified # 🗯 E0.7 right anger bubble +1F4AD ; fully-qualified # 💭 E1.0 thought balloon +1F4A4 ; fully-qualified # 💤 E0.6 ZZZ + +# Smileys & Emotion subtotal: 180 +# Smileys & Emotion subtotal: 180 w/o modifiers + +# group: People & Body + +# subgroup: hand-fingers-open +1F44B ; fully-qualified # 👋 E0.6 waving hand +1F44B 1F3FB ; fully-qualified # 👋🏻 E1.0 waving hand: light skin tone +1F44B 1F3FC ; fully-qualified # 👋🏼 E1.0 waving hand: medium-light skin tone +1F44B 1F3FD ; fully-qualified # 👋🏽 E1.0 waving hand: medium skin tone +1F44B 1F3FE ; fully-qualified # 👋🏾 E1.0 waving hand: medium-dark skin tone +1F44B 1F3FF ; fully-qualified # 👋🏿 E1.0 waving hand: dark skin tone +1F91A ; fully-qualified # 🤚 E3.0 raised back of hand +1F91A 1F3FB ; fully-qualified # 🤚🏻 E3.0 raised back of hand: light skin tone +1F91A 1F3FC ; fully-qualified # 🤚🏼 E3.0 raised back of hand: medium-light skin tone +1F91A 1F3FD ; fully-qualified # 🤚🏽 E3.0 raised back of hand: medium skin tone +1F91A 1F3FE ; fully-qualified # 🤚🏾 E3.0 raised back of hand: medium-dark skin tone +1F91A 1F3FF ; fully-qualified # 🤚🏿 E3.0 raised back of hand: dark skin tone +1F590 FE0F ; fully-qualified # 🖐️ E0.7 hand with fingers splayed +1F590 ; unqualified # 🖐 E0.7 hand with fingers splayed +1F590 1F3FB ; fully-qualified # 🖐🏻 E1.0 hand with fingers splayed: light skin tone +1F590 1F3FC ; fully-qualified # 🖐🏼 E1.0 hand with fingers splayed: medium-light skin tone +1F590 1F3FD ; fully-qualified # 🖐🏽 E1.0 hand with fingers splayed: medium skin tone +1F590 1F3FE ; fully-qualified # 🖐🏾 E1.0 hand with fingers splayed: medium-dark skin tone +1F590 1F3FF ; fully-qualified # 🖐🏿 E1.0 hand with fingers splayed: dark skin tone +270B ; fully-qualified # ✋ E0.6 raised hand +270B 1F3FB ; fully-qualified # ✋🏻 E1.0 raised hand: light skin tone +270B 1F3FC ; fully-qualified # ✋🏼 E1.0 raised hand: medium-light skin tone +270B 1F3FD ; fully-qualified # ✋🏽 E1.0 raised hand: medium skin tone +270B 1F3FE ; fully-qualified # ✋🏾 E1.0 raised hand: medium-dark skin tone +270B 1F3FF ; fully-qualified # ✋🏿 E1.0 raised hand: dark skin tone +1F596 ; fully-qualified # 🖖 E1.0 vulcan salute +1F596 1F3FB ; fully-qualified # 🖖🏻 E1.0 vulcan salute: light skin tone +1F596 1F3FC ; fully-qualified # 🖖🏼 E1.0 vulcan salute: medium-light skin tone +1F596 1F3FD ; fully-qualified # 🖖🏽 E1.0 vulcan salute: medium skin tone +1F596 1F3FE ; fully-qualified # 🖖🏾 E1.0 vulcan salute: medium-dark skin tone +1F596 1F3FF ; fully-qualified # 🖖🏿 E1.0 vulcan salute: dark skin tone +1FAF1 ; fully-qualified # 🫱 E14.0 rightwards hand +1FAF1 1F3FB ; fully-qualified # 🫱🏻 E14.0 rightwards hand: light skin tone +1FAF1 1F3FC ; fully-qualified # 🫱🏼 E14.0 rightwards hand: medium-light skin tone +1FAF1 1F3FD ; fully-qualified # 🫱🏽 E14.0 rightwards hand: medium skin tone +1FAF1 1F3FE ; fully-qualified # 🫱🏾 E14.0 rightwards hand: medium-dark skin tone +1FAF1 1F3FF ; fully-qualified # 🫱🏿 E14.0 rightwards hand: dark skin tone +1FAF2 ; fully-qualified # 🫲 E14.0 leftwards hand +1FAF2 1F3FB ; fully-qualified # 🫲🏻 E14.0 leftwards hand: light skin tone +1FAF2 1F3FC ; fully-qualified # 🫲🏼 E14.0 leftwards hand: medium-light skin tone +1FAF2 1F3FD ; fully-qualified # 🫲🏽 E14.0 leftwards hand: medium skin tone +1FAF2 1F3FE ; fully-qualified # 🫲🏾 E14.0 leftwards hand: medium-dark skin tone +1FAF2 1F3FF ; fully-qualified # 🫲🏿 E14.0 leftwards hand: dark skin tone +1FAF3 ; fully-qualified # 🫳 E14.0 palm down hand +1FAF3 1F3FB ; fully-qualified # 🫳🏻 E14.0 palm down hand: light skin tone +1FAF3 1F3FC ; fully-qualified # 🫳🏼 E14.0 palm down hand: medium-light skin tone +1FAF3 1F3FD ; fully-qualified # 🫳🏽 E14.0 palm down hand: medium skin tone +1FAF3 1F3FE ; fully-qualified # 🫳🏾 E14.0 palm down hand: medium-dark skin tone +1FAF3 1F3FF ; fully-qualified # 🫳🏿 E14.0 palm down hand: dark skin tone +1FAF4 ; fully-qualified # 🫴 E14.0 palm up hand +1FAF4 1F3FB ; fully-qualified # 🫴🏻 E14.0 palm up hand: light skin tone +1FAF4 1F3FC ; fully-qualified # 🫴🏼 E14.0 palm up hand: medium-light skin tone +1FAF4 1F3FD ; fully-qualified # 🫴🏽 E14.0 palm up hand: medium skin tone +1FAF4 1F3FE ; fully-qualified # 🫴🏾 E14.0 palm up hand: medium-dark skin tone +1FAF4 1F3FF ; fully-qualified # 🫴🏿 E14.0 palm up hand: dark skin tone +1FAF7 ; fully-qualified # 🫷 E15.0 leftwards pushing hand +1FAF7 1F3FB ; fully-qualified # 🫷🏻 E15.0 leftwards pushing hand: light skin tone +1FAF7 1F3FC ; fully-qualified # 🫷🏼 E15.0 leftwards pushing hand: medium-light skin tone +1FAF7 1F3FD ; fully-qualified # 🫷🏽 E15.0 leftwards pushing hand: medium skin tone +1FAF7 1F3FE ; fully-qualified # 🫷🏾 E15.0 leftwards pushing hand: medium-dark skin tone +1FAF7 1F3FF ; fully-qualified # 🫷🏿 E15.0 leftwards pushing hand: dark skin tone +1FAF8 ; fully-qualified # 🫸 E15.0 rightwards pushing hand +1FAF8 1F3FB ; fully-qualified # 🫸🏻 E15.0 rightwards pushing hand: light skin tone +1FAF8 1F3FC ; fully-qualified # 🫸🏼 E15.0 rightwards pushing hand: medium-light skin tone +1FAF8 1F3FD ; fully-qualified # 🫸🏽 E15.0 rightwards pushing hand: medium skin tone +1FAF8 1F3FE ; fully-qualified # 🫸🏾 E15.0 rightwards pushing hand: medium-dark skin tone +1FAF8 1F3FF ; fully-qualified # 🫸🏿 E15.0 rightwards pushing hand: dark skin tone + +# subgroup: hand-fingers-partial +1F44C ; fully-qualified # 👌 E0.6 OK hand +1F44C 1F3FB ; fully-qualified # 👌🏻 E1.0 OK hand: light skin tone +1F44C 1F3FC ; fully-qualified # 👌🏼 E1.0 OK hand: medium-light skin tone +1F44C 1F3FD ; fully-qualified # 👌🏽 E1.0 OK hand: medium skin tone +1F44C 1F3FE ; fully-qualified # 👌🏾 E1.0 OK hand: medium-dark skin tone +1F44C 1F3FF ; fully-qualified # 👌🏿 E1.0 OK hand: dark skin tone +1F90C ; fully-qualified # 🤌 E13.0 pinched fingers +1F90C 1F3FB ; fully-qualified # 🤌🏻 E13.0 pinched fingers: light skin tone +1F90C 1F3FC ; fully-qualified # 🤌🏼 E13.0 pinched fingers: medium-light skin tone +1F90C 1F3FD ; fully-qualified # 🤌🏽 E13.0 pinched fingers: medium skin tone +1F90C 1F3FE ; fully-qualified # 🤌🏾 E13.0 pinched fingers: medium-dark skin tone +1F90C 1F3FF ; fully-qualified # 🤌🏿 E13.0 pinched fingers: dark skin tone +1F90F ; fully-qualified # 🤏 E12.0 pinching hand +1F90F 1F3FB ; fully-qualified # 🤏🏻 E12.0 pinching hand: light skin tone +1F90F 1F3FC ; fully-qualified # 🤏🏼 E12.0 pinching hand: medium-light skin tone +1F90F 1F3FD ; fully-qualified # 🤏🏽 E12.0 pinching hand: medium skin tone +1F90F 1F3FE ; fully-qualified # 🤏🏾 E12.0 pinching hand: medium-dark skin tone +1F90F 1F3FF ; fully-qualified # 🤏🏿 E12.0 pinching hand: dark skin tone +270C FE0F ; fully-qualified # ✌️ E0.6 victory hand +270C ; unqualified # ✌ E0.6 victory hand +270C 1F3FB ; fully-qualified # ✌🏻 E1.0 victory hand: light skin tone +270C 1F3FC ; fully-qualified # ✌🏼 E1.0 victory hand: medium-light skin tone +270C 1F3FD ; fully-qualified # ✌🏽 E1.0 victory hand: medium skin tone +270C 1F3FE ; fully-qualified # ✌🏾 E1.0 victory hand: medium-dark skin tone +270C 1F3FF ; fully-qualified # ✌🏿 E1.0 victory hand: dark skin tone +1F91E ; fully-qualified # 🤞 E3.0 crossed fingers +1F91E 1F3FB ; fully-qualified # 🤞🏻 E3.0 crossed fingers: light skin tone +1F91E 1F3FC ; fully-qualified # 🤞🏼 E3.0 crossed fingers: medium-light skin tone +1F91E 1F3FD ; fully-qualified # 🤞🏽 E3.0 crossed fingers: medium skin tone +1F91E 1F3FE ; fully-qualified # 🤞🏾 E3.0 crossed fingers: medium-dark skin tone +1F91E 1F3FF ; fully-qualified # 🤞🏿 E3.0 crossed fingers: dark skin tone +1FAF0 ; fully-qualified # 🫰 E14.0 hand with index finger and thumb crossed +1FAF0 1F3FB ; fully-qualified # 🫰🏻 E14.0 hand with index finger and thumb crossed: light skin tone +1FAF0 1F3FC ; fully-qualified # 🫰🏼 E14.0 hand with index finger and thumb crossed: medium-light skin tone +1FAF0 1F3FD ; fully-qualified # 🫰🏽 E14.0 hand with index finger and thumb crossed: medium skin tone +1FAF0 1F3FE ; fully-qualified # 🫰🏾 E14.0 hand with index finger and thumb crossed: medium-dark skin tone +1FAF0 1F3FF ; fully-qualified # 🫰🏿 E14.0 hand with index finger and thumb crossed: dark skin tone +1F91F ; fully-qualified # 🤟 E5.0 love-you gesture +1F91F 1F3FB ; fully-qualified # 🤟🏻 E5.0 love-you gesture: light skin tone +1F91F 1F3FC ; fully-qualified # 🤟🏼 E5.0 love-you gesture: medium-light skin tone +1F91F 1F3FD ; fully-qualified # 🤟🏽 E5.0 love-you gesture: medium skin tone +1F91F 1F3FE ; fully-qualified # 🤟🏾 E5.0 love-you gesture: medium-dark skin tone +1F91F 1F3FF ; fully-qualified # 🤟🏿 E5.0 love-you gesture: dark skin tone +1F918 ; fully-qualified # 🤘 E1.0 sign of the horns +1F918 1F3FB ; fully-qualified # 🤘🏻 E1.0 sign of the horns: light skin tone +1F918 1F3FC ; fully-qualified # 🤘🏼 E1.0 sign of the horns: medium-light skin tone +1F918 1F3FD ; fully-qualified # 🤘🏽 E1.0 sign of the horns: medium skin tone +1F918 1F3FE ; fully-qualified # 🤘🏾 E1.0 sign of the horns: medium-dark skin tone +1F918 1F3FF ; fully-qualified # 🤘🏿 E1.0 sign of the horns: dark skin tone +1F919 ; fully-qualified # 🤙 E3.0 call me hand +1F919 1F3FB ; fully-qualified # 🤙🏻 E3.0 call me hand: light skin tone +1F919 1F3FC ; fully-qualified # 🤙🏼 E3.0 call me hand: medium-light skin tone +1F919 1F3FD ; fully-qualified # 🤙🏽 E3.0 call me hand: medium skin tone +1F919 1F3FE ; fully-qualified # 🤙🏾 E3.0 call me hand: medium-dark skin tone +1F919 1F3FF ; fully-qualified # 🤙🏿 E3.0 call me hand: dark skin tone + +# subgroup: hand-single-finger +1F448 ; fully-qualified # 👈 E0.6 backhand index pointing left +1F448 1F3FB ; fully-qualified # 👈🏻 E1.0 backhand index pointing left: light skin tone +1F448 1F3FC ; fully-qualified # 👈🏼 E1.0 backhand index pointing left: medium-light skin tone +1F448 1F3FD ; fully-qualified # 👈🏽 E1.0 backhand index pointing left: medium skin tone +1F448 1F3FE ; fully-qualified # 👈🏾 E1.0 backhand index pointing left: medium-dark skin tone +1F448 1F3FF ; fully-qualified # 👈🏿 E1.0 backhand index pointing left: dark skin tone +1F449 ; fully-qualified # 👉 E0.6 backhand index pointing right +1F449 1F3FB ; fully-qualified # 👉🏻 E1.0 backhand index pointing right: light skin tone +1F449 1F3FC ; fully-qualified # 👉🏼 E1.0 backhand index pointing right: medium-light skin tone +1F449 1F3FD ; fully-qualified # 👉🏽 E1.0 backhand index pointing right: medium skin tone +1F449 1F3FE ; fully-qualified # 👉🏾 E1.0 backhand index pointing right: medium-dark skin tone +1F449 1F3FF ; fully-qualified # 👉🏿 E1.0 backhand index pointing right: dark skin tone +1F446 ; fully-qualified # 👆 E0.6 backhand index pointing up +1F446 1F3FB ; fully-qualified # 👆🏻 E1.0 backhand index pointing up: light skin tone +1F446 1F3FC ; fully-qualified # 👆🏼 E1.0 backhand index pointing up: medium-light skin tone +1F446 1F3FD ; fully-qualified # 👆🏽 E1.0 backhand index pointing up: medium skin tone +1F446 1F3FE ; fully-qualified # 👆🏾 E1.0 backhand index pointing up: medium-dark skin tone +1F446 1F3FF ; fully-qualified # 👆🏿 E1.0 backhand index pointing up: dark skin tone +1F595 ; fully-qualified # 🖕 E1.0 middle finger +1F595 1F3FB ; fully-qualified # 🖕🏻 E1.0 middle finger: light skin tone +1F595 1F3FC ; fully-qualified # 🖕🏼 E1.0 middle finger: medium-light skin tone +1F595 1F3FD ; fully-qualified # 🖕🏽 E1.0 middle finger: medium skin tone +1F595 1F3FE ; fully-qualified # 🖕🏾 E1.0 middle finger: medium-dark skin tone +1F595 1F3FF ; fully-qualified # 🖕🏿 E1.0 middle finger: dark skin tone +1F447 ; fully-qualified # 👇 E0.6 backhand index pointing down +1F447 1F3FB ; fully-qualified # 👇🏻 E1.0 backhand index pointing down: light skin tone +1F447 1F3FC ; fully-qualified # 👇🏼 E1.0 backhand index pointing down: medium-light skin tone +1F447 1F3FD ; fully-qualified # 👇🏽 E1.0 backhand index pointing down: medium skin tone +1F447 1F3FE ; fully-qualified # 👇🏾 E1.0 backhand index pointing down: medium-dark skin tone +1F447 1F3FF ; fully-qualified # 👇🏿 E1.0 backhand index pointing down: dark skin tone +261D FE0F ; fully-qualified # ☝️ E0.6 index pointing up +261D ; unqualified # ☝ E0.6 index pointing up +261D 1F3FB ; fully-qualified # ☝🏻 E1.0 index pointing up: light skin tone +261D 1F3FC ; fully-qualified # ☝🏼 E1.0 index pointing up: medium-light skin tone +261D 1F3FD ; fully-qualified # ☝🏽 E1.0 index pointing up: medium skin tone +261D 1F3FE ; fully-qualified # ☝🏾 E1.0 index pointing up: medium-dark skin tone +261D 1F3FF ; fully-qualified # ☝🏿 E1.0 index pointing up: dark skin tone +1FAF5 ; fully-qualified # 🫵 E14.0 index pointing at the viewer +1FAF5 1F3FB ; fully-qualified # 🫵🏻 E14.0 index pointing at the viewer: light skin tone +1FAF5 1F3FC ; fully-qualified # 🫵🏼 E14.0 index pointing at the viewer: medium-light skin tone +1FAF5 1F3FD ; fully-qualified # 🫵🏽 E14.0 index pointing at the viewer: medium skin tone +1FAF5 1F3FE ; fully-qualified # 🫵🏾 E14.0 index pointing at the viewer: medium-dark skin tone +1FAF5 1F3FF ; fully-qualified # 🫵🏿 E14.0 index pointing at the viewer: dark skin tone + +# subgroup: hand-fingers-closed +1F44D ; fully-qualified # 👍 E0.6 thumbs up +1F44D 1F3FB ; fully-qualified # 👍🏻 E1.0 thumbs up: light skin tone +1F44D 1F3FC ; fully-qualified # 👍🏼 E1.0 thumbs up: medium-light skin tone +1F44D 1F3FD ; fully-qualified # 👍🏽 E1.0 thumbs up: medium skin tone +1F44D 1F3FE ; fully-qualified # 👍🏾 E1.0 thumbs up: medium-dark skin tone +1F44D 1F3FF ; fully-qualified # 👍🏿 E1.0 thumbs up: dark skin tone +1F44E ; fully-qualified # 👎 E0.6 thumbs down +1F44E 1F3FB ; fully-qualified # 👎🏻 E1.0 thumbs down: light skin tone +1F44E 1F3FC ; fully-qualified # 👎🏼 E1.0 thumbs down: medium-light skin tone +1F44E 1F3FD ; fully-qualified # 👎🏽 E1.0 thumbs down: medium skin tone +1F44E 1F3FE ; fully-qualified # 👎🏾 E1.0 thumbs down: medium-dark skin tone +1F44E 1F3FF ; fully-qualified # 👎🏿 E1.0 thumbs down: dark skin tone +270A ; fully-qualified # ✊ E0.6 raised fist +270A 1F3FB ; fully-qualified # ✊🏻 E1.0 raised fist: light skin tone +270A 1F3FC ; fully-qualified # ✊🏼 E1.0 raised fist: medium-light skin tone +270A 1F3FD ; fully-qualified # ✊🏽 E1.0 raised fist: medium skin tone +270A 1F3FE ; fully-qualified # ✊🏾 E1.0 raised fist: medium-dark skin tone +270A 1F3FF ; fully-qualified # ✊🏿 E1.0 raised fist: dark skin tone +1F44A ; fully-qualified # 👊 E0.6 oncoming fist +1F44A 1F3FB ; fully-qualified # 👊🏻 E1.0 oncoming fist: light skin tone +1F44A 1F3FC ; fully-qualified # 👊🏼 E1.0 oncoming fist: medium-light skin tone +1F44A 1F3FD ; fully-qualified # 👊🏽 E1.0 oncoming fist: medium skin tone +1F44A 1F3FE ; fully-qualified # 👊🏾 E1.0 oncoming fist: medium-dark skin tone +1F44A 1F3FF ; fully-qualified # 👊🏿 E1.0 oncoming fist: dark skin tone +1F91B ; fully-qualified # 🤛 E3.0 left-facing fist +1F91B 1F3FB ; fully-qualified # 🤛🏻 E3.0 left-facing fist: light skin tone +1F91B 1F3FC ; fully-qualified # 🤛🏼 E3.0 left-facing fist: medium-light skin tone +1F91B 1F3FD ; fully-qualified # 🤛🏽 E3.0 left-facing fist: medium skin tone +1F91B 1F3FE ; fully-qualified # 🤛🏾 E3.0 left-facing fist: medium-dark skin tone +1F91B 1F3FF ; fully-qualified # 🤛🏿 E3.0 left-facing fist: dark skin tone +1F91C ; fully-qualified # 🤜 E3.0 right-facing fist +1F91C 1F3FB ; fully-qualified # 🤜🏻 E3.0 right-facing fist: light skin tone +1F91C 1F3FC ; fully-qualified # 🤜🏼 E3.0 right-facing fist: medium-light skin tone +1F91C 1F3FD ; fully-qualified # 🤜🏽 E3.0 right-facing fist: medium skin tone +1F91C 1F3FE ; fully-qualified # 🤜🏾 E3.0 right-facing fist: medium-dark skin tone +1F91C 1F3FF ; fully-qualified # 🤜🏿 E3.0 right-facing fist: dark skin tone + +# subgroup: hands +1F44F ; fully-qualified # 👏 E0.6 clapping hands +1F44F 1F3FB ; fully-qualified # 👏🏻 E1.0 clapping hands: light skin tone +1F44F 1F3FC ; fully-qualified # 👏🏼 E1.0 clapping hands: medium-light skin tone +1F44F 1F3FD ; fully-qualified # 👏🏽 E1.0 clapping hands: medium skin tone +1F44F 1F3FE ; fully-qualified # 👏🏾 E1.0 clapping hands: medium-dark skin tone +1F44F 1F3FF ; fully-qualified # 👏🏿 E1.0 clapping hands: dark skin tone +1F64C ; fully-qualified # 🙌 E0.6 raising hands +1F64C 1F3FB ; fully-qualified # 🙌🏻 E1.0 raising hands: light skin tone +1F64C 1F3FC ; fully-qualified # 🙌🏼 E1.0 raising hands: medium-light skin tone +1F64C 1F3FD ; fully-qualified # 🙌🏽 E1.0 raising hands: medium skin tone +1F64C 1F3FE ; fully-qualified # 🙌🏾 E1.0 raising hands: medium-dark skin tone +1F64C 1F3FF ; fully-qualified # 🙌🏿 E1.0 raising hands: dark skin tone +1FAF6 ; fully-qualified # 🫶 E14.0 heart hands +1FAF6 1F3FB ; fully-qualified # 🫶🏻 E14.0 heart hands: light skin tone +1FAF6 1F3FC ; fully-qualified # 🫶🏼 E14.0 heart hands: medium-light skin tone +1FAF6 1F3FD ; fully-qualified # 🫶🏽 E14.0 heart hands: medium skin tone +1FAF6 1F3FE ; fully-qualified # 🫶🏾 E14.0 heart hands: medium-dark skin tone +1FAF6 1F3FF ; fully-qualified # 🫶🏿 E14.0 heart hands: dark skin tone +1F450 ; fully-qualified # 👐 E0.6 open hands +1F450 1F3FB ; fully-qualified # 👐🏻 E1.0 open hands: light skin tone +1F450 1F3FC ; fully-qualified # 👐🏼 E1.0 open hands: medium-light skin tone +1F450 1F3FD ; fully-qualified # 👐🏽 E1.0 open hands: medium skin tone +1F450 1F3FE ; fully-qualified # 👐🏾 E1.0 open hands: medium-dark skin tone +1F450 1F3FF ; fully-qualified # 👐🏿 E1.0 open hands: dark skin tone +1F932 ; fully-qualified # 🤲 E5.0 palms up together +1F932 1F3FB ; fully-qualified # 🤲🏻 E5.0 palms up together: light skin tone +1F932 1F3FC ; fully-qualified # 🤲🏼 E5.0 palms up together: medium-light skin tone +1F932 1F3FD ; fully-qualified # 🤲🏽 E5.0 palms up together: medium skin tone +1F932 1F3FE ; fully-qualified # 🤲🏾 E5.0 palms up together: medium-dark skin tone +1F932 1F3FF ; fully-qualified # 🤲🏿 E5.0 palms up together: dark skin tone +1F91D ; fully-qualified # 🤝 E3.0 handshake +1F91D 1F3FB ; fully-qualified # 🤝🏻 E14.0 handshake: light skin tone +1F91D 1F3FC ; fully-qualified # 🤝🏼 E14.0 handshake: medium-light skin tone +1F91D 1F3FD ; fully-qualified # 🤝🏽 E14.0 handshake: medium skin tone +1F91D 1F3FE ; fully-qualified # 🤝🏾 E14.0 handshake: medium-dark skin tone +1F91D 1F3FF ; fully-qualified # 🤝🏿 E14.0 handshake: dark skin tone +1FAF1 1F3FB 200D 1FAF2 1F3FC ; fully-qualified # 🫱🏻‍🫲🏼 E14.0 handshake: light skin tone, medium-light skin tone +1FAF1 1F3FB 200D 1FAF2 1F3FD ; fully-qualified # 🫱🏻‍🫲🏽 E14.0 handshake: light skin tone, medium skin tone +1FAF1 1F3FB 200D 1FAF2 1F3FE ; fully-qualified # 🫱🏻‍🫲🏾 E14.0 handshake: light skin tone, medium-dark skin tone +1FAF1 1F3FB 200D 1FAF2 1F3FF ; fully-qualified # 🫱🏻‍🫲🏿 E14.0 handshake: light skin tone, dark skin tone +1FAF1 1F3FC 200D 1FAF2 1F3FB ; fully-qualified # 🫱🏼‍🫲🏻 E14.0 handshake: medium-light skin tone, light skin tone +1FAF1 1F3FC 200D 1FAF2 1F3FD ; fully-qualified # 🫱🏼‍🫲🏽 E14.0 handshake: medium-light skin tone, medium skin tone +1FAF1 1F3FC 200D 1FAF2 1F3FE ; fully-qualified # 🫱🏼‍🫲🏾 E14.0 handshake: medium-light skin tone, medium-dark skin tone +1FAF1 1F3FC 200D 1FAF2 1F3FF ; fully-qualified # 🫱🏼‍🫲🏿 E14.0 handshake: medium-light skin tone, dark skin tone +1FAF1 1F3FD 200D 1FAF2 1F3FB ; fully-qualified # 🫱🏽‍🫲🏻 E14.0 handshake: medium skin tone, light skin tone +1FAF1 1F3FD 200D 1FAF2 1F3FC ; fully-qualified # 🫱🏽‍🫲🏼 E14.0 handshake: medium skin tone, medium-light skin tone +1FAF1 1F3FD 200D 1FAF2 1F3FE ; fully-qualified # 🫱🏽‍🫲🏾 E14.0 handshake: medium skin tone, medium-dark skin tone +1FAF1 1F3FD 200D 1FAF2 1F3FF ; fully-qualified # 🫱🏽‍🫲🏿 E14.0 handshake: medium skin tone, dark skin tone +1FAF1 1F3FE 200D 1FAF2 1F3FB ; fully-qualified # 🫱🏾‍🫲🏻 E14.0 handshake: medium-dark skin tone, light skin tone +1FAF1 1F3FE 200D 1FAF2 1F3FC ; fully-qualified # 🫱🏾‍🫲🏼 E14.0 handshake: medium-dark skin tone, medium-light skin tone +1FAF1 1F3FE 200D 1FAF2 1F3FD ; fully-qualified # 🫱🏾‍🫲🏽 E14.0 handshake: medium-dark skin tone, medium skin tone +1FAF1 1F3FE 200D 1FAF2 1F3FF ; fully-qualified # 🫱🏾‍🫲🏿 E14.0 handshake: medium-dark skin tone, dark skin tone +1FAF1 1F3FF 200D 1FAF2 1F3FB ; fully-qualified # 🫱🏿‍🫲🏻 E14.0 handshake: dark skin tone, light skin tone +1FAF1 1F3FF 200D 1FAF2 1F3FC ; fully-qualified # 🫱🏿‍🫲🏼 E14.0 handshake: dark skin tone, medium-light skin tone +1FAF1 1F3FF 200D 1FAF2 1F3FD ; fully-qualified # 🫱🏿‍🫲🏽 E14.0 handshake: dark skin tone, medium skin tone +1FAF1 1F3FF 200D 1FAF2 1F3FE ; fully-qualified # 🫱🏿‍🫲🏾 E14.0 handshake: dark skin tone, medium-dark skin tone +1F64F ; fully-qualified # 🙏 E0.6 folded hands +1F64F 1F3FB ; fully-qualified # 🙏🏻 E1.0 folded hands: light skin tone +1F64F 1F3FC ; fully-qualified # 🙏🏼 E1.0 folded hands: medium-light skin tone +1F64F 1F3FD ; fully-qualified # 🙏🏽 E1.0 folded hands: medium skin tone +1F64F 1F3FE ; fully-qualified # 🙏🏾 E1.0 folded hands: medium-dark skin tone +1F64F 1F3FF ; fully-qualified # 🙏🏿 E1.0 folded hands: dark skin tone + +# subgroup: hand-prop +270D FE0F ; fully-qualified # ✍️ E0.7 writing hand +270D ; unqualified # ✍ E0.7 writing hand +270D 1F3FB ; fully-qualified # ✍🏻 E1.0 writing hand: light skin tone +270D 1F3FC ; fully-qualified # ✍🏼 E1.0 writing hand: medium-light skin tone +270D 1F3FD ; fully-qualified # ✍🏽 E1.0 writing hand: medium skin tone +270D 1F3FE ; fully-qualified # ✍🏾 E1.0 writing hand: medium-dark skin tone +270D 1F3FF ; fully-qualified # ✍🏿 E1.0 writing hand: dark skin tone +1F485 ; fully-qualified # 💅 E0.6 nail polish +1F485 1F3FB ; fully-qualified # 💅🏻 E1.0 nail polish: light skin tone +1F485 1F3FC ; fully-qualified # 💅🏼 E1.0 nail polish: medium-light skin tone +1F485 1F3FD ; fully-qualified # 💅🏽 E1.0 nail polish: medium skin tone +1F485 1F3FE ; fully-qualified # 💅🏾 E1.0 nail polish: medium-dark skin tone +1F485 1F3FF ; fully-qualified # 💅🏿 E1.0 nail polish: dark skin tone +1F933 ; fully-qualified # 🤳 E3.0 selfie +1F933 1F3FB ; fully-qualified # 🤳🏻 E3.0 selfie: light skin tone +1F933 1F3FC ; fully-qualified # 🤳🏼 E3.0 selfie: medium-light skin tone +1F933 1F3FD ; fully-qualified # 🤳🏽 E3.0 selfie: medium skin tone +1F933 1F3FE ; fully-qualified # 🤳🏾 E3.0 selfie: medium-dark skin tone +1F933 1F3FF ; fully-qualified # 🤳🏿 E3.0 selfie: dark skin tone + +# subgroup: body-parts +1F4AA ; fully-qualified # 💪 E0.6 flexed biceps +1F4AA 1F3FB ; fully-qualified # 💪🏻 E1.0 flexed biceps: light skin tone +1F4AA 1F3FC ; fully-qualified # 💪🏼 E1.0 flexed biceps: medium-light skin tone +1F4AA 1F3FD ; fully-qualified # 💪🏽 E1.0 flexed biceps: medium skin tone +1F4AA 1F3FE ; fully-qualified # 💪🏾 E1.0 flexed biceps: medium-dark skin tone +1F4AA 1F3FF ; fully-qualified # 💪🏿 E1.0 flexed biceps: dark skin tone +1F9BE ; fully-qualified # 🦾 E12.0 mechanical arm +1F9BF ; fully-qualified # 🦿 E12.0 mechanical leg +1F9B5 ; fully-qualified # 🦵 E11.0 leg +1F9B5 1F3FB ; fully-qualified # 🦵🏻 E11.0 leg: light skin tone +1F9B5 1F3FC ; fully-qualified # 🦵🏼 E11.0 leg: medium-light skin tone +1F9B5 1F3FD ; fully-qualified # 🦵🏽 E11.0 leg: medium skin tone +1F9B5 1F3FE ; fully-qualified # 🦵🏾 E11.0 leg: medium-dark skin tone +1F9B5 1F3FF ; fully-qualified # 🦵🏿 E11.0 leg: dark skin tone +1F9B6 ; fully-qualified # 🦶 E11.0 foot +1F9B6 1F3FB ; fully-qualified # 🦶🏻 E11.0 foot: light skin tone +1F9B6 1F3FC ; fully-qualified # 🦶🏼 E11.0 foot: medium-light skin tone +1F9B6 1F3FD ; fully-qualified # 🦶🏽 E11.0 foot: medium skin tone +1F9B6 1F3FE ; fully-qualified # 🦶🏾 E11.0 foot: medium-dark skin tone +1F9B6 1F3FF ; fully-qualified # 🦶🏿 E11.0 foot: dark skin tone +1F442 ; fully-qualified # 👂 E0.6 ear +1F442 1F3FB ; fully-qualified # 👂🏻 E1.0 ear: light skin tone +1F442 1F3FC ; fully-qualified # 👂🏼 E1.0 ear: medium-light skin tone +1F442 1F3FD ; fully-qualified # 👂🏽 E1.0 ear: medium skin tone +1F442 1F3FE ; fully-qualified # 👂🏾 E1.0 ear: medium-dark skin tone +1F442 1F3FF ; fully-qualified # 👂🏿 E1.0 ear: dark skin tone +1F9BB ; fully-qualified # 🦻 E12.0 ear with hearing aid +1F9BB 1F3FB ; fully-qualified # 🦻🏻 E12.0 ear with hearing aid: light skin tone +1F9BB 1F3FC ; fully-qualified # 🦻🏼 E12.0 ear with hearing aid: medium-light skin tone +1F9BB 1F3FD ; fully-qualified # 🦻🏽 E12.0 ear with hearing aid: medium skin tone +1F9BB 1F3FE ; fully-qualified # 🦻🏾 E12.0 ear with hearing aid: medium-dark skin tone +1F9BB 1F3FF ; fully-qualified # 🦻🏿 E12.0 ear with hearing aid: dark skin tone +1F443 ; fully-qualified # 👃 E0.6 nose +1F443 1F3FB ; fully-qualified # 👃🏻 E1.0 nose: light skin tone +1F443 1F3FC ; fully-qualified # 👃🏼 E1.0 nose: medium-light skin tone +1F443 1F3FD ; fully-qualified # 👃🏽 E1.0 nose: medium skin tone +1F443 1F3FE ; fully-qualified # 👃🏾 E1.0 nose: medium-dark skin tone +1F443 1F3FF ; fully-qualified # 👃🏿 E1.0 nose: dark skin tone +1F9E0 ; fully-qualified # 🧠 E5.0 brain +1FAC0 ; fully-qualified # 🫀 E13.0 anatomical heart +1FAC1 ; fully-qualified # 🫁 E13.0 lungs +1F9B7 ; fully-qualified # 🦷 E11.0 tooth +1F9B4 ; fully-qualified # 🦴 E11.0 bone +1F440 ; fully-qualified # 👀 E0.6 eyes +1F441 FE0F ; fully-qualified # 👁️ E0.7 eye +1F441 ; unqualified # 👁 E0.7 eye +1F445 ; fully-qualified # 👅 E0.6 tongue +1F444 ; fully-qualified # 👄 E0.6 mouth +1FAE6 ; fully-qualified # 🫦 E14.0 biting lip + +# subgroup: person +1F476 ; fully-qualified # 👶 E0.6 baby +1F476 1F3FB ; fully-qualified # 👶🏻 E1.0 baby: light skin tone +1F476 1F3FC ; fully-qualified # 👶🏼 E1.0 baby: medium-light skin tone +1F476 1F3FD ; fully-qualified # 👶🏽 E1.0 baby: medium skin tone +1F476 1F3FE ; fully-qualified # 👶🏾 E1.0 baby: medium-dark skin tone +1F476 1F3FF ; fully-qualified # 👶🏿 E1.0 baby: dark skin tone +1F9D2 ; fully-qualified # 🧒 E5.0 child +1F9D2 1F3FB ; fully-qualified # 🧒🏻 E5.0 child: light skin tone +1F9D2 1F3FC ; fully-qualified # 🧒🏼 E5.0 child: medium-light skin tone +1F9D2 1F3FD ; fully-qualified # 🧒🏽 E5.0 child: medium skin tone +1F9D2 1F3FE ; fully-qualified # 🧒🏾 E5.0 child: medium-dark skin tone +1F9D2 1F3FF ; fully-qualified # 🧒🏿 E5.0 child: dark skin tone +1F466 ; fully-qualified # 👦 E0.6 boy +1F466 1F3FB ; fully-qualified # 👦🏻 E1.0 boy: light skin tone +1F466 1F3FC ; fully-qualified # 👦🏼 E1.0 boy: medium-light skin tone +1F466 1F3FD ; fully-qualified # 👦🏽 E1.0 boy: medium skin tone +1F466 1F3FE ; fully-qualified # 👦🏾 E1.0 boy: medium-dark skin tone +1F466 1F3FF ; fully-qualified # 👦🏿 E1.0 boy: dark skin tone +1F467 ; fully-qualified # 👧 E0.6 girl +1F467 1F3FB ; fully-qualified # 👧🏻 E1.0 girl: light skin tone +1F467 1F3FC ; fully-qualified # 👧🏼 E1.0 girl: medium-light skin tone +1F467 1F3FD ; fully-qualified # 👧🏽 E1.0 girl: medium skin tone +1F467 1F3FE ; fully-qualified # 👧🏾 E1.0 girl: medium-dark skin tone +1F467 1F3FF ; fully-qualified # 👧🏿 E1.0 girl: dark skin tone +1F9D1 ; fully-qualified # 🧑 E5.0 person +1F9D1 1F3FB ; fully-qualified # 🧑🏻 E5.0 person: light skin tone +1F9D1 1F3FC ; fully-qualified # 🧑🏼 E5.0 person: medium-light skin tone +1F9D1 1F3FD ; fully-qualified # 🧑🏽 E5.0 person: medium skin tone +1F9D1 1F3FE ; fully-qualified # 🧑🏾 E5.0 person: medium-dark skin tone +1F9D1 1F3FF ; fully-qualified # 🧑🏿 E5.0 person: dark skin tone +1F471 ; fully-qualified # 👱 E0.6 person: blond hair +1F471 1F3FB ; fully-qualified # 👱🏻 E1.0 person: light skin tone, blond hair +1F471 1F3FC ; fully-qualified # 👱🏼 E1.0 person: medium-light skin tone, blond hair +1F471 1F3FD ; fully-qualified # 👱🏽 E1.0 person: medium skin tone, blond hair +1F471 1F3FE ; fully-qualified # 👱🏾 E1.0 person: medium-dark skin tone, blond hair +1F471 1F3FF ; fully-qualified # 👱🏿 E1.0 person: dark skin tone, blond hair +1F468 ; fully-qualified # 👨 E0.6 man +1F468 1F3FB ; fully-qualified # 👨🏻 E1.0 man: light skin tone +1F468 1F3FC ; fully-qualified # 👨🏼 E1.0 man: medium-light skin tone +1F468 1F3FD ; fully-qualified # 👨🏽 E1.0 man: medium skin tone +1F468 1F3FE ; fully-qualified # 👨🏾 E1.0 man: medium-dark skin tone +1F468 1F3FF ; fully-qualified # 👨🏿 E1.0 man: dark skin tone +1F9D4 ; fully-qualified # 🧔 E5.0 person: beard +1F9D4 1F3FB ; fully-qualified # 🧔🏻 E5.0 person: light skin tone, beard +1F9D4 1F3FC ; fully-qualified # 🧔🏼 E5.0 person: medium-light skin tone, beard +1F9D4 1F3FD ; fully-qualified # 🧔🏽 E5.0 person: medium skin tone, beard +1F9D4 1F3FE ; fully-qualified # 🧔🏾 E5.0 person: medium-dark skin tone, beard +1F9D4 1F3FF ; fully-qualified # 🧔🏿 E5.0 person: dark skin tone, beard +1F9D4 200D 2642 FE0F ; fully-qualified # 🧔‍♂️ E13.1 man: beard +1F9D4 200D 2642 ; minimally-qualified # 🧔‍♂ E13.1 man: beard +1F9D4 1F3FB 200D 2642 FE0F ; fully-qualified # 🧔🏻‍♂️ E13.1 man: light skin tone, beard +1F9D4 1F3FB 200D 2642 ; minimally-qualified # 🧔🏻‍♂ E13.1 man: light skin tone, beard +1F9D4 1F3FC 200D 2642 FE0F ; fully-qualified # 🧔🏼‍♂️ E13.1 man: medium-light skin tone, beard +1F9D4 1F3FC 200D 2642 ; minimally-qualified # 🧔🏼‍♂ E13.1 man: medium-light skin tone, beard +1F9D4 1F3FD 200D 2642 FE0F ; fully-qualified # 🧔🏽‍♂️ E13.1 man: medium skin tone, beard +1F9D4 1F3FD 200D 2642 ; minimally-qualified # 🧔🏽‍♂ E13.1 man: medium skin tone, beard +1F9D4 1F3FE 200D 2642 FE0F ; fully-qualified # 🧔🏾‍♂️ E13.1 man: medium-dark skin tone, beard +1F9D4 1F3FE 200D 2642 ; minimally-qualified # 🧔🏾‍♂ E13.1 man: medium-dark skin tone, beard +1F9D4 1F3FF 200D 2642 FE0F ; fully-qualified # 🧔🏿‍♂️ E13.1 man: dark skin tone, beard +1F9D4 1F3FF 200D 2642 ; minimally-qualified # 🧔🏿‍♂ E13.1 man: dark skin tone, beard +1F9D4 200D 2640 FE0F ; fully-qualified # 🧔‍♀️ E13.1 woman: beard +1F9D4 200D 2640 ; minimally-qualified # 🧔‍♀ E13.1 woman: beard +1F9D4 1F3FB 200D 2640 FE0F ; fully-qualified # 🧔🏻‍♀️ E13.1 woman: light skin tone, beard +1F9D4 1F3FB 200D 2640 ; minimally-qualified # 🧔🏻‍♀ E13.1 woman: light skin tone, beard +1F9D4 1F3FC 200D 2640 FE0F ; fully-qualified # 🧔🏼‍♀️ E13.1 woman: medium-light skin tone, beard +1F9D4 1F3FC 200D 2640 ; minimally-qualified # 🧔🏼‍♀ E13.1 woman: medium-light skin tone, beard +1F9D4 1F3FD 200D 2640 FE0F ; fully-qualified # 🧔🏽‍♀️ E13.1 woman: medium skin tone, beard +1F9D4 1F3FD 200D 2640 ; minimally-qualified # 🧔🏽‍♀ E13.1 woman: medium skin tone, beard +1F9D4 1F3FE 200D 2640 FE0F ; fully-qualified # 🧔🏾‍♀️ E13.1 woman: medium-dark skin tone, beard +1F9D4 1F3FE 200D 2640 ; minimally-qualified # 🧔🏾‍♀ E13.1 woman: medium-dark skin tone, beard +1F9D4 1F3FF 200D 2640 FE0F ; fully-qualified # 🧔🏿‍♀️ E13.1 woman: dark skin tone, beard +1F9D4 1F3FF 200D 2640 ; minimally-qualified # 🧔🏿‍♀ E13.1 woman: dark skin tone, beard +1F468 200D 1F9B0 ; fully-qualified # 👨‍🦰 E11.0 man: red hair +1F468 1F3FB 200D 1F9B0 ; fully-qualified # 👨🏻‍🦰 E11.0 man: light skin tone, red hair +1F468 1F3FC 200D 1F9B0 ; fully-qualified # 👨🏼‍🦰 E11.0 man: medium-light skin tone, red hair +1F468 1F3FD 200D 1F9B0 ; fully-qualified # 👨🏽‍🦰 E11.0 man: medium skin tone, red hair +1F468 1F3FE 200D 1F9B0 ; fully-qualified # 👨🏾‍🦰 E11.0 man: medium-dark skin tone, red hair +1F468 1F3FF 200D 1F9B0 ; fully-qualified # 👨🏿‍🦰 E11.0 man: dark skin tone, red hair +1F468 200D 1F9B1 ; fully-qualified # 👨‍🦱 E11.0 man: curly hair +1F468 1F3FB 200D 1F9B1 ; fully-qualified # 👨🏻‍🦱 E11.0 man: light skin tone, curly hair +1F468 1F3FC 200D 1F9B1 ; fully-qualified # 👨🏼‍🦱 E11.0 man: medium-light skin tone, curly hair +1F468 1F3FD 200D 1F9B1 ; fully-qualified # 👨🏽‍🦱 E11.0 man: medium skin tone, curly hair +1F468 1F3FE 200D 1F9B1 ; fully-qualified # 👨🏾‍🦱 E11.0 man: medium-dark skin tone, curly hair +1F468 1F3FF 200D 1F9B1 ; fully-qualified # 👨🏿‍🦱 E11.0 man: dark skin tone, curly hair +1F468 200D 1F9B3 ; fully-qualified # 👨‍🦳 E11.0 man: white hair +1F468 1F3FB 200D 1F9B3 ; fully-qualified # 👨🏻‍🦳 E11.0 man: light skin tone, white hair +1F468 1F3FC 200D 1F9B3 ; fully-qualified # 👨🏼‍🦳 E11.0 man: medium-light skin tone, white hair +1F468 1F3FD 200D 1F9B3 ; fully-qualified # 👨🏽‍🦳 E11.0 man: medium skin tone, white hair +1F468 1F3FE 200D 1F9B3 ; fully-qualified # 👨🏾‍🦳 E11.0 man: medium-dark skin tone, white hair +1F468 1F3FF 200D 1F9B3 ; fully-qualified # 👨🏿‍🦳 E11.0 man: dark skin tone, white hair +1F468 200D 1F9B2 ; fully-qualified # 👨‍🦲 E11.0 man: bald +1F468 1F3FB 200D 1F9B2 ; fully-qualified # 👨🏻‍🦲 E11.0 man: light skin tone, bald +1F468 1F3FC 200D 1F9B2 ; fully-qualified # 👨🏼‍🦲 E11.0 man: medium-light skin tone, bald +1F468 1F3FD 200D 1F9B2 ; fully-qualified # 👨🏽‍🦲 E11.0 man: medium skin tone, bald +1F468 1F3FE 200D 1F9B2 ; fully-qualified # 👨🏾‍🦲 E11.0 man: medium-dark skin tone, bald +1F468 1F3FF 200D 1F9B2 ; fully-qualified # 👨🏿‍🦲 E11.0 man: dark skin tone, bald +1F469 ; fully-qualified # 👩 E0.6 woman +1F469 1F3FB ; fully-qualified # 👩🏻 E1.0 woman: light skin tone +1F469 1F3FC ; fully-qualified # 👩🏼 E1.0 woman: medium-light skin tone +1F469 1F3FD ; fully-qualified # 👩🏽 E1.0 woman: medium skin tone +1F469 1F3FE ; fully-qualified # 👩🏾 E1.0 woman: medium-dark skin tone +1F469 1F3FF ; fully-qualified # 👩🏿 E1.0 woman: dark skin tone +1F469 200D 1F9B0 ; fully-qualified # 👩‍🦰 E11.0 woman: red hair +1F469 1F3FB 200D 1F9B0 ; fully-qualified # 👩🏻‍🦰 E11.0 woman: light skin tone, red hair +1F469 1F3FC 200D 1F9B0 ; fully-qualified # 👩🏼‍🦰 E11.0 woman: medium-light skin tone, red hair +1F469 1F3FD 200D 1F9B0 ; fully-qualified # 👩🏽‍🦰 E11.0 woman: medium skin tone, red hair +1F469 1F3FE 200D 1F9B0 ; fully-qualified # 👩🏾‍🦰 E11.0 woman: medium-dark skin tone, red hair +1F469 1F3FF 200D 1F9B0 ; fully-qualified # 👩🏿‍🦰 E11.0 woman: dark skin tone, red hair +1F9D1 200D 1F9B0 ; fully-qualified # 🧑‍🦰 E12.1 person: red hair +1F9D1 1F3FB 200D 1F9B0 ; fully-qualified # 🧑🏻‍🦰 E12.1 person: light skin tone, red hair +1F9D1 1F3FC 200D 1F9B0 ; fully-qualified # 🧑🏼‍🦰 E12.1 person: medium-light skin tone, red hair +1F9D1 1F3FD 200D 1F9B0 ; fully-qualified # 🧑🏽‍🦰 E12.1 person: medium skin tone, red hair +1F9D1 1F3FE 200D 1F9B0 ; fully-qualified # 🧑🏾‍🦰 E12.1 person: medium-dark skin tone, red hair +1F9D1 1F3FF 200D 1F9B0 ; fully-qualified # 🧑🏿‍🦰 E12.1 person: dark skin tone, red hair +1F469 200D 1F9B1 ; fully-qualified # 👩‍🦱 E11.0 woman: curly hair +1F469 1F3FB 200D 1F9B1 ; fully-qualified # 👩🏻‍🦱 E11.0 woman: light skin tone, curly hair +1F469 1F3FC 200D 1F9B1 ; fully-qualified # 👩🏼‍🦱 E11.0 woman: medium-light skin tone, curly hair +1F469 1F3FD 200D 1F9B1 ; fully-qualified # 👩🏽‍🦱 E11.0 woman: medium skin tone, curly hair +1F469 1F3FE 200D 1F9B1 ; fully-qualified # 👩🏾‍🦱 E11.0 woman: medium-dark skin tone, curly hair +1F469 1F3FF 200D 1F9B1 ; fully-qualified # 👩🏿‍🦱 E11.0 woman: dark skin tone, curly hair +1F9D1 200D 1F9B1 ; fully-qualified # 🧑‍🦱 E12.1 person: curly hair +1F9D1 1F3FB 200D 1F9B1 ; fully-qualified # 🧑🏻‍🦱 E12.1 person: light skin tone, curly hair +1F9D1 1F3FC 200D 1F9B1 ; fully-qualified # 🧑🏼‍🦱 E12.1 person: medium-light skin tone, curly hair +1F9D1 1F3FD 200D 1F9B1 ; fully-qualified # 🧑🏽‍🦱 E12.1 person: medium skin tone, curly hair +1F9D1 1F3FE 200D 1F9B1 ; fully-qualified # 🧑🏾‍🦱 E12.1 person: medium-dark skin tone, curly hair +1F9D1 1F3FF 200D 1F9B1 ; fully-qualified # 🧑🏿‍🦱 E12.1 person: dark skin tone, curly hair +1F469 200D 1F9B3 ; fully-qualified # 👩‍🦳 E11.0 woman: white hair +1F469 1F3FB 200D 1F9B3 ; fully-qualified # 👩🏻‍🦳 E11.0 woman: light skin tone, white hair +1F469 1F3FC 200D 1F9B3 ; fully-qualified # 👩🏼‍🦳 E11.0 woman: medium-light skin tone, white hair +1F469 1F3FD 200D 1F9B3 ; fully-qualified # 👩🏽‍🦳 E11.0 woman: medium skin tone, white hair +1F469 1F3FE 200D 1F9B3 ; fully-qualified # 👩🏾‍🦳 E11.0 woman: medium-dark skin tone, white hair +1F469 1F3FF 200D 1F9B3 ; fully-qualified # 👩🏿‍🦳 E11.0 woman: dark skin tone, white hair +1F9D1 200D 1F9B3 ; fully-qualified # 🧑‍🦳 E12.1 person: white hair +1F9D1 1F3FB 200D 1F9B3 ; fully-qualified # 🧑🏻‍🦳 E12.1 person: light skin tone, white hair +1F9D1 1F3FC 200D 1F9B3 ; fully-qualified # 🧑🏼‍🦳 E12.1 person: medium-light skin tone, white hair +1F9D1 1F3FD 200D 1F9B3 ; fully-qualified # 🧑🏽‍🦳 E12.1 person: medium skin tone, white hair +1F9D1 1F3FE 200D 1F9B3 ; fully-qualified # 🧑🏾‍🦳 E12.1 person: medium-dark skin tone, white hair +1F9D1 1F3FF 200D 1F9B3 ; fully-qualified # 🧑🏿‍🦳 E12.1 person: dark skin tone, white hair +1F469 200D 1F9B2 ; fully-qualified # 👩‍🦲 E11.0 woman: bald +1F469 1F3FB 200D 1F9B2 ; fully-qualified # 👩🏻‍🦲 E11.0 woman: light skin tone, bald +1F469 1F3FC 200D 1F9B2 ; fully-qualified # 👩🏼‍🦲 E11.0 woman: medium-light skin tone, bald +1F469 1F3FD 200D 1F9B2 ; fully-qualified # 👩🏽‍🦲 E11.0 woman: medium skin tone, bald +1F469 1F3FE 200D 1F9B2 ; fully-qualified # 👩🏾‍🦲 E11.0 woman: medium-dark skin tone, bald +1F469 1F3FF 200D 1F9B2 ; fully-qualified # 👩🏿‍🦲 E11.0 woman: dark skin tone, bald +1F9D1 200D 1F9B2 ; fully-qualified # 🧑‍🦲 E12.1 person: bald +1F9D1 1F3FB 200D 1F9B2 ; fully-qualified # 🧑🏻‍🦲 E12.1 person: light skin tone, bald +1F9D1 1F3FC 200D 1F9B2 ; fully-qualified # 🧑🏼‍🦲 E12.1 person: medium-light skin tone, bald +1F9D1 1F3FD 200D 1F9B2 ; fully-qualified # 🧑🏽‍🦲 E12.1 person: medium skin tone, bald +1F9D1 1F3FE 200D 1F9B2 ; fully-qualified # 🧑🏾‍🦲 E12.1 person: medium-dark skin tone, bald +1F9D1 1F3FF 200D 1F9B2 ; fully-qualified # 🧑🏿‍🦲 E12.1 person: dark skin tone, bald +1F471 200D 2640 FE0F ; fully-qualified # 👱‍♀️ E4.0 woman: blond hair +1F471 200D 2640 ; minimally-qualified # 👱‍♀ E4.0 woman: blond hair +1F471 1F3FB 200D 2640 FE0F ; fully-qualified # 👱🏻‍♀️ E4.0 woman: light skin tone, blond hair +1F471 1F3FB 200D 2640 ; minimally-qualified # 👱🏻‍♀ E4.0 woman: light skin tone, blond hair +1F471 1F3FC 200D 2640 FE0F ; fully-qualified # 👱🏼‍♀️ E4.0 woman: medium-light skin tone, blond hair +1F471 1F3FC 200D 2640 ; minimally-qualified # 👱🏼‍♀ E4.0 woman: medium-light skin tone, blond hair +1F471 1F3FD 200D 2640 FE0F ; fully-qualified # 👱🏽‍♀️ E4.0 woman: medium skin tone, blond hair +1F471 1F3FD 200D 2640 ; minimally-qualified # 👱🏽‍♀ E4.0 woman: medium skin tone, blond hair +1F471 1F3FE 200D 2640 FE0F ; fully-qualified # 👱🏾‍♀️ E4.0 woman: medium-dark skin tone, blond hair +1F471 1F3FE 200D 2640 ; minimally-qualified # 👱🏾‍♀ E4.0 woman: medium-dark skin tone, blond hair +1F471 1F3FF 200D 2640 FE0F ; fully-qualified # 👱🏿‍♀️ E4.0 woman: dark skin tone, blond hair +1F471 1F3FF 200D 2640 ; minimally-qualified # 👱🏿‍♀ E4.0 woman: dark skin tone, blond hair +1F471 200D 2642 FE0F ; fully-qualified # 👱‍♂️ E4.0 man: blond hair +1F471 200D 2642 ; minimally-qualified # 👱‍♂ E4.0 man: blond hair +1F471 1F3FB 200D 2642 FE0F ; fully-qualified # 👱🏻‍♂️ E4.0 man: light skin tone, blond hair +1F471 1F3FB 200D 2642 ; minimally-qualified # 👱🏻‍♂ E4.0 man: light skin tone, blond hair +1F471 1F3FC 200D 2642 FE0F ; fully-qualified # 👱🏼‍♂️ E4.0 man: medium-light skin tone, blond hair +1F471 1F3FC 200D 2642 ; minimally-qualified # 👱🏼‍♂ E4.0 man: medium-light skin tone, blond hair +1F471 1F3FD 200D 2642 FE0F ; fully-qualified # 👱🏽‍♂️ E4.0 man: medium skin tone, blond hair +1F471 1F3FD 200D 2642 ; minimally-qualified # 👱🏽‍♂ E4.0 man: medium skin tone, blond hair +1F471 1F3FE 200D 2642 FE0F ; fully-qualified # 👱🏾‍♂️ E4.0 man: medium-dark skin tone, blond hair +1F471 1F3FE 200D 2642 ; minimally-qualified # 👱🏾‍♂ E4.0 man: medium-dark skin tone, blond hair +1F471 1F3FF 200D 2642 FE0F ; fully-qualified # 👱🏿‍♂️ E4.0 man: dark skin tone, blond hair +1F471 1F3FF 200D 2642 ; minimally-qualified # 👱🏿‍♂ E4.0 man: dark skin tone, blond hair +1F9D3 ; fully-qualified # 🧓 E5.0 older person +1F9D3 1F3FB ; fully-qualified # 🧓🏻 E5.0 older person: light skin tone +1F9D3 1F3FC ; fully-qualified # 🧓🏼 E5.0 older person: medium-light skin tone +1F9D3 1F3FD ; fully-qualified # 🧓🏽 E5.0 older person: medium skin tone +1F9D3 1F3FE ; fully-qualified # 🧓🏾 E5.0 older person: medium-dark skin tone +1F9D3 1F3FF ; fully-qualified # 🧓🏿 E5.0 older person: dark skin tone +1F474 ; fully-qualified # 👴 E0.6 old man +1F474 1F3FB ; fully-qualified # 👴🏻 E1.0 old man: light skin tone +1F474 1F3FC ; fully-qualified # 👴🏼 E1.0 old man: medium-light skin tone +1F474 1F3FD ; fully-qualified # 👴🏽 E1.0 old man: medium skin tone +1F474 1F3FE ; fully-qualified # 👴🏾 E1.0 old man: medium-dark skin tone +1F474 1F3FF ; fully-qualified # 👴🏿 E1.0 old man: dark skin tone +1F475 ; fully-qualified # 👵 E0.6 old woman +1F475 1F3FB ; fully-qualified # 👵🏻 E1.0 old woman: light skin tone +1F475 1F3FC ; fully-qualified # 👵🏼 E1.0 old woman: medium-light skin tone +1F475 1F3FD ; fully-qualified # 👵🏽 E1.0 old woman: medium skin tone +1F475 1F3FE ; fully-qualified # 👵🏾 E1.0 old woman: medium-dark skin tone +1F475 1F3FF ; fully-qualified # 👵🏿 E1.0 old woman: dark skin tone + +# subgroup: person-gesture +1F64D ; fully-qualified # 🙍 E0.6 person frowning +1F64D 1F3FB ; fully-qualified # 🙍🏻 E1.0 person frowning: light skin tone +1F64D 1F3FC ; fully-qualified # 🙍🏼 E1.0 person frowning: medium-light skin tone +1F64D 1F3FD ; fully-qualified # 🙍🏽 E1.0 person frowning: medium skin tone +1F64D 1F3FE ; fully-qualified # 🙍🏾 E1.0 person frowning: medium-dark skin tone +1F64D 1F3FF ; fully-qualified # 🙍🏿 E1.0 person frowning: dark skin tone +1F64D 200D 2642 FE0F ; fully-qualified # 🙍‍♂️ E4.0 man frowning +1F64D 200D 2642 ; minimally-qualified # 🙍‍♂ E4.0 man frowning +1F64D 1F3FB 200D 2642 FE0F ; fully-qualified # 🙍🏻‍♂️ E4.0 man frowning: light skin tone +1F64D 1F3FB 200D 2642 ; minimally-qualified # 🙍🏻‍♂ E4.0 man frowning: light skin tone +1F64D 1F3FC 200D 2642 FE0F ; fully-qualified # 🙍🏼‍♂️ E4.0 man frowning: medium-light skin tone +1F64D 1F3FC 200D 2642 ; minimally-qualified # 🙍🏼‍♂ E4.0 man frowning: medium-light skin tone +1F64D 1F3FD 200D 2642 FE0F ; fully-qualified # 🙍🏽‍♂️ E4.0 man frowning: medium skin tone +1F64D 1F3FD 200D 2642 ; minimally-qualified # 🙍🏽‍♂ E4.0 man frowning: medium skin tone +1F64D 1F3FE 200D 2642 FE0F ; fully-qualified # 🙍🏾‍♂️ E4.0 man frowning: medium-dark skin tone +1F64D 1F3FE 200D 2642 ; minimally-qualified # 🙍🏾‍♂ E4.0 man frowning: medium-dark skin tone +1F64D 1F3FF 200D 2642 FE0F ; fully-qualified # 🙍🏿‍♂️ E4.0 man frowning: dark skin tone +1F64D 1F3FF 200D 2642 ; minimally-qualified # 🙍🏿‍♂ E4.0 man frowning: dark skin tone +1F64D 200D 2640 FE0F ; fully-qualified # 🙍‍♀️ E4.0 woman frowning +1F64D 200D 2640 ; minimally-qualified # 🙍‍♀ E4.0 woman frowning +1F64D 1F3FB 200D 2640 FE0F ; fully-qualified # 🙍🏻‍♀️ E4.0 woman frowning: light skin tone +1F64D 1F3FB 200D 2640 ; minimally-qualified # 🙍🏻‍♀ E4.0 woman frowning: light skin tone +1F64D 1F3FC 200D 2640 FE0F ; fully-qualified # 🙍🏼‍♀️ E4.0 woman frowning: medium-light skin tone +1F64D 1F3FC 200D 2640 ; minimally-qualified # 🙍🏼‍♀ E4.0 woman frowning: medium-light skin tone +1F64D 1F3FD 200D 2640 FE0F ; fully-qualified # 🙍🏽‍♀️ E4.0 woman frowning: medium skin tone +1F64D 1F3FD 200D 2640 ; minimally-qualified # 🙍🏽‍♀ E4.0 woman frowning: medium skin tone +1F64D 1F3FE 200D 2640 FE0F ; fully-qualified # 🙍🏾‍♀️ E4.0 woman frowning: medium-dark skin tone +1F64D 1F3FE 200D 2640 ; minimally-qualified # 🙍🏾‍♀ E4.0 woman frowning: medium-dark skin tone +1F64D 1F3FF 200D 2640 FE0F ; fully-qualified # 🙍🏿‍♀️ E4.0 woman frowning: dark skin tone +1F64D 1F3FF 200D 2640 ; minimally-qualified # 🙍🏿‍♀ E4.0 woman frowning: dark skin tone +1F64E ; fully-qualified # 🙎 E0.6 person pouting +1F64E 1F3FB ; fully-qualified # 🙎🏻 E1.0 person pouting: light skin tone +1F64E 1F3FC ; fully-qualified # 🙎🏼 E1.0 person pouting: medium-light skin tone +1F64E 1F3FD ; fully-qualified # 🙎🏽 E1.0 person pouting: medium skin tone +1F64E 1F3FE ; fully-qualified # 🙎🏾 E1.0 person pouting: medium-dark skin tone +1F64E 1F3FF ; fully-qualified # 🙎🏿 E1.0 person pouting: dark skin tone +1F64E 200D 2642 FE0F ; fully-qualified # 🙎‍♂️ E4.0 man pouting +1F64E 200D 2642 ; minimally-qualified # 🙎‍♂ E4.0 man pouting +1F64E 1F3FB 200D 2642 FE0F ; fully-qualified # 🙎🏻‍♂️ E4.0 man pouting: light skin tone +1F64E 1F3FB 200D 2642 ; minimally-qualified # 🙎🏻‍♂ E4.0 man pouting: light skin tone +1F64E 1F3FC 200D 2642 FE0F ; fully-qualified # 🙎🏼‍♂️ E4.0 man pouting: medium-light skin tone +1F64E 1F3FC 200D 2642 ; minimally-qualified # 🙎🏼‍♂ E4.0 man pouting: medium-light skin tone +1F64E 1F3FD 200D 2642 FE0F ; fully-qualified # 🙎🏽‍♂️ E4.0 man pouting: medium skin tone +1F64E 1F3FD 200D 2642 ; minimally-qualified # 🙎🏽‍♂ E4.0 man pouting: medium skin tone +1F64E 1F3FE 200D 2642 FE0F ; fully-qualified # 🙎🏾‍♂️ E4.0 man pouting: medium-dark skin tone +1F64E 1F3FE 200D 2642 ; minimally-qualified # 🙎🏾‍♂ E4.0 man pouting: medium-dark skin tone +1F64E 1F3FF 200D 2642 FE0F ; fully-qualified # 🙎🏿‍♂️ E4.0 man pouting: dark skin tone +1F64E 1F3FF 200D 2642 ; minimally-qualified # 🙎🏿‍♂ E4.0 man pouting: dark skin tone +1F64E 200D 2640 FE0F ; fully-qualified # 🙎‍♀️ E4.0 woman pouting +1F64E 200D 2640 ; minimally-qualified # 🙎‍♀ E4.0 woman pouting +1F64E 1F3FB 200D 2640 FE0F ; fully-qualified # 🙎🏻‍♀️ E4.0 woman pouting: light skin tone +1F64E 1F3FB 200D 2640 ; minimally-qualified # 🙎🏻‍♀ E4.0 woman pouting: light skin tone +1F64E 1F3FC 200D 2640 FE0F ; fully-qualified # 🙎🏼‍♀️ E4.0 woman pouting: medium-light skin tone +1F64E 1F3FC 200D 2640 ; minimally-qualified # 🙎🏼‍♀ E4.0 woman pouting: medium-light skin tone +1F64E 1F3FD 200D 2640 FE0F ; fully-qualified # 🙎🏽‍♀️ E4.0 woman pouting: medium skin tone +1F64E 1F3FD 200D 2640 ; minimally-qualified # 🙎🏽‍♀ E4.0 woman pouting: medium skin tone +1F64E 1F3FE 200D 2640 FE0F ; fully-qualified # 🙎🏾‍♀️ E4.0 woman pouting: medium-dark skin tone +1F64E 1F3FE 200D 2640 ; minimally-qualified # 🙎🏾‍♀ E4.0 woman pouting: medium-dark skin tone +1F64E 1F3FF 200D 2640 FE0F ; fully-qualified # 🙎🏿‍♀️ E4.0 woman pouting: dark skin tone +1F64E 1F3FF 200D 2640 ; minimally-qualified # 🙎🏿‍♀ E4.0 woman pouting: dark skin tone +1F645 ; fully-qualified # 🙅 E0.6 person gesturing NO +1F645 1F3FB ; fully-qualified # 🙅🏻 E1.0 person gesturing NO: light skin tone +1F645 1F3FC ; fully-qualified # 🙅🏼 E1.0 person gesturing NO: medium-light skin tone +1F645 1F3FD ; fully-qualified # 🙅🏽 E1.0 person gesturing NO: medium skin tone +1F645 1F3FE ; fully-qualified # 🙅🏾 E1.0 person gesturing NO: medium-dark skin tone +1F645 1F3FF ; fully-qualified # 🙅🏿 E1.0 person gesturing NO: dark skin tone +1F645 200D 2642 FE0F ; fully-qualified # 🙅‍♂️ E4.0 man gesturing NO +1F645 200D 2642 ; minimally-qualified # 🙅‍♂ E4.0 man gesturing NO +1F645 1F3FB 200D 2642 FE0F ; fully-qualified # 🙅🏻‍♂️ E4.0 man gesturing NO: light skin tone +1F645 1F3FB 200D 2642 ; minimally-qualified # 🙅🏻‍♂ E4.0 man gesturing NO: light skin tone +1F645 1F3FC 200D 2642 FE0F ; fully-qualified # 🙅🏼‍♂️ E4.0 man gesturing NO: medium-light skin tone +1F645 1F3FC 200D 2642 ; minimally-qualified # 🙅🏼‍♂ E4.0 man gesturing NO: medium-light skin tone +1F645 1F3FD 200D 2642 FE0F ; fully-qualified # 🙅🏽‍♂️ E4.0 man gesturing NO: medium skin tone +1F645 1F3FD 200D 2642 ; minimally-qualified # 🙅🏽‍♂ E4.0 man gesturing NO: medium skin tone +1F645 1F3FE 200D 2642 FE0F ; fully-qualified # 🙅🏾‍♂️ E4.0 man gesturing NO: medium-dark skin tone +1F645 1F3FE 200D 2642 ; minimally-qualified # 🙅🏾‍♂ E4.0 man gesturing NO: medium-dark skin tone +1F645 1F3FF 200D 2642 FE0F ; fully-qualified # 🙅🏿‍♂️ E4.0 man gesturing NO: dark skin tone +1F645 1F3FF 200D 2642 ; minimally-qualified # 🙅🏿‍♂ E4.0 man gesturing NO: dark skin tone +1F645 200D 2640 FE0F ; fully-qualified # 🙅‍♀️ E4.0 woman gesturing NO +1F645 200D 2640 ; minimally-qualified # 🙅‍♀ E4.0 woman gesturing NO +1F645 1F3FB 200D 2640 FE0F ; fully-qualified # 🙅🏻‍♀️ E4.0 woman gesturing NO: light skin tone +1F645 1F3FB 200D 2640 ; minimally-qualified # 🙅🏻‍♀ E4.0 woman gesturing NO: light skin tone +1F645 1F3FC 200D 2640 FE0F ; fully-qualified # 🙅🏼‍♀️ E4.0 woman gesturing NO: medium-light skin tone +1F645 1F3FC 200D 2640 ; minimally-qualified # 🙅🏼‍♀ E4.0 woman gesturing NO: medium-light skin tone +1F645 1F3FD 200D 2640 FE0F ; fully-qualified # 🙅🏽‍♀️ E4.0 woman gesturing NO: medium skin tone +1F645 1F3FD 200D 2640 ; minimally-qualified # 🙅🏽‍♀ E4.0 woman gesturing NO: medium skin tone +1F645 1F3FE 200D 2640 FE0F ; fully-qualified # 🙅🏾‍♀️ E4.0 woman gesturing NO: medium-dark skin tone +1F645 1F3FE 200D 2640 ; minimally-qualified # 🙅🏾‍♀ E4.0 woman gesturing NO: medium-dark skin tone +1F645 1F3FF 200D 2640 FE0F ; fully-qualified # 🙅🏿‍♀️ E4.0 woman gesturing NO: dark skin tone +1F645 1F3FF 200D 2640 ; minimally-qualified # 🙅🏿‍♀ E4.0 woman gesturing NO: dark skin tone +1F646 ; fully-qualified # 🙆 E0.6 person gesturing OK +1F646 1F3FB ; fully-qualified # 🙆🏻 E1.0 person gesturing OK: light skin tone +1F646 1F3FC ; fully-qualified # 🙆🏼 E1.0 person gesturing OK: medium-light skin tone +1F646 1F3FD ; fully-qualified # 🙆🏽 E1.0 person gesturing OK: medium skin tone +1F646 1F3FE ; fully-qualified # 🙆🏾 E1.0 person gesturing OK: medium-dark skin tone +1F646 1F3FF ; fully-qualified # 🙆🏿 E1.0 person gesturing OK: dark skin tone +1F646 200D 2642 FE0F ; fully-qualified # 🙆‍♂️ E4.0 man gesturing OK +1F646 200D 2642 ; minimally-qualified # 🙆‍♂ E4.0 man gesturing OK +1F646 1F3FB 200D 2642 FE0F ; fully-qualified # 🙆🏻‍♂️ E4.0 man gesturing OK: light skin tone +1F646 1F3FB 200D 2642 ; minimally-qualified # 🙆🏻‍♂ E4.0 man gesturing OK: light skin tone +1F646 1F3FC 200D 2642 FE0F ; fully-qualified # 🙆🏼‍♂️ E4.0 man gesturing OK: medium-light skin tone +1F646 1F3FC 200D 2642 ; minimally-qualified # 🙆🏼‍♂ E4.0 man gesturing OK: medium-light skin tone +1F646 1F3FD 200D 2642 FE0F ; fully-qualified # 🙆🏽‍♂️ E4.0 man gesturing OK: medium skin tone +1F646 1F3FD 200D 2642 ; minimally-qualified # 🙆🏽‍♂ E4.0 man gesturing OK: medium skin tone +1F646 1F3FE 200D 2642 FE0F ; fully-qualified # 🙆🏾‍♂️ E4.0 man gesturing OK: medium-dark skin tone +1F646 1F3FE 200D 2642 ; minimally-qualified # 🙆🏾‍♂ E4.0 man gesturing OK: medium-dark skin tone +1F646 1F3FF 200D 2642 FE0F ; fully-qualified # 🙆🏿‍♂️ E4.0 man gesturing OK: dark skin tone +1F646 1F3FF 200D 2642 ; minimally-qualified # 🙆🏿‍♂ E4.0 man gesturing OK: dark skin tone +1F646 200D 2640 FE0F ; fully-qualified # 🙆‍♀️ E4.0 woman gesturing OK +1F646 200D 2640 ; minimally-qualified # 🙆‍♀ E4.0 woman gesturing OK +1F646 1F3FB 200D 2640 FE0F ; fully-qualified # 🙆🏻‍♀️ E4.0 woman gesturing OK: light skin tone +1F646 1F3FB 200D 2640 ; minimally-qualified # 🙆🏻‍♀ E4.0 woman gesturing OK: light skin tone +1F646 1F3FC 200D 2640 FE0F ; fully-qualified # 🙆🏼‍♀️ E4.0 woman gesturing OK: medium-light skin tone +1F646 1F3FC 200D 2640 ; minimally-qualified # 🙆🏼‍♀ E4.0 woman gesturing OK: medium-light skin tone +1F646 1F3FD 200D 2640 FE0F ; fully-qualified # 🙆🏽‍♀️ E4.0 woman gesturing OK: medium skin tone +1F646 1F3FD 200D 2640 ; minimally-qualified # 🙆🏽‍♀ E4.0 woman gesturing OK: medium skin tone +1F646 1F3FE 200D 2640 FE0F ; fully-qualified # 🙆🏾‍♀️ E4.0 woman gesturing OK: medium-dark skin tone +1F646 1F3FE 200D 2640 ; minimally-qualified # 🙆🏾‍♀ E4.0 woman gesturing OK: medium-dark skin tone +1F646 1F3FF 200D 2640 FE0F ; fully-qualified # 🙆🏿‍♀️ E4.0 woman gesturing OK: dark skin tone +1F646 1F3FF 200D 2640 ; minimally-qualified # 🙆🏿‍♀ E4.0 woman gesturing OK: dark skin tone +1F481 ; fully-qualified # 💁 E0.6 person tipping hand +1F481 1F3FB ; fully-qualified # 💁🏻 E1.0 person tipping hand: light skin tone +1F481 1F3FC ; fully-qualified # 💁🏼 E1.0 person tipping hand: medium-light skin tone +1F481 1F3FD ; fully-qualified # 💁🏽 E1.0 person tipping hand: medium skin tone +1F481 1F3FE ; fully-qualified # 💁🏾 E1.0 person tipping hand: medium-dark skin tone +1F481 1F3FF ; fully-qualified # 💁🏿 E1.0 person tipping hand: dark skin tone +1F481 200D 2642 FE0F ; fully-qualified # 💁‍♂️ E4.0 man tipping hand +1F481 200D 2642 ; minimally-qualified # 💁‍♂ E4.0 man tipping hand +1F481 1F3FB 200D 2642 FE0F ; fully-qualified # 💁🏻‍♂️ E4.0 man tipping hand: light skin tone +1F481 1F3FB 200D 2642 ; minimally-qualified # 💁🏻‍♂ E4.0 man tipping hand: light skin tone +1F481 1F3FC 200D 2642 FE0F ; fully-qualified # 💁🏼‍♂️ E4.0 man tipping hand: medium-light skin tone +1F481 1F3FC 200D 2642 ; minimally-qualified # 💁🏼‍♂ E4.0 man tipping hand: medium-light skin tone +1F481 1F3FD 200D 2642 FE0F ; fully-qualified # 💁🏽‍♂️ E4.0 man tipping hand: medium skin tone +1F481 1F3FD 200D 2642 ; minimally-qualified # 💁🏽‍♂ E4.0 man tipping hand: medium skin tone +1F481 1F3FE 200D 2642 FE0F ; fully-qualified # 💁🏾‍♂️ E4.0 man tipping hand: medium-dark skin tone +1F481 1F3FE 200D 2642 ; minimally-qualified # 💁🏾‍♂ E4.0 man tipping hand: medium-dark skin tone +1F481 1F3FF 200D 2642 FE0F ; fully-qualified # 💁🏿‍♂️ E4.0 man tipping hand: dark skin tone +1F481 1F3FF 200D 2642 ; minimally-qualified # 💁🏿‍♂ E4.0 man tipping hand: dark skin tone +1F481 200D 2640 FE0F ; fully-qualified # 💁‍♀️ E4.0 woman tipping hand +1F481 200D 2640 ; minimally-qualified # 💁‍♀ E4.0 woman tipping hand +1F481 1F3FB 200D 2640 FE0F ; fully-qualified # 💁🏻‍♀️ E4.0 woman tipping hand: light skin tone +1F481 1F3FB 200D 2640 ; minimally-qualified # 💁🏻‍♀ E4.0 woman tipping hand: light skin tone +1F481 1F3FC 200D 2640 FE0F ; fully-qualified # 💁🏼‍♀️ E4.0 woman tipping hand: medium-light skin tone +1F481 1F3FC 200D 2640 ; minimally-qualified # 💁🏼‍♀ E4.0 woman tipping hand: medium-light skin tone +1F481 1F3FD 200D 2640 FE0F ; fully-qualified # 💁🏽‍♀️ E4.0 woman tipping hand: medium skin tone +1F481 1F3FD 200D 2640 ; minimally-qualified # 💁🏽‍♀ E4.0 woman tipping hand: medium skin tone +1F481 1F3FE 200D 2640 FE0F ; fully-qualified # 💁🏾‍♀️ E4.0 woman tipping hand: medium-dark skin tone +1F481 1F3FE 200D 2640 ; minimally-qualified # 💁🏾‍♀ E4.0 woman tipping hand: medium-dark skin tone +1F481 1F3FF 200D 2640 FE0F ; fully-qualified # 💁🏿‍♀️ E4.0 woman tipping hand: dark skin tone +1F481 1F3FF 200D 2640 ; minimally-qualified # 💁🏿‍♀ E4.0 woman tipping hand: dark skin tone +1F64B ; fully-qualified # 🙋 E0.6 person raising hand +1F64B 1F3FB ; fully-qualified # 🙋🏻 E1.0 person raising hand: light skin tone +1F64B 1F3FC ; fully-qualified # 🙋🏼 E1.0 person raising hand: medium-light skin tone +1F64B 1F3FD ; fully-qualified # 🙋🏽 E1.0 person raising hand: medium skin tone +1F64B 1F3FE ; fully-qualified # 🙋🏾 E1.0 person raising hand: medium-dark skin tone +1F64B 1F3FF ; fully-qualified # 🙋🏿 E1.0 person raising hand: dark skin tone +1F64B 200D 2642 FE0F ; fully-qualified # 🙋‍♂️ E4.0 man raising hand +1F64B 200D 2642 ; minimally-qualified # 🙋‍♂ E4.0 man raising hand +1F64B 1F3FB 200D 2642 FE0F ; fully-qualified # 🙋🏻‍♂️ E4.0 man raising hand: light skin tone +1F64B 1F3FB 200D 2642 ; minimally-qualified # 🙋🏻‍♂ E4.0 man raising hand: light skin tone +1F64B 1F3FC 200D 2642 FE0F ; fully-qualified # 🙋🏼‍♂️ E4.0 man raising hand: medium-light skin tone +1F64B 1F3FC 200D 2642 ; minimally-qualified # 🙋🏼‍♂ E4.0 man raising hand: medium-light skin tone +1F64B 1F3FD 200D 2642 FE0F ; fully-qualified # 🙋🏽‍♂️ E4.0 man raising hand: medium skin tone +1F64B 1F3FD 200D 2642 ; minimally-qualified # 🙋🏽‍♂ E4.0 man raising hand: medium skin tone +1F64B 1F3FE 200D 2642 FE0F ; fully-qualified # 🙋🏾‍♂️ E4.0 man raising hand: medium-dark skin tone +1F64B 1F3FE 200D 2642 ; minimally-qualified # 🙋🏾‍♂ E4.0 man raising hand: medium-dark skin tone +1F64B 1F3FF 200D 2642 FE0F ; fully-qualified # 🙋🏿‍♂️ E4.0 man raising hand: dark skin tone +1F64B 1F3FF 200D 2642 ; minimally-qualified # 🙋🏿‍♂ E4.0 man raising hand: dark skin tone +1F64B 200D 2640 FE0F ; fully-qualified # 🙋‍♀️ E4.0 woman raising hand +1F64B 200D 2640 ; minimally-qualified # 🙋‍♀ E4.0 woman raising hand +1F64B 1F3FB 200D 2640 FE0F ; fully-qualified # 🙋🏻‍♀️ E4.0 woman raising hand: light skin tone +1F64B 1F3FB 200D 2640 ; minimally-qualified # 🙋🏻‍♀ E4.0 woman raising hand: light skin tone +1F64B 1F3FC 200D 2640 FE0F ; fully-qualified # 🙋🏼‍♀️ E4.0 woman raising hand: medium-light skin tone +1F64B 1F3FC 200D 2640 ; minimally-qualified # 🙋🏼‍♀ E4.0 woman raising hand: medium-light skin tone +1F64B 1F3FD 200D 2640 FE0F ; fully-qualified # 🙋🏽‍♀️ E4.0 woman raising hand: medium skin tone +1F64B 1F3FD 200D 2640 ; minimally-qualified # 🙋🏽‍♀ E4.0 woman raising hand: medium skin tone +1F64B 1F3FE 200D 2640 FE0F ; fully-qualified # 🙋🏾‍♀️ E4.0 woman raising hand: medium-dark skin tone +1F64B 1F3FE 200D 2640 ; minimally-qualified # 🙋🏾‍♀ E4.0 woman raising hand: medium-dark skin tone +1F64B 1F3FF 200D 2640 FE0F ; fully-qualified # 🙋🏿‍♀️ E4.0 woman raising hand: dark skin tone +1F64B 1F3FF 200D 2640 ; minimally-qualified # 🙋🏿‍♀ E4.0 woman raising hand: dark skin tone +1F9CF ; fully-qualified # 🧏 E12.0 deaf person +1F9CF 1F3FB ; fully-qualified # 🧏🏻 E12.0 deaf person: light skin tone +1F9CF 1F3FC ; fully-qualified # 🧏🏼 E12.0 deaf person: medium-light skin tone +1F9CF 1F3FD ; fully-qualified # 🧏🏽 E12.0 deaf person: medium skin tone +1F9CF 1F3FE ; fully-qualified # 🧏🏾 E12.0 deaf person: medium-dark skin tone +1F9CF 1F3FF ; fully-qualified # 🧏🏿 E12.0 deaf person: dark skin tone +1F9CF 200D 2642 FE0F ; fully-qualified # 🧏‍♂️ E12.0 deaf man +1F9CF 200D 2642 ; minimally-qualified # 🧏‍♂ E12.0 deaf man +1F9CF 1F3FB 200D 2642 FE0F ; fully-qualified # 🧏🏻‍♂️ E12.0 deaf man: light skin tone +1F9CF 1F3FB 200D 2642 ; minimally-qualified # 🧏🏻‍♂ E12.0 deaf man: light skin tone +1F9CF 1F3FC 200D 2642 FE0F ; fully-qualified # 🧏🏼‍♂️ E12.0 deaf man: medium-light skin tone +1F9CF 1F3FC 200D 2642 ; minimally-qualified # 🧏🏼‍♂ E12.0 deaf man: medium-light skin tone +1F9CF 1F3FD 200D 2642 FE0F ; fully-qualified # 🧏🏽‍♂️ E12.0 deaf man: medium skin tone +1F9CF 1F3FD 200D 2642 ; minimally-qualified # 🧏🏽‍♂ E12.0 deaf man: medium skin tone +1F9CF 1F3FE 200D 2642 FE0F ; fully-qualified # 🧏🏾‍♂️ E12.0 deaf man: medium-dark skin tone +1F9CF 1F3FE 200D 2642 ; minimally-qualified # 🧏🏾‍♂ E12.0 deaf man: medium-dark skin tone +1F9CF 1F3FF 200D 2642 FE0F ; fully-qualified # 🧏🏿‍♂️ E12.0 deaf man: dark skin tone +1F9CF 1F3FF 200D 2642 ; minimally-qualified # 🧏🏿‍♂ E12.0 deaf man: dark skin tone +1F9CF 200D 2640 FE0F ; fully-qualified # 🧏‍♀️ E12.0 deaf woman +1F9CF 200D 2640 ; minimally-qualified # 🧏‍♀ E12.0 deaf woman +1F9CF 1F3FB 200D 2640 FE0F ; fully-qualified # 🧏🏻‍♀️ E12.0 deaf woman: light skin tone +1F9CF 1F3FB 200D 2640 ; minimally-qualified # 🧏🏻‍♀ E12.0 deaf woman: light skin tone +1F9CF 1F3FC 200D 2640 FE0F ; fully-qualified # 🧏🏼‍♀️ E12.0 deaf woman: medium-light skin tone +1F9CF 1F3FC 200D 2640 ; minimally-qualified # 🧏🏼‍♀ E12.0 deaf woman: medium-light skin tone +1F9CF 1F3FD 200D 2640 FE0F ; fully-qualified # 🧏🏽‍♀️ E12.0 deaf woman: medium skin tone +1F9CF 1F3FD 200D 2640 ; minimally-qualified # 🧏🏽‍♀ E12.0 deaf woman: medium skin tone +1F9CF 1F3FE 200D 2640 FE0F ; fully-qualified # 🧏🏾‍♀️ E12.0 deaf woman: medium-dark skin tone +1F9CF 1F3FE 200D 2640 ; minimally-qualified # 🧏🏾‍♀ E12.0 deaf woman: medium-dark skin tone +1F9CF 1F3FF 200D 2640 FE0F ; fully-qualified # 🧏🏿‍♀️ E12.0 deaf woman: dark skin tone +1F9CF 1F3FF 200D 2640 ; minimally-qualified # 🧏🏿‍♀ E12.0 deaf woman: dark skin tone +1F647 ; fully-qualified # 🙇 E0.6 person bowing +1F647 1F3FB ; fully-qualified # 🙇🏻 E1.0 person bowing: light skin tone +1F647 1F3FC ; fully-qualified # 🙇🏼 E1.0 person bowing: medium-light skin tone +1F647 1F3FD ; fully-qualified # 🙇🏽 E1.0 person bowing: medium skin tone +1F647 1F3FE ; fully-qualified # 🙇🏾 E1.0 person bowing: medium-dark skin tone +1F647 1F3FF ; fully-qualified # 🙇🏿 E1.0 person bowing: dark skin tone +1F647 200D 2642 FE0F ; fully-qualified # 🙇‍♂️ E4.0 man bowing +1F647 200D 2642 ; minimally-qualified # 🙇‍♂ E4.0 man bowing +1F647 1F3FB 200D 2642 FE0F ; fully-qualified # 🙇🏻‍♂️ E4.0 man bowing: light skin tone +1F647 1F3FB 200D 2642 ; minimally-qualified # 🙇🏻‍♂ E4.0 man bowing: light skin tone +1F647 1F3FC 200D 2642 FE0F ; fully-qualified # 🙇🏼‍♂️ E4.0 man bowing: medium-light skin tone +1F647 1F3FC 200D 2642 ; minimally-qualified # 🙇🏼‍♂ E4.0 man bowing: medium-light skin tone +1F647 1F3FD 200D 2642 FE0F ; fully-qualified # 🙇🏽‍♂️ E4.0 man bowing: medium skin tone +1F647 1F3FD 200D 2642 ; minimally-qualified # 🙇🏽‍♂ E4.0 man bowing: medium skin tone +1F647 1F3FE 200D 2642 FE0F ; fully-qualified # 🙇🏾‍♂️ E4.0 man bowing: medium-dark skin tone +1F647 1F3FE 200D 2642 ; minimally-qualified # 🙇🏾‍♂ E4.0 man bowing: medium-dark skin tone +1F647 1F3FF 200D 2642 FE0F ; fully-qualified # 🙇🏿‍♂️ E4.0 man bowing: dark skin tone +1F647 1F3FF 200D 2642 ; minimally-qualified # 🙇🏿‍♂ E4.0 man bowing: dark skin tone +1F647 200D 2640 FE0F ; fully-qualified # 🙇‍♀️ E4.0 woman bowing +1F647 200D 2640 ; minimally-qualified # 🙇‍♀ E4.0 woman bowing +1F647 1F3FB 200D 2640 FE0F ; fully-qualified # 🙇🏻‍♀️ E4.0 woman bowing: light skin tone +1F647 1F3FB 200D 2640 ; minimally-qualified # 🙇🏻‍♀ E4.0 woman bowing: light skin tone +1F647 1F3FC 200D 2640 FE0F ; fully-qualified # 🙇🏼‍♀️ E4.0 woman bowing: medium-light skin tone +1F647 1F3FC 200D 2640 ; minimally-qualified # 🙇🏼‍♀ E4.0 woman bowing: medium-light skin tone +1F647 1F3FD 200D 2640 FE0F ; fully-qualified # 🙇🏽‍♀️ E4.0 woman bowing: medium skin tone +1F647 1F3FD 200D 2640 ; minimally-qualified # 🙇🏽‍♀ E4.0 woman bowing: medium skin tone +1F647 1F3FE 200D 2640 FE0F ; fully-qualified # 🙇🏾‍♀️ E4.0 woman bowing: medium-dark skin tone +1F647 1F3FE 200D 2640 ; minimally-qualified # 🙇🏾‍♀ E4.0 woman bowing: medium-dark skin tone +1F647 1F3FF 200D 2640 FE0F ; fully-qualified # 🙇🏿‍♀️ E4.0 woman bowing: dark skin tone +1F647 1F3FF 200D 2640 ; minimally-qualified # 🙇🏿‍♀ E4.0 woman bowing: dark skin tone +1F926 ; fully-qualified # 🤦 E3.0 person facepalming +1F926 1F3FB ; fully-qualified # 🤦🏻 E3.0 person facepalming: light skin tone +1F926 1F3FC ; fully-qualified # 🤦🏼 E3.0 person facepalming: medium-light skin tone +1F926 1F3FD ; fully-qualified # 🤦🏽 E3.0 person facepalming: medium skin tone +1F926 1F3FE ; fully-qualified # 🤦🏾 E3.0 person facepalming: medium-dark skin tone +1F926 1F3FF ; fully-qualified # 🤦🏿 E3.0 person facepalming: dark skin tone +1F926 200D 2642 FE0F ; fully-qualified # 🤦‍♂️ E4.0 man facepalming +1F926 200D 2642 ; minimally-qualified # 🤦‍♂ E4.0 man facepalming +1F926 1F3FB 200D 2642 FE0F ; fully-qualified # 🤦🏻‍♂️ E4.0 man facepalming: light skin tone +1F926 1F3FB 200D 2642 ; minimally-qualified # 🤦🏻‍♂ E4.0 man facepalming: light skin tone +1F926 1F3FC 200D 2642 FE0F ; fully-qualified # 🤦🏼‍♂️ E4.0 man facepalming: medium-light skin tone +1F926 1F3FC 200D 2642 ; minimally-qualified # 🤦🏼‍♂ E4.0 man facepalming: medium-light skin tone +1F926 1F3FD 200D 2642 FE0F ; fully-qualified # 🤦🏽‍♂️ E4.0 man facepalming: medium skin tone +1F926 1F3FD 200D 2642 ; minimally-qualified # 🤦🏽‍♂ E4.0 man facepalming: medium skin tone +1F926 1F3FE 200D 2642 FE0F ; fully-qualified # 🤦🏾‍♂️ E4.0 man facepalming: medium-dark skin tone +1F926 1F3FE 200D 2642 ; minimally-qualified # 🤦🏾‍♂ E4.0 man facepalming: medium-dark skin tone +1F926 1F3FF 200D 2642 FE0F ; fully-qualified # 🤦🏿‍♂️ E4.0 man facepalming: dark skin tone +1F926 1F3FF 200D 2642 ; minimally-qualified # 🤦🏿‍♂ E4.0 man facepalming: dark skin tone +1F926 200D 2640 FE0F ; fully-qualified # 🤦‍♀️ E4.0 woman facepalming +1F926 200D 2640 ; minimally-qualified # 🤦‍♀ E4.0 woman facepalming +1F926 1F3FB 200D 2640 FE0F ; fully-qualified # 🤦🏻‍♀️ E4.0 woman facepalming: light skin tone +1F926 1F3FB 200D 2640 ; minimally-qualified # 🤦🏻‍♀ E4.0 woman facepalming: light skin tone +1F926 1F3FC 200D 2640 FE0F ; fully-qualified # 🤦🏼‍♀️ E4.0 woman facepalming: medium-light skin tone +1F926 1F3FC 200D 2640 ; minimally-qualified # 🤦🏼‍♀ E4.0 woman facepalming: medium-light skin tone +1F926 1F3FD 200D 2640 FE0F ; fully-qualified # 🤦🏽‍♀️ E4.0 woman facepalming: medium skin tone +1F926 1F3FD 200D 2640 ; minimally-qualified # 🤦🏽‍♀ E4.0 woman facepalming: medium skin tone +1F926 1F3FE 200D 2640 FE0F ; fully-qualified # 🤦🏾‍♀️ E4.0 woman facepalming: medium-dark skin tone +1F926 1F3FE 200D 2640 ; minimally-qualified # 🤦🏾‍♀ E4.0 woman facepalming: medium-dark skin tone +1F926 1F3FF 200D 2640 FE0F ; fully-qualified # 🤦🏿‍♀️ E4.0 woman facepalming: dark skin tone +1F926 1F3FF 200D 2640 ; minimally-qualified # 🤦🏿‍♀ E4.0 woman facepalming: dark skin tone +1F937 ; fully-qualified # 🤷 E3.0 person shrugging +1F937 1F3FB ; fully-qualified # 🤷🏻 E3.0 person shrugging: light skin tone +1F937 1F3FC ; fully-qualified # 🤷🏼 E3.0 person shrugging: medium-light skin tone +1F937 1F3FD ; fully-qualified # 🤷🏽 E3.0 person shrugging: medium skin tone +1F937 1F3FE ; fully-qualified # 🤷🏾 E3.0 person shrugging: medium-dark skin tone +1F937 1F3FF ; fully-qualified # 🤷🏿 E3.0 person shrugging: dark skin tone +1F937 200D 2642 FE0F ; fully-qualified # 🤷‍♂️ E4.0 man shrugging +1F937 200D 2642 ; minimally-qualified # 🤷‍♂ E4.0 man shrugging +1F937 1F3FB 200D 2642 FE0F ; fully-qualified # 🤷🏻‍♂️ E4.0 man shrugging: light skin tone +1F937 1F3FB 200D 2642 ; minimally-qualified # 🤷🏻‍♂ E4.0 man shrugging: light skin tone +1F937 1F3FC 200D 2642 FE0F ; fully-qualified # 🤷🏼‍♂️ E4.0 man shrugging: medium-light skin tone +1F937 1F3FC 200D 2642 ; minimally-qualified # 🤷🏼‍♂ E4.0 man shrugging: medium-light skin tone +1F937 1F3FD 200D 2642 FE0F ; fully-qualified # 🤷🏽‍♂️ E4.0 man shrugging: medium skin tone +1F937 1F3FD 200D 2642 ; minimally-qualified # 🤷🏽‍♂ E4.0 man shrugging: medium skin tone +1F937 1F3FE 200D 2642 FE0F ; fully-qualified # 🤷🏾‍♂️ E4.0 man shrugging: medium-dark skin tone +1F937 1F3FE 200D 2642 ; minimally-qualified # 🤷🏾‍♂ E4.0 man shrugging: medium-dark skin tone +1F937 1F3FF 200D 2642 FE0F ; fully-qualified # 🤷🏿‍♂️ E4.0 man shrugging: dark skin tone +1F937 1F3FF 200D 2642 ; minimally-qualified # 🤷🏿‍♂ E4.0 man shrugging: dark skin tone +1F937 200D 2640 FE0F ; fully-qualified # 🤷‍♀️ E4.0 woman shrugging +1F937 200D 2640 ; minimally-qualified # 🤷‍♀ E4.0 woman shrugging +1F937 1F3FB 200D 2640 FE0F ; fully-qualified # 🤷🏻‍♀️ E4.0 woman shrugging: light skin tone +1F937 1F3FB 200D 2640 ; minimally-qualified # 🤷🏻‍♀ E4.0 woman shrugging: light skin tone +1F937 1F3FC 200D 2640 FE0F ; fully-qualified # 🤷🏼‍♀️ E4.0 woman shrugging: medium-light skin tone +1F937 1F3FC 200D 2640 ; minimally-qualified # 🤷🏼‍♀ E4.0 woman shrugging: medium-light skin tone +1F937 1F3FD 200D 2640 FE0F ; fully-qualified # 🤷🏽‍♀️ E4.0 woman shrugging: medium skin tone +1F937 1F3FD 200D 2640 ; minimally-qualified # 🤷🏽‍♀ E4.0 woman shrugging: medium skin tone +1F937 1F3FE 200D 2640 FE0F ; fully-qualified # 🤷🏾‍♀️ E4.0 woman shrugging: medium-dark skin tone +1F937 1F3FE 200D 2640 ; minimally-qualified # 🤷🏾‍♀ E4.0 woman shrugging: medium-dark skin tone +1F937 1F3FF 200D 2640 FE0F ; fully-qualified # 🤷🏿‍♀️ E4.0 woman shrugging: dark skin tone +1F937 1F3FF 200D 2640 ; minimally-qualified # 🤷🏿‍♀ E4.0 woman shrugging: dark skin tone + +# subgroup: person-role +1F9D1 200D 2695 FE0F ; fully-qualified # 🧑‍⚕️ E12.1 health worker +1F9D1 200D 2695 ; minimally-qualified # 🧑‍⚕ E12.1 health worker +1F9D1 1F3FB 200D 2695 FE0F ; fully-qualified # 🧑🏻‍⚕️ E12.1 health worker: light skin tone +1F9D1 1F3FB 200D 2695 ; minimally-qualified # 🧑🏻‍⚕ E12.1 health worker: light skin tone +1F9D1 1F3FC 200D 2695 FE0F ; fully-qualified # 🧑🏼‍⚕️ E12.1 health worker: medium-light skin tone +1F9D1 1F3FC 200D 2695 ; minimally-qualified # 🧑🏼‍⚕ E12.1 health worker: medium-light skin tone +1F9D1 1F3FD 200D 2695 FE0F ; fully-qualified # 🧑🏽‍⚕️ E12.1 health worker: medium skin tone +1F9D1 1F3FD 200D 2695 ; minimally-qualified # 🧑🏽‍⚕ E12.1 health worker: medium skin tone +1F9D1 1F3FE 200D 2695 FE0F ; fully-qualified # 🧑🏾‍⚕️ E12.1 health worker: medium-dark skin tone +1F9D1 1F3FE 200D 2695 ; minimally-qualified # 🧑🏾‍⚕ E12.1 health worker: medium-dark skin tone +1F9D1 1F3FF 200D 2695 FE0F ; fully-qualified # 🧑🏿‍⚕️ E12.1 health worker: dark skin tone +1F9D1 1F3FF 200D 2695 ; minimally-qualified # 🧑🏿‍⚕ E12.1 health worker: dark skin tone +1F468 200D 2695 FE0F ; fully-qualified # 👨‍⚕️ E4.0 man health worker +1F468 200D 2695 ; minimally-qualified # 👨‍⚕ E4.0 man health worker +1F468 1F3FB 200D 2695 FE0F ; fully-qualified # 👨🏻‍⚕️ E4.0 man health worker: light skin tone +1F468 1F3FB 200D 2695 ; minimally-qualified # 👨🏻‍⚕ E4.0 man health worker: light skin tone +1F468 1F3FC 200D 2695 FE0F ; fully-qualified # 👨🏼‍⚕️ E4.0 man health worker: medium-light skin tone +1F468 1F3FC 200D 2695 ; minimally-qualified # 👨🏼‍⚕ E4.0 man health worker: medium-light skin tone +1F468 1F3FD 200D 2695 FE0F ; fully-qualified # 👨🏽‍⚕️ E4.0 man health worker: medium skin tone +1F468 1F3FD 200D 2695 ; minimally-qualified # 👨🏽‍⚕ E4.0 man health worker: medium skin tone +1F468 1F3FE 200D 2695 FE0F ; fully-qualified # 👨🏾‍⚕️ E4.0 man health worker: medium-dark skin tone +1F468 1F3FE 200D 2695 ; minimally-qualified # 👨🏾‍⚕ E4.0 man health worker: medium-dark skin tone +1F468 1F3FF 200D 2695 FE0F ; fully-qualified # 👨🏿‍⚕️ E4.0 man health worker: dark skin tone +1F468 1F3FF 200D 2695 ; minimally-qualified # 👨🏿‍⚕ E4.0 man health worker: dark skin tone +1F469 200D 2695 FE0F ; fully-qualified # 👩‍⚕️ E4.0 woman health worker +1F469 200D 2695 ; minimally-qualified # 👩‍⚕ E4.0 woman health worker +1F469 1F3FB 200D 2695 FE0F ; fully-qualified # 👩🏻‍⚕️ E4.0 woman health worker: light skin tone +1F469 1F3FB 200D 2695 ; minimally-qualified # 👩🏻‍⚕ E4.0 woman health worker: light skin tone +1F469 1F3FC 200D 2695 FE0F ; fully-qualified # 👩🏼‍⚕️ E4.0 woman health worker: medium-light skin tone +1F469 1F3FC 200D 2695 ; minimally-qualified # 👩🏼‍⚕ E4.0 woman health worker: medium-light skin tone +1F469 1F3FD 200D 2695 FE0F ; fully-qualified # 👩🏽‍⚕️ E4.0 woman health worker: medium skin tone +1F469 1F3FD 200D 2695 ; minimally-qualified # 👩🏽‍⚕ E4.0 woman health worker: medium skin tone +1F469 1F3FE 200D 2695 FE0F ; fully-qualified # 👩🏾‍⚕️ E4.0 woman health worker: medium-dark skin tone +1F469 1F3FE 200D 2695 ; minimally-qualified # 👩🏾‍⚕ E4.0 woman health worker: medium-dark skin tone +1F469 1F3FF 200D 2695 FE0F ; fully-qualified # 👩🏿‍⚕️ E4.0 woman health worker: dark skin tone +1F469 1F3FF 200D 2695 ; minimally-qualified # 👩🏿‍⚕ E4.0 woman health worker: dark skin tone +1F9D1 200D 1F393 ; fully-qualified # 🧑‍🎓 E12.1 student +1F9D1 1F3FB 200D 1F393 ; fully-qualified # 🧑🏻‍🎓 E12.1 student: light skin tone +1F9D1 1F3FC 200D 1F393 ; fully-qualified # 🧑🏼‍🎓 E12.1 student: medium-light skin tone +1F9D1 1F3FD 200D 1F393 ; fully-qualified # 🧑🏽‍🎓 E12.1 student: medium skin tone +1F9D1 1F3FE 200D 1F393 ; fully-qualified # 🧑🏾‍🎓 E12.1 student: medium-dark skin tone +1F9D1 1F3FF 200D 1F393 ; fully-qualified # 🧑🏿‍🎓 E12.1 student: dark skin tone +1F468 200D 1F393 ; fully-qualified # 👨‍🎓 E4.0 man student +1F468 1F3FB 200D 1F393 ; fully-qualified # 👨🏻‍🎓 E4.0 man student: light skin tone +1F468 1F3FC 200D 1F393 ; fully-qualified # 👨🏼‍🎓 E4.0 man student: medium-light skin tone +1F468 1F3FD 200D 1F393 ; fully-qualified # 👨🏽‍🎓 E4.0 man student: medium skin tone +1F468 1F3FE 200D 1F393 ; fully-qualified # 👨🏾‍🎓 E4.0 man student: medium-dark skin tone +1F468 1F3FF 200D 1F393 ; fully-qualified # 👨🏿‍🎓 E4.0 man student: dark skin tone +1F469 200D 1F393 ; fully-qualified # 👩‍🎓 E4.0 woman student +1F469 1F3FB 200D 1F393 ; fully-qualified # 👩🏻‍🎓 E4.0 woman student: light skin tone +1F469 1F3FC 200D 1F393 ; fully-qualified # 👩🏼‍🎓 E4.0 woman student: medium-light skin tone +1F469 1F3FD 200D 1F393 ; fully-qualified # 👩🏽‍🎓 E4.0 woman student: medium skin tone +1F469 1F3FE 200D 1F393 ; fully-qualified # 👩🏾‍🎓 E4.0 woman student: medium-dark skin tone +1F469 1F3FF 200D 1F393 ; fully-qualified # 👩🏿‍🎓 E4.0 woman student: dark skin tone +1F9D1 200D 1F3EB ; fully-qualified # 🧑‍🏫 E12.1 teacher +1F9D1 1F3FB 200D 1F3EB ; fully-qualified # 🧑🏻‍🏫 E12.1 teacher: light skin tone +1F9D1 1F3FC 200D 1F3EB ; fully-qualified # 🧑🏼‍🏫 E12.1 teacher: medium-light skin tone +1F9D1 1F3FD 200D 1F3EB ; fully-qualified # 🧑🏽‍🏫 E12.1 teacher: medium skin tone +1F9D1 1F3FE 200D 1F3EB ; fully-qualified # 🧑🏾‍🏫 E12.1 teacher: medium-dark skin tone +1F9D1 1F3FF 200D 1F3EB ; fully-qualified # 🧑🏿‍🏫 E12.1 teacher: dark skin tone +1F468 200D 1F3EB ; fully-qualified # 👨‍🏫 E4.0 man teacher +1F468 1F3FB 200D 1F3EB ; fully-qualified # 👨🏻‍🏫 E4.0 man teacher: light skin tone +1F468 1F3FC 200D 1F3EB ; fully-qualified # 👨🏼‍🏫 E4.0 man teacher: medium-light skin tone +1F468 1F3FD 200D 1F3EB ; fully-qualified # 👨🏽‍🏫 E4.0 man teacher: medium skin tone +1F468 1F3FE 200D 1F3EB ; fully-qualified # 👨🏾‍🏫 E4.0 man teacher: medium-dark skin tone +1F468 1F3FF 200D 1F3EB ; fully-qualified # 👨🏿‍🏫 E4.0 man teacher: dark skin tone +1F469 200D 1F3EB ; fully-qualified # 👩‍🏫 E4.0 woman teacher +1F469 1F3FB 200D 1F3EB ; fully-qualified # 👩🏻‍🏫 E4.0 woman teacher: light skin tone +1F469 1F3FC 200D 1F3EB ; fully-qualified # 👩🏼‍🏫 E4.0 woman teacher: medium-light skin tone +1F469 1F3FD 200D 1F3EB ; fully-qualified # 👩🏽‍🏫 E4.0 woman teacher: medium skin tone +1F469 1F3FE 200D 1F3EB ; fully-qualified # 👩🏾‍🏫 E4.0 woman teacher: medium-dark skin tone +1F469 1F3FF 200D 1F3EB ; fully-qualified # 👩🏿‍🏫 E4.0 woman teacher: dark skin tone +1F9D1 200D 2696 FE0F ; fully-qualified # 🧑‍⚖️ E12.1 judge +1F9D1 200D 2696 ; minimally-qualified # 🧑‍⚖ E12.1 judge +1F9D1 1F3FB 200D 2696 FE0F ; fully-qualified # 🧑🏻‍⚖️ E12.1 judge: light skin tone +1F9D1 1F3FB 200D 2696 ; minimally-qualified # 🧑🏻‍⚖ E12.1 judge: light skin tone +1F9D1 1F3FC 200D 2696 FE0F ; fully-qualified # 🧑🏼‍⚖️ E12.1 judge: medium-light skin tone +1F9D1 1F3FC 200D 2696 ; minimally-qualified # 🧑🏼‍⚖ E12.1 judge: medium-light skin tone +1F9D1 1F3FD 200D 2696 FE0F ; fully-qualified # 🧑🏽‍⚖️ E12.1 judge: medium skin tone +1F9D1 1F3FD 200D 2696 ; minimally-qualified # 🧑🏽‍⚖ E12.1 judge: medium skin tone +1F9D1 1F3FE 200D 2696 FE0F ; fully-qualified # 🧑🏾‍⚖️ E12.1 judge: medium-dark skin tone +1F9D1 1F3FE 200D 2696 ; minimally-qualified # 🧑🏾‍⚖ E12.1 judge: medium-dark skin tone +1F9D1 1F3FF 200D 2696 FE0F ; fully-qualified # 🧑🏿‍⚖️ E12.1 judge: dark skin tone +1F9D1 1F3FF 200D 2696 ; minimally-qualified # 🧑🏿‍⚖ E12.1 judge: dark skin tone +1F468 200D 2696 FE0F ; fully-qualified # 👨‍⚖️ E4.0 man judge +1F468 200D 2696 ; minimally-qualified # 👨‍⚖ E4.0 man judge +1F468 1F3FB 200D 2696 FE0F ; fully-qualified # 👨🏻‍⚖️ E4.0 man judge: light skin tone +1F468 1F3FB 200D 2696 ; minimally-qualified # 👨🏻‍⚖ E4.0 man judge: light skin tone +1F468 1F3FC 200D 2696 FE0F ; fully-qualified # 👨🏼‍⚖️ E4.0 man judge: medium-light skin tone +1F468 1F3FC 200D 2696 ; minimally-qualified # 👨🏼‍⚖ E4.0 man judge: medium-light skin tone +1F468 1F3FD 200D 2696 FE0F ; fully-qualified # 👨🏽‍⚖️ E4.0 man judge: medium skin tone +1F468 1F3FD 200D 2696 ; minimally-qualified # 👨🏽‍⚖ E4.0 man judge: medium skin tone +1F468 1F3FE 200D 2696 FE0F ; fully-qualified # 👨🏾‍⚖️ E4.0 man judge: medium-dark skin tone +1F468 1F3FE 200D 2696 ; minimally-qualified # 👨🏾‍⚖ E4.0 man judge: medium-dark skin tone +1F468 1F3FF 200D 2696 FE0F ; fully-qualified # 👨🏿‍⚖️ E4.0 man judge: dark skin tone +1F468 1F3FF 200D 2696 ; minimally-qualified # 👨🏿‍⚖ E4.0 man judge: dark skin tone +1F469 200D 2696 FE0F ; fully-qualified # 👩‍⚖️ E4.0 woman judge +1F469 200D 2696 ; minimally-qualified # 👩‍⚖ E4.0 woman judge +1F469 1F3FB 200D 2696 FE0F ; fully-qualified # 👩🏻‍⚖️ E4.0 woman judge: light skin tone +1F469 1F3FB 200D 2696 ; minimally-qualified # 👩🏻‍⚖ E4.0 woman judge: light skin tone +1F469 1F3FC 200D 2696 FE0F ; fully-qualified # 👩🏼‍⚖️ E4.0 woman judge: medium-light skin tone +1F469 1F3FC 200D 2696 ; minimally-qualified # 👩🏼‍⚖ E4.0 woman judge: medium-light skin tone +1F469 1F3FD 200D 2696 FE0F ; fully-qualified # 👩🏽‍⚖️ E4.0 woman judge: medium skin tone +1F469 1F3FD 200D 2696 ; minimally-qualified # 👩🏽‍⚖ E4.0 woman judge: medium skin tone +1F469 1F3FE 200D 2696 FE0F ; fully-qualified # 👩🏾‍⚖️ E4.0 woman judge: medium-dark skin tone +1F469 1F3FE 200D 2696 ; minimally-qualified # 👩🏾‍⚖ E4.0 woman judge: medium-dark skin tone +1F469 1F3FF 200D 2696 FE0F ; fully-qualified # 👩🏿‍⚖️ E4.0 woman judge: dark skin tone +1F469 1F3FF 200D 2696 ; minimally-qualified # 👩🏿‍⚖ E4.0 woman judge: dark skin tone +1F9D1 200D 1F33E ; fully-qualified # 🧑‍🌾 E12.1 farmer +1F9D1 1F3FB 200D 1F33E ; fully-qualified # 🧑🏻‍🌾 E12.1 farmer: light skin tone +1F9D1 1F3FC 200D 1F33E ; fully-qualified # 🧑🏼‍🌾 E12.1 farmer: medium-light skin tone +1F9D1 1F3FD 200D 1F33E ; fully-qualified # 🧑🏽‍🌾 E12.1 farmer: medium skin tone +1F9D1 1F3FE 200D 1F33E ; fully-qualified # 🧑🏾‍🌾 E12.1 farmer: medium-dark skin tone +1F9D1 1F3FF 200D 1F33E ; fully-qualified # 🧑🏿‍🌾 E12.1 farmer: dark skin tone +1F468 200D 1F33E ; fully-qualified # 👨‍🌾 E4.0 man farmer +1F468 1F3FB 200D 1F33E ; fully-qualified # 👨🏻‍🌾 E4.0 man farmer: light skin tone +1F468 1F3FC 200D 1F33E ; fully-qualified # 👨🏼‍🌾 E4.0 man farmer: medium-light skin tone +1F468 1F3FD 200D 1F33E ; fully-qualified # 👨🏽‍🌾 E4.0 man farmer: medium skin tone +1F468 1F3FE 200D 1F33E ; fully-qualified # 👨🏾‍🌾 E4.0 man farmer: medium-dark skin tone +1F468 1F3FF 200D 1F33E ; fully-qualified # 👨🏿‍🌾 E4.0 man farmer: dark skin tone +1F469 200D 1F33E ; fully-qualified # 👩‍🌾 E4.0 woman farmer +1F469 1F3FB 200D 1F33E ; fully-qualified # 👩🏻‍🌾 E4.0 woman farmer: light skin tone +1F469 1F3FC 200D 1F33E ; fully-qualified # 👩🏼‍🌾 E4.0 woman farmer: medium-light skin tone +1F469 1F3FD 200D 1F33E ; fully-qualified # 👩🏽‍🌾 E4.0 woman farmer: medium skin tone +1F469 1F3FE 200D 1F33E ; fully-qualified # 👩🏾‍🌾 E4.0 woman farmer: medium-dark skin tone +1F469 1F3FF 200D 1F33E ; fully-qualified # 👩🏿‍🌾 E4.0 woman farmer: dark skin tone +1F9D1 200D 1F373 ; fully-qualified # 🧑‍🍳 E12.1 cook +1F9D1 1F3FB 200D 1F373 ; fully-qualified # 🧑🏻‍🍳 E12.1 cook: light skin tone +1F9D1 1F3FC 200D 1F373 ; fully-qualified # 🧑🏼‍🍳 E12.1 cook: medium-light skin tone +1F9D1 1F3FD 200D 1F373 ; fully-qualified # 🧑🏽‍🍳 E12.1 cook: medium skin tone +1F9D1 1F3FE 200D 1F373 ; fully-qualified # 🧑🏾‍🍳 E12.1 cook: medium-dark skin tone +1F9D1 1F3FF 200D 1F373 ; fully-qualified # 🧑🏿‍🍳 E12.1 cook: dark skin tone +1F468 200D 1F373 ; fully-qualified # 👨‍🍳 E4.0 man cook +1F468 1F3FB 200D 1F373 ; fully-qualified # 👨🏻‍🍳 E4.0 man cook: light skin tone +1F468 1F3FC 200D 1F373 ; fully-qualified # 👨🏼‍🍳 E4.0 man cook: medium-light skin tone +1F468 1F3FD 200D 1F373 ; fully-qualified # 👨🏽‍🍳 E4.0 man cook: medium skin tone +1F468 1F3FE 200D 1F373 ; fully-qualified # 👨🏾‍🍳 E4.0 man cook: medium-dark skin tone +1F468 1F3FF 200D 1F373 ; fully-qualified # 👨🏿‍🍳 E4.0 man cook: dark skin tone +1F469 200D 1F373 ; fully-qualified # 👩‍🍳 E4.0 woman cook +1F469 1F3FB 200D 1F373 ; fully-qualified # 👩🏻‍🍳 E4.0 woman cook: light skin tone +1F469 1F3FC 200D 1F373 ; fully-qualified # 👩🏼‍🍳 E4.0 woman cook: medium-light skin tone +1F469 1F3FD 200D 1F373 ; fully-qualified # 👩🏽‍🍳 E4.0 woman cook: medium skin tone +1F469 1F3FE 200D 1F373 ; fully-qualified # 👩🏾‍🍳 E4.0 woman cook: medium-dark skin tone +1F469 1F3FF 200D 1F373 ; fully-qualified # 👩🏿‍🍳 E4.0 woman cook: dark skin tone +1F9D1 200D 1F527 ; fully-qualified # 🧑‍🔧 E12.1 mechanic +1F9D1 1F3FB 200D 1F527 ; fully-qualified # 🧑🏻‍🔧 E12.1 mechanic: light skin tone +1F9D1 1F3FC 200D 1F527 ; fully-qualified # 🧑🏼‍🔧 E12.1 mechanic: medium-light skin tone +1F9D1 1F3FD 200D 1F527 ; fully-qualified # 🧑🏽‍🔧 E12.1 mechanic: medium skin tone +1F9D1 1F3FE 200D 1F527 ; fully-qualified # 🧑🏾‍🔧 E12.1 mechanic: medium-dark skin tone +1F9D1 1F3FF 200D 1F527 ; fully-qualified # 🧑🏿‍🔧 E12.1 mechanic: dark skin tone +1F468 200D 1F527 ; fully-qualified # 👨‍🔧 E4.0 man mechanic +1F468 1F3FB 200D 1F527 ; fully-qualified # 👨🏻‍🔧 E4.0 man mechanic: light skin tone +1F468 1F3FC 200D 1F527 ; fully-qualified # 👨🏼‍🔧 E4.0 man mechanic: medium-light skin tone +1F468 1F3FD 200D 1F527 ; fully-qualified # 👨🏽‍🔧 E4.0 man mechanic: medium skin tone +1F468 1F3FE 200D 1F527 ; fully-qualified # 👨🏾‍🔧 E4.0 man mechanic: medium-dark skin tone +1F468 1F3FF 200D 1F527 ; fully-qualified # 👨🏿‍🔧 E4.0 man mechanic: dark skin tone +1F469 200D 1F527 ; fully-qualified # 👩‍🔧 E4.0 woman mechanic +1F469 1F3FB 200D 1F527 ; fully-qualified # 👩🏻‍🔧 E4.0 woman mechanic: light skin tone +1F469 1F3FC 200D 1F527 ; fully-qualified # 👩🏼‍🔧 E4.0 woman mechanic: medium-light skin tone +1F469 1F3FD 200D 1F527 ; fully-qualified # 👩🏽‍🔧 E4.0 woman mechanic: medium skin tone +1F469 1F3FE 200D 1F527 ; fully-qualified # 👩🏾‍🔧 E4.0 woman mechanic: medium-dark skin tone +1F469 1F3FF 200D 1F527 ; fully-qualified # 👩🏿‍🔧 E4.0 woman mechanic: dark skin tone +1F9D1 200D 1F3ED ; fully-qualified # 🧑‍🏭 E12.1 factory worker +1F9D1 1F3FB 200D 1F3ED ; fully-qualified # 🧑🏻‍🏭 E12.1 factory worker: light skin tone +1F9D1 1F3FC 200D 1F3ED ; fully-qualified # 🧑🏼‍🏭 E12.1 factory worker: medium-light skin tone +1F9D1 1F3FD 200D 1F3ED ; fully-qualified # 🧑🏽‍🏭 E12.1 factory worker: medium skin tone +1F9D1 1F3FE 200D 1F3ED ; fully-qualified # 🧑🏾‍🏭 E12.1 factory worker: medium-dark skin tone +1F9D1 1F3FF 200D 1F3ED ; fully-qualified # 🧑🏿‍🏭 E12.1 factory worker: dark skin tone +1F468 200D 1F3ED ; fully-qualified # 👨‍🏭 E4.0 man factory worker +1F468 1F3FB 200D 1F3ED ; fully-qualified # 👨🏻‍🏭 E4.0 man factory worker: light skin tone +1F468 1F3FC 200D 1F3ED ; fully-qualified # 👨🏼‍🏭 E4.0 man factory worker: medium-light skin tone +1F468 1F3FD 200D 1F3ED ; fully-qualified # 👨🏽‍🏭 E4.0 man factory worker: medium skin tone +1F468 1F3FE 200D 1F3ED ; fully-qualified # 👨🏾‍🏭 E4.0 man factory worker: medium-dark skin tone +1F468 1F3FF 200D 1F3ED ; fully-qualified # 👨🏿‍🏭 E4.0 man factory worker: dark skin tone +1F469 200D 1F3ED ; fully-qualified # 👩‍🏭 E4.0 woman factory worker +1F469 1F3FB 200D 1F3ED ; fully-qualified # 👩🏻‍🏭 E4.0 woman factory worker: light skin tone +1F469 1F3FC 200D 1F3ED ; fully-qualified # 👩🏼‍🏭 E4.0 woman factory worker: medium-light skin tone +1F469 1F3FD 200D 1F3ED ; fully-qualified # 👩🏽‍🏭 E4.0 woman factory worker: medium skin tone +1F469 1F3FE 200D 1F3ED ; fully-qualified # 👩🏾‍🏭 E4.0 woman factory worker: medium-dark skin tone +1F469 1F3FF 200D 1F3ED ; fully-qualified # 👩🏿‍🏭 E4.0 woman factory worker: dark skin tone +1F9D1 200D 1F4BC ; fully-qualified # 🧑‍💼 E12.1 office worker +1F9D1 1F3FB 200D 1F4BC ; fully-qualified # 🧑🏻‍💼 E12.1 office worker: light skin tone +1F9D1 1F3FC 200D 1F4BC ; fully-qualified # 🧑🏼‍💼 E12.1 office worker: medium-light skin tone +1F9D1 1F3FD 200D 1F4BC ; fully-qualified # 🧑🏽‍💼 E12.1 office worker: medium skin tone +1F9D1 1F3FE 200D 1F4BC ; fully-qualified # 🧑🏾‍💼 E12.1 office worker: medium-dark skin tone +1F9D1 1F3FF 200D 1F4BC ; fully-qualified # 🧑🏿‍💼 E12.1 office worker: dark skin tone +1F468 200D 1F4BC ; fully-qualified # 👨‍💼 E4.0 man office worker +1F468 1F3FB 200D 1F4BC ; fully-qualified # 👨🏻‍💼 E4.0 man office worker: light skin tone +1F468 1F3FC 200D 1F4BC ; fully-qualified # 👨🏼‍💼 E4.0 man office worker: medium-light skin tone +1F468 1F3FD 200D 1F4BC ; fully-qualified # 👨🏽‍💼 E4.0 man office worker: medium skin tone +1F468 1F3FE 200D 1F4BC ; fully-qualified # 👨🏾‍💼 E4.0 man office worker: medium-dark skin tone +1F468 1F3FF 200D 1F4BC ; fully-qualified # 👨🏿‍💼 E4.0 man office worker: dark skin tone +1F469 200D 1F4BC ; fully-qualified # 👩‍💼 E4.0 woman office worker +1F469 1F3FB 200D 1F4BC ; fully-qualified # 👩🏻‍💼 E4.0 woman office worker: light skin tone +1F469 1F3FC 200D 1F4BC ; fully-qualified # 👩🏼‍💼 E4.0 woman office worker: medium-light skin tone +1F469 1F3FD 200D 1F4BC ; fully-qualified # 👩🏽‍💼 E4.0 woman office worker: medium skin tone +1F469 1F3FE 200D 1F4BC ; fully-qualified # 👩🏾‍💼 E4.0 woman office worker: medium-dark skin tone +1F469 1F3FF 200D 1F4BC ; fully-qualified # 👩🏿‍💼 E4.0 woman office worker: dark skin tone +1F9D1 200D 1F52C ; fully-qualified # 🧑‍🔬 E12.1 scientist +1F9D1 1F3FB 200D 1F52C ; fully-qualified # 🧑🏻‍🔬 E12.1 scientist: light skin tone +1F9D1 1F3FC 200D 1F52C ; fully-qualified # 🧑🏼‍🔬 E12.1 scientist: medium-light skin tone +1F9D1 1F3FD 200D 1F52C ; fully-qualified # 🧑🏽‍🔬 E12.1 scientist: medium skin tone +1F9D1 1F3FE 200D 1F52C ; fully-qualified # 🧑🏾‍🔬 E12.1 scientist: medium-dark skin tone +1F9D1 1F3FF 200D 1F52C ; fully-qualified # 🧑🏿‍🔬 E12.1 scientist: dark skin tone +1F468 200D 1F52C ; fully-qualified # 👨‍🔬 E4.0 man scientist +1F468 1F3FB 200D 1F52C ; fully-qualified # 👨🏻‍🔬 E4.0 man scientist: light skin tone +1F468 1F3FC 200D 1F52C ; fully-qualified # 👨🏼‍🔬 E4.0 man scientist: medium-light skin tone +1F468 1F3FD 200D 1F52C ; fully-qualified # 👨🏽‍🔬 E4.0 man scientist: medium skin tone +1F468 1F3FE 200D 1F52C ; fully-qualified # 👨🏾‍🔬 E4.0 man scientist: medium-dark skin tone +1F468 1F3FF 200D 1F52C ; fully-qualified # 👨🏿‍🔬 E4.0 man scientist: dark skin tone +1F469 200D 1F52C ; fully-qualified # 👩‍🔬 E4.0 woman scientist +1F469 1F3FB 200D 1F52C ; fully-qualified # 👩🏻‍🔬 E4.0 woman scientist: light skin tone +1F469 1F3FC 200D 1F52C ; fully-qualified # 👩🏼‍🔬 E4.0 woman scientist: medium-light skin tone +1F469 1F3FD 200D 1F52C ; fully-qualified # 👩🏽‍🔬 E4.0 woman scientist: medium skin tone +1F469 1F3FE 200D 1F52C ; fully-qualified # 👩🏾‍🔬 E4.0 woman scientist: medium-dark skin tone +1F469 1F3FF 200D 1F52C ; fully-qualified # 👩🏿‍🔬 E4.0 woman scientist: dark skin tone +1F9D1 200D 1F4BB ; fully-qualified # 🧑‍💻 E12.1 technologist +1F9D1 1F3FB 200D 1F4BB ; fully-qualified # 🧑🏻‍💻 E12.1 technologist: light skin tone +1F9D1 1F3FC 200D 1F4BB ; fully-qualified # 🧑🏼‍💻 E12.1 technologist: medium-light skin tone +1F9D1 1F3FD 200D 1F4BB ; fully-qualified # 🧑🏽‍💻 E12.1 technologist: medium skin tone +1F9D1 1F3FE 200D 1F4BB ; fully-qualified # 🧑🏾‍💻 E12.1 technologist: medium-dark skin tone +1F9D1 1F3FF 200D 1F4BB ; fully-qualified # 🧑🏿‍💻 E12.1 technologist: dark skin tone +1F468 200D 1F4BB ; fully-qualified # 👨‍💻 E4.0 man technologist +1F468 1F3FB 200D 1F4BB ; fully-qualified # 👨🏻‍💻 E4.0 man technologist: light skin tone +1F468 1F3FC 200D 1F4BB ; fully-qualified # 👨🏼‍💻 E4.0 man technologist: medium-light skin tone +1F468 1F3FD 200D 1F4BB ; fully-qualified # 👨🏽‍💻 E4.0 man technologist: medium skin tone +1F468 1F3FE 200D 1F4BB ; fully-qualified # 👨🏾‍💻 E4.0 man technologist: medium-dark skin tone +1F468 1F3FF 200D 1F4BB ; fully-qualified # 👨🏿‍💻 E4.0 man technologist: dark skin tone +1F469 200D 1F4BB ; fully-qualified # 👩‍💻 E4.0 woman technologist +1F469 1F3FB 200D 1F4BB ; fully-qualified # 👩🏻‍💻 E4.0 woman technologist: light skin tone +1F469 1F3FC 200D 1F4BB ; fully-qualified # 👩🏼‍💻 E4.0 woman technologist: medium-light skin tone +1F469 1F3FD 200D 1F4BB ; fully-qualified # 👩🏽‍💻 E4.0 woman technologist: medium skin tone +1F469 1F3FE 200D 1F4BB ; fully-qualified # 👩🏾‍💻 E4.0 woman technologist: medium-dark skin tone +1F469 1F3FF 200D 1F4BB ; fully-qualified # 👩🏿‍💻 E4.0 woman technologist: dark skin tone +1F9D1 200D 1F3A4 ; fully-qualified # 🧑‍🎤 E12.1 singer +1F9D1 1F3FB 200D 1F3A4 ; fully-qualified # 🧑🏻‍🎤 E12.1 singer: light skin tone +1F9D1 1F3FC 200D 1F3A4 ; fully-qualified # 🧑🏼‍🎤 E12.1 singer: medium-light skin tone +1F9D1 1F3FD 200D 1F3A4 ; fully-qualified # 🧑🏽‍🎤 E12.1 singer: medium skin tone +1F9D1 1F3FE 200D 1F3A4 ; fully-qualified # 🧑🏾‍🎤 E12.1 singer: medium-dark skin tone +1F9D1 1F3FF 200D 1F3A4 ; fully-qualified # 🧑🏿‍🎤 E12.1 singer: dark skin tone +1F468 200D 1F3A4 ; fully-qualified # 👨‍🎤 E4.0 man singer +1F468 1F3FB 200D 1F3A4 ; fully-qualified # 👨🏻‍🎤 E4.0 man singer: light skin tone +1F468 1F3FC 200D 1F3A4 ; fully-qualified # 👨🏼‍🎤 E4.0 man singer: medium-light skin tone +1F468 1F3FD 200D 1F3A4 ; fully-qualified # 👨🏽‍🎤 E4.0 man singer: medium skin tone +1F468 1F3FE 200D 1F3A4 ; fully-qualified # 👨🏾‍🎤 E4.0 man singer: medium-dark skin tone +1F468 1F3FF 200D 1F3A4 ; fully-qualified # 👨🏿‍🎤 E4.0 man singer: dark skin tone +1F469 200D 1F3A4 ; fully-qualified # 👩‍🎤 E4.0 woman singer +1F469 1F3FB 200D 1F3A4 ; fully-qualified # 👩🏻‍🎤 E4.0 woman singer: light skin tone +1F469 1F3FC 200D 1F3A4 ; fully-qualified # 👩🏼‍🎤 E4.0 woman singer: medium-light skin tone +1F469 1F3FD 200D 1F3A4 ; fully-qualified # 👩🏽‍🎤 E4.0 woman singer: medium skin tone +1F469 1F3FE 200D 1F3A4 ; fully-qualified # 👩🏾‍🎤 E4.0 woman singer: medium-dark skin tone +1F469 1F3FF 200D 1F3A4 ; fully-qualified # 👩🏿‍🎤 E4.0 woman singer: dark skin tone +1F9D1 200D 1F3A8 ; fully-qualified # 🧑‍🎨 E12.1 artist +1F9D1 1F3FB 200D 1F3A8 ; fully-qualified # 🧑🏻‍🎨 E12.1 artist: light skin tone +1F9D1 1F3FC 200D 1F3A8 ; fully-qualified # 🧑🏼‍🎨 E12.1 artist: medium-light skin tone +1F9D1 1F3FD 200D 1F3A8 ; fully-qualified # 🧑🏽‍🎨 E12.1 artist: medium skin tone +1F9D1 1F3FE 200D 1F3A8 ; fully-qualified # 🧑🏾‍🎨 E12.1 artist: medium-dark skin tone +1F9D1 1F3FF 200D 1F3A8 ; fully-qualified # 🧑🏿‍🎨 E12.1 artist: dark skin tone +1F468 200D 1F3A8 ; fully-qualified # 👨‍🎨 E4.0 man artist +1F468 1F3FB 200D 1F3A8 ; fully-qualified # 👨🏻‍🎨 E4.0 man artist: light skin tone +1F468 1F3FC 200D 1F3A8 ; fully-qualified # 👨🏼‍🎨 E4.0 man artist: medium-light skin tone +1F468 1F3FD 200D 1F3A8 ; fully-qualified # 👨🏽‍🎨 E4.0 man artist: medium skin tone +1F468 1F3FE 200D 1F3A8 ; fully-qualified # 👨🏾‍🎨 E4.0 man artist: medium-dark skin tone +1F468 1F3FF 200D 1F3A8 ; fully-qualified # 👨🏿‍🎨 E4.0 man artist: dark skin tone +1F469 200D 1F3A8 ; fully-qualified # 👩‍🎨 E4.0 woman artist +1F469 1F3FB 200D 1F3A8 ; fully-qualified # 👩🏻‍🎨 E4.0 woman artist: light skin tone +1F469 1F3FC 200D 1F3A8 ; fully-qualified # 👩🏼‍🎨 E4.0 woman artist: medium-light skin tone +1F469 1F3FD 200D 1F3A8 ; fully-qualified # 👩🏽‍🎨 E4.0 woman artist: medium skin tone +1F469 1F3FE 200D 1F3A8 ; fully-qualified # 👩🏾‍🎨 E4.0 woman artist: medium-dark skin tone +1F469 1F3FF 200D 1F3A8 ; fully-qualified # 👩🏿‍🎨 E4.0 woman artist: dark skin tone +1F9D1 200D 2708 FE0F ; fully-qualified # 🧑‍✈️ E12.1 pilot +1F9D1 200D 2708 ; minimally-qualified # 🧑‍✈ E12.1 pilot +1F9D1 1F3FB 200D 2708 FE0F ; fully-qualified # 🧑🏻‍✈️ E12.1 pilot: light skin tone +1F9D1 1F3FB 200D 2708 ; minimally-qualified # 🧑🏻‍✈ E12.1 pilot: light skin tone +1F9D1 1F3FC 200D 2708 FE0F ; fully-qualified # 🧑🏼‍✈️ E12.1 pilot: medium-light skin tone +1F9D1 1F3FC 200D 2708 ; minimally-qualified # 🧑🏼‍✈ E12.1 pilot: medium-light skin tone +1F9D1 1F3FD 200D 2708 FE0F ; fully-qualified # 🧑🏽‍✈️ E12.1 pilot: medium skin tone +1F9D1 1F3FD 200D 2708 ; minimally-qualified # 🧑🏽‍✈ E12.1 pilot: medium skin tone +1F9D1 1F3FE 200D 2708 FE0F ; fully-qualified # 🧑🏾‍✈️ E12.1 pilot: medium-dark skin tone +1F9D1 1F3FE 200D 2708 ; minimally-qualified # 🧑🏾‍✈ E12.1 pilot: medium-dark skin tone +1F9D1 1F3FF 200D 2708 FE0F ; fully-qualified # 🧑🏿‍✈️ E12.1 pilot: dark skin tone +1F9D1 1F3FF 200D 2708 ; minimally-qualified # 🧑🏿‍✈ E12.1 pilot: dark skin tone +1F468 200D 2708 FE0F ; fully-qualified # 👨‍✈️ E4.0 man pilot +1F468 200D 2708 ; minimally-qualified # 👨‍✈ E4.0 man pilot +1F468 1F3FB 200D 2708 FE0F ; fully-qualified # 👨🏻‍✈️ E4.0 man pilot: light skin tone +1F468 1F3FB 200D 2708 ; minimally-qualified # 👨🏻‍✈ E4.0 man pilot: light skin tone +1F468 1F3FC 200D 2708 FE0F ; fully-qualified # 👨🏼‍✈️ E4.0 man pilot: medium-light skin tone +1F468 1F3FC 200D 2708 ; minimally-qualified # 👨🏼‍✈ E4.0 man pilot: medium-light skin tone +1F468 1F3FD 200D 2708 FE0F ; fully-qualified # 👨🏽‍✈️ E4.0 man pilot: medium skin tone +1F468 1F3FD 200D 2708 ; minimally-qualified # 👨🏽‍✈ E4.0 man pilot: medium skin tone +1F468 1F3FE 200D 2708 FE0F ; fully-qualified # 👨🏾‍✈️ E4.0 man pilot: medium-dark skin tone +1F468 1F3FE 200D 2708 ; minimally-qualified # 👨🏾‍✈ E4.0 man pilot: medium-dark skin tone +1F468 1F3FF 200D 2708 FE0F ; fully-qualified # 👨🏿‍✈️ E4.0 man pilot: dark skin tone +1F468 1F3FF 200D 2708 ; minimally-qualified # 👨🏿‍✈ E4.0 man pilot: dark skin tone +1F469 200D 2708 FE0F ; fully-qualified # 👩‍✈️ E4.0 woman pilot +1F469 200D 2708 ; minimally-qualified # 👩‍✈ E4.0 woman pilot +1F469 1F3FB 200D 2708 FE0F ; fully-qualified # 👩🏻‍✈️ E4.0 woman pilot: light skin tone +1F469 1F3FB 200D 2708 ; minimally-qualified # 👩🏻‍✈ E4.0 woman pilot: light skin tone +1F469 1F3FC 200D 2708 FE0F ; fully-qualified # 👩🏼‍✈️ E4.0 woman pilot: medium-light skin tone +1F469 1F3FC 200D 2708 ; minimally-qualified # 👩🏼‍✈ E4.0 woman pilot: medium-light skin tone +1F469 1F3FD 200D 2708 FE0F ; fully-qualified # 👩🏽‍✈️ E4.0 woman pilot: medium skin tone +1F469 1F3FD 200D 2708 ; minimally-qualified # 👩🏽‍✈ E4.0 woman pilot: medium skin tone +1F469 1F3FE 200D 2708 FE0F ; fully-qualified # 👩🏾‍✈️ E4.0 woman pilot: medium-dark skin tone +1F469 1F3FE 200D 2708 ; minimally-qualified # 👩🏾‍✈ E4.0 woman pilot: medium-dark skin tone +1F469 1F3FF 200D 2708 FE0F ; fully-qualified # 👩🏿‍✈️ E4.0 woman pilot: dark skin tone +1F469 1F3FF 200D 2708 ; minimally-qualified # 👩🏿‍✈ E4.0 woman pilot: dark skin tone +1F9D1 200D 1F680 ; fully-qualified # 🧑‍🚀 E12.1 astronaut +1F9D1 1F3FB 200D 1F680 ; fully-qualified # 🧑🏻‍🚀 E12.1 astronaut: light skin tone +1F9D1 1F3FC 200D 1F680 ; fully-qualified # 🧑🏼‍🚀 E12.1 astronaut: medium-light skin tone +1F9D1 1F3FD 200D 1F680 ; fully-qualified # 🧑🏽‍🚀 E12.1 astronaut: medium skin tone +1F9D1 1F3FE 200D 1F680 ; fully-qualified # 🧑🏾‍🚀 E12.1 astronaut: medium-dark skin tone +1F9D1 1F3FF 200D 1F680 ; fully-qualified # 🧑🏿‍🚀 E12.1 astronaut: dark skin tone +1F468 200D 1F680 ; fully-qualified # 👨‍🚀 E4.0 man astronaut +1F468 1F3FB 200D 1F680 ; fully-qualified # 👨🏻‍🚀 E4.0 man astronaut: light skin tone +1F468 1F3FC 200D 1F680 ; fully-qualified # 👨🏼‍🚀 E4.0 man astronaut: medium-light skin tone +1F468 1F3FD 200D 1F680 ; fully-qualified # 👨🏽‍🚀 E4.0 man astronaut: medium skin tone +1F468 1F3FE 200D 1F680 ; fully-qualified # 👨🏾‍🚀 E4.0 man astronaut: medium-dark skin tone +1F468 1F3FF 200D 1F680 ; fully-qualified # 👨🏿‍🚀 E4.0 man astronaut: dark skin tone +1F469 200D 1F680 ; fully-qualified # 👩‍🚀 E4.0 woman astronaut +1F469 1F3FB 200D 1F680 ; fully-qualified # 👩🏻‍🚀 E4.0 woman astronaut: light skin tone +1F469 1F3FC 200D 1F680 ; fully-qualified # 👩🏼‍🚀 E4.0 woman astronaut: medium-light skin tone +1F469 1F3FD 200D 1F680 ; fully-qualified # 👩🏽‍🚀 E4.0 woman astronaut: medium skin tone +1F469 1F3FE 200D 1F680 ; fully-qualified # 👩🏾‍🚀 E4.0 woman astronaut: medium-dark skin tone +1F469 1F3FF 200D 1F680 ; fully-qualified # 👩🏿‍🚀 E4.0 woman astronaut: dark skin tone +1F9D1 200D 1F692 ; fully-qualified # 🧑‍🚒 E12.1 firefighter +1F9D1 1F3FB 200D 1F692 ; fully-qualified # 🧑🏻‍🚒 E12.1 firefighter: light skin tone +1F9D1 1F3FC 200D 1F692 ; fully-qualified # 🧑🏼‍🚒 E12.1 firefighter: medium-light skin tone +1F9D1 1F3FD 200D 1F692 ; fully-qualified # 🧑🏽‍🚒 E12.1 firefighter: medium skin tone +1F9D1 1F3FE 200D 1F692 ; fully-qualified # 🧑🏾‍🚒 E12.1 firefighter: medium-dark skin tone +1F9D1 1F3FF 200D 1F692 ; fully-qualified # 🧑🏿‍🚒 E12.1 firefighter: dark skin tone +1F468 200D 1F692 ; fully-qualified # 👨‍🚒 E4.0 man firefighter +1F468 1F3FB 200D 1F692 ; fully-qualified # 👨🏻‍🚒 E4.0 man firefighter: light skin tone +1F468 1F3FC 200D 1F692 ; fully-qualified # 👨🏼‍🚒 E4.0 man firefighter: medium-light skin tone +1F468 1F3FD 200D 1F692 ; fully-qualified # 👨🏽‍🚒 E4.0 man firefighter: medium skin tone +1F468 1F3FE 200D 1F692 ; fully-qualified # 👨🏾‍🚒 E4.0 man firefighter: medium-dark skin tone +1F468 1F3FF 200D 1F692 ; fully-qualified # 👨🏿‍🚒 E4.0 man firefighter: dark skin tone +1F469 200D 1F692 ; fully-qualified # 👩‍🚒 E4.0 woman firefighter +1F469 1F3FB 200D 1F692 ; fully-qualified # 👩🏻‍🚒 E4.0 woman firefighter: light skin tone +1F469 1F3FC 200D 1F692 ; fully-qualified # 👩🏼‍🚒 E4.0 woman firefighter: medium-light skin tone +1F469 1F3FD 200D 1F692 ; fully-qualified # 👩🏽‍🚒 E4.0 woman firefighter: medium skin tone +1F469 1F3FE 200D 1F692 ; fully-qualified # 👩🏾‍🚒 E4.0 woman firefighter: medium-dark skin tone +1F469 1F3FF 200D 1F692 ; fully-qualified # 👩🏿‍🚒 E4.0 woman firefighter: dark skin tone +1F46E ; fully-qualified # 👮 E0.6 police officer +1F46E 1F3FB ; fully-qualified # 👮🏻 E1.0 police officer: light skin tone +1F46E 1F3FC ; fully-qualified # 👮🏼 E1.0 police officer: medium-light skin tone +1F46E 1F3FD ; fully-qualified # 👮🏽 E1.0 police officer: medium skin tone +1F46E 1F3FE ; fully-qualified # 👮🏾 E1.0 police officer: medium-dark skin tone +1F46E 1F3FF ; fully-qualified # 👮🏿 E1.0 police officer: dark skin tone +1F46E 200D 2642 FE0F ; fully-qualified # 👮‍♂️ E4.0 man police officer +1F46E 200D 2642 ; minimally-qualified # 👮‍♂ E4.0 man police officer +1F46E 1F3FB 200D 2642 FE0F ; fully-qualified # 👮🏻‍♂️ E4.0 man police officer: light skin tone +1F46E 1F3FB 200D 2642 ; minimally-qualified # 👮🏻‍♂ E4.0 man police officer: light skin tone +1F46E 1F3FC 200D 2642 FE0F ; fully-qualified # 👮🏼‍♂️ E4.0 man police officer: medium-light skin tone +1F46E 1F3FC 200D 2642 ; minimally-qualified # 👮🏼‍♂ E4.0 man police officer: medium-light skin tone +1F46E 1F3FD 200D 2642 FE0F ; fully-qualified # 👮🏽‍♂️ E4.0 man police officer: medium skin tone +1F46E 1F3FD 200D 2642 ; minimally-qualified # 👮🏽‍♂ E4.0 man police officer: medium skin tone +1F46E 1F3FE 200D 2642 FE0F ; fully-qualified # 👮🏾‍♂️ E4.0 man police officer: medium-dark skin tone +1F46E 1F3FE 200D 2642 ; minimally-qualified # 👮🏾‍♂ E4.0 man police officer: medium-dark skin tone +1F46E 1F3FF 200D 2642 FE0F ; fully-qualified # 👮🏿‍♂️ E4.0 man police officer: dark skin tone +1F46E 1F3FF 200D 2642 ; minimally-qualified # 👮🏿‍♂ E4.0 man police officer: dark skin tone +1F46E 200D 2640 FE0F ; fully-qualified # 👮‍♀️ E4.0 woman police officer +1F46E 200D 2640 ; minimally-qualified # 👮‍♀ E4.0 woman police officer +1F46E 1F3FB 200D 2640 FE0F ; fully-qualified # 👮🏻‍♀️ E4.0 woman police officer: light skin tone +1F46E 1F3FB 200D 2640 ; minimally-qualified # 👮🏻‍♀ E4.0 woman police officer: light skin tone +1F46E 1F3FC 200D 2640 FE0F ; fully-qualified # 👮🏼‍♀️ E4.0 woman police officer: medium-light skin tone +1F46E 1F3FC 200D 2640 ; minimally-qualified # 👮🏼‍♀ E4.0 woman police officer: medium-light skin tone +1F46E 1F3FD 200D 2640 FE0F ; fully-qualified # 👮🏽‍♀️ E4.0 woman police officer: medium skin tone +1F46E 1F3FD 200D 2640 ; minimally-qualified # 👮🏽‍♀ E4.0 woman police officer: medium skin tone +1F46E 1F3FE 200D 2640 FE0F ; fully-qualified # 👮🏾‍♀️ E4.0 woman police officer: medium-dark skin tone +1F46E 1F3FE 200D 2640 ; minimally-qualified # 👮🏾‍♀ E4.0 woman police officer: medium-dark skin tone +1F46E 1F3FF 200D 2640 FE0F ; fully-qualified # 👮🏿‍♀️ E4.0 woman police officer: dark skin tone +1F46E 1F3FF 200D 2640 ; minimally-qualified # 👮🏿‍♀ E4.0 woman police officer: dark skin tone +1F575 FE0F ; fully-qualified # 🕵️ E0.7 detective +1F575 ; unqualified # 🕵 E0.7 detective +1F575 1F3FB ; fully-qualified # 🕵🏻 E2.0 detective: light skin tone +1F575 1F3FC ; fully-qualified # 🕵🏼 E2.0 detective: medium-light skin tone +1F575 1F3FD ; fully-qualified # 🕵🏽 E2.0 detective: medium skin tone +1F575 1F3FE ; fully-qualified # 🕵🏾 E2.0 detective: medium-dark skin tone +1F575 1F3FF ; fully-qualified # 🕵🏿 E2.0 detective: dark skin tone +1F575 FE0F 200D 2642 FE0F ; fully-qualified # 🕵️‍♂️ E4.0 man detective +1F575 200D 2642 FE0F ; unqualified # 🕵‍♂️ E4.0 man detective +1F575 FE0F 200D 2642 ; minimally-qualified # 🕵️‍♂ E4.0 man detective +1F575 200D 2642 ; unqualified # 🕵‍♂ E4.0 man detective +1F575 1F3FB 200D 2642 FE0F ; fully-qualified # 🕵🏻‍♂️ E4.0 man detective: light skin tone +1F575 1F3FB 200D 2642 ; minimally-qualified # 🕵🏻‍♂ E4.0 man detective: light skin tone +1F575 1F3FC 200D 2642 FE0F ; fully-qualified # 🕵🏼‍♂️ E4.0 man detective: medium-light skin tone +1F575 1F3FC 200D 2642 ; minimally-qualified # 🕵🏼‍♂ E4.0 man detective: medium-light skin tone +1F575 1F3FD 200D 2642 FE0F ; fully-qualified # 🕵🏽‍♂️ E4.0 man detective: medium skin tone +1F575 1F3FD 200D 2642 ; minimally-qualified # 🕵🏽‍♂ E4.0 man detective: medium skin tone +1F575 1F3FE 200D 2642 FE0F ; fully-qualified # 🕵🏾‍♂️ E4.0 man detective: medium-dark skin tone +1F575 1F3FE 200D 2642 ; minimally-qualified # 🕵🏾‍♂ E4.0 man detective: medium-dark skin tone +1F575 1F3FF 200D 2642 FE0F ; fully-qualified # 🕵🏿‍♂️ E4.0 man detective: dark skin tone +1F575 1F3FF 200D 2642 ; minimally-qualified # 🕵🏿‍♂ E4.0 man detective: dark skin tone +1F575 FE0F 200D 2640 FE0F ; fully-qualified # 🕵️‍♀️ E4.0 woman detective +1F575 200D 2640 FE0F ; unqualified # 🕵‍♀️ E4.0 woman detective +1F575 FE0F 200D 2640 ; minimally-qualified # 🕵️‍♀ E4.0 woman detective +1F575 200D 2640 ; unqualified # 🕵‍♀ E4.0 woman detective +1F575 1F3FB 200D 2640 FE0F ; fully-qualified # 🕵🏻‍♀️ E4.0 woman detective: light skin tone +1F575 1F3FB 200D 2640 ; minimally-qualified # 🕵🏻‍♀ E4.0 woman detective: light skin tone +1F575 1F3FC 200D 2640 FE0F ; fully-qualified # 🕵🏼‍♀️ E4.0 woman detective: medium-light skin tone +1F575 1F3FC 200D 2640 ; minimally-qualified # 🕵🏼‍♀ E4.0 woman detective: medium-light skin tone +1F575 1F3FD 200D 2640 FE0F ; fully-qualified # 🕵🏽‍♀️ E4.0 woman detective: medium skin tone +1F575 1F3FD 200D 2640 ; minimally-qualified # 🕵🏽‍♀ E4.0 woman detective: medium skin tone +1F575 1F3FE 200D 2640 FE0F ; fully-qualified # 🕵🏾‍♀️ E4.0 woman detective: medium-dark skin tone +1F575 1F3FE 200D 2640 ; minimally-qualified # 🕵🏾‍♀ E4.0 woman detective: medium-dark skin tone +1F575 1F3FF 200D 2640 FE0F ; fully-qualified # 🕵🏿‍♀️ E4.0 woman detective: dark skin tone +1F575 1F3FF 200D 2640 ; minimally-qualified # 🕵🏿‍♀ E4.0 woman detective: dark skin tone +1F482 ; fully-qualified # 💂 E0.6 guard +1F482 1F3FB ; fully-qualified # 💂🏻 E1.0 guard: light skin tone +1F482 1F3FC ; fully-qualified # 💂🏼 E1.0 guard: medium-light skin tone +1F482 1F3FD ; fully-qualified # 💂🏽 E1.0 guard: medium skin tone +1F482 1F3FE ; fully-qualified # 💂🏾 E1.0 guard: medium-dark skin tone +1F482 1F3FF ; fully-qualified # 💂🏿 E1.0 guard: dark skin tone +1F482 200D 2642 FE0F ; fully-qualified # 💂‍♂️ E4.0 man guard +1F482 200D 2642 ; minimally-qualified # 💂‍♂ E4.0 man guard +1F482 1F3FB 200D 2642 FE0F ; fully-qualified # 💂🏻‍♂️ E4.0 man guard: light skin tone +1F482 1F3FB 200D 2642 ; minimally-qualified # 💂🏻‍♂ E4.0 man guard: light skin tone +1F482 1F3FC 200D 2642 FE0F ; fully-qualified # 💂🏼‍♂️ E4.0 man guard: medium-light skin tone +1F482 1F3FC 200D 2642 ; minimally-qualified # 💂🏼‍♂ E4.0 man guard: medium-light skin tone +1F482 1F3FD 200D 2642 FE0F ; fully-qualified # 💂🏽‍♂️ E4.0 man guard: medium skin tone +1F482 1F3FD 200D 2642 ; minimally-qualified # 💂🏽‍♂ E4.0 man guard: medium skin tone +1F482 1F3FE 200D 2642 FE0F ; fully-qualified # 💂🏾‍♂️ E4.0 man guard: medium-dark skin tone +1F482 1F3FE 200D 2642 ; minimally-qualified # 💂🏾‍♂ E4.0 man guard: medium-dark skin tone +1F482 1F3FF 200D 2642 FE0F ; fully-qualified # 💂🏿‍♂️ E4.0 man guard: dark skin tone +1F482 1F3FF 200D 2642 ; minimally-qualified # 💂🏿‍♂ E4.0 man guard: dark skin tone +1F482 200D 2640 FE0F ; fully-qualified # 💂‍♀️ E4.0 woman guard +1F482 200D 2640 ; minimally-qualified # 💂‍♀ E4.0 woman guard +1F482 1F3FB 200D 2640 FE0F ; fully-qualified # 💂🏻‍♀️ E4.0 woman guard: light skin tone +1F482 1F3FB 200D 2640 ; minimally-qualified # 💂🏻‍♀ E4.0 woman guard: light skin tone +1F482 1F3FC 200D 2640 FE0F ; fully-qualified # 💂🏼‍♀️ E4.0 woman guard: medium-light skin tone +1F482 1F3FC 200D 2640 ; minimally-qualified # 💂🏼‍♀ E4.0 woman guard: medium-light skin tone +1F482 1F3FD 200D 2640 FE0F ; fully-qualified # 💂🏽‍♀️ E4.0 woman guard: medium skin tone +1F482 1F3FD 200D 2640 ; minimally-qualified # 💂🏽‍♀ E4.0 woman guard: medium skin tone +1F482 1F3FE 200D 2640 FE0F ; fully-qualified # 💂🏾‍♀️ E4.0 woman guard: medium-dark skin tone +1F482 1F3FE 200D 2640 ; minimally-qualified # 💂🏾‍♀ E4.0 woman guard: medium-dark skin tone +1F482 1F3FF 200D 2640 FE0F ; fully-qualified # 💂🏿‍♀️ E4.0 woman guard: dark skin tone +1F482 1F3FF 200D 2640 ; minimally-qualified # 💂🏿‍♀ E4.0 woman guard: dark skin tone +1F977 ; fully-qualified # 🥷 E13.0 ninja +1F977 1F3FB ; fully-qualified # 🥷🏻 E13.0 ninja: light skin tone +1F977 1F3FC ; fully-qualified # 🥷🏼 E13.0 ninja: medium-light skin tone +1F977 1F3FD ; fully-qualified # 🥷🏽 E13.0 ninja: medium skin tone +1F977 1F3FE ; fully-qualified # 🥷🏾 E13.0 ninja: medium-dark skin tone +1F977 1F3FF ; fully-qualified # 🥷🏿 E13.0 ninja: dark skin tone +1F477 ; fully-qualified # 👷 E0.6 construction worker +1F477 1F3FB ; fully-qualified # 👷🏻 E1.0 construction worker: light skin tone +1F477 1F3FC ; fully-qualified # 👷🏼 E1.0 construction worker: medium-light skin tone +1F477 1F3FD ; fully-qualified # 👷🏽 E1.0 construction worker: medium skin tone +1F477 1F3FE ; fully-qualified # 👷🏾 E1.0 construction worker: medium-dark skin tone +1F477 1F3FF ; fully-qualified # 👷🏿 E1.0 construction worker: dark skin tone +1F477 200D 2642 FE0F ; fully-qualified # 👷‍♂️ E4.0 man construction worker +1F477 200D 2642 ; minimally-qualified # 👷‍♂ E4.0 man construction worker +1F477 1F3FB 200D 2642 FE0F ; fully-qualified # 👷🏻‍♂️ E4.0 man construction worker: light skin tone +1F477 1F3FB 200D 2642 ; minimally-qualified # 👷🏻‍♂ E4.0 man construction worker: light skin tone +1F477 1F3FC 200D 2642 FE0F ; fully-qualified # 👷🏼‍♂️ E4.0 man construction worker: medium-light skin tone +1F477 1F3FC 200D 2642 ; minimally-qualified # 👷🏼‍♂ E4.0 man construction worker: medium-light skin tone +1F477 1F3FD 200D 2642 FE0F ; fully-qualified # 👷🏽‍♂️ E4.0 man construction worker: medium skin tone +1F477 1F3FD 200D 2642 ; minimally-qualified # 👷🏽‍♂ E4.0 man construction worker: medium skin tone +1F477 1F3FE 200D 2642 FE0F ; fully-qualified # 👷🏾‍♂️ E4.0 man construction worker: medium-dark skin tone +1F477 1F3FE 200D 2642 ; minimally-qualified # 👷🏾‍♂ E4.0 man construction worker: medium-dark skin tone +1F477 1F3FF 200D 2642 FE0F ; fully-qualified # 👷🏿‍♂️ E4.0 man construction worker: dark skin tone +1F477 1F3FF 200D 2642 ; minimally-qualified # 👷🏿‍♂ E4.0 man construction worker: dark skin tone +1F477 200D 2640 FE0F ; fully-qualified # 👷‍♀️ E4.0 woman construction worker +1F477 200D 2640 ; minimally-qualified # 👷‍♀ E4.0 woman construction worker +1F477 1F3FB 200D 2640 FE0F ; fully-qualified # 👷🏻‍♀️ E4.0 woman construction worker: light skin tone +1F477 1F3FB 200D 2640 ; minimally-qualified # 👷🏻‍♀ E4.0 woman construction worker: light skin tone +1F477 1F3FC 200D 2640 FE0F ; fully-qualified # 👷🏼‍♀️ E4.0 woman construction worker: medium-light skin tone +1F477 1F3FC 200D 2640 ; minimally-qualified # 👷🏼‍♀ E4.0 woman construction worker: medium-light skin tone +1F477 1F3FD 200D 2640 FE0F ; fully-qualified # 👷🏽‍♀️ E4.0 woman construction worker: medium skin tone +1F477 1F3FD 200D 2640 ; minimally-qualified # 👷🏽‍♀ E4.0 woman construction worker: medium skin tone +1F477 1F3FE 200D 2640 FE0F ; fully-qualified # 👷🏾‍♀️ E4.0 woman construction worker: medium-dark skin tone +1F477 1F3FE 200D 2640 ; minimally-qualified # 👷🏾‍♀ E4.0 woman construction worker: medium-dark skin tone +1F477 1F3FF 200D 2640 FE0F ; fully-qualified # 👷🏿‍♀️ E4.0 woman construction worker: dark skin tone +1F477 1F3FF 200D 2640 ; minimally-qualified # 👷🏿‍♀ E4.0 woman construction worker: dark skin tone +1FAC5 ; fully-qualified # 🫅 E14.0 person with crown +1FAC5 1F3FB ; fully-qualified # 🫅🏻 E14.0 person with crown: light skin tone +1FAC5 1F3FC ; fully-qualified # 🫅🏼 E14.0 person with crown: medium-light skin tone +1FAC5 1F3FD ; fully-qualified # 🫅🏽 E14.0 person with crown: medium skin tone +1FAC5 1F3FE ; fully-qualified # 🫅🏾 E14.0 person with crown: medium-dark skin tone +1FAC5 1F3FF ; fully-qualified # 🫅🏿 E14.0 person with crown: dark skin tone +1F934 ; fully-qualified # 🤴 E3.0 prince +1F934 1F3FB ; fully-qualified # 🤴🏻 E3.0 prince: light skin tone +1F934 1F3FC ; fully-qualified # 🤴🏼 E3.0 prince: medium-light skin tone +1F934 1F3FD ; fully-qualified # 🤴🏽 E3.0 prince: medium skin tone +1F934 1F3FE ; fully-qualified # 🤴🏾 E3.0 prince: medium-dark skin tone +1F934 1F3FF ; fully-qualified # 🤴🏿 E3.0 prince: dark skin tone +1F478 ; fully-qualified # 👸 E0.6 princess +1F478 1F3FB ; fully-qualified # 👸🏻 E1.0 princess: light skin tone +1F478 1F3FC ; fully-qualified # 👸🏼 E1.0 princess: medium-light skin tone +1F478 1F3FD ; fully-qualified # 👸🏽 E1.0 princess: medium skin tone +1F478 1F3FE ; fully-qualified # 👸🏾 E1.0 princess: medium-dark skin tone +1F478 1F3FF ; fully-qualified # 👸🏿 E1.0 princess: dark skin tone +1F473 ; fully-qualified # 👳 E0.6 person wearing turban +1F473 1F3FB ; fully-qualified # 👳🏻 E1.0 person wearing turban: light skin tone +1F473 1F3FC ; fully-qualified # 👳🏼 E1.0 person wearing turban: medium-light skin tone +1F473 1F3FD ; fully-qualified # 👳🏽 E1.0 person wearing turban: medium skin tone +1F473 1F3FE ; fully-qualified # 👳🏾 E1.0 person wearing turban: medium-dark skin tone +1F473 1F3FF ; fully-qualified # 👳🏿 E1.0 person wearing turban: dark skin tone +1F473 200D 2642 FE0F ; fully-qualified # 👳‍♂️ E4.0 man wearing turban +1F473 200D 2642 ; minimally-qualified # 👳‍♂ E4.0 man wearing turban +1F473 1F3FB 200D 2642 FE0F ; fully-qualified # 👳🏻‍♂️ E4.0 man wearing turban: light skin tone +1F473 1F3FB 200D 2642 ; minimally-qualified # 👳🏻‍♂ E4.0 man wearing turban: light skin tone +1F473 1F3FC 200D 2642 FE0F ; fully-qualified # 👳🏼‍♂️ E4.0 man wearing turban: medium-light skin tone +1F473 1F3FC 200D 2642 ; minimally-qualified # 👳🏼‍♂ E4.0 man wearing turban: medium-light skin tone +1F473 1F3FD 200D 2642 FE0F ; fully-qualified # 👳🏽‍♂️ E4.0 man wearing turban: medium skin tone +1F473 1F3FD 200D 2642 ; minimally-qualified # 👳🏽‍♂ E4.0 man wearing turban: medium skin tone +1F473 1F3FE 200D 2642 FE0F ; fully-qualified # 👳🏾‍♂️ E4.0 man wearing turban: medium-dark skin tone +1F473 1F3FE 200D 2642 ; minimally-qualified # 👳🏾‍♂ E4.0 man wearing turban: medium-dark skin tone +1F473 1F3FF 200D 2642 FE0F ; fully-qualified # 👳🏿‍♂️ E4.0 man wearing turban: dark skin tone +1F473 1F3FF 200D 2642 ; minimally-qualified # 👳🏿‍♂ E4.0 man wearing turban: dark skin tone +1F473 200D 2640 FE0F ; fully-qualified # 👳‍♀️ E4.0 woman wearing turban +1F473 200D 2640 ; minimally-qualified # 👳‍♀ E4.0 woman wearing turban +1F473 1F3FB 200D 2640 FE0F ; fully-qualified # 👳🏻‍♀️ E4.0 woman wearing turban: light skin tone +1F473 1F3FB 200D 2640 ; minimally-qualified # 👳🏻‍♀ E4.0 woman wearing turban: light skin tone +1F473 1F3FC 200D 2640 FE0F ; fully-qualified # 👳🏼‍♀️ E4.0 woman wearing turban: medium-light skin tone +1F473 1F3FC 200D 2640 ; minimally-qualified # 👳🏼‍♀ E4.0 woman wearing turban: medium-light skin tone +1F473 1F3FD 200D 2640 FE0F ; fully-qualified # 👳🏽‍♀️ E4.0 woman wearing turban: medium skin tone +1F473 1F3FD 200D 2640 ; minimally-qualified # 👳🏽‍♀ E4.0 woman wearing turban: medium skin tone +1F473 1F3FE 200D 2640 FE0F ; fully-qualified # 👳🏾‍♀️ E4.0 woman wearing turban: medium-dark skin tone +1F473 1F3FE 200D 2640 ; minimally-qualified # 👳🏾‍♀ E4.0 woman wearing turban: medium-dark skin tone +1F473 1F3FF 200D 2640 FE0F ; fully-qualified # 👳🏿‍♀️ E4.0 woman wearing turban: dark skin tone +1F473 1F3FF 200D 2640 ; minimally-qualified # 👳🏿‍♀ E4.0 woman wearing turban: dark skin tone +1F472 ; fully-qualified # 👲 E0.6 person with skullcap +1F472 1F3FB ; fully-qualified # 👲🏻 E1.0 person with skullcap: light skin tone +1F472 1F3FC ; fully-qualified # 👲🏼 E1.0 person with skullcap: medium-light skin tone +1F472 1F3FD ; fully-qualified # 👲🏽 E1.0 person with skullcap: medium skin tone +1F472 1F3FE ; fully-qualified # 👲🏾 E1.0 person with skullcap: medium-dark skin tone +1F472 1F3FF ; fully-qualified # 👲🏿 E1.0 person with skullcap: dark skin tone +1F9D5 ; fully-qualified # 🧕 E5.0 woman with headscarf +1F9D5 1F3FB ; fully-qualified # 🧕🏻 E5.0 woman with headscarf: light skin tone +1F9D5 1F3FC ; fully-qualified # 🧕🏼 E5.0 woman with headscarf: medium-light skin tone +1F9D5 1F3FD ; fully-qualified # 🧕🏽 E5.0 woman with headscarf: medium skin tone +1F9D5 1F3FE ; fully-qualified # 🧕🏾 E5.0 woman with headscarf: medium-dark skin tone +1F9D5 1F3FF ; fully-qualified # 🧕🏿 E5.0 woman with headscarf: dark skin tone +1F935 ; fully-qualified # 🤵 E3.0 person in tuxedo +1F935 1F3FB ; fully-qualified # 🤵🏻 E3.0 person in tuxedo: light skin tone +1F935 1F3FC ; fully-qualified # 🤵🏼 E3.0 person in tuxedo: medium-light skin tone +1F935 1F3FD ; fully-qualified # 🤵🏽 E3.0 person in tuxedo: medium skin tone +1F935 1F3FE ; fully-qualified # 🤵🏾 E3.0 person in tuxedo: medium-dark skin tone +1F935 1F3FF ; fully-qualified # 🤵🏿 E3.0 person in tuxedo: dark skin tone +1F935 200D 2642 FE0F ; fully-qualified # 🤵‍♂️ E13.0 man in tuxedo +1F935 200D 2642 ; minimally-qualified # 🤵‍♂ E13.0 man in tuxedo +1F935 1F3FB 200D 2642 FE0F ; fully-qualified # 🤵🏻‍♂️ E13.0 man in tuxedo: light skin tone +1F935 1F3FB 200D 2642 ; minimally-qualified # 🤵🏻‍♂ E13.0 man in tuxedo: light skin tone +1F935 1F3FC 200D 2642 FE0F ; fully-qualified # 🤵🏼‍♂️ E13.0 man in tuxedo: medium-light skin tone +1F935 1F3FC 200D 2642 ; minimally-qualified # 🤵🏼‍♂ E13.0 man in tuxedo: medium-light skin tone +1F935 1F3FD 200D 2642 FE0F ; fully-qualified # 🤵🏽‍♂️ E13.0 man in tuxedo: medium skin tone +1F935 1F3FD 200D 2642 ; minimally-qualified # 🤵🏽‍♂ E13.0 man in tuxedo: medium skin tone +1F935 1F3FE 200D 2642 FE0F ; fully-qualified # 🤵🏾‍♂️ E13.0 man in tuxedo: medium-dark skin tone +1F935 1F3FE 200D 2642 ; minimally-qualified # 🤵🏾‍♂ E13.0 man in tuxedo: medium-dark skin tone +1F935 1F3FF 200D 2642 FE0F ; fully-qualified # 🤵🏿‍♂️ E13.0 man in tuxedo: dark skin tone +1F935 1F3FF 200D 2642 ; minimally-qualified # 🤵🏿‍♂ E13.0 man in tuxedo: dark skin tone +1F935 200D 2640 FE0F ; fully-qualified # 🤵‍♀️ E13.0 woman in tuxedo +1F935 200D 2640 ; minimally-qualified # 🤵‍♀ E13.0 woman in tuxedo +1F935 1F3FB 200D 2640 FE0F ; fully-qualified # 🤵🏻‍♀️ E13.0 woman in tuxedo: light skin tone +1F935 1F3FB 200D 2640 ; minimally-qualified # 🤵🏻‍♀ E13.0 woman in tuxedo: light skin tone +1F935 1F3FC 200D 2640 FE0F ; fully-qualified # 🤵🏼‍♀️ E13.0 woman in tuxedo: medium-light skin tone +1F935 1F3FC 200D 2640 ; minimally-qualified # 🤵🏼‍♀ E13.0 woman in tuxedo: medium-light skin tone +1F935 1F3FD 200D 2640 FE0F ; fully-qualified # 🤵🏽‍♀️ E13.0 woman in tuxedo: medium skin tone +1F935 1F3FD 200D 2640 ; minimally-qualified # 🤵🏽‍♀ E13.0 woman in tuxedo: medium skin tone +1F935 1F3FE 200D 2640 FE0F ; fully-qualified # 🤵🏾‍♀️ E13.0 woman in tuxedo: medium-dark skin tone +1F935 1F3FE 200D 2640 ; minimally-qualified # 🤵🏾‍♀ E13.0 woman in tuxedo: medium-dark skin tone +1F935 1F3FF 200D 2640 FE0F ; fully-qualified # 🤵🏿‍♀️ E13.0 woman in tuxedo: dark skin tone +1F935 1F3FF 200D 2640 ; minimally-qualified # 🤵🏿‍♀ E13.0 woman in tuxedo: dark skin tone +1F470 ; fully-qualified # 👰 E0.6 person with veil +1F470 1F3FB ; fully-qualified # 👰🏻 E1.0 person with veil: light skin tone +1F470 1F3FC ; fully-qualified # 👰🏼 E1.0 person with veil: medium-light skin tone +1F470 1F3FD ; fully-qualified # 👰🏽 E1.0 person with veil: medium skin tone +1F470 1F3FE ; fully-qualified # 👰🏾 E1.0 person with veil: medium-dark skin tone +1F470 1F3FF ; fully-qualified # 👰🏿 E1.0 person with veil: dark skin tone +1F470 200D 2642 FE0F ; fully-qualified # 👰‍♂️ E13.0 man with veil +1F470 200D 2642 ; minimally-qualified # 👰‍♂ E13.0 man with veil +1F470 1F3FB 200D 2642 FE0F ; fully-qualified # 👰🏻‍♂️ E13.0 man with veil: light skin tone +1F470 1F3FB 200D 2642 ; minimally-qualified # 👰🏻‍♂ E13.0 man with veil: light skin tone +1F470 1F3FC 200D 2642 FE0F ; fully-qualified # 👰🏼‍♂️ E13.0 man with veil: medium-light skin tone +1F470 1F3FC 200D 2642 ; minimally-qualified # 👰🏼‍♂ E13.0 man with veil: medium-light skin tone +1F470 1F3FD 200D 2642 FE0F ; fully-qualified # 👰🏽‍♂️ E13.0 man with veil: medium skin tone +1F470 1F3FD 200D 2642 ; minimally-qualified # 👰🏽‍♂ E13.0 man with veil: medium skin tone +1F470 1F3FE 200D 2642 FE0F ; fully-qualified # 👰🏾‍♂️ E13.0 man with veil: medium-dark skin tone +1F470 1F3FE 200D 2642 ; minimally-qualified # 👰🏾‍♂ E13.0 man with veil: medium-dark skin tone +1F470 1F3FF 200D 2642 FE0F ; fully-qualified # 👰🏿‍♂️ E13.0 man with veil: dark skin tone +1F470 1F3FF 200D 2642 ; minimally-qualified # 👰🏿‍♂ E13.0 man with veil: dark skin tone +1F470 200D 2640 FE0F ; fully-qualified # 👰‍♀️ E13.0 woman with veil +1F470 200D 2640 ; minimally-qualified # 👰‍♀ E13.0 woman with veil +1F470 1F3FB 200D 2640 FE0F ; fully-qualified # 👰🏻‍♀️ E13.0 woman with veil: light skin tone +1F470 1F3FB 200D 2640 ; minimally-qualified # 👰🏻‍♀ E13.0 woman with veil: light skin tone +1F470 1F3FC 200D 2640 FE0F ; fully-qualified # 👰🏼‍♀️ E13.0 woman with veil: medium-light skin tone +1F470 1F3FC 200D 2640 ; minimally-qualified # 👰🏼‍♀ E13.0 woman with veil: medium-light skin tone +1F470 1F3FD 200D 2640 FE0F ; fully-qualified # 👰🏽‍♀️ E13.0 woman with veil: medium skin tone +1F470 1F3FD 200D 2640 ; minimally-qualified # 👰🏽‍♀ E13.0 woman with veil: medium skin tone +1F470 1F3FE 200D 2640 FE0F ; fully-qualified # 👰🏾‍♀️ E13.0 woman with veil: medium-dark skin tone +1F470 1F3FE 200D 2640 ; minimally-qualified # 👰🏾‍♀ E13.0 woman with veil: medium-dark skin tone +1F470 1F3FF 200D 2640 FE0F ; fully-qualified # 👰🏿‍♀️ E13.0 woman with veil: dark skin tone +1F470 1F3FF 200D 2640 ; minimally-qualified # 👰🏿‍♀ E13.0 woman with veil: dark skin tone +1F930 ; fully-qualified # 🤰 E3.0 pregnant woman +1F930 1F3FB ; fully-qualified # 🤰🏻 E3.0 pregnant woman: light skin tone +1F930 1F3FC ; fully-qualified # 🤰🏼 E3.0 pregnant woman: medium-light skin tone +1F930 1F3FD ; fully-qualified # 🤰🏽 E3.0 pregnant woman: medium skin tone +1F930 1F3FE ; fully-qualified # 🤰🏾 E3.0 pregnant woman: medium-dark skin tone +1F930 1F3FF ; fully-qualified # 🤰🏿 E3.0 pregnant woman: dark skin tone +1FAC3 ; fully-qualified # 🫃 E14.0 pregnant man +1FAC3 1F3FB ; fully-qualified # 🫃🏻 E14.0 pregnant man: light skin tone +1FAC3 1F3FC ; fully-qualified # 🫃🏼 E14.0 pregnant man: medium-light skin tone +1FAC3 1F3FD ; fully-qualified # 🫃🏽 E14.0 pregnant man: medium skin tone +1FAC3 1F3FE ; fully-qualified # 🫃🏾 E14.0 pregnant man: medium-dark skin tone +1FAC3 1F3FF ; fully-qualified # 🫃🏿 E14.0 pregnant man: dark skin tone +1FAC4 ; fully-qualified # 🫄 E14.0 pregnant person +1FAC4 1F3FB ; fully-qualified # 🫄🏻 E14.0 pregnant person: light skin tone +1FAC4 1F3FC ; fully-qualified # 🫄🏼 E14.0 pregnant person: medium-light skin tone +1FAC4 1F3FD ; fully-qualified # 🫄🏽 E14.0 pregnant person: medium skin tone +1FAC4 1F3FE ; fully-qualified # 🫄🏾 E14.0 pregnant person: medium-dark skin tone +1FAC4 1F3FF ; fully-qualified # 🫄🏿 E14.0 pregnant person: dark skin tone +1F931 ; fully-qualified # 🤱 E5.0 breast-feeding +1F931 1F3FB ; fully-qualified # 🤱🏻 E5.0 breast-feeding: light skin tone +1F931 1F3FC ; fully-qualified # 🤱🏼 E5.0 breast-feeding: medium-light skin tone +1F931 1F3FD ; fully-qualified # 🤱🏽 E5.0 breast-feeding: medium skin tone +1F931 1F3FE ; fully-qualified # 🤱🏾 E5.0 breast-feeding: medium-dark skin tone +1F931 1F3FF ; fully-qualified # 🤱🏿 E5.0 breast-feeding: dark skin tone +1F469 200D 1F37C ; fully-qualified # 👩‍🍼 E13.0 woman feeding baby +1F469 1F3FB 200D 1F37C ; fully-qualified # 👩🏻‍🍼 E13.0 woman feeding baby: light skin tone +1F469 1F3FC 200D 1F37C ; fully-qualified # 👩🏼‍🍼 E13.0 woman feeding baby: medium-light skin tone +1F469 1F3FD 200D 1F37C ; fully-qualified # 👩🏽‍🍼 E13.0 woman feeding baby: medium skin tone +1F469 1F3FE 200D 1F37C ; fully-qualified # 👩🏾‍🍼 E13.0 woman feeding baby: medium-dark skin tone +1F469 1F3FF 200D 1F37C ; fully-qualified # 👩🏿‍🍼 E13.0 woman feeding baby: dark skin tone +1F468 200D 1F37C ; fully-qualified # 👨‍🍼 E13.0 man feeding baby +1F468 1F3FB 200D 1F37C ; fully-qualified # 👨🏻‍🍼 E13.0 man feeding baby: light skin tone +1F468 1F3FC 200D 1F37C ; fully-qualified # 👨🏼‍🍼 E13.0 man feeding baby: medium-light skin tone +1F468 1F3FD 200D 1F37C ; fully-qualified # 👨🏽‍🍼 E13.0 man feeding baby: medium skin tone +1F468 1F3FE 200D 1F37C ; fully-qualified # 👨🏾‍🍼 E13.0 man feeding baby: medium-dark skin tone +1F468 1F3FF 200D 1F37C ; fully-qualified # 👨🏿‍🍼 E13.0 man feeding baby: dark skin tone +1F9D1 200D 1F37C ; fully-qualified # 🧑‍🍼 E13.0 person feeding baby +1F9D1 1F3FB 200D 1F37C ; fully-qualified # 🧑🏻‍🍼 E13.0 person feeding baby: light skin tone +1F9D1 1F3FC 200D 1F37C ; fully-qualified # 🧑🏼‍🍼 E13.0 person feeding baby: medium-light skin tone +1F9D1 1F3FD 200D 1F37C ; fully-qualified # 🧑🏽‍🍼 E13.0 person feeding baby: medium skin tone +1F9D1 1F3FE 200D 1F37C ; fully-qualified # 🧑🏾‍🍼 E13.0 person feeding baby: medium-dark skin tone +1F9D1 1F3FF 200D 1F37C ; fully-qualified # 🧑🏿‍🍼 E13.0 person feeding baby: dark skin tone + +# subgroup: person-fantasy +1F47C ; fully-qualified # 👼 E0.6 baby angel +1F47C 1F3FB ; fully-qualified # 👼🏻 E1.0 baby angel: light skin tone +1F47C 1F3FC ; fully-qualified # 👼🏼 E1.0 baby angel: medium-light skin tone +1F47C 1F3FD ; fully-qualified # 👼🏽 E1.0 baby angel: medium skin tone +1F47C 1F3FE ; fully-qualified # 👼🏾 E1.0 baby angel: medium-dark skin tone +1F47C 1F3FF ; fully-qualified # 👼🏿 E1.0 baby angel: dark skin tone +1F385 ; fully-qualified # 🎅 E0.6 Santa Claus +1F385 1F3FB ; fully-qualified # 🎅🏻 E1.0 Santa Claus: light skin tone +1F385 1F3FC ; fully-qualified # 🎅🏼 E1.0 Santa Claus: medium-light skin tone +1F385 1F3FD ; fully-qualified # 🎅🏽 E1.0 Santa Claus: medium skin tone +1F385 1F3FE ; fully-qualified # 🎅🏾 E1.0 Santa Claus: medium-dark skin tone +1F385 1F3FF ; fully-qualified # 🎅🏿 E1.0 Santa Claus: dark skin tone +1F936 ; fully-qualified # 🤶 E3.0 Mrs. Claus +1F936 1F3FB ; fully-qualified # 🤶🏻 E3.0 Mrs. Claus: light skin tone +1F936 1F3FC ; fully-qualified # 🤶🏼 E3.0 Mrs. Claus: medium-light skin tone +1F936 1F3FD ; fully-qualified # 🤶🏽 E3.0 Mrs. Claus: medium skin tone +1F936 1F3FE ; fully-qualified # 🤶🏾 E3.0 Mrs. Claus: medium-dark skin tone +1F936 1F3FF ; fully-qualified # 🤶🏿 E3.0 Mrs. Claus: dark skin tone +1F9D1 200D 1F384 ; fully-qualified # 🧑‍🎄 E13.0 mx claus +1F9D1 1F3FB 200D 1F384 ; fully-qualified # 🧑🏻‍🎄 E13.0 mx claus: light skin tone +1F9D1 1F3FC 200D 1F384 ; fully-qualified # 🧑🏼‍🎄 E13.0 mx claus: medium-light skin tone +1F9D1 1F3FD 200D 1F384 ; fully-qualified # 🧑🏽‍🎄 E13.0 mx claus: medium skin tone +1F9D1 1F3FE 200D 1F384 ; fully-qualified # 🧑🏾‍🎄 E13.0 mx claus: medium-dark skin tone +1F9D1 1F3FF 200D 1F384 ; fully-qualified # 🧑🏿‍🎄 E13.0 mx claus: dark skin tone +1F9B8 ; fully-qualified # 🦸 E11.0 superhero +1F9B8 1F3FB ; fully-qualified # 🦸🏻 E11.0 superhero: light skin tone +1F9B8 1F3FC ; fully-qualified # 🦸🏼 E11.0 superhero: medium-light skin tone +1F9B8 1F3FD ; fully-qualified # 🦸🏽 E11.0 superhero: medium skin tone +1F9B8 1F3FE ; fully-qualified # 🦸🏾 E11.0 superhero: medium-dark skin tone +1F9B8 1F3FF ; fully-qualified # 🦸🏿 E11.0 superhero: dark skin tone +1F9B8 200D 2642 FE0F ; fully-qualified # 🦸‍♂️ E11.0 man superhero +1F9B8 200D 2642 ; minimally-qualified # 🦸‍♂ E11.0 man superhero +1F9B8 1F3FB 200D 2642 FE0F ; fully-qualified # 🦸🏻‍♂️ E11.0 man superhero: light skin tone +1F9B8 1F3FB 200D 2642 ; minimally-qualified # 🦸🏻‍♂ E11.0 man superhero: light skin tone +1F9B8 1F3FC 200D 2642 FE0F ; fully-qualified # 🦸🏼‍♂️ E11.0 man superhero: medium-light skin tone +1F9B8 1F3FC 200D 2642 ; minimally-qualified # 🦸🏼‍♂ E11.0 man superhero: medium-light skin tone +1F9B8 1F3FD 200D 2642 FE0F ; fully-qualified # 🦸🏽‍♂️ E11.0 man superhero: medium skin tone +1F9B8 1F3FD 200D 2642 ; minimally-qualified # 🦸🏽‍♂ E11.0 man superhero: medium skin tone +1F9B8 1F3FE 200D 2642 FE0F ; fully-qualified # 🦸🏾‍♂️ E11.0 man superhero: medium-dark skin tone +1F9B8 1F3FE 200D 2642 ; minimally-qualified # 🦸🏾‍♂ E11.0 man superhero: medium-dark skin tone +1F9B8 1F3FF 200D 2642 FE0F ; fully-qualified # 🦸🏿‍♂️ E11.0 man superhero: dark skin tone +1F9B8 1F3FF 200D 2642 ; minimally-qualified # 🦸🏿‍♂ E11.0 man superhero: dark skin tone +1F9B8 200D 2640 FE0F ; fully-qualified # 🦸‍♀️ E11.0 woman superhero +1F9B8 200D 2640 ; minimally-qualified # 🦸‍♀ E11.0 woman superhero +1F9B8 1F3FB 200D 2640 FE0F ; fully-qualified # 🦸🏻‍♀️ E11.0 woman superhero: light skin tone +1F9B8 1F3FB 200D 2640 ; minimally-qualified # 🦸🏻‍♀ E11.0 woman superhero: light skin tone +1F9B8 1F3FC 200D 2640 FE0F ; fully-qualified # 🦸🏼‍♀️ E11.0 woman superhero: medium-light skin tone +1F9B8 1F3FC 200D 2640 ; minimally-qualified # 🦸🏼‍♀ E11.0 woman superhero: medium-light skin tone +1F9B8 1F3FD 200D 2640 FE0F ; fully-qualified # 🦸🏽‍♀️ E11.0 woman superhero: medium skin tone +1F9B8 1F3FD 200D 2640 ; minimally-qualified # 🦸🏽‍♀ E11.0 woman superhero: medium skin tone +1F9B8 1F3FE 200D 2640 FE0F ; fully-qualified # 🦸🏾‍♀️ E11.0 woman superhero: medium-dark skin tone +1F9B8 1F3FE 200D 2640 ; minimally-qualified # 🦸🏾‍♀ E11.0 woman superhero: medium-dark skin tone +1F9B8 1F3FF 200D 2640 FE0F ; fully-qualified # 🦸🏿‍♀️ E11.0 woman superhero: dark skin tone +1F9B8 1F3FF 200D 2640 ; minimally-qualified # 🦸🏿‍♀ E11.0 woman superhero: dark skin tone +1F9B9 ; fully-qualified # 🦹 E11.0 supervillain +1F9B9 1F3FB ; fully-qualified # 🦹🏻 E11.0 supervillain: light skin tone +1F9B9 1F3FC ; fully-qualified # 🦹🏼 E11.0 supervillain: medium-light skin tone +1F9B9 1F3FD ; fully-qualified # 🦹🏽 E11.0 supervillain: medium skin tone +1F9B9 1F3FE ; fully-qualified # 🦹🏾 E11.0 supervillain: medium-dark skin tone +1F9B9 1F3FF ; fully-qualified # 🦹🏿 E11.0 supervillain: dark skin tone +1F9B9 200D 2642 FE0F ; fully-qualified # 🦹‍♂️ E11.0 man supervillain +1F9B9 200D 2642 ; minimally-qualified # 🦹‍♂ E11.0 man supervillain +1F9B9 1F3FB 200D 2642 FE0F ; fully-qualified # 🦹🏻‍♂️ E11.0 man supervillain: light skin tone +1F9B9 1F3FB 200D 2642 ; minimally-qualified # 🦹🏻‍♂ E11.0 man supervillain: light skin tone +1F9B9 1F3FC 200D 2642 FE0F ; fully-qualified # 🦹🏼‍♂️ E11.0 man supervillain: medium-light skin tone +1F9B9 1F3FC 200D 2642 ; minimally-qualified # 🦹🏼‍♂ E11.0 man supervillain: medium-light skin tone +1F9B9 1F3FD 200D 2642 FE0F ; fully-qualified # 🦹🏽‍♂️ E11.0 man supervillain: medium skin tone +1F9B9 1F3FD 200D 2642 ; minimally-qualified # 🦹🏽‍♂ E11.0 man supervillain: medium skin tone +1F9B9 1F3FE 200D 2642 FE0F ; fully-qualified # 🦹🏾‍♂️ E11.0 man supervillain: medium-dark skin tone +1F9B9 1F3FE 200D 2642 ; minimally-qualified # 🦹🏾‍♂ E11.0 man supervillain: medium-dark skin tone +1F9B9 1F3FF 200D 2642 FE0F ; fully-qualified # 🦹🏿‍♂️ E11.0 man supervillain: dark skin tone +1F9B9 1F3FF 200D 2642 ; minimally-qualified # 🦹🏿‍♂ E11.0 man supervillain: dark skin tone +1F9B9 200D 2640 FE0F ; fully-qualified # 🦹‍♀️ E11.0 woman supervillain +1F9B9 200D 2640 ; minimally-qualified # 🦹‍♀ E11.0 woman supervillain +1F9B9 1F3FB 200D 2640 FE0F ; fully-qualified # 🦹🏻‍♀️ E11.0 woman supervillain: light skin tone +1F9B9 1F3FB 200D 2640 ; minimally-qualified # 🦹🏻‍♀ E11.0 woman supervillain: light skin tone +1F9B9 1F3FC 200D 2640 FE0F ; fully-qualified # 🦹🏼‍♀️ E11.0 woman supervillain: medium-light skin tone +1F9B9 1F3FC 200D 2640 ; minimally-qualified # 🦹🏼‍♀ E11.0 woman supervillain: medium-light skin tone +1F9B9 1F3FD 200D 2640 FE0F ; fully-qualified # 🦹🏽‍♀️ E11.0 woman supervillain: medium skin tone +1F9B9 1F3FD 200D 2640 ; minimally-qualified # 🦹🏽‍♀ E11.0 woman supervillain: medium skin tone +1F9B9 1F3FE 200D 2640 FE0F ; fully-qualified # 🦹🏾‍♀️ E11.0 woman supervillain: medium-dark skin tone +1F9B9 1F3FE 200D 2640 ; minimally-qualified # 🦹🏾‍♀ E11.0 woman supervillain: medium-dark skin tone +1F9B9 1F3FF 200D 2640 FE0F ; fully-qualified # 🦹🏿‍♀️ E11.0 woman supervillain: dark skin tone +1F9B9 1F3FF 200D 2640 ; minimally-qualified # 🦹🏿‍♀ E11.0 woman supervillain: dark skin tone +1F9D9 ; fully-qualified # 🧙 E5.0 mage +1F9D9 1F3FB ; fully-qualified # 🧙🏻 E5.0 mage: light skin tone +1F9D9 1F3FC ; fully-qualified # 🧙🏼 E5.0 mage: medium-light skin tone +1F9D9 1F3FD ; fully-qualified # 🧙🏽 E5.0 mage: medium skin tone +1F9D9 1F3FE ; fully-qualified # 🧙🏾 E5.0 mage: medium-dark skin tone +1F9D9 1F3FF ; fully-qualified # 🧙🏿 E5.0 mage: dark skin tone +1F9D9 200D 2642 FE0F ; fully-qualified # 🧙‍♂️ E5.0 man mage +1F9D9 200D 2642 ; minimally-qualified # 🧙‍♂ E5.0 man mage +1F9D9 1F3FB 200D 2642 FE0F ; fully-qualified # 🧙🏻‍♂️ E5.0 man mage: light skin tone +1F9D9 1F3FB 200D 2642 ; minimally-qualified # 🧙🏻‍♂ E5.0 man mage: light skin tone +1F9D9 1F3FC 200D 2642 FE0F ; fully-qualified # 🧙🏼‍♂️ E5.0 man mage: medium-light skin tone +1F9D9 1F3FC 200D 2642 ; minimally-qualified # 🧙🏼‍♂ E5.0 man mage: medium-light skin tone +1F9D9 1F3FD 200D 2642 FE0F ; fully-qualified # 🧙🏽‍♂️ E5.0 man mage: medium skin tone +1F9D9 1F3FD 200D 2642 ; minimally-qualified # 🧙🏽‍♂ E5.0 man mage: medium skin tone +1F9D9 1F3FE 200D 2642 FE0F ; fully-qualified # 🧙🏾‍♂️ E5.0 man mage: medium-dark skin tone +1F9D9 1F3FE 200D 2642 ; minimally-qualified # 🧙🏾‍♂ E5.0 man mage: medium-dark skin tone +1F9D9 1F3FF 200D 2642 FE0F ; fully-qualified # 🧙🏿‍♂️ E5.0 man mage: dark skin tone +1F9D9 1F3FF 200D 2642 ; minimally-qualified # 🧙🏿‍♂ E5.0 man mage: dark skin tone +1F9D9 200D 2640 FE0F ; fully-qualified # 🧙‍♀️ E5.0 woman mage +1F9D9 200D 2640 ; minimally-qualified # 🧙‍♀ E5.0 woman mage +1F9D9 1F3FB 200D 2640 FE0F ; fully-qualified # 🧙🏻‍♀️ E5.0 woman mage: light skin tone +1F9D9 1F3FB 200D 2640 ; minimally-qualified # 🧙🏻‍♀ E5.0 woman mage: light skin tone +1F9D9 1F3FC 200D 2640 FE0F ; fully-qualified # 🧙🏼‍♀️ E5.0 woman mage: medium-light skin tone +1F9D9 1F3FC 200D 2640 ; minimally-qualified # 🧙🏼‍♀ E5.0 woman mage: medium-light skin tone +1F9D9 1F3FD 200D 2640 FE0F ; fully-qualified # 🧙🏽‍♀️ E5.0 woman mage: medium skin tone +1F9D9 1F3FD 200D 2640 ; minimally-qualified # 🧙🏽‍♀ E5.0 woman mage: medium skin tone +1F9D9 1F3FE 200D 2640 FE0F ; fully-qualified # 🧙🏾‍♀️ E5.0 woman mage: medium-dark skin tone +1F9D9 1F3FE 200D 2640 ; minimally-qualified # 🧙🏾‍♀ E5.0 woman mage: medium-dark skin tone +1F9D9 1F3FF 200D 2640 FE0F ; fully-qualified # 🧙🏿‍♀️ E5.0 woman mage: dark skin tone +1F9D9 1F3FF 200D 2640 ; minimally-qualified # 🧙🏿‍♀ E5.0 woman mage: dark skin tone +1F9DA ; fully-qualified # 🧚 E5.0 fairy +1F9DA 1F3FB ; fully-qualified # 🧚🏻 E5.0 fairy: light skin tone +1F9DA 1F3FC ; fully-qualified # 🧚🏼 E5.0 fairy: medium-light skin tone +1F9DA 1F3FD ; fully-qualified # 🧚🏽 E5.0 fairy: medium skin tone +1F9DA 1F3FE ; fully-qualified # 🧚🏾 E5.0 fairy: medium-dark skin tone +1F9DA 1F3FF ; fully-qualified # 🧚🏿 E5.0 fairy: dark skin tone +1F9DA 200D 2642 FE0F ; fully-qualified # 🧚‍♂️ E5.0 man fairy +1F9DA 200D 2642 ; minimally-qualified # 🧚‍♂ E5.0 man fairy +1F9DA 1F3FB 200D 2642 FE0F ; fully-qualified # 🧚🏻‍♂️ E5.0 man fairy: light skin tone +1F9DA 1F3FB 200D 2642 ; minimally-qualified # 🧚🏻‍♂ E5.0 man fairy: light skin tone +1F9DA 1F3FC 200D 2642 FE0F ; fully-qualified # 🧚🏼‍♂️ E5.0 man fairy: medium-light skin tone +1F9DA 1F3FC 200D 2642 ; minimally-qualified # 🧚🏼‍♂ E5.0 man fairy: medium-light skin tone +1F9DA 1F3FD 200D 2642 FE0F ; fully-qualified # 🧚🏽‍♂️ E5.0 man fairy: medium skin tone +1F9DA 1F3FD 200D 2642 ; minimally-qualified # 🧚🏽‍♂ E5.0 man fairy: medium skin tone +1F9DA 1F3FE 200D 2642 FE0F ; fully-qualified # 🧚🏾‍♂️ E5.0 man fairy: medium-dark skin tone +1F9DA 1F3FE 200D 2642 ; minimally-qualified # 🧚🏾‍♂ E5.0 man fairy: medium-dark skin tone +1F9DA 1F3FF 200D 2642 FE0F ; fully-qualified # 🧚🏿‍♂️ E5.0 man fairy: dark skin tone +1F9DA 1F3FF 200D 2642 ; minimally-qualified # 🧚🏿‍♂ E5.0 man fairy: dark skin tone +1F9DA 200D 2640 FE0F ; fully-qualified # 🧚‍♀️ E5.0 woman fairy +1F9DA 200D 2640 ; minimally-qualified # 🧚‍♀ E5.0 woman fairy +1F9DA 1F3FB 200D 2640 FE0F ; fully-qualified # 🧚🏻‍♀️ E5.0 woman fairy: light skin tone +1F9DA 1F3FB 200D 2640 ; minimally-qualified # 🧚🏻‍♀ E5.0 woman fairy: light skin tone +1F9DA 1F3FC 200D 2640 FE0F ; fully-qualified # 🧚🏼‍♀️ E5.0 woman fairy: medium-light skin tone +1F9DA 1F3FC 200D 2640 ; minimally-qualified # 🧚🏼‍♀ E5.0 woman fairy: medium-light skin tone +1F9DA 1F3FD 200D 2640 FE0F ; fully-qualified # 🧚🏽‍♀️ E5.0 woman fairy: medium skin tone +1F9DA 1F3FD 200D 2640 ; minimally-qualified # 🧚🏽‍♀ E5.0 woman fairy: medium skin tone +1F9DA 1F3FE 200D 2640 FE0F ; fully-qualified # 🧚🏾‍♀️ E5.0 woman fairy: medium-dark skin tone +1F9DA 1F3FE 200D 2640 ; minimally-qualified # 🧚🏾‍♀ E5.0 woman fairy: medium-dark skin tone +1F9DA 1F3FF 200D 2640 FE0F ; fully-qualified # 🧚🏿‍♀️ E5.0 woman fairy: dark skin tone +1F9DA 1F3FF 200D 2640 ; minimally-qualified # 🧚🏿‍♀ E5.0 woman fairy: dark skin tone +1F9DB ; fully-qualified # 🧛 E5.0 vampire +1F9DB 1F3FB ; fully-qualified # 🧛🏻 E5.0 vampire: light skin tone +1F9DB 1F3FC ; fully-qualified # 🧛🏼 E5.0 vampire: medium-light skin tone +1F9DB 1F3FD ; fully-qualified # 🧛🏽 E5.0 vampire: medium skin tone +1F9DB 1F3FE ; fully-qualified # 🧛🏾 E5.0 vampire: medium-dark skin tone +1F9DB 1F3FF ; fully-qualified # 🧛🏿 E5.0 vampire: dark skin tone +1F9DB 200D 2642 FE0F ; fully-qualified # 🧛‍♂️ E5.0 man vampire +1F9DB 200D 2642 ; minimally-qualified # 🧛‍♂ E5.0 man vampire +1F9DB 1F3FB 200D 2642 FE0F ; fully-qualified # 🧛🏻‍♂️ E5.0 man vampire: light skin tone +1F9DB 1F3FB 200D 2642 ; minimally-qualified # 🧛🏻‍♂ E5.0 man vampire: light skin tone +1F9DB 1F3FC 200D 2642 FE0F ; fully-qualified # 🧛🏼‍♂️ E5.0 man vampire: medium-light skin tone +1F9DB 1F3FC 200D 2642 ; minimally-qualified # 🧛🏼‍♂ E5.0 man vampire: medium-light skin tone +1F9DB 1F3FD 200D 2642 FE0F ; fully-qualified # 🧛🏽‍♂️ E5.0 man vampire: medium skin tone +1F9DB 1F3FD 200D 2642 ; minimally-qualified # 🧛🏽‍♂ E5.0 man vampire: medium skin tone +1F9DB 1F3FE 200D 2642 FE0F ; fully-qualified # 🧛🏾‍♂️ E5.0 man vampire: medium-dark skin tone +1F9DB 1F3FE 200D 2642 ; minimally-qualified # 🧛🏾‍♂ E5.0 man vampire: medium-dark skin tone +1F9DB 1F3FF 200D 2642 FE0F ; fully-qualified # 🧛🏿‍♂️ E5.0 man vampire: dark skin tone +1F9DB 1F3FF 200D 2642 ; minimally-qualified # 🧛🏿‍♂ E5.0 man vampire: dark skin tone +1F9DB 200D 2640 FE0F ; fully-qualified # 🧛‍♀️ E5.0 woman vampire +1F9DB 200D 2640 ; minimally-qualified # 🧛‍♀ E5.0 woman vampire +1F9DB 1F3FB 200D 2640 FE0F ; fully-qualified # 🧛🏻‍♀️ E5.0 woman vampire: light skin tone +1F9DB 1F3FB 200D 2640 ; minimally-qualified # 🧛🏻‍♀ E5.0 woman vampire: light skin tone +1F9DB 1F3FC 200D 2640 FE0F ; fully-qualified # 🧛🏼‍♀️ E5.0 woman vampire: medium-light skin tone +1F9DB 1F3FC 200D 2640 ; minimally-qualified # 🧛🏼‍♀ E5.0 woman vampire: medium-light skin tone +1F9DB 1F3FD 200D 2640 FE0F ; fully-qualified # 🧛🏽‍♀️ E5.0 woman vampire: medium skin tone +1F9DB 1F3FD 200D 2640 ; minimally-qualified # 🧛🏽‍♀ E5.0 woman vampire: medium skin tone +1F9DB 1F3FE 200D 2640 FE0F ; fully-qualified # 🧛🏾‍♀️ E5.0 woman vampire: medium-dark skin tone +1F9DB 1F3FE 200D 2640 ; minimally-qualified # 🧛🏾‍♀ E5.0 woman vampire: medium-dark skin tone +1F9DB 1F3FF 200D 2640 FE0F ; fully-qualified # 🧛🏿‍♀️ E5.0 woman vampire: dark skin tone +1F9DB 1F3FF 200D 2640 ; minimally-qualified # 🧛🏿‍♀ E5.0 woman vampire: dark skin tone +1F9DC ; fully-qualified # 🧜 E5.0 merperson +1F9DC 1F3FB ; fully-qualified # 🧜🏻 E5.0 merperson: light skin tone +1F9DC 1F3FC ; fully-qualified # 🧜🏼 E5.0 merperson: medium-light skin tone +1F9DC 1F3FD ; fully-qualified # 🧜🏽 E5.0 merperson: medium skin tone +1F9DC 1F3FE ; fully-qualified # 🧜🏾 E5.0 merperson: medium-dark skin tone +1F9DC 1F3FF ; fully-qualified # 🧜🏿 E5.0 merperson: dark skin tone +1F9DC 200D 2642 FE0F ; fully-qualified # 🧜‍♂️ E5.0 merman +1F9DC 200D 2642 ; minimally-qualified # 🧜‍♂ E5.0 merman +1F9DC 1F3FB 200D 2642 FE0F ; fully-qualified # 🧜🏻‍♂️ E5.0 merman: light skin tone +1F9DC 1F3FB 200D 2642 ; minimally-qualified # 🧜🏻‍♂ E5.0 merman: light skin tone +1F9DC 1F3FC 200D 2642 FE0F ; fully-qualified # 🧜🏼‍♂️ E5.0 merman: medium-light skin tone +1F9DC 1F3FC 200D 2642 ; minimally-qualified # 🧜🏼‍♂ E5.0 merman: medium-light skin tone +1F9DC 1F3FD 200D 2642 FE0F ; fully-qualified # 🧜🏽‍♂️ E5.0 merman: medium skin tone +1F9DC 1F3FD 200D 2642 ; minimally-qualified # 🧜🏽‍♂ E5.0 merman: medium skin tone +1F9DC 1F3FE 200D 2642 FE0F ; fully-qualified # 🧜🏾‍♂️ E5.0 merman: medium-dark skin tone +1F9DC 1F3FE 200D 2642 ; minimally-qualified # 🧜🏾‍♂ E5.0 merman: medium-dark skin tone +1F9DC 1F3FF 200D 2642 FE0F ; fully-qualified # 🧜🏿‍♂️ E5.0 merman: dark skin tone +1F9DC 1F3FF 200D 2642 ; minimally-qualified # 🧜🏿‍♂ E5.0 merman: dark skin tone +1F9DC 200D 2640 FE0F ; fully-qualified # 🧜‍♀️ E5.0 mermaid +1F9DC 200D 2640 ; minimally-qualified # 🧜‍♀ E5.0 mermaid +1F9DC 1F3FB 200D 2640 FE0F ; fully-qualified # 🧜🏻‍♀️ E5.0 mermaid: light skin tone +1F9DC 1F3FB 200D 2640 ; minimally-qualified # 🧜🏻‍♀ E5.0 mermaid: light skin tone +1F9DC 1F3FC 200D 2640 FE0F ; fully-qualified # 🧜🏼‍♀️ E5.0 mermaid: medium-light skin tone +1F9DC 1F3FC 200D 2640 ; minimally-qualified # 🧜🏼‍♀ E5.0 mermaid: medium-light skin tone +1F9DC 1F3FD 200D 2640 FE0F ; fully-qualified # 🧜🏽‍♀️ E5.0 mermaid: medium skin tone +1F9DC 1F3FD 200D 2640 ; minimally-qualified # 🧜🏽‍♀ E5.0 mermaid: medium skin tone +1F9DC 1F3FE 200D 2640 FE0F ; fully-qualified # 🧜🏾‍♀️ E5.0 mermaid: medium-dark skin tone +1F9DC 1F3FE 200D 2640 ; minimally-qualified # 🧜🏾‍♀ E5.0 mermaid: medium-dark skin tone +1F9DC 1F3FF 200D 2640 FE0F ; fully-qualified # 🧜🏿‍♀️ E5.0 mermaid: dark skin tone +1F9DC 1F3FF 200D 2640 ; minimally-qualified # 🧜🏿‍♀ E5.0 mermaid: dark skin tone +1F9DD ; fully-qualified # 🧝 E5.0 elf +1F9DD 1F3FB ; fully-qualified # 🧝🏻 E5.0 elf: light skin tone +1F9DD 1F3FC ; fully-qualified # 🧝🏼 E5.0 elf: medium-light skin tone +1F9DD 1F3FD ; fully-qualified # 🧝🏽 E5.0 elf: medium skin tone +1F9DD 1F3FE ; fully-qualified # 🧝🏾 E5.0 elf: medium-dark skin tone +1F9DD 1F3FF ; fully-qualified # 🧝🏿 E5.0 elf: dark skin tone +1F9DD 200D 2642 FE0F ; fully-qualified # 🧝‍♂️ E5.0 man elf +1F9DD 200D 2642 ; minimally-qualified # 🧝‍♂ E5.0 man elf +1F9DD 1F3FB 200D 2642 FE0F ; fully-qualified # 🧝🏻‍♂️ E5.0 man elf: light skin tone +1F9DD 1F3FB 200D 2642 ; minimally-qualified # 🧝🏻‍♂ E5.0 man elf: light skin tone +1F9DD 1F3FC 200D 2642 FE0F ; fully-qualified # 🧝🏼‍♂️ E5.0 man elf: medium-light skin tone +1F9DD 1F3FC 200D 2642 ; minimally-qualified # 🧝🏼‍♂ E5.0 man elf: medium-light skin tone +1F9DD 1F3FD 200D 2642 FE0F ; fully-qualified # 🧝🏽‍♂️ E5.0 man elf: medium skin tone +1F9DD 1F3FD 200D 2642 ; minimally-qualified # 🧝🏽‍♂ E5.0 man elf: medium skin tone +1F9DD 1F3FE 200D 2642 FE0F ; fully-qualified # 🧝🏾‍♂️ E5.0 man elf: medium-dark skin tone +1F9DD 1F3FE 200D 2642 ; minimally-qualified # 🧝🏾‍♂ E5.0 man elf: medium-dark skin tone +1F9DD 1F3FF 200D 2642 FE0F ; fully-qualified # 🧝🏿‍♂️ E5.0 man elf: dark skin tone +1F9DD 1F3FF 200D 2642 ; minimally-qualified # 🧝🏿‍♂ E5.0 man elf: dark skin tone +1F9DD 200D 2640 FE0F ; fully-qualified # 🧝‍♀️ E5.0 woman elf +1F9DD 200D 2640 ; minimally-qualified # 🧝‍♀ E5.0 woman elf +1F9DD 1F3FB 200D 2640 FE0F ; fully-qualified # 🧝🏻‍♀️ E5.0 woman elf: light skin tone +1F9DD 1F3FB 200D 2640 ; minimally-qualified # 🧝🏻‍♀ E5.0 woman elf: light skin tone +1F9DD 1F3FC 200D 2640 FE0F ; fully-qualified # 🧝🏼‍♀️ E5.0 woman elf: medium-light skin tone +1F9DD 1F3FC 200D 2640 ; minimally-qualified # 🧝🏼‍♀ E5.0 woman elf: medium-light skin tone +1F9DD 1F3FD 200D 2640 FE0F ; fully-qualified # 🧝🏽‍♀️ E5.0 woman elf: medium skin tone +1F9DD 1F3FD 200D 2640 ; minimally-qualified # 🧝🏽‍♀ E5.0 woman elf: medium skin tone +1F9DD 1F3FE 200D 2640 FE0F ; fully-qualified # 🧝🏾‍♀️ E5.0 woman elf: medium-dark skin tone +1F9DD 1F3FE 200D 2640 ; minimally-qualified # 🧝🏾‍♀ E5.0 woman elf: medium-dark skin tone +1F9DD 1F3FF 200D 2640 FE0F ; fully-qualified # 🧝🏿‍♀️ E5.0 woman elf: dark skin tone +1F9DD 1F3FF 200D 2640 ; minimally-qualified # 🧝🏿‍♀ E5.0 woman elf: dark skin tone +1F9DE ; fully-qualified # 🧞 E5.0 genie +1F9DE 200D 2642 FE0F ; fully-qualified # 🧞‍♂️ E5.0 man genie +1F9DE 200D 2642 ; minimally-qualified # 🧞‍♂ E5.0 man genie +1F9DE 200D 2640 FE0F ; fully-qualified # 🧞‍♀️ E5.0 woman genie +1F9DE 200D 2640 ; minimally-qualified # 🧞‍♀ E5.0 woman genie +1F9DF ; fully-qualified # 🧟 E5.0 zombie +1F9DF 200D 2642 FE0F ; fully-qualified # 🧟‍♂️ E5.0 man zombie +1F9DF 200D 2642 ; minimally-qualified # 🧟‍♂ E5.0 man zombie +1F9DF 200D 2640 FE0F ; fully-qualified # 🧟‍♀️ E5.0 woman zombie +1F9DF 200D 2640 ; minimally-qualified # 🧟‍♀ E5.0 woman zombie +1F9CC ; fully-qualified # 🧌 E14.0 troll + +# subgroup: person-activity +1F486 ; fully-qualified # 💆 E0.6 person getting massage +1F486 1F3FB ; fully-qualified # 💆🏻 E1.0 person getting massage: light skin tone +1F486 1F3FC ; fully-qualified # 💆🏼 E1.0 person getting massage: medium-light skin tone +1F486 1F3FD ; fully-qualified # 💆🏽 E1.0 person getting massage: medium skin tone +1F486 1F3FE ; fully-qualified # 💆🏾 E1.0 person getting massage: medium-dark skin tone +1F486 1F3FF ; fully-qualified # 💆🏿 E1.0 person getting massage: dark skin tone +1F486 200D 2642 FE0F ; fully-qualified # 💆‍♂️ E4.0 man getting massage +1F486 200D 2642 ; minimally-qualified # 💆‍♂ E4.0 man getting massage +1F486 1F3FB 200D 2642 FE0F ; fully-qualified # 💆🏻‍♂️ E4.0 man getting massage: light skin tone +1F486 1F3FB 200D 2642 ; minimally-qualified # 💆🏻‍♂ E4.0 man getting massage: light skin tone +1F486 1F3FC 200D 2642 FE0F ; fully-qualified # 💆🏼‍♂️ E4.0 man getting massage: medium-light skin tone +1F486 1F3FC 200D 2642 ; minimally-qualified # 💆🏼‍♂ E4.0 man getting massage: medium-light skin tone +1F486 1F3FD 200D 2642 FE0F ; fully-qualified # 💆🏽‍♂️ E4.0 man getting massage: medium skin tone +1F486 1F3FD 200D 2642 ; minimally-qualified # 💆🏽‍♂ E4.0 man getting massage: medium skin tone +1F486 1F3FE 200D 2642 FE0F ; fully-qualified # 💆🏾‍♂️ E4.0 man getting massage: medium-dark skin tone +1F486 1F3FE 200D 2642 ; minimally-qualified # 💆🏾‍♂ E4.0 man getting massage: medium-dark skin tone +1F486 1F3FF 200D 2642 FE0F ; fully-qualified # 💆🏿‍♂️ E4.0 man getting massage: dark skin tone +1F486 1F3FF 200D 2642 ; minimally-qualified # 💆🏿‍♂ E4.0 man getting massage: dark skin tone +1F486 200D 2640 FE0F ; fully-qualified # 💆‍♀️ E4.0 woman getting massage +1F486 200D 2640 ; minimally-qualified # 💆‍♀ E4.0 woman getting massage +1F486 1F3FB 200D 2640 FE0F ; fully-qualified # 💆🏻‍♀️ E4.0 woman getting massage: light skin tone +1F486 1F3FB 200D 2640 ; minimally-qualified # 💆🏻‍♀ E4.0 woman getting massage: light skin tone +1F486 1F3FC 200D 2640 FE0F ; fully-qualified # 💆🏼‍♀️ E4.0 woman getting massage: medium-light skin tone +1F486 1F3FC 200D 2640 ; minimally-qualified # 💆🏼‍♀ E4.0 woman getting massage: medium-light skin tone +1F486 1F3FD 200D 2640 FE0F ; fully-qualified # 💆🏽‍♀️ E4.0 woman getting massage: medium skin tone +1F486 1F3FD 200D 2640 ; minimally-qualified # 💆🏽‍♀ E4.0 woman getting massage: medium skin tone +1F486 1F3FE 200D 2640 FE0F ; fully-qualified # 💆🏾‍♀️ E4.0 woman getting massage: medium-dark skin tone +1F486 1F3FE 200D 2640 ; minimally-qualified # 💆🏾‍♀ E4.0 woman getting massage: medium-dark skin tone +1F486 1F3FF 200D 2640 FE0F ; fully-qualified # 💆🏿‍♀️ E4.0 woman getting massage: dark skin tone +1F486 1F3FF 200D 2640 ; minimally-qualified # 💆🏿‍♀ E4.0 woman getting massage: dark skin tone +1F487 ; fully-qualified # 💇 E0.6 person getting haircut +1F487 1F3FB ; fully-qualified # 💇🏻 E1.0 person getting haircut: light skin tone +1F487 1F3FC ; fully-qualified # 💇🏼 E1.0 person getting haircut: medium-light skin tone +1F487 1F3FD ; fully-qualified # 💇🏽 E1.0 person getting haircut: medium skin tone +1F487 1F3FE ; fully-qualified # 💇🏾 E1.0 person getting haircut: medium-dark skin tone +1F487 1F3FF ; fully-qualified # 💇🏿 E1.0 person getting haircut: dark skin tone +1F487 200D 2642 FE0F ; fully-qualified # 💇‍♂️ E4.0 man getting haircut +1F487 200D 2642 ; minimally-qualified # 💇‍♂ E4.0 man getting haircut +1F487 1F3FB 200D 2642 FE0F ; fully-qualified # 💇🏻‍♂️ E4.0 man getting haircut: light skin tone +1F487 1F3FB 200D 2642 ; minimally-qualified # 💇🏻‍♂ E4.0 man getting haircut: light skin tone +1F487 1F3FC 200D 2642 FE0F ; fully-qualified # 💇🏼‍♂️ E4.0 man getting haircut: medium-light skin tone +1F487 1F3FC 200D 2642 ; minimally-qualified # 💇🏼‍♂ E4.0 man getting haircut: medium-light skin tone +1F487 1F3FD 200D 2642 FE0F ; fully-qualified # 💇🏽‍♂️ E4.0 man getting haircut: medium skin tone +1F487 1F3FD 200D 2642 ; minimally-qualified # 💇🏽‍♂ E4.0 man getting haircut: medium skin tone +1F487 1F3FE 200D 2642 FE0F ; fully-qualified # 💇🏾‍♂️ E4.0 man getting haircut: medium-dark skin tone +1F487 1F3FE 200D 2642 ; minimally-qualified # 💇🏾‍♂ E4.0 man getting haircut: medium-dark skin tone +1F487 1F3FF 200D 2642 FE0F ; fully-qualified # 💇🏿‍♂️ E4.0 man getting haircut: dark skin tone +1F487 1F3FF 200D 2642 ; minimally-qualified # 💇🏿‍♂ E4.0 man getting haircut: dark skin tone +1F487 200D 2640 FE0F ; fully-qualified # 💇‍♀️ E4.0 woman getting haircut +1F487 200D 2640 ; minimally-qualified # 💇‍♀ E4.0 woman getting haircut +1F487 1F3FB 200D 2640 FE0F ; fully-qualified # 💇🏻‍♀️ E4.0 woman getting haircut: light skin tone +1F487 1F3FB 200D 2640 ; minimally-qualified # 💇🏻‍♀ E4.0 woman getting haircut: light skin tone +1F487 1F3FC 200D 2640 FE0F ; fully-qualified # 💇🏼‍♀️ E4.0 woman getting haircut: medium-light skin tone +1F487 1F3FC 200D 2640 ; minimally-qualified # 💇🏼‍♀ E4.0 woman getting haircut: medium-light skin tone +1F487 1F3FD 200D 2640 FE0F ; fully-qualified # 💇🏽‍♀️ E4.0 woman getting haircut: medium skin tone +1F487 1F3FD 200D 2640 ; minimally-qualified # 💇🏽‍♀ E4.0 woman getting haircut: medium skin tone +1F487 1F3FE 200D 2640 FE0F ; fully-qualified # 💇🏾‍♀️ E4.0 woman getting haircut: medium-dark skin tone +1F487 1F3FE 200D 2640 ; minimally-qualified # 💇🏾‍♀ E4.0 woman getting haircut: medium-dark skin tone +1F487 1F3FF 200D 2640 FE0F ; fully-qualified # 💇🏿‍♀️ E4.0 woman getting haircut: dark skin tone +1F487 1F3FF 200D 2640 ; minimally-qualified # 💇🏿‍♀ E4.0 woman getting haircut: dark skin tone +1F6B6 ; fully-qualified # 🚶 E0.6 person walking +1F6B6 1F3FB ; fully-qualified # 🚶🏻 E1.0 person walking: light skin tone +1F6B6 1F3FC ; fully-qualified # 🚶🏼 E1.0 person walking: medium-light skin tone +1F6B6 1F3FD ; fully-qualified # 🚶🏽 E1.0 person walking: medium skin tone +1F6B6 1F3FE ; fully-qualified # 🚶🏾 E1.0 person walking: medium-dark skin tone +1F6B6 1F3FF ; fully-qualified # 🚶🏿 E1.0 person walking: dark skin tone +1F6B6 200D 2642 FE0F ; fully-qualified # 🚶‍♂️ E4.0 man walking +1F6B6 200D 2642 ; minimally-qualified # 🚶‍♂ E4.0 man walking +1F6B6 1F3FB 200D 2642 FE0F ; fully-qualified # 🚶🏻‍♂️ E4.0 man walking: light skin tone +1F6B6 1F3FB 200D 2642 ; minimally-qualified # 🚶🏻‍♂ E4.0 man walking: light skin tone +1F6B6 1F3FC 200D 2642 FE0F ; fully-qualified # 🚶🏼‍♂️ E4.0 man walking: medium-light skin tone +1F6B6 1F3FC 200D 2642 ; minimally-qualified # 🚶🏼‍♂ E4.0 man walking: medium-light skin tone +1F6B6 1F3FD 200D 2642 FE0F ; fully-qualified # 🚶🏽‍♂️ E4.0 man walking: medium skin tone +1F6B6 1F3FD 200D 2642 ; minimally-qualified # 🚶🏽‍♂ E4.0 man walking: medium skin tone +1F6B6 1F3FE 200D 2642 FE0F ; fully-qualified # 🚶🏾‍♂️ E4.0 man walking: medium-dark skin tone +1F6B6 1F3FE 200D 2642 ; minimally-qualified # 🚶🏾‍♂ E4.0 man walking: medium-dark skin tone +1F6B6 1F3FF 200D 2642 FE0F ; fully-qualified # 🚶🏿‍♂️ E4.0 man walking: dark skin tone +1F6B6 1F3FF 200D 2642 ; minimally-qualified # 🚶🏿‍♂ E4.0 man walking: dark skin tone +1F6B6 200D 2640 FE0F ; fully-qualified # 🚶‍♀️ E4.0 woman walking +1F6B6 200D 2640 ; minimally-qualified # 🚶‍♀ E4.0 woman walking +1F6B6 1F3FB 200D 2640 FE0F ; fully-qualified # 🚶🏻‍♀️ E4.0 woman walking: light skin tone +1F6B6 1F3FB 200D 2640 ; minimally-qualified # 🚶🏻‍♀ E4.0 woman walking: light skin tone +1F6B6 1F3FC 200D 2640 FE0F ; fully-qualified # 🚶🏼‍♀️ E4.0 woman walking: medium-light skin tone +1F6B6 1F3FC 200D 2640 ; minimally-qualified # 🚶🏼‍♀ E4.0 woman walking: medium-light skin tone +1F6B6 1F3FD 200D 2640 FE0F ; fully-qualified # 🚶🏽‍♀️ E4.0 woman walking: medium skin tone +1F6B6 1F3FD 200D 2640 ; minimally-qualified # 🚶🏽‍♀ E4.0 woman walking: medium skin tone +1F6B6 1F3FE 200D 2640 FE0F ; fully-qualified # 🚶🏾‍♀️ E4.0 woman walking: medium-dark skin tone +1F6B6 1F3FE 200D 2640 ; minimally-qualified # 🚶🏾‍♀ E4.0 woman walking: medium-dark skin tone +1F6B6 1F3FF 200D 2640 FE0F ; fully-qualified # 🚶🏿‍♀️ E4.0 woman walking: dark skin tone +1F6B6 1F3FF 200D 2640 ; minimally-qualified # 🚶🏿‍♀ E4.0 woman walking: dark skin tone +1F9CD ; fully-qualified # 🧍 E12.0 person standing +1F9CD 1F3FB ; fully-qualified # 🧍🏻 E12.0 person standing: light skin tone +1F9CD 1F3FC ; fully-qualified # 🧍🏼 E12.0 person standing: medium-light skin tone +1F9CD 1F3FD ; fully-qualified # 🧍🏽 E12.0 person standing: medium skin tone +1F9CD 1F3FE ; fully-qualified # 🧍🏾 E12.0 person standing: medium-dark skin tone +1F9CD 1F3FF ; fully-qualified # 🧍🏿 E12.0 person standing: dark skin tone +1F9CD 200D 2642 FE0F ; fully-qualified # 🧍‍♂️ E12.0 man standing +1F9CD 200D 2642 ; minimally-qualified # 🧍‍♂ E12.0 man standing +1F9CD 1F3FB 200D 2642 FE0F ; fully-qualified # 🧍🏻‍♂️ E12.0 man standing: light skin tone +1F9CD 1F3FB 200D 2642 ; minimally-qualified # 🧍🏻‍♂ E12.0 man standing: light skin tone +1F9CD 1F3FC 200D 2642 FE0F ; fully-qualified # 🧍🏼‍♂️ E12.0 man standing: medium-light skin tone +1F9CD 1F3FC 200D 2642 ; minimally-qualified # 🧍🏼‍♂ E12.0 man standing: medium-light skin tone +1F9CD 1F3FD 200D 2642 FE0F ; fully-qualified # 🧍🏽‍♂️ E12.0 man standing: medium skin tone +1F9CD 1F3FD 200D 2642 ; minimally-qualified # 🧍🏽‍♂ E12.0 man standing: medium skin tone +1F9CD 1F3FE 200D 2642 FE0F ; fully-qualified # 🧍🏾‍♂️ E12.0 man standing: medium-dark skin tone +1F9CD 1F3FE 200D 2642 ; minimally-qualified # 🧍🏾‍♂ E12.0 man standing: medium-dark skin tone +1F9CD 1F3FF 200D 2642 FE0F ; fully-qualified # 🧍🏿‍♂️ E12.0 man standing: dark skin tone +1F9CD 1F3FF 200D 2642 ; minimally-qualified # 🧍🏿‍♂ E12.0 man standing: dark skin tone +1F9CD 200D 2640 FE0F ; fully-qualified # 🧍‍♀️ E12.0 woman standing +1F9CD 200D 2640 ; minimally-qualified # 🧍‍♀ E12.0 woman standing +1F9CD 1F3FB 200D 2640 FE0F ; fully-qualified # 🧍🏻‍♀️ E12.0 woman standing: light skin tone +1F9CD 1F3FB 200D 2640 ; minimally-qualified # 🧍🏻‍♀ E12.0 woman standing: light skin tone +1F9CD 1F3FC 200D 2640 FE0F ; fully-qualified # 🧍🏼‍♀️ E12.0 woman standing: medium-light skin tone +1F9CD 1F3FC 200D 2640 ; minimally-qualified # 🧍🏼‍♀ E12.0 woman standing: medium-light skin tone +1F9CD 1F3FD 200D 2640 FE0F ; fully-qualified # 🧍🏽‍♀️ E12.0 woman standing: medium skin tone +1F9CD 1F3FD 200D 2640 ; minimally-qualified # 🧍🏽‍♀ E12.0 woman standing: medium skin tone +1F9CD 1F3FE 200D 2640 FE0F ; fully-qualified # 🧍🏾‍♀️ E12.0 woman standing: medium-dark skin tone +1F9CD 1F3FE 200D 2640 ; minimally-qualified # 🧍🏾‍♀ E12.0 woman standing: medium-dark skin tone +1F9CD 1F3FF 200D 2640 FE0F ; fully-qualified # 🧍🏿‍♀️ E12.0 woman standing: dark skin tone +1F9CD 1F3FF 200D 2640 ; minimally-qualified # 🧍🏿‍♀ E12.0 woman standing: dark skin tone +1F9CE ; fully-qualified # 🧎 E12.0 person kneeling +1F9CE 1F3FB ; fully-qualified # 🧎🏻 E12.0 person kneeling: light skin tone +1F9CE 1F3FC ; fully-qualified # 🧎🏼 E12.0 person kneeling: medium-light skin tone +1F9CE 1F3FD ; fully-qualified # 🧎🏽 E12.0 person kneeling: medium skin tone +1F9CE 1F3FE ; fully-qualified # 🧎🏾 E12.0 person kneeling: medium-dark skin tone +1F9CE 1F3FF ; fully-qualified # 🧎🏿 E12.0 person kneeling: dark skin tone +1F9CE 200D 2642 FE0F ; fully-qualified # 🧎‍♂️ E12.0 man kneeling +1F9CE 200D 2642 ; minimally-qualified # 🧎‍♂ E12.0 man kneeling +1F9CE 1F3FB 200D 2642 FE0F ; fully-qualified # 🧎🏻‍♂️ E12.0 man kneeling: light skin tone +1F9CE 1F3FB 200D 2642 ; minimally-qualified # 🧎🏻‍♂ E12.0 man kneeling: light skin tone +1F9CE 1F3FC 200D 2642 FE0F ; fully-qualified # 🧎🏼‍♂️ E12.0 man kneeling: medium-light skin tone +1F9CE 1F3FC 200D 2642 ; minimally-qualified # 🧎🏼‍♂ E12.0 man kneeling: medium-light skin tone +1F9CE 1F3FD 200D 2642 FE0F ; fully-qualified # 🧎🏽‍♂️ E12.0 man kneeling: medium skin tone +1F9CE 1F3FD 200D 2642 ; minimally-qualified # 🧎🏽‍♂ E12.0 man kneeling: medium skin tone +1F9CE 1F3FE 200D 2642 FE0F ; fully-qualified # 🧎🏾‍♂️ E12.0 man kneeling: medium-dark skin tone +1F9CE 1F3FE 200D 2642 ; minimally-qualified # 🧎🏾‍♂ E12.0 man kneeling: medium-dark skin tone +1F9CE 1F3FF 200D 2642 FE0F ; fully-qualified # 🧎🏿‍♂️ E12.0 man kneeling: dark skin tone +1F9CE 1F3FF 200D 2642 ; minimally-qualified # 🧎🏿‍♂ E12.0 man kneeling: dark skin tone +1F9CE 200D 2640 FE0F ; fully-qualified # 🧎‍♀️ E12.0 woman kneeling +1F9CE 200D 2640 ; minimally-qualified # 🧎‍♀ E12.0 woman kneeling +1F9CE 1F3FB 200D 2640 FE0F ; fully-qualified # 🧎🏻‍♀️ E12.0 woman kneeling: light skin tone +1F9CE 1F3FB 200D 2640 ; minimally-qualified # 🧎🏻‍♀ E12.0 woman kneeling: light skin tone +1F9CE 1F3FC 200D 2640 FE0F ; fully-qualified # 🧎🏼‍♀️ E12.0 woman kneeling: medium-light skin tone +1F9CE 1F3FC 200D 2640 ; minimally-qualified # 🧎🏼‍♀ E12.0 woman kneeling: medium-light skin tone +1F9CE 1F3FD 200D 2640 FE0F ; fully-qualified # 🧎🏽‍♀️ E12.0 woman kneeling: medium skin tone +1F9CE 1F3FD 200D 2640 ; minimally-qualified # 🧎🏽‍♀ E12.0 woman kneeling: medium skin tone +1F9CE 1F3FE 200D 2640 FE0F ; fully-qualified # 🧎🏾‍♀️ E12.0 woman kneeling: medium-dark skin tone +1F9CE 1F3FE 200D 2640 ; minimally-qualified # 🧎🏾‍♀ E12.0 woman kneeling: medium-dark skin tone +1F9CE 1F3FF 200D 2640 FE0F ; fully-qualified # 🧎🏿‍♀️ E12.0 woman kneeling: dark skin tone +1F9CE 1F3FF 200D 2640 ; minimally-qualified # 🧎🏿‍♀ E12.0 woman kneeling: dark skin tone +1F9D1 200D 1F9AF ; fully-qualified # 🧑‍🦯 E12.1 person with white cane +1F9D1 1F3FB 200D 1F9AF ; fully-qualified # 🧑🏻‍🦯 E12.1 person with white cane: light skin tone +1F9D1 1F3FC 200D 1F9AF ; fully-qualified # 🧑🏼‍🦯 E12.1 person with white cane: medium-light skin tone +1F9D1 1F3FD 200D 1F9AF ; fully-qualified # 🧑🏽‍🦯 E12.1 person with white cane: medium skin tone +1F9D1 1F3FE 200D 1F9AF ; fully-qualified # 🧑🏾‍🦯 E12.1 person with white cane: medium-dark skin tone +1F9D1 1F3FF 200D 1F9AF ; fully-qualified # 🧑🏿‍🦯 E12.1 person with white cane: dark skin tone +1F468 200D 1F9AF ; fully-qualified # 👨‍🦯 E12.0 man with white cane +1F468 1F3FB 200D 1F9AF ; fully-qualified # 👨🏻‍🦯 E12.0 man with white cane: light skin tone +1F468 1F3FC 200D 1F9AF ; fully-qualified # 👨🏼‍🦯 E12.0 man with white cane: medium-light skin tone +1F468 1F3FD 200D 1F9AF ; fully-qualified # 👨🏽‍🦯 E12.0 man with white cane: medium skin tone +1F468 1F3FE 200D 1F9AF ; fully-qualified # 👨🏾‍🦯 E12.0 man with white cane: medium-dark skin tone +1F468 1F3FF 200D 1F9AF ; fully-qualified # 👨🏿‍🦯 E12.0 man with white cane: dark skin tone +1F469 200D 1F9AF ; fully-qualified # 👩‍🦯 E12.0 woman with white cane +1F469 1F3FB 200D 1F9AF ; fully-qualified # 👩🏻‍🦯 E12.0 woman with white cane: light skin tone +1F469 1F3FC 200D 1F9AF ; fully-qualified # 👩🏼‍🦯 E12.0 woman with white cane: medium-light skin tone +1F469 1F3FD 200D 1F9AF ; fully-qualified # 👩🏽‍🦯 E12.0 woman with white cane: medium skin tone +1F469 1F3FE 200D 1F9AF ; fully-qualified # 👩🏾‍🦯 E12.0 woman with white cane: medium-dark skin tone +1F469 1F3FF 200D 1F9AF ; fully-qualified # 👩🏿‍🦯 E12.0 woman with white cane: dark skin tone +1F9D1 200D 1F9BC ; fully-qualified # 🧑‍🦼 E12.1 person in motorized wheelchair +1F9D1 1F3FB 200D 1F9BC ; fully-qualified # 🧑🏻‍🦼 E12.1 person in motorized wheelchair: light skin tone +1F9D1 1F3FC 200D 1F9BC ; fully-qualified # 🧑🏼‍🦼 E12.1 person in motorized wheelchair: medium-light skin tone +1F9D1 1F3FD 200D 1F9BC ; fully-qualified # 🧑🏽‍🦼 E12.1 person in motorized wheelchair: medium skin tone +1F9D1 1F3FE 200D 1F9BC ; fully-qualified # 🧑🏾‍🦼 E12.1 person in motorized wheelchair: medium-dark skin tone +1F9D1 1F3FF 200D 1F9BC ; fully-qualified # 🧑🏿‍🦼 E12.1 person in motorized wheelchair: dark skin tone +1F468 200D 1F9BC ; fully-qualified # 👨‍🦼 E12.0 man in motorized wheelchair +1F468 1F3FB 200D 1F9BC ; fully-qualified # 👨🏻‍🦼 E12.0 man in motorized wheelchair: light skin tone +1F468 1F3FC 200D 1F9BC ; fully-qualified # 👨🏼‍🦼 E12.0 man in motorized wheelchair: medium-light skin tone +1F468 1F3FD 200D 1F9BC ; fully-qualified # 👨🏽‍🦼 E12.0 man in motorized wheelchair: medium skin tone +1F468 1F3FE 200D 1F9BC ; fully-qualified # 👨🏾‍🦼 E12.0 man in motorized wheelchair: medium-dark skin tone +1F468 1F3FF 200D 1F9BC ; fully-qualified # 👨🏿‍🦼 E12.0 man in motorized wheelchair: dark skin tone +1F469 200D 1F9BC ; fully-qualified # 👩‍🦼 E12.0 woman in motorized wheelchair +1F469 1F3FB 200D 1F9BC ; fully-qualified # 👩🏻‍🦼 E12.0 woman in motorized wheelchair: light skin tone +1F469 1F3FC 200D 1F9BC ; fully-qualified # 👩🏼‍🦼 E12.0 woman in motorized wheelchair: medium-light skin tone +1F469 1F3FD 200D 1F9BC ; fully-qualified # 👩🏽‍🦼 E12.0 woman in motorized wheelchair: medium skin tone +1F469 1F3FE 200D 1F9BC ; fully-qualified # 👩🏾‍🦼 E12.0 woman in motorized wheelchair: medium-dark skin tone +1F469 1F3FF 200D 1F9BC ; fully-qualified # 👩🏿‍🦼 E12.0 woman in motorized wheelchair: dark skin tone +1F9D1 200D 1F9BD ; fully-qualified # 🧑‍🦽 E12.1 person in manual wheelchair +1F9D1 1F3FB 200D 1F9BD ; fully-qualified # 🧑🏻‍🦽 E12.1 person in manual wheelchair: light skin tone +1F9D1 1F3FC 200D 1F9BD ; fully-qualified # 🧑🏼‍🦽 E12.1 person in manual wheelchair: medium-light skin tone +1F9D1 1F3FD 200D 1F9BD ; fully-qualified # 🧑🏽‍🦽 E12.1 person in manual wheelchair: medium skin tone +1F9D1 1F3FE 200D 1F9BD ; fully-qualified # 🧑🏾‍🦽 E12.1 person in manual wheelchair: medium-dark skin tone +1F9D1 1F3FF 200D 1F9BD ; fully-qualified # 🧑🏿‍🦽 E12.1 person in manual wheelchair: dark skin tone +1F468 200D 1F9BD ; fully-qualified # 👨‍🦽 E12.0 man in manual wheelchair +1F468 1F3FB 200D 1F9BD ; fully-qualified # 👨🏻‍🦽 E12.0 man in manual wheelchair: light skin tone +1F468 1F3FC 200D 1F9BD ; fully-qualified # 👨🏼‍🦽 E12.0 man in manual wheelchair: medium-light skin tone +1F468 1F3FD 200D 1F9BD ; fully-qualified # 👨🏽‍🦽 E12.0 man in manual wheelchair: medium skin tone +1F468 1F3FE 200D 1F9BD ; fully-qualified # 👨🏾‍🦽 E12.0 man in manual wheelchair: medium-dark skin tone +1F468 1F3FF 200D 1F9BD ; fully-qualified # 👨🏿‍🦽 E12.0 man in manual wheelchair: dark skin tone +1F469 200D 1F9BD ; fully-qualified # 👩‍🦽 E12.0 woman in manual wheelchair +1F469 1F3FB 200D 1F9BD ; fully-qualified # 👩🏻‍🦽 E12.0 woman in manual wheelchair: light skin tone +1F469 1F3FC 200D 1F9BD ; fully-qualified # 👩🏼‍🦽 E12.0 woman in manual wheelchair: medium-light skin tone +1F469 1F3FD 200D 1F9BD ; fully-qualified # 👩🏽‍🦽 E12.0 woman in manual wheelchair: medium skin tone +1F469 1F3FE 200D 1F9BD ; fully-qualified # 👩🏾‍🦽 E12.0 woman in manual wheelchair: medium-dark skin tone +1F469 1F3FF 200D 1F9BD ; fully-qualified # 👩🏿‍🦽 E12.0 woman in manual wheelchair: dark skin tone +1F3C3 ; fully-qualified # 🏃 E0.6 person running +1F3C3 1F3FB ; fully-qualified # 🏃🏻 E1.0 person running: light skin tone +1F3C3 1F3FC ; fully-qualified # 🏃🏼 E1.0 person running: medium-light skin tone +1F3C3 1F3FD ; fully-qualified # 🏃🏽 E1.0 person running: medium skin tone +1F3C3 1F3FE ; fully-qualified # 🏃🏾 E1.0 person running: medium-dark skin tone +1F3C3 1F3FF ; fully-qualified # 🏃🏿 E1.0 person running: dark skin tone +1F3C3 200D 2642 FE0F ; fully-qualified # 🏃‍♂️ E4.0 man running +1F3C3 200D 2642 ; minimally-qualified # 🏃‍♂ E4.0 man running +1F3C3 1F3FB 200D 2642 FE0F ; fully-qualified # 🏃🏻‍♂️ E4.0 man running: light skin tone +1F3C3 1F3FB 200D 2642 ; minimally-qualified # 🏃🏻‍♂ E4.0 man running: light skin tone +1F3C3 1F3FC 200D 2642 FE0F ; fully-qualified # 🏃🏼‍♂️ E4.0 man running: medium-light skin tone +1F3C3 1F3FC 200D 2642 ; minimally-qualified # 🏃🏼‍♂ E4.0 man running: medium-light skin tone +1F3C3 1F3FD 200D 2642 FE0F ; fully-qualified # 🏃🏽‍♂️ E4.0 man running: medium skin tone +1F3C3 1F3FD 200D 2642 ; minimally-qualified # 🏃🏽‍♂ E4.0 man running: medium skin tone +1F3C3 1F3FE 200D 2642 FE0F ; fully-qualified # 🏃🏾‍♂️ E4.0 man running: medium-dark skin tone +1F3C3 1F3FE 200D 2642 ; minimally-qualified # 🏃🏾‍♂ E4.0 man running: medium-dark skin tone +1F3C3 1F3FF 200D 2642 FE0F ; fully-qualified # 🏃🏿‍♂️ E4.0 man running: dark skin tone +1F3C3 1F3FF 200D 2642 ; minimally-qualified # 🏃🏿‍♂ E4.0 man running: dark skin tone +1F3C3 200D 2640 FE0F ; fully-qualified # 🏃‍♀️ E4.0 woman running +1F3C3 200D 2640 ; minimally-qualified # 🏃‍♀ E4.0 woman running +1F3C3 1F3FB 200D 2640 FE0F ; fully-qualified # 🏃🏻‍♀️ E4.0 woman running: light skin tone +1F3C3 1F3FB 200D 2640 ; minimally-qualified # 🏃🏻‍♀ E4.0 woman running: light skin tone +1F3C3 1F3FC 200D 2640 FE0F ; fully-qualified # 🏃🏼‍♀️ E4.0 woman running: medium-light skin tone +1F3C3 1F3FC 200D 2640 ; minimally-qualified # 🏃🏼‍♀ E4.0 woman running: medium-light skin tone +1F3C3 1F3FD 200D 2640 FE0F ; fully-qualified # 🏃🏽‍♀️ E4.0 woman running: medium skin tone +1F3C3 1F3FD 200D 2640 ; minimally-qualified # 🏃🏽‍♀ E4.0 woman running: medium skin tone +1F3C3 1F3FE 200D 2640 FE0F ; fully-qualified # 🏃🏾‍♀️ E4.0 woman running: medium-dark skin tone +1F3C3 1F3FE 200D 2640 ; minimally-qualified # 🏃🏾‍♀ E4.0 woman running: medium-dark skin tone +1F3C3 1F3FF 200D 2640 FE0F ; fully-qualified # 🏃🏿‍♀️ E4.0 woman running: dark skin tone +1F3C3 1F3FF 200D 2640 ; minimally-qualified # 🏃🏿‍♀ E4.0 woman running: dark skin tone +1F483 ; fully-qualified # 💃 E0.6 woman dancing +1F483 1F3FB ; fully-qualified # 💃🏻 E1.0 woman dancing: light skin tone +1F483 1F3FC ; fully-qualified # 💃🏼 E1.0 woman dancing: medium-light skin tone +1F483 1F3FD ; fully-qualified # 💃🏽 E1.0 woman dancing: medium skin tone +1F483 1F3FE ; fully-qualified # 💃🏾 E1.0 woman dancing: medium-dark skin tone +1F483 1F3FF ; fully-qualified # 💃🏿 E1.0 woman dancing: dark skin tone +1F57A ; fully-qualified # 🕺 E3.0 man dancing +1F57A 1F3FB ; fully-qualified # 🕺🏻 E3.0 man dancing: light skin tone +1F57A 1F3FC ; fully-qualified # 🕺🏼 E3.0 man dancing: medium-light skin tone +1F57A 1F3FD ; fully-qualified # 🕺🏽 E3.0 man dancing: medium skin tone +1F57A 1F3FE ; fully-qualified # 🕺🏾 E3.0 man dancing: medium-dark skin tone +1F57A 1F3FF ; fully-qualified # 🕺🏿 E3.0 man dancing: dark skin tone +1F574 FE0F ; fully-qualified # 🕴️ E0.7 person in suit levitating +1F574 ; unqualified # 🕴 E0.7 person in suit levitating +1F574 1F3FB ; fully-qualified # 🕴🏻 E4.0 person in suit levitating: light skin tone +1F574 1F3FC ; fully-qualified # 🕴🏼 E4.0 person in suit levitating: medium-light skin tone +1F574 1F3FD ; fully-qualified # 🕴🏽 E4.0 person in suit levitating: medium skin tone +1F574 1F3FE ; fully-qualified # 🕴🏾 E4.0 person in suit levitating: medium-dark skin tone +1F574 1F3FF ; fully-qualified # 🕴🏿 E4.0 person in suit levitating: dark skin tone +1F46F ; fully-qualified # 👯 E0.6 people with bunny ears +1F46F 200D 2642 FE0F ; fully-qualified # 👯‍♂️ E4.0 men with bunny ears +1F46F 200D 2642 ; minimally-qualified # 👯‍♂ E4.0 men with bunny ears +1F46F 200D 2640 FE0F ; fully-qualified # 👯‍♀️ E4.0 women with bunny ears +1F46F 200D 2640 ; minimally-qualified # 👯‍♀ E4.0 women with bunny ears +1F9D6 ; fully-qualified # 🧖 E5.0 person in steamy room +1F9D6 1F3FB ; fully-qualified # 🧖🏻 E5.0 person in steamy room: light skin tone +1F9D6 1F3FC ; fully-qualified # 🧖🏼 E5.0 person in steamy room: medium-light skin tone +1F9D6 1F3FD ; fully-qualified # 🧖🏽 E5.0 person in steamy room: medium skin tone +1F9D6 1F3FE ; fully-qualified # 🧖🏾 E5.0 person in steamy room: medium-dark skin tone +1F9D6 1F3FF ; fully-qualified # 🧖🏿 E5.0 person in steamy room: dark skin tone +1F9D6 200D 2642 FE0F ; fully-qualified # 🧖‍♂️ E5.0 man in steamy room +1F9D6 200D 2642 ; minimally-qualified # 🧖‍♂ E5.0 man in steamy room +1F9D6 1F3FB 200D 2642 FE0F ; fully-qualified # 🧖🏻‍♂️ E5.0 man in steamy room: light skin tone +1F9D6 1F3FB 200D 2642 ; minimally-qualified # 🧖🏻‍♂ E5.0 man in steamy room: light skin tone +1F9D6 1F3FC 200D 2642 FE0F ; fully-qualified # 🧖🏼‍♂️ E5.0 man in steamy room: medium-light skin tone +1F9D6 1F3FC 200D 2642 ; minimally-qualified # 🧖🏼‍♂ E5.0 man in steamy room: medium-light skin tone +1F9D6 1F3FD 200D 2642 FE0F ; fully-qualified # 🧖🏽‍♂️ E5.0 man in steamy room: medium skin tone +1F9D6 1F3FD 200D 2642 ; minimally-qualified # 🧖🏽‍♂ E5.0 man in steamy room: medium skin tone +1F9D6 1F3FE 200D 2642 FE0F ; fully-qualified # 🧖🏾‍♂️ E5.0 man in steamy room: medium-dark skin tone +1F9D6 1F3FE 200D 2642 ; minimally-qualified # 🧖🏾‍♂ E5.0 man in steamy room: medium-dark skin tone +1F9D6 1F3FF 200D 2642 FE0F ; fully-qualified # 🧖🏿‍♂️ E5.0 man in steamy room: dark skin tone +1F9D6 1F3FF 200D 2642 ; minimally-qualified # 🧖🏿‍♂ E5.0 man in steamy room: dark skin tone +1F9D6 200D 2640 FE0F ; fully-qualified # 🧖‍♀️ E5.0 woman in steamy room +1F9D6 200D 2640 ; minimally-qualified # 🧖‍♀ E5.0 woman in steamy room +1F9D6 1F3FB 200D 2640 FE0F ; fully-qualified # 🧖🏻‍♀️ E5.0 woman in steamy room: light skin tone +1F9D6 1F3FB 200D 2640 ; minimally-qualified # 🧖🏻‍♀ E5.0 woman in steamy room: light skin tone +1F9D6 1F3FC 200D 2640 FE0F ; fully-qualified # 🧖🏼‍♀️ E5.0 woman in steamy room: medium-light skin tone +1F9D6 1F3FC 200D 2640 ; minimally-qualified # 🧖🏼‍♀ E5.0 woman in steamy room: medium-light skin tone +1F9D6 1F3FD 200D 2640 FE0F ; fully-qualified # 🧖🏽‍♀️ E5.0 woman in steamy room: medium skin tone +1F9D6 1F3FD 200D 2640 ; minimally-qualified # 🧖🏽‍♀ E5.0 woman in steamy room: medium skin tone +1F9D6 1F3FE 200D 2640 FE0F ; fully-qualified # 🧖🏾‍♀️ E5.0 woman in steamy room: medium-dark skin tone +1F9D6 1F3FE 200D 2640 ; minimally-qualified # 🧖🏾‍♀ E5.0 woman in steamy room: medium-dark skin tone +1F9D6 1F3FF 200D 2640 FE0F ; fully-qualified # 🧖🏿‍♀️ E5.0 woman in steamy room: dark skin tone +1F9D6 1F3FF 200D 2640 ; minimally-qualified # 🧖🏿‍♀ E5.0 woman in steamy room: dark skin tone +1F9D7 ; fully-qualified # 🧗 E5.0 person climbing +1F9D7 1F3FB ; fully-qualified # 🧗🏻 E5.0 person climbing: light skin tone +1F9D7 1F3FC ; fully-qualified # 🧗🏼 E5.0 person climbing: medium-light skin tone +1F9D7 1F3FD ; fully-qualified # 🧗🏽 E5.0 person climbing: medium skin tone +1F9D7 1F3FE ; fully-qualified # 🧗🏾 E5.0 person climbing: medium-dark skin tone +1F9D7 1F3FF ; fully-qualified # 🧗🏿 E5.0 person climbing: dark skin tone +1F9D7 200D 2642 FE0F ; fully-qualified # 🧗‍♂️ E5.0 man climbing +1F9D7 200D 2642 ; minimally-qualified # 🧗‍♂ E5.0 man climbing +1F9D7 1F3FB 200D 2642 FE0F ; fully-qualified # 🧗🏻‍♂️ E5.0 man climbing: light skin tone +1F9D7 1F3FB 200D 2642 ; minimally-qualified # 🧗🏻‍♂ E5.0 man climbing: light skin tone +1F9D7 1F3FC 200D 2642 FE0F ; fully-qualified # 🧗🏼‍♂️ E5.0 man climbing: medium-light skin tone +1F9D7 1F3FC 200D 2642 ; minimally-qualified # 🧗🏼‍♂ E5.0 man climbing: medium-light skin tone +1F9D7 1F3FD 200D 2642 FE0F ; fully-qualified # 🧗🏽‍♂️ E5.0 man climbing: medium skin tone +1F9D7 1F3FD 200D 2642 ; minimally-qualified # 🧗🏽‍♂ E5.0 man climbing: medium skin tone +1F9D7 1F3FE 200D 2642 FE0F ; fully-qualified # 🧗🏾‍♂️ E5.0 man climbing: medium-dark skin tone +1F9D7 1F3FE 200D 2642 ; minimally-qualified # 🧗🏾‍♂ E5.0 man climbing: medium-dark skin tone +1F9D7 1F3FF 200D 2642 FE0F ; fully-qualified # 🧗🏿‍♂️ E5.0 man climbing: dark skin tone +1F9D7 1F3FF 200D 2642 ; minimally-qualified # 🧗🏿‍♂ E5.0 man climbing: dark skin tone +1F9D7 200D 2640 FE0F ; fully-qualified # 🧗‍♀️ E5.0 woman climbing +1F9D7 200D 2640 ; minimally-qualified # 🧗‍♀ E5.0 woman climbing +1F9D7 1F3FB 200D 2640 FE0F ; fully-qualified # 🧗🏻‍♀️ E5.0 woman climbing: light skin tone +1F9D7 1F3FB 200D 2640 ; minimally-qualified # 🧗🏻‍♀ E5.0 woman climbing: light skin tone +1F9D7 1F3FC 200D 2640 FE0F ; fully-qualified # 🧗🏼‍♀️ E5.0 woman climbing: medium-light skin tone +1F9D7 1F3FC 200D 2640 ; minimally-qualified # 🧗🏼‍♀ E5.0 woman climbing: medium-light skin tone +1F9D7 1F3FD 200D 2640 FE0F ; fully-qualified # 🧗🏽‍♀️ E5.0 woman climbing: medium skin tone +1F9D7 1F3FD 200D 2640 ; minimally-qualified # 🧗🏽‍♀ E5.0 woman climbing: medium skin tone +1F9D7 1F3FE 200D 2640 FE0F ; fully-qualified # 🧗🏾‍♀️ E5.0 woman climbing: medium-dark skin tone +1F9D7 1F3FE 200D 2640 ; minimally-qualified # 🧗🏾‍♀ E5.0 woman climbing: medium-dark skin tone +1F9D7 1F3FF 200D 2640 FE0F ; fully-qualified # 🧗🏿‍♀️ E5.0 woman climbing: dark skin tone +1F9D7 1F3FF 200D 2640 ; minimally-qualified # 🧗🏿‍♀ E5.0 woman climbing: dark skin tone + +# subgroup: person-sport +1F93A ; fully-qualified # 🤺 E3.0 person fencing +1F3C7 ; fully-qualified # 🏇 E1.0 horse racing +1F3C7 1F3FB ; fully-qualified # 🏇🏻 E1.0 horse racing: light skin tone +1F3C7 1F3FC ; fully-qualified # 🏇🏼 E1.0 horse racing: medium-light skin tone +1F3C7 1F3FD ; fully-qualified # 🏇🏽 E1.0 horse racing: medium skin tone +1F3C7 1F3FE ; fully-qualified # 🏇🏾 E1.0 horse racing: medium-dark skin tone +1F3C7 1F3FF ; fully-qualified # 🏇🏿 E1.0 horse racing: dark skin tone +26F7 FE0F ; fully-qualified # ⛷️ E0.7 skier +26F7 ; unqualified # ⛷ E0.7 skier +1F3C2 ; fully-qualified # 🏂 E0.6 snowboarder +1F3C2 1F3FB ; fully-qualified # 🏂🏻 E1.0 snowboarder: light skin tone +1F3C2 1F3FC ; fully-qualified # 🏂🏼 E1.0 snowboarder: medium-light skin tone +1F3C2 1F3FD ; fully-qualified # 🏂🏽 E1.0 snowboarder: medium skin tone +1F3C2 1F3FE ; fully-qualified # 🏂🏾 E1.0 snowboarder: medium-dark skin tone +1F3C2 1F3FF ; fully-qualified # 🏂🏿 E1.0 snowboarder: dark skin tone +1F3CC FE0F ; fully-qualified # 🏌️ E0.7 person golfing +1F3CC ; unqualified # 🏌 E0.7 person golfing +1F3CC 1F3FB ; fully-qualified # 🏌🏻 E4.0 person golfing: light skin tone +1F3CC 1F3FC ; fully-qualified # 🏌🏼 E4.0 person golfing: medium-light skin tone +1F3CC 1F3FD ; fully-qualified # 🏌🏽 E4.0 person golfing: medium skin tone +1F3CC 1F3FE ; fully-qualified # 🏌🏾 E4.0 person golfing: medium-dark skin tone +1F3CC 1F3FF ; fully-qualified # 🏌🏿 E4.0 person golfing: dark skin tone +1F3CC FE0F 200D 2642 FE0F ; fully-qualified # 🏌️‍♂️ E4.0 man golfing +1F3CC 200D 2642 FE0F ; unqualified # 🏌‍♂️ E4.0 man golfing +1F3CC FE0F 200D 2642 ; minimally-qualified # 🏌️‍♂ E4.0 man golfing +1F3CC 200D 2642 ; unqualified # 🏌‍♂ E4.0 man golfing +1F3CC 1F3FB 200D 2642 FE0F ; fully-qualified # 🏌🏻‍♂️ E4.0 man golfing: light skin tone +1F3CC 1F3FB 200D 2642 ; minimally-qualified # 🏌🏻‍♂ E4.0 man golfing: light skin tone +1F3CC 1F3FC 200D 2642 FE0F ; fully-qualified # 🏌🏼‍♂️ E4.0 man golfing: medium-light skin tone +1F3CC 1F3FC 200D 2642 ; minimally-qualified # 🏌🏼‍♂ E4.0 man golfing: medium-light skin tone +1F3CC 1F3FD 200D 2642 FE0F ; fully-qualified # 🏌🏽‍♂️ E4.0 man golfing: medium skin tone +1F3CC 1F3FD 200D 2642 ; minimally-qualified # 🏌🏽‍♂ E4.0 man golfing: medium skin tone +1F3CC 1F3FE 200D 2642 FE0F ; fully-qualified # 🏌🏾‍♂️ E4.0 man golfing: medium-dark skin tone +1F3CC 1F3FE 200D 2642 ; minimally-qualified # 🏌🏾‍♂ E4.0 man golfing: medium-dark skin tone +1F3CC 1F3FF 200D 2642 FE0F ; fully-qualified # 🏌🏿‍♂️ E4.0 man golfing: dark skin tone +1F3CC 1F3FF 200D 2642 ; minimally-qualified # 🏌🏿‍♂ E4.0 man golfing: dark skin tone +1F3CC FE0F 200D 2640 FE0F ; fully-qualified # 🏌️‍♀️ E4.0 woman golfing +1F3CC 200D 2640 FE0F ; unqualified # 🏌‍♀️ E4.0 woman golfing +1F3CC FE0F 200D 2640 ; minimally-qualified # 🏌️‍♀ E4.0 woman golfing +1F3CC 200D 2640 ; unqualified # 🏌‍♀ E4.0 woman golfing +1F3CC 1F3FB 200D 2640 FE0F ; fully-qualified # 🏌🏻‍♀️ E4.0 woman golfing: light skin tone +1F3CC 1F3FB 200D 2640 ; minimally-qualified # 🏌🏻‍♀ E4.0 woman golfing: light skin tone +1F3CC 1F3FC 200D 2640 FE0F ; fully-qualified # 🏌🏼‍♀️ E4.0 woman golfing: medium-light skin tone +1F3CC 1F3FC 200D 2640 ; minimally-qualified # 🏌🏼‍♀ E4.0 woman golfing: medium-light skin tone +1F3CC 1F3FD 200D 2640 FE0F ; fully-qualified # 🏌🏽‍♀️ E4.0 woman golfing: medium skin tone +1F3CC 1F3FD 200D 2640 ; minimally-qualified # 🏌🏽‍♀ E4.0 woman golfing: medium skin tone +1F3CC 1F3FE 200D 2640 FE0F ; fully-qualified # 🏌🏾‍♀️ E4.0 woman golfing: medium-dark skin tone +1F3CC 1F3FE 200D 2640 ; minimally-qualified # 🏌🏾‍♀ E4.0 woman golfing: medium-dark skin tone +1F3CC 1F3FF 200D 2640 FE0F ; fully-qualified # 🏌🏿‍♀️ E4.0 woman golfing: dark skin tone +1F3CC 1F3FF 200D 2640 ; minimally-qualified # 🏌🏿‍♀ E4.0 woman golfing: dark skin tone +1F3C4 ; fully-qualified # 🏄 E0.6 person surfing +1F3C4 1F3FB ; fully-qualified # 🏄🏻 E1.0 person surfing: light skin tone +1F3C4 1F3FC ; fully-qualified # 🏄🏼 E1.0 person surfing: medium-light skin tone +1F3C4 1F3FD ; fully-qualified # 🏄🏽 E1.0 person surfing: medium skin tone +1F3C4 1F3FE ; fully-qualified # 🏄🏾 E1.0 person surfing: medium-dark skin tone +1F3C4 1F3FF ; fully-qualified # 🏄🏿 E1.0 person surfing: dark skin tone +1F3C4 200D 2642 FE0F ; fully-qualified # 🏄‍♂️ E4.0 man surfing +1F3C4 200D 2642 ; minimally-qualified # 🏄‍♂ E4.0 man surfing +1F3C4 1F3FB 200D 2642 FE0F ; fully-qualified # 🏄🏻‍♂️ E4.0 man surfing: light skin tone +1F3C4 1F3FB 200D 2642 ; minimally-qualified # 🏄🏻‍♂ E4.0 man surfing: light skin tone +1F3C4 1F3FC 200D 2642 FE0F ; fully-qualified # 🏄🏼‍♂️ E4.0 man surfing: medium-light skin tone +1F3C4 1F3FC 200D 2642 ; minimally-qualified # 🏄🏼‍♂ E4.0 man surfing: medium-light skin tone +1F3C4 1F3FD 200D 2642 FE0F ; fully-qualified # 🏄🏽‍♂️ E4.0 man surfing: medium skin tone +1F3C4 1F3FD 200D 2642 ; minimally-qualified # 🏄🏽‍♂ E4.0 man surfing: medium skin tone +1F3C4 1F3FE 200D 2642 FE0F ; fully-qualified # 🏄🏾‍♂️ E4.0 man surfing: medium-dark skin tone +1F3C4 1F3FE 200D 2642 ; minimally-qualified # 🏄🏾‍♂ E4.0 man surfing: medium-dark skin tone +1F3C4 1F3FF 200D 2642 FE0F ; fully-qualified # 🏄🏿‍♂️ E4.0 man surfing: dark skin tone +1F3C4 1F3FF 200D 2642 ; minimally-qualified # 🏄🏿‍♂ E4.0 man surfing: dark skin tone +1F3C4 200D 2640 FE0F ; fully-qualified # 🏄‍♀️ E4.0 woman surfing +1F3C4 200D 2640 ; minimally-qualified # 🏄‍♀ E4.0 woman surfing +1F3C4 1F3FB 200D 2640 FE0F ; fully-qualified # 🏄🏻‍♀️ E4.0 woman surfing: light skin tone +1F3C4 1F3FB 200D 2640 ; minimally-qualified # 🏄🏻‍♀ E4.0 woman surfing: light skin tone +1F3C4 1F3FC 200D 2640 FE0F ; fully-qualified # 🏄🏼‍♀️ E4.0 woman surfing: medium-light skin tone +1F3C4 1F3FC 200D 2640 ; minimally-qualified # 🏄🏼‍♀ E4.0 woman surfing: medium-light skin tone +1F3C4 1F3FD 200D 2640 FE0F ; fully-qualified # 🏄🏽‍♀️ E4.0 woman surfing: medium skin tone +1F3C4 1F3FD 200D 2640 ; minimally-qualified # 🏄🏽‍♀ E4.0 woman surfing: medium skin tone +1F3C4 1F3FE 200D 2640 FE0F ; fully-qualified # 🏄🏾‍♀️ E4.0 woman surfing: medium-dark skin tone +1F3C4 1F3FE 200D 2640 ; minimally-qualified # 🏄🏾‍♀ E4.0 woman surfing: medium-dark skin tone +1F3C4 1F3FF 200D 2640 FE0F ; fully-qualified # 🏄🏿‍♀️ E4.0 woman surfing: dark skin tone +1F3C4 1F3FF 200D 2640 ; minimally-qualified # 🏄🏿‍♀ E4.0 woman surfing: dark skin tone +1F6A3 ; fully-qualified # 🚣 E1.0 person rowing boat +1F6A3 1F3FB ; fully-qualified # 🚣🏻 E1.0 person rowing boat: light skin tone +1F6A3 1F3FC ; fully-qualified # 🚣🏼 E1.0 person rowing boat: medium-light skin tone +1F6A3 1F3FD ; fully-qualified # 🚣🏽 E1.0 person rowing boat: medium skin tone +1F6A3 1F3FE ; fully-qualified # 🚣🏾 E1.0 person rowing boat: medium-dark skin tone +1F6A3 1F3FF ; fully-qualified # 🚣🏿 E1.0 person rowing boat: dark skin tone +1F6A3 200D 2642 FE0F ; fully-qualified # 🚣‍♂️ E4.0 man rowing boat +1F6A3 200D 2642 ; minimally-qualified # 🚣‍♂ E4.0 man rowing boat +1F6A3 1F3FB 200D 2642 FE0F ; fully-qualified # 🚣🏻‍♂️ E4.0 man rowing boat: light skin tone +1F6A3 1F3FB 200D 2642 ; minimally-qualified # 🚣🏻‍♂ E4.0 man rowing boat: light skin tone +1F6A3 1F3FC 200D 2642 FE0F ; fully-qualified # 🚣🏼‍♂️ E4.0 man rowing boat: medium-light skin tone +1F6A3 1F3FC 200D 2642 ; minimally-qualified # 🚣🏼‍♂ E4.0 man rowing boat: medium-light skin tone +1F6A3 1F3FD 200D 2642 FE0F ; fully-qualified # 🚣🏽‍♂️ E4.0 man rowing boat: medium skin tone +1F6A3 1F3FD 200D 2642 ; minimally-qualified # 🚣🏽‍♂ E4.0 man rowing boat: medium skin tone +1F6A3 1F3FE 200D 2642 FE0F ; fully-qualified # 🚣🏾‍♂️ E4.0 man rowing boat: medium-dark skin tone +1F6A3 1F3FE 200D 2642 ; minimally-qualified # 🚣🏾‍♂ E4.0 man rowing boat: medium-dark skin tone +1F6A3 1F3FF 200D 2642 FE0F ; fully-qualified # 🚣🏿‍♂️ E4.0 man rowing boat: dark skin tone +1F6A3 1F3FF 200D 2642 ; minimally-qualified # 🚣🏿‍♂ E4.0 man rowing boat: dark skin tone +1F6A3 200D 2640 FE0F ; fully-qualified # 🚣‍♀️ E4.0 woman rowing boat +1F6A3 200D 2640 ; minimally-qualified # 🚣‍♀ E4.0 woman rowing boat +1F6A3 1F3FB 200D 2640 FE0F ; fully-qualified # 🚣🏻‍♀️ E4.0 woman rowing boat: light skin tone +1F6A3 1F3FB 200D 2640 ; minimally-qualified # 🚣🏻‍♀ E4.0 woman rowing boat: light skin tone +1F6A3 1F3FC 200D 2640 FE0F ; fully-qualified # 🚣🏼‍♀️ E4.0 woman rowing boat: medium-light skin tone +1F6A3 1F3FC 200D 2640 ; minimally-qualified # 🚣🏼‍♀ E4.0 woman rowing boat: medium-light skin tone +1F6A3 1F3FD 200D 2640 FE0F ; fully-qualified # 🚣🏽‍♀️ E4.0 woman rowing boat: medium skin tone +1F6A3 1F3FD 200D 2640 ; minimally-qualified # 🚣🏽‍♀ E4.0 woman rowing boat: medium skin tone +1F6A3 1F3FE 200D 2640 FE0F ; fully-qualified # 🚣🏾‍♀️ E4.0 woman rowing boat: medium-dark skin tone +1F6A3 1F3FE 200D 2640 ; minimally-qualified # 🚣🏾‍♀ E4.0 woman rowing boat: medium-dark skin tone +1F6A3 1F3FF 200D 2640 FE0F ; fully-qualified # 🚣🏿‍♀️ E4.0 woman rowing boat: dark skin tone +1F6A3 1F3FF 200D 2640 ; minimally-qualified # 🚣🏿‍♀ E4.0 woman rowing boat: dark skin tone +1F3CA ; fully-qualified # 🏊 E0.6 person swimming +1F3CA 1F3FB ; fully-qualified # 🏊🏻 E1.0 person swimming: light skin tone +1F3CA 1F3FC ; fully-qualified # 🏊🏼 E1.0 person swimming: medium-light skin tone +1F3CA 1F3FD ; fully-qualified # 🏊🏽 E1.0 person swimming: medium skin tone +1F3CA 1F3FE ; fully-qualified # 🏊🏾 E1.0 person swimming: medium-dark skin tone +1F3CA 1F3FF ; fully-qualified # 🏊🏿 E1.0 person swimming: dark skin tone +1F3CA 200D 2642 FE0F ; fully-qualified # 🏊‍♂️ E4.0 man swimming +1F3CA 200D 2642 ; minimally-qualified # 🏊‍♂ E4.0 man swimming +1F3CA 1F3FB 200D 2642 FE0F ; fully-qualified # 🏊🏻‍♂️ E4.0 man swimming: light skin tone +1F3CA 1F3FB 200D 2642 ; minimally-qualified # 🏊🏻‍♂ E4.0 man swimming: light skin tone +1F3CA 1F3FC 200D 2642 FE0F ; fully-qualified # 🏊🏼‍♂️ E4.0 man swimming: medium-light skin tone +1F3CA 1F3FC 200D 2642 ; minimally-qualified # 🏊🏼‍♂ E4.0 man swimming: medium-light skin tone +1F3CA 1F3FD 200D 2642 FE0F ; fully-qualified # 🏊🏽‍♂️ E4.0 man swimming: medium skin tone +1F3CA 1F3FD 200D 2642 ; minimally-qualified # 🏊🏽‍♂ E4.0 man swimming: medium skin tone +1F3CA 1F3FE 200D 2642 FE0F ; fully-qualified # 🏊🏾‍♂️ E4.0 man swimming: medium-dark skin tone +1F3CA 1F3FE 200D 2642 ; minimally-qualified # 🏊🏾‍♂ E4.0 man swimming: medium-dark skin tone +1F3CA 1F3FF 200D 2642 FE0F ; fully-qualified # 🏊🏿‍♂️ E4.0 man swimming: dark skin tone +1F3CA 1F3FF 200D 2642 ; minimally-qualified # 🏊🏿‍♂ E4.0 man swimming: dark skin tone +1F3CA 200D 2640 FE0F ; fully-qualified # 🏊‍♀️ E4.0 woman swimming +1F3CA 200D 2640 ; minimally-qualified # 🏊‍♀ E4.0 woman swimming +1F3CA 1F3FB 200D 2640 FE0F ; fully-qualified # 🏊🏻‍♀️ E4.0 woman swimming: light skin tone +1F3CA 1F3FB 200D 2640 ; minimally-qualified # 🏊🏻‍♀ E4.0 woman swimming: light skin tone +1F3CA 1F3FC 200D 2640 FE0F ; fully-qualified # 🏊🏼‍♀️ E4.0 woman swimming: medium-light skin tone +1F3CA 1F3FC 200D 2640 ; minimally-qualified # 🏊🏼‍♀ E4.0 woman swimming: medium-light skin tone +1F3CA 1F3FD 200D 2640 FE0F ; fully-qualified # 🏊🏽‍♀️ E4.0 woman swimming: medium skin tone +1F3CA 1F3FD 200D 2640 ; minimally-qualified # 🏊🏽‍♀ E4.0 woman swimming: medium skin tone +1F3CA 1F3FE 200D 2640 FE0F ; fully-qualified # 🏊🏾‍♀️ E4.0 woman swimming: medium-dark skin tone +1F3CA 1F3FE 200D 2640 ; minimally-qualified # 🏊🏾‍♀ E4.0 woman swimming: medium-dark skin tone +1F3CA 1F3FF 200D 2640 FE0F ; fully-qualified # 🏊🏿‍♀️ E4.0 woman swimming: dark skin tone +1F3CA 1F3FF 200D 2640 ; minimally-qualified # 🏊🏿‍♀ E4.0 woman swimming: dark skin tone +26F9 FE0F ; fully-qualified # ⛹️ E0.7 person bouncing ball +26F9 ; unqualified # ⛹ E0.7 person bouncing ball +26F9 1F3FB ; fully-qualified # ⛹🏻 E2.0 person bouncing ball: light skin tone +26F9 1F3FC ; fully-qualified # ⛹🏼 E2.0 person bouncing ball: medium-light skin tone +26F9 1F3FD ; fully-qualified # ⛹🏽 E2.0 person bouncing ball: medium skin tone +26F9 1F3FE ; fully-qualified # ⛹🏾 E2.0 person bouncing ball: medium-dark skin tone +26F9 1F3FF ; fully-qualified # ⛹🏿 E2.0 person bouncing ball: dark skin tone +26F9 FE0F 200D 2642 FE0F ; fully-qualified # ⛹️‍♂️ E4.0 man bouncing ball +26F9 200D 2642 FE0F ; unqualified # ⛹‍♂️ E4.0 man bouncing ball +26F9 FE0F 200D 2642 ; minimally-qualified # ⛹️‍♂ E4.0 man bouncing ball +26F9 200D 2642 ; unqualified # ⛹‍♂ E4.0 man bouncing ball +26F9 1F3FB 200D 2642 FE0F ; fully-qualified # ⛹🏻‍♂️ E4.0 man bouncing ball: light skin tone +26F9 1F3FB 200D 2642 ; minimally-qualified # ⛹🏻‍♂ E4.0 man bouncing ball: light skin tone +26F9 1F3FC 200D 2642 FE0F ; fully-qualified # ⛹🏼‍♂️ E4.0 man bouncing ball: medium-light skin tone +26F9 1F3FC 200D 2642 ; minimally-qualified # ⛹🏼‍♂ E4.0 man bouncing ball: medium-light skin tone +26F9 1F3FD 200D 2642 FE0F ; fully-qualified # ⛹🏽‍♂️ E4.0 man bouncing ball: medium skin tone +26F9 1F3FD 200D 2642 ; minimally-qualified # ⛹🏽‍♂ E4.0 man bouncing ball: medium skin tone +26F9 1F3FE 200D 2642 FE0F ; fully-qualified # ⛹🏾‍♂️ E4.0 man bouncing ball: medium-dark skin tone +26F9 1F3FE 200D 2642 ; minimally-qualified # ⛹🏾‍♂ E4.0 man bouncing ball: medium-dark skin tone +26F9 1F3FF 200D 2642 FE0F ; fully-qualified # ⛹🏿‍♂️ E4.0 man bouncing ball: dark skin tone +26F9 1F3FF 200D 2642 ; minimally-qualified # ⛹🏿‍♂ E4.0 man bouncing ball: dark skin tone +26F9 FE0F 200D 2640 FE0F ; fully-qualified # ⛹️‍♀️ E4.0 woman bouncing ball +26F9 200D 2640 FE0F ; unqualified # ⛹‍♀️ E4.0 woman bouncing ball +26F9 FE0F 200D 2640 ; minimally-qualified # ⛹️‍♀ E4.0 woman bouncing ball +26F9 200D 2640 ; unqualified # ⛹‍♀ E4.0 woman bouncing ball +26F9 1F3FB 200D 2640 FE0F ; fully-qualified # ⛹🏻‍♀️ E4.0 woman bouncing ball: light skin tone +26F9 1F3FB 200D 2640 ; minimally-qualified # ⛹🏻‍♀ E4.0 woman bouncing ball: light skin tone +26F9 1F3FC 200D 2640 FE0F ; fully-qualified # ⛹🏼‍♀️ E4.0 woman bouncing ball: medium-light skin tone +26F9 1F3FC 200D 2640 ; minimally-qualified # ⛹🏼‍♀ E4.0 woman bouncing ball: medium-light skin tone +26F9 1F3FD 200D 2640 FE0F ; fully-qualified # ⛹🏽‍♀️ E4.0 woman bouncing ball: medium skin tone +26F9 1F3FD 200D 2640 ; minimally-qualified # ⛹🏽‍♀ E4.0 woman bouncing ball: medium skin tone +26F9 1F3FE 200D 2640 FE0F ; fully-qualified # ⛹🏾‍♀️ E4.0 woman bouncing ball: medium-dark skin tone +26F9 1F3FE 200D 2640 ; minimally-qualified # ⛹🏾‍♀ E4.0 woman bouncing ball: medium-dark skin tone +26F9 1F3FF 200D 2640 FE0F ; fully-qualified # ⛹🏿‍♀️ E4.0 woman bouncing ball: dark skin tone +26F9 1F3FF 200D 2640 ; minimally-qualified # ⛹🏿‍♀ E4.0 woman bouncing ball: dark skin tone +1F3CB FE0F ; fully-qualified # 🏋️ E0.7 person lifting weights +1F3CB ; unqualified # 🏋 E0.7 person lifting weights +1F3CB 1F3FB ; fully-qualified # 🏋🏻 E2.0 person lifting weights: light skin tone +1F3CB 1F3FC ; fully-qualified # 🏋🏼 E2.0 person lifting weights: medium-light skin tone +1F3CB 1F3FD ; fully-qualified # 🏋🏽 E2.0 person lifting weights: medium skin tone +1F3CB 1F3FE ; fully-qualified # 🏋🏾 E2.0 person lifting weights: medium-dark skin tone +1F3CB 1F3FF ; fully-qualified # 🏋🏿 E2.0 person lifting weights: dark skin tone +1F3CB FE0F 200D 2642 FE0F ; fully-qualified # 🏋️‍♂️ E4.0 man lifting weights +1F3CB 200D 2642 FE0F ; unqualified # 🏋‍♂️ E4.0 man lifting weights +1F3CB FE0F 200D 2642 ; minimally-qualified # 🏋️‍♂ E4.0 man lifting weights +1F3CB 200D 2642 ; unqualified # 🏋‍♂ E4.0 man lifting weights +1F3CB 1F3FB 200D 2642 FE0F ; fully-qualified # 🏋🏻‍♂️ E4.0 man lifting weights: light skin tone +1F3CB 1F3FB 200D 2642 ; minimally-qualified # 🏋🏻‍♂ E4.0 man lifting weights: light skin tone +1F3CB 1F3FC 200D 2642 FE0F ; fully-qualified # 🏋🏼‍♂️ E4.0 man lifting weights: medium-light skin tone +1F3CB 1F3FC 200D 2642 ; minimally-qualified # 🏋🏼‍♂ E4.0 man lifting weights: medium-light skin tone +1F3CB 1F3FD 200D 2642 FE0F ; fully-qualified # 🏋🏽‍♂️ E4.0 man lifting weights: medium skin tone +1F3CB 1F3FD 200D 2642 ; minimally-qualified # 🏋🏽‍♂ E4.0 man lifting weights: medium skin tone +1F3CB 1F3FE 200D 2642 FE0F ; fully-qualified # 🏋🏾‍♂️ E4.0 man lifting weights: medium-dark skin tone +1F3CB 1F3FE 200D 2642 ; minimally-qualified # 🏋🏾‍♂ E4.0 man lifting weights: medium-dark skin tone +1F3CB 1F3FF 200D 2642 FE0F ; fully-qualified # 🏋🏿‍♂️ E4.0 man lifting weights: dark skin tone +1F3CB 1F3FF 200D 2642 ; minimally-qualified # 🏋🏿‍♂ E4.0 man lifting weights: dark skin tone +1F3CB FE0F 200D 2640 FE0F ; fully-qualified # 🏋️‍♀️ E4.0 woman lifting weights +1F3CB 200D 2640 FE0F ; unqualified # 🏋‍♀️ E4.0 woman lifting weights +1F3CB FE0F 200D 2640 ; minimally-qualified # 🏋️‍♀ E4.0 woman lifting weights +1F3CB 200D 2640 ; unqualified # 🏋‍♀ E4.0 woman lifting weights +1F3CB 1F3FB 200D 2640 FE0F ; fully-qualified # 🏋🏻‍♀️ E4.0 woman lifting weights: light skin tone +1F3CB 1F3FB 200D 2640 ; minimally-qualified # 🏋🏻‍♀ E4.0 woman lifting weights: light skin tone +1F3CB 1F3FC 200D 2640 FE0F ; fully-qualified # 🏋🏼‍♀️ E4.0 woman lifting weights: medium-light skin tone +1F3CB 1F3FC 200D 2640 ; minimally-qualified # 🏋🏼‍♀ E4.0 woman lifting weights: medium-light skin tone +1F3CB 1F3FD 200D 2640 FE0F ; fully-qualified # 🏋🏽‍♀️ E4.0 woman lifting weights: medium skin tone +1F3CB 1F3FD 200D 2640 ; minimally-qualified # 🏋🏽‍♀ E4.0 woman lifting weights: medium skin tone +1F3CB 1F3FE 200D 2640 FE0F ; fully-qualified # 🏋🏾‍♀️ E4.0 woman lifting weights: medium-dark skin tone +1F3CB 1F3FE 200D 2640 ; minimally-qualified # 🏋🏾‍♀ E4.0 woman lifting weights: medium-dark skin tone +1F3CB 1F3FF 200D 2640 FE0F ; fully-qualified # 🏋🏿‍♀️ E4.0 woman lifting weights: dark skin tone +1F3CB 1F3FF 200D 2640 ; minimally-qualified # 🏋🏿‍♀ E4.0 woman lifting weights: dark skin tone +1F6B4 ; fully-qualified # 🚴 E1.0 person biking +1F6B4 1F3FB ; fully-qualified # 🚴🏻 E1.0 person biking: light skin tone +1F6B4 1F3FC ; fully-qualified # 🚴🏼 E1.0 person biking: medium-light skin tone +1F6B4 1F3FD ; fully-qualified # 🚴🏽 E1.0 person biking: medium skin tone +1F6B4 1F3FE ; fully-qualified # 🚴🏾 E1.0 person biking: medium-dark skin tone +1F6B4 1F3FF ; fully-qualified # 🚴🏿 E1.0 person biking: dark skin tone +1F6B4 200D 2642 FE0F ; fully-qualified # 🚴‍♂️ E4.0 man biking +1F6B4 200D 2642 ; minimally-qualified # 🚴‍♂ E4.0 man biking +1F6B4 1F3FB 200D 2642 FE0F ; fully-qualified # 🚴🏻‍♂️ E4.0 man biking: light skin tone +1F6B4 1F3FB 200D 2642 ; minimally-qualified # 🚴🏻‍♂ E4.0 man biking: light skin tone +1F6B4 1F3FC 200D 2642 FE0F ; fully-qualified # 🚴🏼‍♂️ E4.0 man biking: medium-light skin tone +1F6B4 1F3FC 200D 2642 ; minimally-qualified # 🚴🏼‍♂ E4.0 man biking: medium-light skin tone +1F6B4 1F3FD 200D 2642 FE0F ; fully-qualified # 🚴🏽‍♂️ E4.0 man biking: medium skin tone +1F6B4 1F3FD 200D 2642 ; minimally-qualified # 🚴🏽‍♂ E4.0 man biking: medium skin tone +1F6B4 1F3FE 200D 2642 FE0F ; fully-qualified # 🚴🏾‍♂️ E4.0 man biking: medium-dark skin tone +1F6B4 1F3FE 200D 2642 ; minimally-qualified # 🚴🏾‍♂ E4.0 man biking: medium-dark skin tone +1F6B4 1F3FF 200D 2642 FE0F ; fully-qualified # 🚴🏿‍♂️ E4.0 man biking: dark skin tone +1F6B4 1F3FF 200D 2642 ; minimally-qualified # 🚴🏿‍♂ E4.0 man biking: dark skin tone +1F6B4 200D 2640 FE0F ; fully-qualified # 🚴‍♀️ E4.0 woman biking +1F6B4 200D 2640 ; minimally-qualified # 🚴‍♀ E4.0 woman biking +1F6B4 1F3FB 200D 2640 FE0F ; fully-qualified # 🚴🏻‍♀️ E4.0 woman biking: light skin tone +1F6B4 1F3FB 200D 2640 ; minimally-qualified # 🚴🏻‍♀ E4.0 woman biking: light skin tone +1F6B4 1F3FC 200D 2640 FE0F ; fully-qualified # 🚴🏼‍♀️ E4.0 woman biking: medium-light skin tone +1F6B4 1F3FC 200D 2640 ; minimally-qualified # 🚴🏼‍♀ E4.0 woman biking: medium-light skin tone +1F6B4 1F3FD 200D 2640 FE0F ; fully-qualified # 🚴🏽‍♀️ E4.0 woman biking: medium skin tone +1F6B4 1F3FD 200D 2640 ; minimally-qualified # 🚴🏽‍♀ E4.0 woman biking: medium skin tone +1F6B4 1F3FE 200D 2640 FE0F ; fully-qualified # 🚴🏾‍♀️ E4.0 woman biking: medium-dark skin tone +1F6B4 1F3FE 200D 2640 ; minimally-qualified # 🚴🏾‍♀ E4.0 woman biking: medium-dark skin tone +1F6B4 1F3FF 200D 2640 FE0F ; fully-qualified # 🚴🏿‍♀️ E4.0 woman biking: dark skin tone +1F6B4 1F3FF 200D 2640 ; minimally-qualified # 🚴🏿‍♀ E4.0 woman biking: dark skin tone +1F6B5 ; fully-qualified # 🚵 E1.0 person mountain biking +1F6B5 1F3FB ; fully-qualified # 🚵🏻 E1.0 person mountain biking: light skin tone +1F6B5 1F3FC ; fully-qualified # 🚵🏼 E1.0 person mountain biking: medium-light skin tone +1F6B5 1F3FD ; fully-qualified # 🚵🏽 E1.0 person mountain biking: medium skin tone +1F6B5 1F3FE ; fully-qualified # 🚵🏾 E1.0 person mountain biking: medium-dark skin tone +1F6B5 1F3FF ; fully-qualified # 🚵🏿 E1.0 person mountain biking: dark skin tone +1F6B5 200D 2642 FE0F ; fully-qualified # 🚵‍♂️ E4.0 man mountain biking +1F6B5 200D 2642 ; minimally-qualified # 🚵‍♂ E4.0 man mountain biking +1F6B5 1F3FB 200D 2642 FE0F ; fully-qualified # 🚵🏻‍♂️ E4.0 man mountain biking: light skin tone +1F6B5 1F3FB 200D 2642 ; minimally-qualified # 🚵🏻‍♂ E4.0 man mountain biking: light skin tone +1F6B5 1F3FC 200D 2642 FE0F ; fully-qualified # 🚵🏼‍♂️ E4.0 man mountain biking: medium-light skin tone +1F6B5 1F3FC 200D 2642 ; minimally-qualified # 🚵🏼‍♂ E4.0 man mountain biking: medium-light skin tone +1F6B5 1F3FD 200D 2642 FE0F ; fully-qualified # 🚵🏽‍♂️ E4.0 man mountain biking: medium skin tone +1F6B5 1F3FD 200D 2642 ; minimally-qualified # 🚵🏽‍♂ E4.0 man mountain biking: medium skin tone +1F6B5 1F3FE 200D 2642 FE0F ; fully-qualified # 🚵🏾‍♂️ E4.0 man mountain biking: medium-dark skin tone +1F6B5 1F3FE 200D 2642 ; minimally-qualified # 🚵🏾‍♂ E4.0 man mountain biking: medium-dark skin tone +1F6B5 1F3FF 200D 2642 FE0F ; fully-qualified # 🚵🏿‍♂️ E4.0 man mountain biking: dark skin tone +1F6B5 1F3FF 200D 2642 ; minimally-qualified # 🚵🏿‍♂ E4.0 man mountain biking: dark skin tone +1F6B5 200D 2640 FE0F ; fully-qualified # 🚵‍♀️ E4.0 woman mountain biking +1F6B5 200D 2640 ; minimally-qualified # 🚵‍♀ E4.0 woman mountain biking +1F6B5 1F3FB 200D 2640 FE0F ; fully-qualified # 🚵🏻‍♀️ E4.0 woman mountain biking: light skin tone +1F6B5 1F3FB 200D 2640 ; minimally-qualified # 🚵🏻‍♀ E4.0 woman mountain biking: light skin tone +1F6B5 1F3FC 200D 2640 FE0F ; fully-qualified # 🚵🏼‍♀️ E4.0 woman mountain biking: medium-light skin tone +1F6B5 1F3FC 200D 2640 ; minimally-qualified # 🚵🏼‍♀ E4.0 woman mountain biking: medium-light skin tone +1F6B5 1F3FD 200D 2640 FE0F ; fully-qualified # 🚵🏽‍♀️ E4.0 woman mountain biking: medium skin tone +1F6B5 1F3FD 200D 2640 ; minimally-qualified # 🚵🏽‍♀ E4.0 woman mountain biking: medium skin tone +1F6B5 1F3FE 200D 2640 FE0F ; fully-qualified # 🚵🏾‍♀️ E4.0 woman mountain biking: medium-dark skin tone +1F6B5 1F3FE 200D 2640 ; minimally-qualified # 🚵🏾‍♀ E4.0 woman mountain biking: medium-dark skin tone +1F6B5 1F3FF 200D 2640 FE0F ; fully-qualified # 🚵🏿‍♀️ E4.0 woman mountain biking: dark skin tone +1F6B5 1F3FF 200D 2640 ; minimally-qualified # 🚵🏿‍♀ E4.0 woman mountain biking: dark skin tone +1F938 ; fully-qualified # 🤸 E3.0 person cartwheeling +1F938 1F3FB ; fully-qualified # 🤸🏻 E3.0 person cartwheeling: light skin tone +1F938 1F3FC ; fully-qualified # 🤸🏼 E3.0 person cartwheeling: medium-light skin tone +1F938 1F3FD ; fully-qualified # 🤸🏽 E3.0 person cartwheeling: medium skin tone +1F938 1F3FE ; fully-qualified # 🤸🏾 E3.0 person cartwheeling: medium-dark skin tone +1F938 1F3FF ; fully-qualified # 🤸🏿 E3.0 person cartwheeling: dark skin tone +1F938 200D 2642 FE0F ; fully-qualified # 🤸‍♂️ E4.0 man cartwheeling +1F938 200D 2642 ; minimally-qualified # 🤸‍♂ E4.0 man cartwheeling +1F938 1F3FB 200D 2642 FE0F ; fully-qualified # 🤸🏻‍♂️ E4.0 man cartwheeling: light skin tone +1F938 1F3FB 200D 2642 ; minimally-qualified # 🤸🏻‍♂ E4.0 man cartwheeling: light skin tone +1F938 1F3FC 200D 2642 FE0F ; fully-qualified # 🤸🏼‍♂️ E4.0 man cartwheeling: medium-light skin tone +1F938 1F3FC 200D 2642 ; minimally-qualified # 🤸🏼‍♂ E4.0 man cartwheeling: medium-light skin tone +1F938 1F3FD 200D 2642 FE0F ; fully-qualified # 🤸🏽‍♂️ E4.0 man cartwheeling: medium skin tone +1F938 1F3FD 200D 2642 ; minimally-qualified # 🤸🏽‍♂ E4.0 man cartwheeling: medium skin tone +1F938 1F3FE 200D 2642 FE0F ; fully-qualified # 🤸🏾‍♂️ E4.0 man cartwheeling: medium-dark skin tone +1F938 1F3FE 200D 2642 ; minimally-qualified # 🤸🏾‍♂ E4.0 man cartwheeling: medium-dark skin tone +1F938 1F3FF 200D 2642 FE0F ; fully-qualified # 🤸🏿‍♂️ E4.0 man cartwheeling: dark skin tone +1F938 1F3FF 200D 2642 ; minimally-qualified # 🤸🏿‍♂ E4.0 man cartwheeling: dark skin tone +1F938 200D 2640 FE0F ; fully-qualified # 🤸‍♀️ E4.0 woman cartwheeling +1F938 200D 2640 ; minimally-qualified # 🤸‍♀ E4.0 woman cartwheeling +1F938 1F3FB 200D 2640 FE0F ; fully-qualified # 🤸🏻‍♀️ E4.0 woman cartwheeling: light skin tone +1F938 1F3FB 200D 2640 ; minimally-qualified # 🤸🏻‍♀ E4.0 woman cartwheeling: light skin tone +1F938 1F3FC 200D 2640 FE0F ; fully-qualified # 🤸🏼‍♀️ E4.0 woman cartwheeling: medium-light skin tone +1F938 1F3FC 200D 2640 ; minimally-qualified # 🤸🏼‍♀ E4.0 woman cartwheeling: medium-light skin tone +1F938 1F3FD 200D 2640 FE0F ; fully-qualified # 🤸🏽‍♀️ E4.0 woman cartwheeling: medium skin tone +1F938 1F3FD 200D 2640 ; minimally-qualified # 🤸🏽‍♀ E4.0 woman cartwheeling: medium skin tone +1F938 1F3FE 200D 2640 FE0F ; fully-qualified # 🤸🏾‍♀️ E4.0 woman cartwheeling: medium-dark skin tone +1F938 1F3FE 200D 2640 ; minimally-qualified # 🤸🏾‍♀ E4.0 woman cartwheeling: medium-dark skin tone +1F938 1F3FF 200D 2640 FE0F ; fully-qualified # 🤸🏿‍♀️ E4.0 woman cartwheeling: dark skin tone +1F938 1F3FF 200D 2640 ; minimally-qualified # 🤸🏿‍♀ E4.0 woman cartwheeling: dark skin tone +1F93C ; fully-qualified # 🤼 E3.0 people wrestling +1F93C 200D 2642 FE0F ; fully-qualified # 🤼‍♂️ E4.0 men wrestling +1F93C 200D 2642 ; minimally-qualified # 🤼‍♂ E4.0 men wrestling +1F93C 200D 2640 FE0F ; fully-qualified # 🤼‍♀️ E4.0 women wrestling +1F93C 200D 2640 ; minimally-qualified # 🤼‍♀ E4.0 women wrestling +1F93D ; fully-qualified # 🤽 E3.0 person playing water polo +1F93D 1F3FB ; fully-qualified # 🤽🏻 E3.0 person playing water polo: light skin tone +1F93D 1F3FC ; fully-qualified # 🤽🏼 E3.0 person playing water polo: medium-light skin tone +1F93D 1F3FD ; fully-qualified # 🤽🏽 E3.0 person playing water polo: medium skin tone +1F93D 1F3FE ; fully-qualified # 🤽🏾 E3.0 person playing water polo: medium-dark skin tone +1F93D 1F3FF ; fully-qualified # 🤽🏿 E3.0 person playing water polo: dark skin tone +1F93D 200D 2642 FE0F ; fully-qualified # 🤽‍♂️ E4.0 man playing water polo +1F93D 200D 2642 ; minimally-qualified # 🤽‍♂ E4.0 man playing water polo +1F93D 1F3FB 200D 2642 FE0F ; fully-qualified # 🤽🏻‍♂️ E4.0 man playing water polo: light skin tone +1F93D 1F3FB 200D 2642 ; minimally-qualified # 🤽🏻‍♂ E4.0 man playing water polo: light skin tone +1F93D 1F3FC 200D 2642 FE0F ; fully-qualified # 🤽🏼‍♂️ E4.0 man playing water polo: medium-light skin tone +1F93D 1F3FC 200D 2642 ; minimally-qualified # 🤽🏼‍♂ E4.0 man playing water polo: medium-light skin tone +1F93D 1F3FD 200D 2642 FE0F ; fully-qualified # 🤽🏽‍♂️ E4.0 man playing water polo: medium skin tone +1F93D 1F3FD 200D 2642 ; minimally-qualified # 🤽🏽‍♂ E4.0 man playing water polo: medium skin tone +1F93D 1F3FE 200D 2642 FE0F ; fully-qualified # 🤽🏾‍♂️ E4.0 man playing water polo: medium-dark skin tone +1F93D 1F3FE 200D 2642 ; minimally-qualified # 🤽🏾‍♂ E4.0 man playing water polo: medium-dark skin tone +1F93D 1F3FF 200D 2642 FE0F ; fully-qualified # 🤽🏿‍♂️ E4.0 man playing water polo: dark skin tone +1F93D 1F3FF 200D 2642 ; minimally-qualified # 🤽🏿‍♂ E4.0 man playing water polo: dark skin tone +1F93D 200D 2640 FE0F ; fully-qualified # 🤽‍♀️ E4.0 woman playing water polo +1F93D 200D 2640 ; minimally-qualified # 🤽‍♀ E4.0 woman playing water polo +1F93D 1F3FB 200D 2640 FE0F ; fully-qualified # 🤽🏻‍♀️ E4.0 woman playing water polo: light skin tone +1F93D 1F3FB 200D 2640 ; minimally-qualified # 🤽🏻‍♀ E4.0 woman playing water polo: light skin tone +1F93D 1F3FC 200D 2640 FE0F ; fully-qualified # 🤽🏼‍♀️ E4.0 woman playing water polo: medium-light skin tone +1F93D 1F3FC 200D 2640 ; minimally-qualified # 🤽🏼‍♀ E4.0 woman playing water polo: medium-light skin tone +1F93D 1F3FD 200D 2640 FE0F ; fully-qualified # 🤽🏽‍♀️ E4.0 woman playing water polo: medium skin tone +1F93D 1F3FD 200D 2640 ; minimally-qualified # 🤽🏽‍♀ E4.0 woman playing water polo: medium skin tone +1F93D 1F3FE 200D 2640 FE0F ; fully-qualified # 🤽🏾‍♀️ E4.0 woman playing water polo: medium-dark skin tone +1F93D 1F3FE 200D 2640 ; minimally-qualified # 🤽🏾‍♀ E4.0 woman playing water polo: medium-dark skin tone +1F93D 1F3FF 200D 2640 FE0F ; fully-qualified # 🤽🏿‍♀️ E4.0 woman playing water polo: dark skin tone +1F93D 1F3FF 200D 2640 ; minimally-qualified # 🤽🏿‍♀ E4.0 woman playing water polo: dark skin tone +1F93E ; fully-qualified # 🤾 E3.0 person playing handball +1F93E 1F3FB ; fully-qualified # 🤾🏻 E3.0 person playing handball: light skin tone +1F93E 1F3FC ; fully-qualified # 🤾🏼 E3.0 person playing handball: medium-light skin tone +1F93E 1F3FD ; fully-qualified # 🤾🏽 E3.0 person playing handball: medium skin tone +1F93E 1F3FE ; fully-qualified # 🤾🏾 E3.0 person playing handball: medium-dark skin tone +1F93E 1F3FF ; fully-qualified # 🤾🏿 E3.0 person playing handball: dark skin tone +1F93E 200D 2642 FE0F ; fully-qualified # 🤾‍♂️ E4.0 man playing handball +1F93E 200D 2642 ; minimally-qualified # 🤾‍♂ E4.0 man playing handball +1F93E 1F3FB 200D 2642 FE0F ; fully-qualified # 🤾🏻‍♂️ E4.0 man playing handball: light skin tone +1F93E 1F3FB 200D 2642 ; minimally-qualified # 🤾🏻‍♂ E4.0 man playing handball: light skin tone +1F93E 1F3FC 200D 2642 FE0F ; fully-qualified # 🤾🏼‍♂️ E4.0 man playing handball: medium-light skin tone +1F93E 1F3FC 200D 2642 ; minimally-qualified # 🤾🏼‍♂ E4.0 man playing handball: medium-light skin tone +1F93E 1F3FD 200D 2642 FE0F ; fully-qualified # 🤾🏽‍♂️ E4.0 man playing handball: medium skin tone +1F93E 1F3FD 200D 2642 ; minimally-qualified # 🤾🏽‍♂ E4.0 man playing handball: medium skin tone +1F93E 1F3FE 200D 2642 FE0F ; fully-qualified # 🤾🏾‍♂️ E4.0 man playing handball: medium-dark skin tone +1F93E 1F3FE 200D 2642 ; minimally-qualified # 🤾🏾‍♂ E4.0 man playing handball: medium-dark skin tone +1F93E 1F3FF 200D 2642 FE0F ; fully-qualified # 🤾🏿‍♂️ E4.0 man playing handball: dark skin tone +1F93E 1F3FF 200D 2642 ; minimally-qualified # 🤾🏿‍♂ E4.0 man playing handball: dark skin tone +1F93E 200D 2640 FE0F ; fully-qualified # 🤾‍♀️ E4.0 woman playing handball +1F93E 200D 2640 ; minimally-qualified # 🤾‍♀ E4.0 woman playing handball +1F93E 1F3FB 200D 2640 FE0F ; fully-qualified # 🤾🏻‍♀️ E4.0 woman playing handball: light skin tone +1F93E 1F3FB 200D 2640 ; minimally-qualified # 🤾🏻‍♀ E4.0 woman playing handball: light skin tone +1F93E 1F3FC 200D 2640 FE0F ; fully-qualified # 🤾🏼‍♀️ E4.0 woman playing handball: medium-light skin tone +1F93E 1F3FC 200D 2640 ; minimally-qualified # 🤾🏼‍♀ E4.0 woman playing handball: medium-light skin tone +1F93E 1F3FD 200D 2640 FE0F ; fully-qualified # 🤾🏽‍♀️ E4.0 woman playing handball: medium skin tone +1F93E 1F3FD 200D 2640 ; minimally-qualified # 🤾🏽‍♀ E4.0 woman playing handball: medium skin tone +1F93E 1F3FE 200D 2640 FE0F ; fully-qualified # 🤾🏾‍♀️ E4.0 woman playing handball: medium-dark skin tone +1F93E 1F3FE 200D 2640 ; minimally-qualified # 🤾🏾‍♀ E4.0 woman playing handball: medium-dark skin tone +1F93E 1F3FF 200D 2640 FE0F ; fully-qualified # 🤾🏿‍♀️ E4.0 woman playing handball: dark skin tone +1F93E 1F3FF 200D 2640 ; minimally-qualified # 🤾🏿‍♀ E4.0 woman playing handball: dark skin tone +1F939 ; fully-qualified # 🤹 E3.0 person juggling +1F939 1F3FB ; fully-qualified # 🤹🏻 E3.0 person juggling: light skin tone +1F939 1F3FC ; fully-qualified # 🤹🏼 E3.0 person juggling: medium-light skin tone +1F939 1F3FD ; fully-qualified # 🤹🏽 E3.0 person juggling: medium skin tone +1F939 1F3FE ; fully-qualified # 🤹🏾 E3.0 person juggling: medium-dark skin tone +1F939 1F3FF ; fully-qualified # 🤹🏿 E3.0 person juggling: dark skin tone +1F939 200D 2642 FE0F ; fully-qualified # 🤹‍♂️ E4.0 man juggling +1F939 200D 2642 ; minimally-qualified # 🤹‍♂ E4.0 man juggling +1F939 1F3FB 200D 2642 FE0F ; fully-qualified # 🤹🏻‍♂️ E4.0 man juggling: light skin tone +1F939 1F3FB 200D 2642 ; minimally-qualified # 🤹🏻‍♂ E4.0 man juggling: light skin tone +1F939 1F3FC 200D 2642 FE0F ; fully-qualified # 🤹🏼‍♂️ E4.0 man juggling: medium-light skin tone +1F939 1F3FC 200D 2642 ; minimally-qualified # 🤹🏼‍♂ E4.0 man juggling: medium-light skin tone +1F939 1F3FD 200D 2642 FE0F ; fully-qualified # 🤹🏽‍♂️ E4.0 man juggling: medium skin tone +1F939 1F3FD 200D 2642 ; minimally-qualified # 🤹🏽‍♂ E4.0 man juggling: medium skin tone +1F939 1F3FE 200D 2642 FE0F ; fully-qualified # 🤹🏾‍♂️ E4.0 man juggling: medium-dark skin tone +1F939 1F3FE 200D 2642 ; minimally-qualified # 🤹🏾‍♂ E4.0 man juggling: medium-dark skin tone +1F939 1F3FF 200D 2642 FE0F ; fully-qualified # 🤹🏿‍♂️ E4.0 man juggling: dark skin tone +1F939 1F3FF 200D 2642 ; minimally-qualified # 🤹🏿‍♂ E4.0 man juggling: dark skin tone +1F939 200D 2640 FE0F ; fully-qualified # 🤹‍♀️ E4.0 woman juggling +1F939 200D 2640 ; minimally-qualified # 🤹‍♀ E4.0 woman juggling +1F939 1F3FB 200D 2640 FE0F ; fully-qualified # 🤹🏻‍♀️ E4.0 woman juggling: light skin tone +1F939 1F3FB 200D 2640 ; minimally-qualified # 🤹🏻‍♀ E4.0 woman juggling: light skin tone +1F939 1F3FC 200D 2640 FE0F ; fully-qualified # 🤹🏼‍♀️ E4.0 woman juggling: medium-light skin tone +1F939 1F3FC 200D 2640 ; minimally-qualified # 🤹🏼‍♀ E4.0 woman juggling: medium-light skin tone +1F939 1F3FD 200D 2640 FE0F ; fully-qualified # 🤹🏽‍♀️ E4.0 woman juggling: medium skin tone +1F939 1F3FD 200D 2640 ; minimally-qualified # 🤹🏽‍♀ E4.0 woman juggling: medium skin tone +1F939 1F3FE 200D 2640 FE0F ; fully-qualified # 🤹🏾‍♀️ E4.0 woman juggling: medium-dark skin tone +1F939 1F3FE 200D 2640 ; minimally-qualified # 🤹🏾‍♀ E4.0 woman juggling: medium-dark skin tone +1F939 1F3FF 200D 2640 FE0F ; fully-qualified # 🤹🏿‍♀️ E4.0 woman juggling: dark skin tone +1F939 1F3FF 200D 2640 ; minimally-qualified # 🤹🏿‍♀ E4.0 woman juggling: dark skin tone + +# subgroup: person-resting +1F9D8 ; fully-qualified # 🧘 E5.0 person in lotus position +1F9D8 1F3FB ; fully-qualified # 🧘🏻 E5.0 person in lotus position: light skin tone +1F9D8 1F3FC ; fully-qualified # 🧘🏼 E5.0 person in lotus position: medium-light skin tone +1F9D8 1F3FD ; fully-qualified # 🧘🏽 E5.0 person in lotus position: medium skin tone +1F9D8 1F3FE ; fully-qualified # 🧘🏾 E5.0 person in lotus position: medium-dark skin tone +1F9D8 1F3FF ; fully-qualified # 🧘🏿 E5.0 person in lotus position: dark skin tone +1F9D8 200D 2642 FE0F ; fully-qualified # 🧘‍♂️ E5.0 man in lotus position +1F9D8 200D 2642 ; minimally-qualified # 🧘‍♂ E5.0 man in lotus position +1F9D8 1F3FB 200D 2642 FE0F ; fully-qualified # 🧘🏻‍♂️ E5.0 man in lotus position: light skin tone +1F9D8 1F3FB 200D 2642 ; minimally-qualified # 🧘🏻‍♂ E5.0 man in lotus position: light skin tone +1F9D8 1F3FC 200D 2642 FE0F ; fully-qualified # 🧘🏼‍♂️ E5.0 man in lotus position: medium-light skin tone +1F9D8 1F3FC 200D 2642 ; minimally-qualified # 🧘🏼‍♂ E5.0 man in lotus position: medium-light skin tone +1F9D8 1F3FD 200D 2642 FE0F ; fully-qualified # 🧘🏽‍♂️ E5.0 man in lotus position: medium skin tone +1F9D8 1F3FD 200D 2642 ; minimally-qualified # 🧘🏽‍♂ E5.0 man in lotus position: medium skin tone +1F9D8 1F3FE 200D 2642 FE0F ; fully-qualified # 🧘🏾‍♂️ E5.0 man in lotus position: medium-dark skin tone +1F9D8 1F3FE 200D 2642 ; minimally-qualified # 🧘🏾‍♂ E5.0 man in lotus position: medium-dark skin tone +1F9D8 1F3FF 200D 2642 FE0F ; fully-qualified # 🧘🏿‍♂️ E5.0 man in lotus position: dark skin tone +1F9D8 1F3FF 200D 2642 ; minimally-qualified # 🧘🏿‍♂ E5.0 man in lotus position: dark skin tone +1F9D8 200D 2640 FE0F ; fully-qualified # 🧘‍♀️ E5.0 woman in lotus position +1F9D8 200D 2640 ; minimally-qualified # 🧘‍♀ E5.0 woman in lotus position +1F9D8 1F3FB 200D 2640 FE0F ; fully-qualified # 🧘🏻‍♀️ E5.0 woman in lotus position: light skin tone +1F9D8 1F3FB 200D 2640 ; minimally-qualified # 🧘🏻‍♀ E5.0 woman in lotus position: light skin tone +1F9D8 1F3FC 200D 2640 FE0F ; fully-qualified # 🧘🏼‍♀️ E5.0 woman in lotus position: medium-light skin tone +1F9D8 1F3FC 200D 2640 ; minimally-qualified # 🧘🏼‍♀ E5.0 woman in lotus position: medium-light skin tone +1F9D8 1F3FD 200D 2640 FE0F ; fully-qualified # 🧘🏽‍♀️ E5.0 woman in lotus position: medium skin tone +1F9D8 1F3FD 200D 2640 ; minimally-qualified # 🧘🏽‍♀ E5.0 woman in lotus position: medium skin tone +1F9D8 1F3FE 200D 2640 FE0F ; fully-qualified # 🧘🏾‍♀️ E5.0 woman in lotus position: medium-dark skin tone +1F9D8 1F3FE 200D 2640 ; minimally-qualified # 🧘🏾‍♀ E5.0 woman in lotus position: medium-dark skin tone +1F9D8 1F3FF 200D 2640 FE0F ; fully-qualified # 🧘🏿‍♀️ E5.0 woman in lotus position: dark skin tone +1F9D8 1F3FF 200D 2640 ; minimally-qualified # 🧘🏿‍♀ E5.0 woman in lotus position: dark skin tone +1F6C0 ; fully-qualified # 🛀 E0.6 person taking bath +1F6C0 1F3FB ; fully-qualified # 🛀🏻 E1.0 person taking bath: light skin tone +1F6C0 1F3FC ; fully-qualified # 🛀🏼 E1.0 person taking bath: medium-light skin tone +1F6C0 1F3FD ; fully-qualified # 🛀🏽 E1.0 person taking bath: medium skin tone +1F6C0 1F3FE ; fully-qualified # 🛀🏾 E1.0 person taking bath: medium-dark skin tone +1F6C0 1F3FF ; fully-qualified # 🛀🏿 E1.0 person taking bath: dark skin tone +1F6CC ; fully-qualified # 🛌 E1.0 person in bed +1F6CC 1F3FB ; fully-qualified # 🛌🏻 E4.0 person in bed: light skin tone +1F6CC 1F3FC ; fully-qualified # 🛌🏼 E4.0 person in bed: medium-light skin tone +1F6CC 1F3FD ; fully-qualified # 🛌🏽 E4.0 person in bed: medium skin tone +1F6CC 1F3FE ; fully-qualified # 🛌🏾 E4.0 person in bed: medium-dark skin tone +1F6CC 1F3FF ; fully-qualified # 🛌🏿 E4.0 person in bed: dark skin tone + +# subgroup: family +1F9D1 200D 1F91D 200D 1F9D1 ; fully-qualified # 🧑‍🤝‍🧑 E12.0 people holding hands +1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏻‍🤝‍🧑🏻 E12.0 people holding hands: light skin tone +1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏻‍🤝‍🧑🏼 E12.1 people holding hands: light skin tone, medium-light skin tone +1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏻‍🤝‍🧑🏽 E12.1 people holding hands: light skin tone, medium skin tone +1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏻‍🤝‍🧑🏾 E12.1 people holding hands: light skin tone, medium-dark skin tone +1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏻‍🤝‍🧑🏿 E12.1 people holding hands: light skin tone, dark skin tone +1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏼‍🤝‍🧑🏻 E12.0 people holding hands: medium-light skin tone, light skin tone +1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏼‍🤝‍🧑🏼 E12.0 people holding hands: medium-light skin tone +1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏼‍🤝‍🧑🏽 E12.1 people holding hands: medium-light skin tone, medium skin tone +1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏼‍🤝‍🧑🏾 E12.1 people holding hands: medium-light skin tone, medium-dark skin tone +1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏼‍🤝‍🧑🏿 E12.1 people holding hands: medium-light skin tone, dark skin tone +1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏽‍🤝‍🧑🏻 E12.0 people holding hands: medium skin tone, light skin tone +1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏽‍🤝‍🧑🏼 E12.0 people holding hands: medium skin tone, medium-light skin tone +1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏽‍🤝‍🧑🏽 E12.0 people holding hands: medium skin tone +1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏽‍🤝‍🧑🏾 E12.1 people holding hands: medium skin tone, medium-dark skin tone +1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏽‍🤝‍🧑🏿 E12.1 people holding hands: medium skin tone, dark skin tone +1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏾‍🤝‍🧑🏻 E12.0 people holding hands: medium-dark skin tone, light skin tone +1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏾‍🤝‍🧑🏼 E12.0 people holding hands: medium-dark skin tone, medium-light skin tone +1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏾‍🤝‍🧑🏽 E12.0 people holding hands: medium-dark skin tone, medium skin tone +1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏾‍🤝‍🧑🏾 E12.0 people holding hands: medium-dark skin tone +1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏾‍🤝‍🧑🏿 E12.1 people holding hands: medium-dark skin tone, dark skin tone +1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏿‍🤝‍🧑🏻 E12.0 people holding hands: dark skin tone, light skin tone +1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏿‍🤝‍🧑🏼 E12.0 people holding hands: dark skin tone, medium-light skin tone +1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏿‍🤝‍🧑🏽 E12.0 people holding hands: dark skin tone, medium skin tone +1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏿‍🤝‍🧑🏾 E12.0 people holding hands: dark skin tone, medium-dark skin tone +1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏿‍🤝‍🧑🏿 E12.0 people holding hands: dark skin tone +1F46D ; fully-qualified # 👭 E1.0 women holding hands +1F46D 1F3FB ; fully-qualified # 👭🏻 E12.0 women holding hands: light skin tone +1F469 1F3FB 200D 1F91D 200D 1F469 1F3FC ; fully-qualified # 👩🏻‍🤝‍👩🏼 E12.1 women holding hands: light skin tone, medium-light skin tone +1F469 1F3FB 200D 1F91D 200D 1F469 1F3FD ; fully-qualified # 👩🏻‍🤝‍👩🏽 E12.1 women holding hands: light skin tone, medium skin tone +1F469 1F3FB 200D 1F91D 200D 1F469 1F3FE ; fully-qualified # 👩🏻‍🤝‍👩🏾 E12.1 women holding hands: light skin tone, medium-dark skin tone +1F469 1F3FB 200D 1F91D 200D 1F469 1F3FF ; fully-qualified # 👩🏻‍🤝‍👩🏿 E12.1 women holding hands: light skin tone, dark skin tone +1F469 1F3FC 200D 1F91D 200D 1F469 1F3FB ; fully-qualified # 👩🏼‍🤝‍👩🏻 E12.0 women holding hands: medium-light skin tone, light skin tone +1F46D 1F3FC ; fully-qualified # 👭🏼 E12.0 women holding hands: medium-light skin tone +1F469 1F3FC 200D 1F91D 200D 1F469 1F3FD ; fully-qualified # 👩🏼‍🤝‍👩🏽 E12.1 women holding hands: medium-light skin tone, medium skin tone +1F469 1F3FC 200D 1F91D 200D 1F469 1F3FE ; fully-qualified # 👩🏼‍🤝‍👩🏾 E12.1 women holding hands: medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 1F91D 200D 1F469 1F3FF ; fully-qualified # 👩🏼‍🤝‍👩🏿 E12.1 women holding hands: medium-light skin tone, dark skin tone +1F469 1F3FD 200D 1F91D 200D 1F469 1F3FB ; fully-qualified # 👩🏽‍🤝‍👩🏻 E12.0 women holding hands: medium skin tone, light skin tone +1F469 1F3FD 200D 1F91D 200D 1F469 1F3FC ; fully-qualified # 👩🏽‍🤝‍👩🏼 E12.0 women holding hands: medium skin tone, medium-light skin tone +1F46D 1F3FD ; fully-qualified # 👭🏽 E12.0 women holding hands: medium skin tone +1F469 1F3FD 200D 1F91D 200D 1F469 1F3FE ; fully-qualified # 👩🏽‍🤝‍👩🏾 E12.1 women holding hands: medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 1F91D 200D 1F469 1F3FF ; fully-qualified # 👩🏽‍🤝‍👩🏿 E12.1 women holding hands: medium skin tone, dark skin tone +1F469 1F3FE 200D 1F91D 200D 1F469 1F3FB ; fully-qualified # 👩🏾‍🤝‍👩🏻 E12.0 women holding hands: medium-dark skin tone, light skin tone +1F469 1F3FE 200D 1F91D 200D 1F469 1F3FC ; fully-qualified # 👩🏾‍🤝‍👩🏼 E12.0 women holding hands: medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 1F91D 200D 1F469 1F3FD ; fully-qualified # 👩🏾‍🤝‍👩🏽 E12.0 women holding hands: medium-dark skin tone, medium skin tone +1F46D 1F3FE ; fully-qualified # 👭🏾 E12.0 women holding hands: medium-dark skin tone +1F469 1F3FE 200D 1F91D 200D 1F469 1F3FF ; fully-qualified # 👩🏾‍🤝‍👩🏿 E12.1 women holding hands: medium-dark skin tone, dark skin tone +1F469 1F3FF 200D 1F91D 200D 1F469 1F3FB ; fully-qualified # 👩🏿‍🤝‍👩🏻 E12.0 women holding hands: dark skin tone, light skin tone +1F469 1F3FF 200D 1F91D 200D 1F469 1F3FC ; fully-qualified # 👩🏿‍🤝‍👩🏼 E12.0 women holding hands: dark skin tone, medium-light skin tone +1F469 1F3FF 200D 1F91D 200D 1F469 1F3FD ; fully-qualified # 👩🏿‍🤝‍👩🏽 E12.0 women holding hands: dark skin tone, medium skin tone +1F469 1F3FF 200D 1F91D 200D 1F469 1F3FE ; fully-qualified # 👩🏿‍🤝‍👩🏾 E12.0 women holding hands: dark skin tone, medium-dark skin tone +1F46D 1F3FF ; fully-qualified # 👭🏿 E12.0 women holding hands: dark skin tone +1F46B ; fully-qualified # 👫 E0.6 woman and man holding hands +1F46B 1F3FB ; fully-qualified # 👫🏻 E12.0 woman and man holding hands: light skin tone +1F469 1F3FB 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👩🏻‍🤝‍👨🏼 E12.0 woman and man holding hands: light skin tone, medium-light skin tone +1F469 1F3FB 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👩🏻‍🤝‍👨🏽 E12.0 woman and man holding hands: light skin tone, medium skin tone +1F469 1F3FB 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👩🏻‍🤝‍👨🏾 E12.0 woman and man holding hands: light skin tone, medium-dark skin tone +1F469 1F3FB 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👩🏻‍🤝‍👨🏿 E12.0 woman and man holding hands: light skin tone, dark skin tone +1F469 1F3FC 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👩🏼‍🤝‍👨🏻 E12.0 woman and man holding hands: medium-light skin tone, light skin tone +1F46B 1F3FC ; fully-qualified # 👫🏼 E12.0 woman and man holding hands: medium-light skin tone +1F469 1F3FC 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👩🏼‍🤝‍👨🏽 E12.0 woman and man holding hands: medium-light skin tone, medium skin tone +1F469 1F3FC 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👩🏼‍🤝‍👨🏾 E12.0 woman and man holding hands: medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👩🏼‍🤝‍👨🏿 E12.0 woman and man holding hands: medium-light skin tone, dark skin tone +1F469 1F3FD 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👩🏽‍🤝‍👨🏻 E12.0 woman and man holding hands: medium skin tone, light skin tone +1F469 1F3FD 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👩🏽‍🤝‍👨🏼 E12.0 woman and man holding hands: medium skin tone, medium-light skin tone +1F46B 1F3FD ; fully-qualified # 👫🏽 E12.0 woman and man holding hands: medium skin tone +1F469 1F3FD 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👩🏽‍🤝‍👨🏾 E12.0 woman and man holding hands: medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👩🏽‍🤝‍👨🏿 E12.0 woman and man holding hands: medium skin tone, dark skin tone +1F469 1F3FE 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👩🏾‍🤝‍👨🏻 E12.0 woman and man holding hands: medium-dark skin tone, light skin tone +1F469 1F3FE 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👩🏾‍🤝‍👨🏼 E12.0 woman and man holding hands: medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👩🏾‍🤝‍👨🏽 E12.0 woman and man holding hands: medium-dark skin tone, medium skin tone +1F46B 1F3FE ; fully-qualified # 👫🏾 E12.0 woman and man holding hands: medium-dark skin tone +1F469 1F3FE 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👩🏾‍🤝‍👨🏿 E12.0 woman and man holding hands: medium-dark skin tone, dark skin tone +1F469 1F3FF 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👩🏿‍🤝‍👨🏻 E12.0 woman and man holding hands: dark skin tone, light skin tone +1F469 1F3FF 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👩🏿‍🤝‍👨🏼 E12.0 woman and man holding hands: dark skin tone, medium-light skin tone +1F469 1F3FF 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👩🏿‍🤝‍👨🏽 E12.0 woman and man holding hands: dark skin tone, medium skin tone +1F469 1F3FF 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👩🏿‍🤝‍👨🏾 E12.0 woman and man holding hands: dark skin tone, medium-dark skin tone +1F46B 1F3FF ; fully-qualified # 👫🏿 E12.0 woman and man holding hands: dark skin tone +1F46C ; fully-qualified # 👬 E1.0 men holding hands +1F46C 1F3FB ; fully-qualified # 👬🏻 E12.0 men holding hands: light skin tone +1F468 1F3FB 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👨🏻‍🤝‍👨🏼 E12.1 men holding hands: light skin tone, medium-light skin tone +1F468 1F3FB 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👨🏻‍🤝‍👨🏽 E12.1 men holding hands: light skin tone, medium skin tone +1F468 1F3FB 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👨🏻‍🤝‍👨🏾 E12.1 men holding hands: light skin tone, medium-dark skin tone +1F468 1F3FB 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👨🏻‍🤝‍👨🏿 E12.1 men holding hands: light skin tone, dark skin tone +1F468 1F3FC 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👨🏼‍🤝‍👨🏻 E12.0 men holding hands: medium-light skin tone, light skin tone +1F46C 1F3FC ; fully-qualified # 👬🏼 E12.0 men holding hands: medium-light skin tone +1F468 1F3FC 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👨🏼‍🤝‍👨🏽 E12.1 men holding hands: medium-light skin tone, medium skin tone +1F468 1F3FC 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👨🏼‍🤝‍👨🏾 E12.1 men holding hands: medium-light skin tone, medium-dark skin tone +1F468 1F3FC 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👨🏼‍🤝‍👨🏿 E12.1 men holding hands: medium-light skin tone, dark skin tone +1F468 1F3FD 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👨🏽‍🤝‍👨🏻 E12.0 men holding hands: medium skin tone, light skin tone +1F468 1F3FD 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👨🏽‍🤝‍👨🏼 E12.0 men holding hands: medium skin tone, medium-light skin tone +1F46C 1F3FD ; fully-qualified # 👬🏽 E12.0 men holding hands: medium skin tone +1F468 1F3FD 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👨🏽‍🤝‍👨🏾 E12.1 men holding hands: medium skin tone, medium-dark skin tone +1F468 1F3FD 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👨🏽‍🤝‍👨🏿 E12.1 men holding hands: medium skin tone, dark skin tone +1F468 1F3FE 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👨🏾‍🤝‍👨🏻 E12.0 men holding hands: medium-dark skin tone, light skin tone +1F468 1F3FE 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👨🏾‍🤝‍👨🏼 E12.0 men holding hands: medium-dark skin tone, medium-light skin tone +1F468 1F3FE 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👨🏾‍🤝‍👨🏽 E12.0 men holding hands: medium-dark skin tone, medium skin tone +1F46C 1F3FE ; fully-qualified # 👬🏾 E12.0 men holding hands: medium-dark skin tone +1F468 1F3FE 200D 1F91D 200D 1F468 1F3FF ; fully-qualified # 👨🏾‍🤝‍👨🏿 E12.1 men holding hands: medium-dark skin tone, dark skin tone +1F468 1F3FF 200D 1F91D 200D 1F468 1F3FB ; fully-qualified # 👨🏿‍🤝‍👨🏻 E12.0 men holding hands: dark skin tone, light skin tone +1F468 1F3FF 200D 1F91D 200D 1F468 1F3FC ; fully-qualified # 👨🏿‍🤝‍👨🏼 E12.0 men holding hands: dark skin tone, medium-light skin tone +1F468 1F3FF 200D 1F91D 200D 1F468 1F3FD ; fully-qualified # 👨🏿‍🤝‍👨🏽 E12.0 men holding hands: dark skin tone, medium skin tone +1F468 1F3FF 200D 1F91D 200D 1F468 1F3FE ; fully-qualified # 👨🏿‍🤝‍👨🏾 E12.0 men holding hands: dark skin tone, medium-dark skin tone +1F46C 1F3FF ; fully-qualified # 👬🏿 E12.0 men holding hands: dark skin tone +1F48F ; fully-qualified # 💏 E0.6 kiss +1F48F 1F3FB ; fully-qualified # 💏🏻 E13.1 kiss: light skin tone +1F48F 1F3FC ; fully-qualified # 💏🏼 E13.1 kiss: medium-light skin tone +1F48F 1F3FD ; fully-qualified # 💏🏽 E13.1 kiss: medium skin tone +1F48F 1F3FE ; fully-qualified # 💏🏾 E13.1 kiss: medium-dark skin tone +1F48F 1F3FF ; fully-qualified # 💏🏿 E13.1 kiss: dark skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏻‍❤️‍💋‍🧑🏼 E13.1 kiss: person, person, light skin tone, medium-light skin tone +1F9D1 1F3FB 200D 2764 200D 1F48B 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏻‍❤‍💋‍🧑🏼 E13.1 kiss: person, person, light skin tone, medium-light skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏻‍❤️‍💋‍🧑🏽 E13.1 kiss: person, person, light skin tone, medium skin tone +1F9D1 1F3FB 200D 2764 200D 1F48B 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏻‍❤‍💋‍🧑🏽 E13.1 kiss: person, person, light skin tone, medium skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏻‍❤️‍💋‍🧑🏾 E13.1 kiss: person, person, light skin tone, medium-dark skin tone +1F9D1 1F3FB 200D 2764 200D 1F48B 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏻‍❤‍💋‍🧑🏾 E13.1 kiss: person, person, light skin tone, medium-dark skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏻‍❤️‍💋‍🧑🏿 E13.1 kiss: person, person, light skin tone, dark skin tone +1F9D1 1F3FB 200D 2764 200D 1F48B 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏻‍❤‍💋‍🧑🏿 E13.1 kiss: person, person, light skin tone, dark skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏼‍❤️‍💋‍🧑🏻 E13.1 kiss: person, person, medium-light skin tone, light skin tone +1F9D1 1F3FC 200D 2764 200D 1F48B 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏼‍❤‍💋‍🧑🏻 E13.1 kiss: person, person, medium-light skin tone, light skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏼‍❤️‍💋‍🧑🏽 E13.1 kiss: person, person, medium-light skin tone, medium skin tone +1F9D1 1F3FC 200D 2764 200D 1F48B 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏼‍❤‍💋‍🧑🏽 E13.1 kiss: person, person, medium-light skin tone, medium skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏼‍❤️‍💋‍🧑🏾 E13.1 kiss: person, person, medium-light skin tone, medium-dark skin tone +1F9D1 1F3FC 200D 2764 200D 1F48B 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏼‍❤‍💋‍🧑🏾 E13.1 kiss: person, person, medium-light skin tone, medium-dark skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏼‍❤️‍💋‍🧑🏿 E13.1 kiss: person, person, medium-light skin tone, dark skin tone +1F9D1 1F3FC 200D 2764 200D 1F48B 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏼‍❤‍💋‍🧑🏿 E13.1 kiss: person, person, medium-light skin tone, dark skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏽‍❤️‍💋‍🧑🏻 E13.1 kiss: person, person, medium skin tone, light skin tone +1F9D1 1F3FD 200D 2764 200D 1F48B 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏽‍❤‍💋‍🧑🏻 E13.1 kiss: person, person, medium skin tone, light skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏽‍❤️‍💋‍🧑🏼 E13.1 kiss: person, person, medium skin tone, medium-light skin tone +1F9D1 1F3FD 200D 2764 200D 1F48B 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏽‍❤‍💋‍🧑🏼 E13.1 kiss: person, person, medium skin tone, medium-light skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏽‍❤️‍💋‍🧑🏾 E13.1 kiss: person, person, medium skin tone, medium-dark skin tone +1F9D1 1F3FD 200D 2764 200D 1F48B 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏽‍❤‍💋‍🧑🏾 E13.1 kiss: person, person, medium skin tone, medium-dark skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏽‍❤️‍💋‍🧑🏿 E13.1 kiss: person, person, medium skin tone, dark skin tone +1F9D1 1F3FD 200D 2764 200D 1F48B 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏽‍❤‍💋‍🧑🏿 E13.1 kiss: person, person, medium skin tone, dark skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏾‍❤️‍💋‍🧑🏻 E13.1 kiss: person, person, medium-dark skin tone, light skin tone +1F9D1 1F3FE 200D 2764 200D 1F48B 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏾‍❤‍💋‍🧑🏻 E13.1 kiss: person, person, medium-dark skin tone, light skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏾‍❤️‍💋‍🧑🏼 E13.1 kiss: person, person, medium-dark skin tone, medium-light skin tone +1F9D1 1F3FE 200D 2764 200D 1F48B 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏾‍❤‍💋‍🧑🏼 E13.1 kiss: person, person, medium-dark skin tone, medium-light skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏾‍❤️‍💋‍🧑🏽 E13.1 kiss: person, person, medium-dark skin tone, medium skin tone +1F9D1 1F3FE 200D 2764 200D 1F48B 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏾‍❤‍💋‍🧑🏽 E13.1 kiss: person, person, medium-dark skin tone, medium skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏾‍❤️‍💋‍🧑🏿 E13.1 kiss: person, person, medium-dark skin tone, dark skin tone +1F9D1 1F3FE 200D 2764 200D 1F48B 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏾‍❤‍💋‍🧑🏿 E13.1 kiss: person, person, medium-dark skin tone, dark skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏿‍❤️‍💋‍🧑🏻 E13.1 kiss: person, person, dark skin tone, light skin tone +1F9D1 1F3FF 200D 2764 200D 1F48B 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏿‍❤‍💋‍🧑🏻 E13.1 kiss: person, person, dark skin tone, light skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏿‍❤️‍💋‍🧑🏼 E13.1 kiss: person, person, dark skin tone, medium-light skin tone +1F9D1 1F3FF 200D 2764 200D 1F48B 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏿‍❤‍💋‍🧑🏼 E13.1 kiss: person, person, dark skin tone, medium-light skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏿‍❤️‍💋‍🧑🏽 E13.1 kiss: person, person, dark skin tone, medium skin tone +1F9D1 1F3FF 200D 2764 200D 1F48B 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏿‍❤‍💋‍🧑🏽 E13.1 kiss: person, person, dark skin tone, medium skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏿‍❤️‍💋‍🧑🏾 E13.1 kiss: person, person, dark skin tone, medium-dark skin tone +1F9D1 1F3FF 200D 2764 200D 1F48B 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏿‍❤‍💋‍🧑🏾 E13.1 kiss: person, person, dark skin tone, medium-dark skin tone +1F469 200D 2764 FE0F 200D 1F48B 200D 1F468 ; fully-qualified # 👩‍❤️‍💋‍👨 E2.0 kiss: woman, man +1F469 200D 2764 200D 1F48B 200D 1F468 ; minimally-qualified # 👩‍❤‍💋‍👨 E2.0 kiss: woman, man +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👩🏻‍❤️‍💋‍👨🏻 E13.1 kiss: woman, man, light skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👩🏻‍❤‍💋‍👨🏻 E13.1 kiss: woman, man, light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👩🏻‍❤️‍💋‍👨🏼 E13.1 kiss: woman, man, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👩🏻‍❤‍💋‍👨🏼 E13.1 kiss: woman, man, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👩🏻‍❤️‍💋‍👨🏽 E13.1 kiss: woman, man, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👩🏻‍❤‍💋‍👨🏽 E13.1 kiss: woman, man, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👩🏻‍❤️‍💋‍👨🏾 E13.1 kiss: woman, man, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👩🏻‍❤‍💋‍👨🏾 E13.1 kiss: woman, man, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👩🏻‍❤️‍💋‍👨🏿 E13.1 kiss: woman, man, light skin tone, dark skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👩🏻‍❤‍💋‍👨🏿 E13.1 kiss: woman, man, light skin tone, dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👩🏼‍❤️‍💋‍👨🏻 E13.1 kiss: woman, man, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👩🏼‍❤‍💋‍👨🏻 E13.1 kiss: woman, man, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👩🏼‍❤️‍💋‍👨🏼 E13.1 kiss: woman, man, medium-light skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👩🏼‍❤‍💋‍👨🏼 E13.1 kiss: woman, man, medium-light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👩🏼‍❤️‍💋‍👨🏽 E13.1 kiss: woman, man, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👩🏼‍❤‍💋‍👨🏽 E13.1 kiss: woman, man, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👩🏼‍❤️‍💋‍👨🏾 E13.1 kiss: woman, man, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👩🏼‍❤‍💋‍👨🏾 E13.1 kiss: woman, man, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👩🏼‍❤️‍💋‍👨🏿 E13.1 kiss: woman, man, medium-light skin tone, dark skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👩🏼‍❤‍💋‍👨🏿 E13.1 kiss: woman, man, medium-light skin tone, dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👩🏽‍❤️‍💋‍👨🏻 E13.1 kiss: woman, man, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👩🏽‍❤‍💋‍👨🏻 E13.1 kiss: woman, man, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👩🏽‍❤️‍💋‍👨🏼 E13.1 kiss: woman, man, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👩🏽‍❤‍💋‍👨🏼 E13.1 kiss: woman, man, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👩🏽‍❤️‍💋‍👨🏽 E13.1 kiss: woman, man, medium skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👩🏽‍❤‍💋‍👨🏽 E13.1 kiss: woman, man, medium skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👩🏽‍❤️‍💋‍👨🏾 E13.1 kiss: woman, man, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👩🏽‍❤‍💋‍👨🏾 E13.1 kiss: woman, man, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👩🏽‍❤️‍💋‍👨🏿 E13.1 kiss: woman, man, medium skin tone, dark skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👩🏽‍❤‍💋‍👨🏿 E13.1 kiss: woman, man, medium skin tone, dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👩🏾‍❤️‍💋‍👨🏻 E13.1 kiss: woman, man, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👩🏾‍❤‍💋‍👨🏻 E13.1 kiss: woman, man, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👩🏾‍❤️‍💋‍👨🏼 E13.1 kiss: woman, man, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👩🏾‍❤‍💋‍👨🏼 E13.1 kiss: woman, man, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👩🏾‍❤️‍💋‍👨🏽 E13.1 kiss: woman, man, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👩🏾‍❤‍💋‍👨🏽 E13.1 kiss: woman, man, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👩🏾‍❤️‍💋‍👨🏾 E13.1 kiss: woman, man, medium-dark skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👩🏾‍❤‍💋‍👨🏾 E13.1 kiss: woman, man, medium-dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👩🏾‍❤️‍💋‍👨🏿 E13.1 kiss: woman, man, medium-dark skin tone, dark skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👩🏾‍❤‍💋‍👨🏿 E13.1 kiss: woman, man, medium-dark skin tone, dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👩🏿‍❤️‍💋‍👨🏻 E13.1 kiss: woman, man, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👩🏿‍❤‍💋‍👨🏻 E13.1 kiss: woman, man, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👩🏿‍❤️‍💋‍👨🏼 E13.1 kiss: woman, man, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👩🏿‍❤‍💋‍👨🏼 E13.1 kiss: woman, man, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👩🏿‍❤️‍💋‍👨🏽 E13.1 kiss: woman, man, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👩🏿‍❤‍💋‍👨🏽 E13.1 kiss: woman, man, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👩🏿‍❤️‍💋‍👨🏾 E13.1 kiss: woman, man, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👩🏿‍❤‍💋‍👨🏾 E13.1 kiss: woman, man, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👩🏿‍❤️‍💋‍👨🏿 E13.1 kiss: woman, man, dark skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👩🏿‍❤‍💋‍👨🏿 E13.1 kiss: woman, man, dark skin tone +1F468 200D 2764 FE0F 200D 1F48B 200D 1F468 ; fully-qualified # 👨‍❤️‍💋‍👨 E2.0 kiss: man, man +1F468 200D 2764 200D 1F48B 200D 1F468 ; minimally-qualified # 👨‍❤‍💋‍👨 E2.0 kiss: man, man +1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👨🏻‍❤️‍💋‍👨🏻 E13.1 kiss: man, man, light skin tone +1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👨🏻‍❤‍💋‍👨🏻 E13.1 kiss: man, man, light skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👨🏻‍❤️‍💋‍👨🏼 E13.1 kiss: man, man, light skin tone, medium-light skin tone +1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👨🏻‍❤‍💋‍👨🏼 E13.1 kiss: man, man, light skin tone, medium-light skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👨🏻‍❤️‍💋‍👨🏽 E13.1 kiss: man, man, light skin tone, medium skin tone +1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👨🏻‍❤‍💋‍👨🏽 E13.1 kiss: man, man, light skin tone, medium skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👨🏻‍❤️‍💋‍👨🏾 E13.1 kiss: man, man, light skin tone, medium-dark skin tone +1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👨🏻‍❤‍💋‍👨🏾 E13.1 kiss: man, man, light skin tone, medium-dark skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👨🏻‍❤️‍💋‍👨🏿 E13.1 kiss: man, man, light skin tone, dark skin tone +1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👨🏻‍❤‍💋‍👨🏿 E13.1 kiss: man, man, light skin tone, dark skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👨🏼‍❤️‍💋‍👨🏻 E13.1 kiss: man, man, medium-light skin tone, light skin tone +1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👨🏼‍❤‍💋‍👨🏻 E13.1 kiss: man, man, medium-light skin tone, light skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👨🏼‍❤️‍💋‍👨🏼 E13.1 kiss: man, man, medium-light skin tone +1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👨🏼‍❤‍💋‍👨🏼 E13.1 kiss: man, man, medium-light skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👨🏼‍❤️‍💋‍👨🏽 E13.1 kiss: man, man, medium-light skin tone, medium skin tone +1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👨🏼‍❤‍💋‍👨🏽 E13.1 kiss: man, man, medium-light skin tone, medium skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👨🏼‍❤️‍💋‍👨🏾 E13.1 kiss: man, man, medium-light skin tone, medium-dark skin tone +1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👨🏼‍❤‍💋‍👨🏾 E13.1 kiss: man, man, medium-light skin tone, medium-dark skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👨🏼‍❤️‍💋‍👨🏿 E13.1 kiss: man, man, medium-light skin tone, dark skin tone +1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👨🏼‍❤‍💋‍👨🏿 E13.1 kiss: man, man, medium-light skin tone, dark skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👨🏽‍❤️‍💋‍👨🏻 E13.1 kiss: man, man, medium skin tone, light skin tone +1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👨🏽‍❤‍💋‍👨🏻 E13.1 kiss: man, man, medium skin tone, light skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👨🏽‍❤️‍💋‍👨🏼 E13.1 kiss: man, man, medium skin tone, medium-light skin tone +1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👨🏽‍❤‍💋‍👨🏼 E13.1 kiss: man, man, medium skin tone, medium-light skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👨🏽‍❤️‍💋‍👨🏽 E13.1 kiss: man, man, medium skin tone +1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👨🏽‍❤‍💋‍👨🏽 E13.1 kiss: man, man, medium skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👨🏽‍❤️‍💋‍👨🏾 E13.1 kiss: man, man, medium skin tone, medium-dark skin tone +1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👨🏽‍❤‍💋‍👨🏾 E13.1 kiss: man, man, medium skin tone, medium-dark skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👨🏽‍❤️‍💋‍👨🏿 E13.1 kiss: man, man, medium skin tone, dark skin tone +1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👨🏽‍❤‍💋‍👨🏿 E13.1 kiss: man, man, medium skin tone, dark skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👨🏾‍❤️‍💋‍👨🏻 E13.1 kiss: man, man, medium-dark skin tone, light skin tone +1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👨🏾‍❤‍💋‍👨🏻 E13.1 kiss: man, man, medium-dark skin tone, light skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👨🏾‍❤️‍💋‍👨🏼 E13.1 kiss: man, man, medium-dark skin tone, medium-light skin tone +1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👨🏾‍❤‍💋‍👨🏼 E13.1 kiss: man, man, medium-dark skin tone, medium-light skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👨🏾‍❤️‍💋‍👨🏽 E13.1 kiss: man, man, medium-dark skin tone, medium skin tone +1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👨🏾‍❤‍💋‍👨🏽 E13.1 kiss: man, man, medium-dark skin tone, medium skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👨🏾‍❤️‍💋‍👨🏾 E13.1 kiss: man, man, medium-dark skin tone +1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👨🏾‍❤‍💋‍👨🏾 E13.1 kiss: man, man, medium-dark skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👨🏾‍❤️‍💋‍👨🏿 E13.1 kiss: man, man, medium-dark skin tone, dark skin tone +1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👨🏾‍❤‍💋‍👨🏿 E13.1 kiss: man, man, medium-dark skin tone, dark skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB ; fully-qualified # 👨🏿‍❤️‍💋‍👨🏻 E13.1 kiss: man, man, dark skin tone, light skin tone +1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FB ; minimally-qualified # 👨🏿‍❤‍💋‍👨🏻 E13.1 kiss: man, man, dark skin tone, light skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC ; fully-qualified # 👨🏿‍❤️‍💋‍👨🏼 E13.1 kiss: man, man, dark skin tone, medium-light skin tone +1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FC ; minimally-qualified # 👨🏿‍❤‍💋‍👨🏼 E13.1 kiss: man, man, dark skin tone, medium-light skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD ; fully-qualified # 👨🏿‍❤️‍💋‍👨🏽 E13.1 kiss: man, man, dark skin tone, medium skin tone +1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FD ; minimally-qualified # 👨🏿‍❤‍💋‍👨🏽 E13.1 kiss: man, man, dark skin tone, medium skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE ; fully-qualified # 👨🏿‍❤️‍💋‍👨🏾 E13.1 kiss: man, man, dark skin tone, medium-dark skin tone +1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FE ; minimally-qualified # 👨🏿‍❤‍💋‍👨🏾 E13.1 kiss: man, man, dark skin tone, medium-dark skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF ; fully-qualified # 👨🏿‍❤️‍💋‍👨🏿 E13.1 kiss: man, man, dark skin tone +1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FF ; minimally-qualified # 👨🏿‍❤‍💋‍👨🏿 E13.1 kiss: man, man, dark skin tone +1F469 200D 2764 FE0F 200D 1F48B 200D 1F469 ; fully-qualified # 👩‍❤️‍💋‍👩 E2.0 kiss: woman, woman +1F469 200D 2764 200D 1F48B 200D 1F469 ; minimally-qualified # 👩‍❤‍💋‍👩 E2.0 kiss: woman, woman +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB ; fully-qualified # 👩🏻‍❤️‍💋‍👩🏻 E13.1 kiss: woman, woman, light skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FB ; minimally-qualified # 👩🏻‍❤‍💋‍👩🏻 E13.1 kiss: woman, woman, light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC ; fully-qualified # 👩🏻‍❤️‍💋‍👩🏼 E13.1 kiss: woman, woman, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FC ; minimally-qualified # 👩🏻‍❤‍💋‍👩🏼 E13.1 kiss: woman, woman, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD ; fully-qualified # 👩🏻‍❤️‍💋‍👩🏽 E13.1 kiss: woman, woman, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FD ; minimally-qualified # 👩🏻‍❤‍💋‍👩🏽 E13.1 kiss: woman, woman, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE ; fully-qualified # 👩🏻‍❤️‍💋‍👩🏾 E13.1 kiss: woman, woman, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FE ; minimally-qualified # 👩🏻‍❤‍💋‍👩🏾 E13.1 kiss: woman, woman, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF ; fully-qualified # 👩🏻‍❤️‍💋‍👩🏿 E13.1 kiss: woman, woman, light skin tone, dark skin tone +1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FF ; minimally-qualified # 👩🏻‍❤‍💋‍👩🏿 E13.1 kiss: woman, woman, light skin tone, dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB ; fully-qualified # 👩🏼‍❤️‍💋‍👩🏻 E13.1 kiss: woman, woman, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FB ; minimally-qualified # 👩🏼‍❤‍💋‍👩🏻 E13.1 kiss: woman, woman, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC ; fully-qualified # 👩🏼‍❤️‍💋‍👩🏼 E13.1 kiss: woman, woman, medium-light skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FC ; minimally-qualified # 👩🏼‍❤‍💋‍👩🏼 E13.1 kiss: woman, woman, medium-light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD ; fully-qualified # 👩🏼‍❤️‍💋‍👩🏽 E13.1 kiss: woman, woman, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FD ; minimally-qualified # 👩🏼‍❤‍💋‍👩🏽 E13.1 kiss: woman, woman, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE ; fully-qualified # 👩🏼‍❤️‍💋‍👩🏾 E13.1 kiss: woman, woman, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FE ; minimally-qualified # 👩🏼‍❤‍💋‍👩🏾 E13.1 kiss: woman, woman, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF ; fully-qualified # 👩🏼‍❤️‍💋‍👩🏿 E13.1 kiss: woman, woman, medium-light skin tone, dark skin tone +1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FF ; minimally-qualified # 👩🏼‍❤‍💋‍👩🏿 E13.1 kiss: woman, woman, medium-light skin tone, dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB ; fully-qualified # 👩🏽‍❤️‍💋‍👩🏻 E13.1 kiss: woman, woman, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FB ; minimally-qualified # 👩🏽‍❤‍💋‍👩🏻 E13.1 kiss: woman, woman, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC ; fully-qualified # 👩🏽‍❤️‍💋‍👩🏼 E13.1 kiss: woman, woman, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FC ; minimally-qualified # 👩🏽‍❤‍💋‍👩🏼 E13.1 kiss: woman, woman, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD ; fully-qualified # 👩🏽‍❤️‍💋‍👩🏽 E13.1 kiss: woman, woman, medium skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FD ; minimally-qualified # 👩🏽‍❤‍💋‍👩🏽 E13.1 kiss: woman, woman, medium skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE ; fully-qualified # 👩🏽‍❤️‍💋‍👩🏾 E13.1 kiss: woman, woman, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FE ; minimally-qualified # 👩🏽‍❤‍💋‍👩🏾 E13.1 kiss: woman, woman, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF ; fully-qualified # 👩🏽‍❤️‍💋‍👩🏿 E13.1 kiss: woman, woman, medium skin tone, dark skin tone +1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FF ; minimally-qualified # 👩🏽‍❤‍💋‍👩🏿 E13.1 kiss: woman, woman, medium skin tone, dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB ; fully-qualified # 👩🏾‍❤️‍💋‍👩🏻 E13.1 kiss: woman, woman, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FB ; minimally-qualified # 👩🏾‍❤‍💋‍👩🏻 E13.1 kiss: woman, woman, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC ; fully-qualified # 👩🏾‍❤️‍💋‍👩🏼 E13.1 kiss: woman, woman, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FC ; minimally-qualified # 👩🏾‍❤‍💋‍👩🏼 E13.1 kiss: woman, woman, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD ; fully-qualified # 👩🏾‍❤️‍💋‍👩🏽 E13.1 kiss: woman, woman, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FD ; minimally-qualified # 👩🏾‍❤‍💋‍👩🏽 E13.1 kiss: woman, woman, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE ; fully-qualified # 👩🏾‍❤️‍💋‍👩🏾 E13.1 kiss: woman, woman, medium-dark skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FE ; minimally-qualified # 👩🏾‍❤‍💋‍👩🏾 E13.1 kiss: woman, woman, medium-dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF ; fully-qualified # 👩🏾‍❤️‍💋‍👩🏿 E13.1 kiss: woman, woman, medium-dark skin tone, dark skin tone +1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FF ; minimally-qualified # 👩🏾‍❤‍💋‍👩🏿 E13.1 kiss: woman, woman, medium-dark skin tone, dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB ; fully-qualified # 👩🏿‍❤️‍💋‍👩🏻 E13.1 kiss: woman, woman, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FB ; minimally-qualified # 👩🏿‍❤‍💋‍👩🏻 E13.1 kiss: woman, woman, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC ; fully-qualified # 👩🏿‍❤️‍💋‍👩🏼 E13.1 kiss: woman, woman, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FC ; minimally-qualified # 👩🏿‍❤‍💋‍👩🏼 E13.1 kiss: woman, woman, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD ; fully-qualified # 👩🏿‍❤️‍💋‍👩🏽 E13.1 kiss: woman, woman, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FD ; minimally-qualified # 👩🏿‍❤‍💋‍👩🏽 E13.1 kiss: woman, woman, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE ; fully-qualified # 👩🏿‍❤️‍💋‍👩🏾 E13.1 kiss: woman, woman, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FE ; minimally-qualified # 👩🏿‍❤‍💋‍👩🏾 E13.1 kiss: woman, woman, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF ; fully-qualified # 👩🏿‍❤️‍💋‍👩🏿 E13.1 kiss: woman, woman, dark skin tone +1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FF ; minimally-qualified # 👩🏿‍❤‍💋‍👩🏿 E13.1 kiss: woman, woman, dark skin tone +1F491 ; fully-qualified # 💑 E0.6 couple with heart +1F491 1F3FB ; fully-qualified # 💑🏻 E13.1 couple with heart: light skin tone +1F491 1F3FC ; fully-qualified # 💑🏼 E13.1 couple with heart: medium-light skin tone +1F491 1F3FD ; fully-qualified # 💑🏽 E13.1 couple with heart: medium skin tone +1F491 1F3FE ; fully-qualified # 💑🏾 E13.1 couple with heart: medium-dark skin tone +1F491 1F3FF ; fully-qualified # 💑🏿 E13.1 couple with heart: dark skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏻‍❤️‍🧑🏼 E13.1 couple with heart: person, person, light skin tone, medium-light skin tone +1F9D1 1F3FB 200D 2764 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏻‍❤‍🧑🏼 E13.1 couple with heart: person, person, light skin tone, medium-light skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏻‍❤️‍🧑🏽 E13.1 couple with heart: person, person, light skin tone, medium skin tone +1F9D1 1F3FB 200D 2764 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏻‍❤‍🧑🏽 E13.1 couple with heart: person, person, light skin tone, medium skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏻‍❤️‍🧑🏾 E13.1 couple with heart: person, person, light skin tone, medium-dark skin tone +1F9D1 1F3FB 200D 2764 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏻‍❤‍🧑🏾 E13.1 couple with heart: person, person, light skin tone, medium-dark skin tone +1F9D1 1F3FB 200D 2764 FE0F 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏻‍❤️‍🧑🏿 E13.1 couple with heart: person, person, light skin tone, dark skin tone +1F9D1 1F3FB 200D 2764 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏻‍❤‍🧑🏿 E13.1 couple with heart: person, person, light skin tone, dark skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏼‍❤️‍🧑🏻 E13.1 couple with heart: person, person, medium-light skin tone, light skin tone +1F9D1 1F3FC 200D 2764 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏼‍❤‍🧑🏻 E13.1 couple with heart: person, person, medium-light skin tone, light skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏼‍❤️‍🧑🏽 E13.1 couple with heart: person, person, medium-light skin tone, medium skin tone +1F9D1 1F3FC 200D 2764 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏼‍❤‍🧑🏽 E13.1 couple with heart: person, person, medium-light skin tone, medium skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏼‍❤️‍🧑🏾 E13.1 couple with heart: person, person, medium-light skin tone, medium-dark skin tone +1F9D1 1F3FC 200D 2764 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏼‍❤‍🧑🏾 E13.1 couple with heart: person, person, medium-light skin tone, medium-dark skin tone +1F9D1 1F3FC 200D 2764 FE0F 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏼‍❤️‍🧑🏿 E13.1 couple with heart: person, person, medium-light skin tone, dark skin tone +1F9D1 1F3FC 200D 2764 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏼‍❤‍🧑🏿 E13.1 couple with heart: person, person, medium-light skin tone, dark skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏽‍❤️‍🧑🏻 E13.1 couple with heart: person, person, medium skin tone, light skin tone +1F9D1 1F3FD 200D 2764 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏽‍❤‍🧑🏻 E13.1 couple with heart: person, person, medium skin tone, light skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏽‍❤️‍🧑🏼 E13.1 couple with heart: person, person, medium skin tone, medium-light skin tone +1F9D1 1F3FD 200D 2764 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏽‍❤‍🧑🏼 E13.1 couple with heart: person, person, medium skin tone, medium-light skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏽‍❤️‍🧑🏾 E13.1 couple with heart: person, person, medium skin tone, medium-dark skin tone +1F9D1 1F3FD 200D 2764 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏽‍❤‍🧑🏾 E13.1 couple with heart: person, person, medium skin tone, medium-dark skin tone +1F9D1 1F3FD 200D 2764 FE0F 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏽‍❤️‍🧑🏿 E13.1 couple with heart: person, person, medium skin tone, dark skin tone +1F9D1 1F3FD 200D 2764 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏽‍❤‍🧑🏿 E13.1 couple with heart: person, person, medium skin tone, dark skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏾‍❤️‍🧑🏻 E13.1 couple with heart: person, person, medium-dark skin tone, light skin tone +1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏾‍❤‍🧑🏻 E13.1 couple with heart: person, person, medium-dark skin tone, light skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏾‍❤️‍🧑🏼 E13.1 couple with heart: person, person, medium-dark skin tone, medium-light skin tone +1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏾‍❤‍🧑🏼 E13.1 couple with heart: person, person, medium-dark skin tone, medium-light skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏾‍❤️‍🧑🏽 E13.1 couple with heart: person, person, medium-dark skin tone, medium skin tone +1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏾‍❤‍🧑🏽 E13.1 couple with heart: person, person, medium-dark skin tone, medium skin tone +1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FF ; fully-qualified # 🧑🏾‍❤️‍🧑🏿 E13.1 couple with heart: person, person, medium-dark skin tone, dark skin tone +1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FF ; minimally-qualified # 🧑🏾‍❤‍🧑🏿 E13.1 couple with heart: person, person, medium-dark skin tone, dark skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F9D1 1F3FB ; fully-qualified # 🧑🏿‍❤️‍🧑🏻 E13.1 couple with heart: person, person, dark skin tone, light skin tone +1F9D1 1F3FF 200D 2764 200D 1F9D1 1F3FB ; minimally-qualified # 🧑🏿‍❤‍🧑🏻 E13.1 couple with heart: person, person, dark skin tone, light skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F9D1 1F3FC ; fully-qualified # 🧑🏿‍❤️‍🧑🏼 E13.1 couple with heart: person, person, dark skin tone, medium-light skin tone +1F9D1 1F3FF 200D 2764 200D 1F9D1 1F3FC ; minimally-qualified # 🧑🏿‍❤‍🧑🏼 E13.1 couple with heart: person, person, dark skin tone, medium-light skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F9D1 1F3FD ; fully-qualified # 🧑🏿‍❤️‍🧑🏽 E13.1 couple with heart: person, person, dark skin tone, medium skin tone +1F9D1 1F3FF 200D 2764 200D 1F9D1 1F3FD ; minimally-qualified # 🧑🏿‍❤‍🧑🏽 E13.1 couple with heart: person, person, dark skin tone, medium skin tone +1F9D1 1F3FF 200D 2764 FE0F 200D 1F9D1 1F3FE ; fully-qualified # 🧑🏿‍❤️‍🧑🏾 E13.1 couple with heart: person, person, dark skin tone, medium-dark skin tone +1F9D1 1F3FF 200D 2764 200D 1F9D1 1F3FE ; minimally-qualified # 🧑🏿‍❤‍🧑🏾 E13.1 couple with heart: person, person, dark skin tone, medium-dark skin tone +1F469 200D 2764 FE0F 200D 1F468 ; fully-qualified # 👩‍❤️‍👨 E2.0 couple with heart: woman, man +1F469 200D 2764 200D 1F468 ; minimally-qualified # 👩‍❤‍👨 E2.0 couple with heart: woman, man +1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👩🏻‍❤️‍👨🏻 E13.1 couple with heart: woman, man, light skin tone +1F469 1F3FB 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👩🏻‍❤‍👨🏻 E13.1 couple with heart: woman, man, light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👩🏻‍❤️‍👨🏼 E13.1 couple with heart: woman, man, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👩🏻‍❤‍👨🏼 E13.1 couple with heart: woman, man, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👩🏻‍❤️‍👨🏽 E13.1 couple with heart: woman, man, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👩🏻‍❤‍👨🏽 E13.1 couple with heart: woman, man, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👩🏻‍❤️‍👨🏾 E13.1 couple with heart: woman, man, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👩🏻‍❤‍👨🏾 E13.1 couple with heart: woman, man, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👩🏻‍❤️‍👨🏿 E13.1 couple with heart: woman, man, light skin tone, dark skin tone +1F469 1F3FB 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👩🏻‍❤‍👨🏿 E13.1 couple with heart: woman, man, light skin tone, dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👩🏼‍❤️‍👨🏻 E13.1 couple with heart: woman, man, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👩🏼‍❤‍👨🏻 E13.1 couple with heart: woman, man, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👩🏼‍❤️‍👨🏼 E13.1 couple with heart: woman, man, medium-light skin tone +1F469 1F3FC 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👩🏼‍❤‍👨🏼 E13.1 couple with heart: woman, man, medium-light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👩🏼‍❤️‍👨🏽 E13.1 couple with heart: woman, man, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👩🏼‍❤‍👨🏽 E13.1 couple with heart: woman, man, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👩🏼‍❤️‍👨🏾 E13.1 couple with heart: woman, man, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👩🏼‍❤‍👨🏾 E13.1 couple with heart: woman, man, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👩🏼‍❤️‍👨🏿 E13.1 couple with heart: woman, man, medium-light skin tone, dark skin tone +1F469 1F3FC 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👩🏼‍❤‍👨🏿 E13.1 couple with heart: woman, man, medium-light skin tone, dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👩🏽‍❤️‍👨🏻 E13.1 couple with heart: woman, man, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👩🏽‍❤‍👨🏻 E13.1 couple with heart: woman, man, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👩🏽‍❤️‍👨🏼 E13.1 couple with heart: woman, man, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👩🏽‍❤‍👨🏼 E13.1 couple with heart: woman, man, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👩🏽‍❤️‍👨🏽 E13.1 couple with heart: woman, man, medium skin tone +1F469 1F3FD 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👩🏽‍❤‍👨🏽 E13.1 couple with heart: woman, man, medium skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👩🏽‍❤️‍👨🏾 E13.1 couple with heart: woman, man, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👩🏽‍❤‍👨🏾 E13.1 couple with heart: woman, man, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👩🏽‍❤️‍👨🏿 E13.1 couple with heart: woman, man, medium skin tone, dark skin tone +1F469 1F3FD 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👩🏽‍❤‍👨🏿 E13.1 couple with heart: woman, man, medium skin tone, dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👩🏾‍❤️‍👨🏻 E13.1 couple with heart: woman, man, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👩🏾‍❤‍👨🏻 E13.1 couple with heart: woman, man, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👩🏾‍❤️‍👨🏼 E13.1 couple with heart: woman, man, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👩🏾‍❤‍👨🏼 E13.1 couple with heart: woman, man, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👩🏾‍❤️‍👨🏽 E13.1 couple with heart: woman, man, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👩🏾‍❤‍👨🏽 E13.1 couple with heart: woman, man, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👩🏾‍❤️‍👨🏾 E13.1 couple with heart: woman, man, medium-dark skin tone +1F469 1F3FE 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👩🏾‍❤‍👨🏾 E13.1 couple with heart: woman, man, medium-dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👩🏾‍❤️‍👨🏿 E13.1 couple with heart: woman, man, medium-dark skin tone, dark skin tone +1F469 1F3FE 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👩🏾‍❤‍👨🏿 E13.1 couple with heart: woman, man, medium-dark skin tone, dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👩🏿‍❤️‍👨🏻 E13.1 couple with heart: woman, man, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👩🏿‍❤‍👨🏻 E13.1 couple with heart: woman, man, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👩🏿‍❤️‍👨🏼 E13.1 couple with heart: woman, man, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👩🏿‍❤‍👨🏼 E13.1 couple with heart: woman, man, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👩🏿‍❤️‍👨🏽 E13.1 couple with heart: woman, man, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👩🏿‍❤‍👨🏽 E13.1 couple with heart: woman, man, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👩🏿‍❤️‍👨🏾 E13.1 couple with heart: woman, man, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👩🏿‍❤‍👨🏾 E13.1 couple with heart: woman, man, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👩🏿‍❤️‍👨🏿 E13.1 couple with heart: woman, man, dark skin tone +1F469 1F3FF 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👩🏿‍❤‍👨🏿 E13.1 couple with heart: woman, man, dark skin tone +1F468 200D 2764 FE0F 200D 1F468 ; fully-qualified # 👨‍❤️‍👨 E2.0 couple with heart: man, man +1F468 200D 2764 200D 1F468 ; minimally-qualified # 👨‍❤‍👨 E2.0 couple with heart: man, man +1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👨🏻‍❤️‍👨🏻 E13.1 couple with heart: man, man, light skin tone +1F468 1F3FB 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👨🏻‍❤‍👨🏻 E13.1 couple with heart: man, man, light skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👨🏻‍❤️‍👨🏼 E13.1 couple with heart: man, man, light skin tone, medium-light skin tone +1F468 1F3FB 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👨🏻‍❤‍👨🏼 E13.1 couple with heart: man, man, light skin tone, medium-light skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👨🏻‍❤️‍👨🏽 E13.1 couple with heart: man, man, light skin tone, medium skin tone +1F468 1F3FB 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👨🏻‍❤‍👨🏽 E13.1 couple with heart: man, man, light skin tone, medium skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👨🏻‍❤️‍👨🏾 E13.1 couple with heart: man, man, light skin tone, medium-dark skin tone +1F468 1F3FB 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👨🏻‍❤‍👨🏾 E13.1 couple with heart: man, man, light skin tone, medium-dark skin tone +1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👨🏻‍❤️‍👨🏿 E13.1 couple with heart: man, man, light skin tone, dark skin tone +1F468 1F3FB 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👨🏻‍❤‍👨🏿 E13.1 couple with heart: man, man, light skin tone, dark skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👨🏼‍❤️‍👨🏻 E13.1 couple with heart: man, man, medium-light skin tone, light skin tone +1F468 1F3FC 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👨🏼‍❤‍👨🏻 E13.1 couple with heart: man, man, medium-light skin tone, light skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👨🏼‍❤️‍👨🏼 E13.1 couple with heart: man, man, medium-light skin tone +1F468 1F3FC 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👨🏼‍❤‍👨🏼 E13.1 couple with heart: man, man, medium-light skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👨🏼‍❤️‍👨🏽 E13.1 couple with heart: man, man, medium-light skin tone, medium skin tone +1F468 1F3FC 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👨🏼‍❤‍👨🏽 E13.1 couple with heart: man, man, medium-light skin tone, medium skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👨🏼‍❤️‍👨🏾 E13.1 couple with heart: man, man, medium-light skin tone, medium-dark skin tone +1F468 1F3FC 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👨🏼‍❤‍👨🏾 E13.1 couple with heart: man, man, medium-light skin tone, medium-dark skin tone +1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👨🏼‍❤️‍👨🏿 E13.1 couple with heart: man, man, medium-light skin tone, dark skin tone +1F468 1F3FC 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👨🏼‍❤‍👨🏿 E13.1 couple with heart: man, man, medium-light skin tone, dark skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👨🏽‍❤️‍👨🏻 E13.1 couple with heart: man, man, medium skin tone, light skin tone +1F468 1F3FD 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👨🏽‍❤‍👨🏻 E13.1 couple with heart: man, man, medium skin tone, light skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👨🏽‍❤️‍👨🏼 E13.1 couple with heart: man, man, medium skin tone, medium-light skin tone +1F468 1F3FD 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👨🏽‍❤‍👨🏼 E13.1 couple with heart: man, man, medium skin tone, medium-light skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👨🏽‍❤️‍👨🏽 E13.1 couple with heart: man, man, medium skin tone +1F468 1F3FD 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👨🏽‍❤‍👨🏽 E13.1 couple with heart: man, man, medium skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👨🏽‍❤️‍👨🏾 E13.1 couple with heart: man, man, medium skin tone, medium-dark skin tone +1F468 1F3FD 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👨🏽‍❤‍👨🏾 E13.1 couple with heart: man, man, medium skin tone, medium-dark skin tone +1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👨🏽‍❤️‍👨🏿 E13.1 couple with heart: man, man, medium skin tone, dark skin tone +1F468 1F3FD 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👨🏽‍❤‍👨🏿 E13.1 couple with heart: man, man, medium skin tone, dark skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👨🏾‍❤️‍👨🏻 E13.1 couple with heart: man, man, medium-dark skin tone, light skin tone +1F468 1F3FE 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👨🏾‍❤‍👨🏻 E13.1 couple with heart: man, man, medium-dark skin tone, light skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👨🏾‍❤️‍👨🏼 E13.1 couple with heart: man, man, medium-dark skin tone, medium-light skin tone +1F468 1F3FE 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👨🏾‍❤‍👨🏼 E13.1 couple with heart: man, man, medium-dark skin tone, medium-light skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👨🏾‍❤️‍👨🏽 E13.1 couple with heart: man, man, medium-dark skin tone, medium skin tone +1F468 1F3FE 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👨🏾‍❤‍👨🏽 E13.1 couple with heart: man, man, medium-dark skin tone, medium skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👨🏾‍❤️‍👨🏾 E13.1 couple with heart: man, man, medium-dark skin tone +1F468 1F3FE 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👨🏾‍❤‍👨🏾 E13.1 couple with heart: man, man, medium-dark skin tone +1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👨🏾‍❤️‍👨🏿 E13.1 couple with heart: man, man, medium-dark skin tone, dark skin tone +1F468 1F3FE 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👨🏾‍❤‍👨🏿 E13.1 couple with heart: man, man, medium-dark skin tone, dark skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FB ; fully-qualified # 👨🏿‍❤️‍👨🏻 E13.1 couple with heart: man, man, dark skin tone, light skin tone +1F468 1F3FF 200D 2764 200D 1F468 1F3FB ; minimally-qualified # 👨🏿‍❤‍👨🏻 E13.1 couple with heart: man, man, dark skin tone, light skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FC ; fully-qualified # 👨🏿‍❤️‍👨🏼 E13.1 couple with heart: man, man, dark skin tone, medium-light skin tone +1F468 1F3FF 200D 2764 200D 1F468 1F3FC ; minimally-qualified # 👨🏿‍❤‍👨🏼 E13.1 couple with heart: man, man, dark skin tone, medium-light skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FD ; fully-qualified # 👨🏿‍❤️‍👨🏽 E13.1 couple with heart: man, man, dark skin tone, medium skin tone +1F468 1F3FF 200D 2764 200D 1F468 1F3FD ; minimally-qualified # 👨🏿‍❤‍👨🏽 E13.1 couple with heart: man, man, dark skin tone, medium skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FE ; fully-qualified # 👨🏿‍❤️‍👨🏾 E13.1 couple with heart: man, man, dark skin tone, medium-dark skin tone +1F468 1F3FF 200D 2764 200D 1F468 1F3FE ; minimally-qualified # 👨🏿‍❤‍👨🏾 E13.1 couple with heart: man, man, dark skin tone, medium-dark skin tone +1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FF ; fully-qualified # 👨🏿‍❤️‍👨🏿 E13.1 couple with heart: man, man, dark skin tone +1F468 1F3FF 200D 2764 200D 1F468 1F3FF ; minimally-qualified # 👨🏿‍❤‍👨🏿 E13.1 couple with heart: man, man, dark skin tone +1F469 200D 2764 FE0F 200D 1F469 ; fully-qualified # 👩‍❤️‍👩 E2.0 couple with heart: woman, woman +1F469 200D 2764 200D 1F469 ; minimally-qualified # 👩‍❤‍👩 E2.0 couple with heart: woman, woman +1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FB ; fully-qualified # 👩🏻‍❤️‍👩🏻 E13.1 couple with heart: woman, woman, light skin tone +1F469 1F3FB 200D 2764 200D 1F469 1F3FB ; minimally-qualified # 👩🏻‍❤‍👩🏻 E13.1 couple with heart: woman, woman, light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FC ; fully-qualified # 👩🏻‍❤️‍👩🏼 E13.1 couple with heart: woman, woman, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 200D 1F469 1F3FC ; minimally-qualified # 👩🏻‍❤‍👩🏼 E13.1 couple with heart: woman, woman, light skin tone, medium-light skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FD ; fully-qualified # 👩🏻‍❤️‍👩🏽 E13.1 couple with heart: woman, woman, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 200D 1F469 1F3FD ; minimally-qualified # 👩🏻‍❤‍👩🏽 E13.1 couple with heart: woman, woman, light skin tone, medium skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FE ; fully-qualified # 👩🏻‍❤️‍👩🏾 E13.1 couple with heart: woman, woman, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 200D 1F469 1F3FE ; minimally-qualified # 👩🏻‍❤‍👩🏾 E13.1 couple with heart: woman, woman, light skin tone, medium-dark skin tone +1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FF ; fully-qualified # 👩🏻‍❤️‍👩🏿 E13.1 couple with heart: woman, woman, light skin tone, dark skin tone +1F469 1F3FB 200D 2764 200D 1F469 1F3FF ; minimally-qualified # 👩🏻‍❤‍👩🏿 E13.1 couple with heart: woman, woman, light skin tone, dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FB ; fully-qualified # 👩🏼‍❤️‍👩🏻 E13.1 couple with heart: woman, woman, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 200D 1F469 1F3FB ; minimally-qualified # 👩🏼‍❤‍👩🏻 E13.1 couple with heart: woman, woman, medium-light skin tone, light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FC ; fully-qualified # 👩🏼‍❤️‍👩🏼 E13.1 couple with heart: woman, woman, medium-light skin tone +1F469 1F3FC 200D 2764 200D 1F469 1F3FC ; minimally-qualified # 👩🏼‍❤‍👩🏼 E13.1 couple with heart: woman, woman, medium-light skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FD ; fully-qualified # 👩🏼‍❤️‍👩🏽 E13.1 couple with heart: woman, woman, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 200D 1F469 1F3FD ; minimally-qualified # 👩🏼‍❤‍👩🏽 E13.1 couple with heart: woman, woman, medium-light skin tone, medium skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FE ; fully-qualified # 👩🏼‍❤️‍👩🏾 E13.1 couple with heart: woman, woman, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 200D 1F469 1F3FE ; minimally-qualified # 👩🏼‍❤‍👩🏾 E13.1 couple with heart: woman, woman, medium-light skin tone, medium-dark skin tone +1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FF ; fully-qualified # 👩🏼‍❤️‍👩🏿 E13.1 couple with heart: woman, woman, medium-light skin tone, dark skin tone +1F469 1F3FC 200D 2764 200D 1F469 1F3FF ; minimally-qualified # 👩🏼‍❤‍👩🏿 E13.1 couple with heart: woman, woman, medium-light skin tone, dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FB ; fully-qualified # 👩🏽‍❤️‍👩🏻 E13.1 couple with heart: woman, woman, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 200D 1F469 1F3FB ; minimally-qualified # 👩🏽‍❤‍👩🏻 E13.1 couple with heart: woman, woman, medium skin tone, light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FC ; fully-qualified # 👩🏽‍❤️‍👩🏼 E13.1 couple with heart: woman, woman, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 200D 1F469 1F3FC ; minimally-qualified # 👩🏽‍❤‍👩🏼 E13.1 couple with heart: woman, woman, medium skin tone, medium-light skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FD ; fully-qualified # 👩🏽‍❤️‍👩🏽 E13.1 couple with heart: woman, woman, medium skin tone +1F469 1F3FD 200D 2764 200D 1F469 1F3FD ; minimally-qualified # 👩🏽‍❤‍👩🏽 E13.1 couple with heart: woman, woman, medium skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FE ; fully-qualified # 👩🏽‍❤️‍👩🏾 E13.1 couple with heart: woman, woman, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 200D 1F469 1F3FE ; minimally-qualified # 👩🏽‍❤‍👩🏾 E13.1 couple with heart: woman, woman, medium skin tone, medium-dark skin tone +1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FF ; fully-qualified # 👩🏽‍❤️‍👩🏿 E13.1 couple with heart: woman, woman, medium skin tone, dark skin tone +1F469 1F3FD 200D 2764 200D 1F469 1F3FF ; minimally-qualified # 👩🏽‍❤‍👩🏿 E13.1 couple with heart: woman, woman, medium skin tone, dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FB ; fully-qualified # 👩🏾‍❤️‍👩🏻 E13.1 couple with heart: woman, woman, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 200D 1F469 1F3FB ; minimally-qualified # 👩🏾‍❤‍👩🏻 E13.1 couple with heart: woman, woman, medium-dark skin tone, light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FC ; fully-qualified # 👩🏾‍❤️‍👩🏼 E13.1 couple with heart: woman, woman, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 200D 1F469 1F3FC ; minimally-qualified # 👩🏾‍❤‍👩🏼 E13.1 couple with heart: woman, woman, medium-dark skin tone, medium-light skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FD ; fully-qualified # 👩🏾‍❤️‍👩🏽 E13.1 couple with heart: woman, woman, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 200D 1F469 1F3FD ; minimally-qualified # 👩🏾‍❤‍👩🏽 E13.1 couple with heart: woman, woman, medium-dark skin tone, medium skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FE ; fully-qualified # 👩🏾‍❤️‍👩🏾 E13.1 couple with heart: woman, woman, medium-dark skin tone +1F469 1F3FE 200D 2764 200D 1F469 1F3FE ; minimally-qualified # 👩🏾‍❤‍👩🏾 E13.1 couple with heart: woman, woman, medium-dark skin tone +1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FF ; fully-qualified # 👩🏾‍❤️‍👩🏿 E13.1 couple with heart: woman, woman, medium-dark skin tone, dark skin tone +1F469 1F3FE 200D 2764 200D 1F469 1F3FF ; minimally-qualified # 👩🏾‍❤‍👩🏿 E13.1 couple with heart: woman, woman, medium-dark skin tone, dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FB ; fully-qualified # 👩🏿‍❤️‍👩🏻 E13.1 couple with heart: woman, woman, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 200D 1F469 1F3FB ; minimally-qualified # 👩🏿‍❤‍👩🏻 E13.1 couple with heart: woman, woman, dark skin tone, light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FC ; fully-qualified # 👩🏿‍❤️‍👩🏼 E13.1 couple with heart: woman, woman, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 200D 1F469 1F3FC ; minimally-qualified # 👩🏿‍❤‍👩🏼 E13.1 couple with heart: woman, woman, dark skin tone, medium-light skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FD ; fully-qualified # 👩🏿‍❤️‍👩🏽 E13.1 couple with heart: woman, woman, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 200D 1F469 1F3FD ; minimally-qualified # 👩🏿‍❤‍👩🏽 E13.1 couple with heart: woman, woman, dark skin tone, medium skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FE ; fully-qualified # 👩🏿‍❤️‍👩🏾 E13.1 couple with heart: woman, woman, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 200D 1F469 1F3FE ; minimally-qualified # 👩🏿‍❤‍👩🏾 E13.1 couple with heart: woman, woman, dark skin tone, medium-dark skin tone +1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FF ; fully-qualified # 👩🏿‍❤️‍👩🏿 E13.1 couple with heart: woman, woman, dark skin tone +1F469 1F3FF 200D 2764 200D 1F469 1F3FF ; minimally-qualified # 👩🏿‍❤‍👩🏿 E13.1 couple with heart: woman, woman, dark skin tone +1F46A ; fully-qualified # 👪 E0.6 family +1F468 200D 1F469 200D 1F466 ; fully-qualified # 👨‍👩‍👦 E2.0 family: man, woman, boy +1F468 200D 1F469 200D 1F467 ; fully-qualified # 👨‍👩‍👧 E2.0 family: man, woman, girl +1F468 200D 1F469 200D 1F467 200D 1F466 ; fully-qualified # 👨‍👩‍👧‍👦 E2.0 family: man, woman, girl, boy +1F468 200D 1F469 200D 1F466 200D 1F466 ; fully-qualified # 👨‍👩‍👦‍👦 E2.0 family: man, woman, boy, boy +1F468 200D 1F469 200D 1F467 200D 1F467 ; fully-qualified # 👨‍👩‍👧‍👧 E2.0 family: man, woman, girl, girl +1F468 200D 1F468 200D 1F466 ; fully-qualified # 👨‍👨‍👦 E2.0 family: man, man, boy +1F468 200D 1F468 200D 1F467 ; fully-qualified # 👨‍👨‍👧 E2.0 family: man, man, girl +1F468 200D 1F468 200D 1F467 200D 1F466 ; fully-qualified # 👨‍👨‍👧‍👦 E2.0 family: man, man, girl, boy +1F468 200D 1F468 200D 1F466 200D 1F466 ; fully-qualified # 👨‍👨‍👦‍👦 E2.0 family: man, man, boy, boy +1F468 200D 1F468 200D 1F467 200D 1F467 ; fully-qualified # 👨‍👨‍👧‍👧 E2.0 family: man, man, girl, girl +1F469 200D 1F469 200D 1F466 ; fully-qualified # 👩‍👩‍👦 E2.0 family: woman, woman, boy +1F469 200D 1F469 200D 1F467 ; fully-qualified # 👩‍👩‍👧 E2.0 family: woman, woman, girl +1F469 200D 1F469 200D 1F467 200D 1F466 ; fully-qualified # 👩‍👩‍👧‍👦 E2.0 family: woman, woman, girl, boy +1F469 200D 1F469 200D 1F466 200D 1F466 ; fully-qualified # 👩‍👩‍👦‍👦 E2.0 family: woman, woman, boy, boy +1F469 200D 1F469 200D 1F467 200D 1F467 ; fully-qualified # 👩‍👩‍👧‍👧 E2.0 family: woman, woman, girl, girl +1F468 200D 1F466 ; fully-qualified # 👨‍👦 E4.0 family: man, boy +1F468 200D 1F466 200D 1F466 ; fully-qualified # 👨‍👦‍👦 E4.0 family: man, boy, boy +1F468 200D 1F467 ; fully-qualified # 👨‍👧 E4.0 family: man, girl +1F468 200D 1F467 200D 1F466 ; fully-qualified # 👨‍👧‍👦 E4.0 family: man, girl, boy +1F468 200D 1F467 200D 1F467 ; fully-qualified # 👨‍👧‍👧 E4.0 family: man, girl, girl +1F469 200D 1F466 ; fully-qualified # 👩‍👦 E4.0 family: woman, boy +1F469 200D 1F466 200D 1F466 ; fully-qualified # 👩‍👦‍👦 E4.0 family: woman, boy, boy +1F469 200D 1F467 ; fully-qualified # 👩‍👧 E4.0 family: woman, girl +1F469 200D 1F467 200D 1F466 ; fully-qualified # 👩‍👧‍👦 E4.0 family: woman, girl, boy +1F469 200D 1F467 200D 1F467 ; fully-qualified # 👩‍👧‍👧 E4.0 family: woman, girl, girl + +# subgroup: person-symbol +1F5E3 FE0F ; fully-qualified # 🗣️ E0.7 speaking head +1F5E3 ; unqualified # 🗣 E0.7 speaking head +1F464 ; fully-qualified # 👤 E0.6 bust in silhouette +1F465 ; fully-qualified # 👥 E1.0 busts in silhouette +1FAC2 ; fully-qualified # 🫂 E13.0 people hugging +1F463 ; fully-qualified # 👣 E0.6 footprints + +# People & Body subtotal: 2998 +# People & Body subtotal: 508 w/o modifiers + +# group: Component + +# subgroup: skin-tone +1F3FB ; component # 🏻 E1.0 light skin tone +1F3FC ; component # 🏼 E1.0 medium-light skin tone +1F3FD ; component # 🏽 E1.0 medium skin tone +1F3FE ; component # 🏾 E1.0 medium-dark skin tone +1F3FF ; component # 🏿 E1.0 dark skin tone + +# subgroup: hair-style +1F9B0 ; component # 🦰 E11.0 red hair +1F9B1 ; component # 🦱 E11.0 curly hair +1F9B3 ; component # 🦳 E11.0 white hair +1F9B2 ; component # 🦲 E11.0 bald + +# Component subtotal: 9 +# Component subtotal: 4 w/o modifiers + +# group: Animals & Nature + +# subgroup: animal-mammal +1F435 ; fully-qualified # 🐵 E0.6 monkey face +1F412 ; fully-qualified # 🐒 E0.6 monkey +1F98D ; fully-qualified # 🦍 E3.0 gorilla +1F9A7 ; fully-qualified # 🦧 E12.0 orangutan +1F436 ; fully-qualified # 🐶 E0.6 dog face +1F415 ; fully-qualified # 🐕 E0.7 dog +1F9AE ; fully-qualified # 🦮 E12.0 guide dog +1F415 200D 1F9BA ; fully-qualified # 🐕‍🦺 E12.0 service dog +1F429 ; fully-qualified # 🐩 E0.6 poodle +1F43A ; fully-qualified # 🐺 E0.6 wolf +1F98A ; fully-qualified # 🦊 E3.0 fox +1F99D ; fully-qualified # 🦝 E11.0 raccoon +1F431 ; fully-qualified # 🐱 E0.6 cat face +1F408 ; fully-qualified # 🐈 E0.7 cat +1F408 200D 2B1B ; fully-qualified # 🐈‍⬛ E13.0 black cat +1F981 ; fully-qualified # 🦁 E1.0 lion +1F42F ; fully-qualified # 🐯 E0.6 tiger face +1F405 ; fully-qualified # 🐅 E1.0 tiger +1F406 ; fully-qualified # 🐆 E1.0 leopard +1F434 ; fully-qualified # 🐴 E0.6 horse face +1FACE ; fully-qualified # 🫎 E15.0 moose +1FACF ; fully-qualified # 🫏 E15.0 donkey +1F40E ; fully-qualified # 🐎 E0.6 horse +1F984 ; fully-qualified # 🦄 E1.0 unicorn +1F993 ; fully-qualified # 🦓 E5.0 zebra +1F98C ; fully-qualified # 🦌 E3.0 deer +1F9AC ; fully-qualified # 🦬 E13.0 bison +1F42E ; fully-qualified # 🐮 E0.6 cow face +1F402 ; fully-qualified # 🐂 E1.0 ox +1F403 ; fully-qualified # 🐃 E1.0 water buffalo +1F404 ; fully-qualified # 🐄 E1.0 cow +1F437 ; fully-qualified # 🐷 E0.6 pig face +1F416 ; fully-qualified # 🐖 E1.0 pig +1F417 ; fully-qualified # 🐗 E0.6 boar +1F43D ; fully-qualified # 🐽 E0.6 pig nose +1F40F ; fully-qualified # 🐏 E1.0 ram +1F411 ; fully-qualified # 🐑 E0.6 ewe +1F410 ; fully-qualified # 🐐 E1.0 goat +1F42A ; fully-qualified # 🐪 E1.0 camel +1F42B ; fully-qualified # 🐫 E0.6 two-hump camel +1F999 ; fully-qualified # 🦙 E11.0 llama +1F992 ; fully-qualified # 🦒 E5.0 giraffe +1F418 ; fully-qualified # 🐘 E0.6 elephant +1F9A3 ; fully-qualified # 🦣 E13.0 mammoth +1F98F ; fully-qualified # 🦏 E3.0 rhinoceros +1F99B ; fully-qualified # 🦛 E11.0 hippopotamus +1F42D ; fully-qualified # 🐭 E0.6 mouse face +1F401 ; fully-qualified # 🐁 E1.0 mouse +1F400 ; fully-qualified # 🐀 E1.0 rat +1F439 ; fully-qualified # 🐹 E0.6 hamster +1F430 ; fully-qualified # 🐰 E0.6 rabbit face +1F407 ; fully-qualified # 🐇 E1.0 rabbit +1F43F FE0F ; fully-qualified # 🐿️ E0.7 chipmunk +1F43F ; unqualified # 🐿 E0.7 chipmunk +1F9AB ; fully-qualified # 🦫 E13.0 beaver +1F994 ; fully-qualified # 🦔 E5.0 hedgehog +1F987 ; fully-qualified # 🦇 E3.0 bat +1F43B ; fully-qualified # 🐻 E0.6 bear +1F43B 200D 2744 FE0F ; fully-qualified # 🐻‍❄️ E13.0 polar bear +1F43B 200D 2744 ; minimally-qualified # 🐻‍❄ E13.0 polar bear +1F428 ; fully-qualified # 🐨 E0.6 koala +1F43C ; fully-qualified # 🐼 E0.6 panda +1F9A5 ; fully-qualified # 🦥 E12.0 sloth +1F9A6 ; fully-qualified # 🦦 E12.0 otter +1F9A8 ; fully-qualified # 🦨 E12.0 skunk +1F998 ; fully-qualified # 🦘 E11.0 kangaroo +1F9A1 ; fully-qualified # 🦡 E11.0 badger +1F43E ; fully-qualified # 🐾 E0.6 paw prints + +# subgroup: animal-bird +1F983 ; fully-qualified # 🦃 E1.0 turkey +1F414 ; fully-qualified # 🐔 E0.6 chicken +1F413 ; fully-qualified # 🐓 E1.0 rooster +1F423 ; fully-qualified # 🐣 E0.6 hatching chick +1F424 ; fully-qualified # 🐤 E0.6 baby chick +1F425 ; fully-qualified # 🐥 E0.6 front-facing baby chick +1F426 ; fully-qualified # 🐦 E0.6 bird +1F427 ; fully-qualified # 🐧 E0.6 penguin +1F54A FE0F ; fully-qualified # 🕊️ E0.7 dove +1F54A ; unqualified # 🕊 E0.7 dove +1F985 ; fully-qualified # 🦅 E3.0 eagle +1F986 ; fully-qualified # 🦆 E3.0 duck +1F9A2 ; fully-qualified # 🦢 E11.0 swan +1F989 ; fully-qualified # 🦉 E3.0 owl +1F9A4 ; fully-qualified # 🦤 E13.0 dodo +1FAB6 ; fully-qualified # 🪶 E13.0 feather +1F9A9 ; fully-qualified # 🦩 E12.0 flamingo +1F99A ; fully-qualified # 🦚 E11.0 peacock +1F99C ; fully-qualified # 🦜 E11.0 parrot +1FABD ; fully-qualified # 🪽 E15.0 wing +1F426 200D 2B1B ; fully-qualified # 🐦‍⬛ E15.0 black bird +1FABF ; fully-qualified # 🪿 E15.0 goose + +# subgroup: animal-amphibian +1F438 ; fully-qualified # 🐸 E0.6 frog + +# subgroup: animal-reptile +1F40A ; fully-qualified # 🐊 E1.0 crocodile +1F422 ; fully-qualified # 🐢 E0.6 turtle +1F98E ; fully-qualified # 🦎 E3.0 lizard +1F40D ; fully-qualified # 🐍 E0.6 snake +1F432 ; fully-qualified # 🐲 E0.6 dragon face +1F409 ; fully-qualified # 🐉 E1.0 dragon +1F995 ; fully-qualified # 🦕 E5.0 sauropod +1F996 ; fully-qualified # 🦖 E5.0 T-Rex + +# subgroup: animal-marine +1F433 ; fully-qualified # 🐳 E0.6 spouting whale +1F40B ; fully-qualified # 🐋 E1.0 whale +1F42C ; fully-qualified # 🐬 E0.6 dolphin +1F9AD ; fully-qualified # 🦭 E13.0 seal +1F41F ; fully-qualified # 🐟 E0.6 fish +1F420 ; fully-qualified # 🐠 E0.6 tropical fish +1F421 ; fully-qualified # 🐡 E0.6 blowfish +1F988 ; fully-qualified # 🦈 E3.0 shark +1F419 ; fully-qualified # 🐙 E0.6 octopus +1F41A ; fully-qualified # 🐚 E0.6 spiral shell +1FAB8 ; fully-qualified # 🪸 E14.0 coral +1FABC ; fully-qualified # 🪼 E15.0 jellyfish + +# subgroup: animal-bug +1F40C ; fully-qualified # 🐌 E0.6 snail +1F98B ; fully-qualified # 🦋 E3.0 butterfly +1F41B ; fully-qualified # 🐛 E0.6 bug +1F41C ; fully-qualified # 🐜 E0.6 ant +1F41D ; fully-qualified # 🐝 E0.6 honeybee +1FAB2 ; fully-qualified # 🪲 E13.0 beetle +1F41E ; fully-qualified # 🐞 E0.6 lady beetle +1F997 ; fully-qualified # 🦗 E5.0 cricket +1FAB3 ; fully-qualified # 🪳 E13.0 cockroach +1F577 FE0F ; fully-qualified # 🕷️ E0.7 spider +1F577 ; unqualified # 🕷 E0.7 spider +1F578 FE0F ; fully-qualified # 🕸️ E0.7 spider web +1F578 ; unqualified # 🕸 E0.7 spider web +1F982 ; fully-qualified # 🦂 E1.0 scorpion +1F99F ; fully-qualified # 🦟 E11.0 mosquito +1FAB0 ; fully-qualified # 🪰 E13.0 fly +1FAB1 ; fully-qualified # 🪱 E13.0 worm +1F9A0 ; fully-qualified # 🦠 E11.0 microbe + +# subgroup: plant-flower +1F490 ; fully-qualified # 💐 E0.6 bouquet +1F338 ; fully-qualified # 🌸 E0.6 cherry blossom +1F4AE ; fully-qualified # 💮 E0.6 white flower +1FAB7 ; fully-qualified # 🪷 E14.0 lotus +1F3F5 FE0F ; fully-qualified # 🏵️ E0.7 rosette +1F3F5 ; unqualified # 🏵 E0.7 rosette +1F339 ; fully-qualified # 🌹 E0.6 rose +1F940 ; fully-qualified # 🥀 E3.0 wilted flower +1F33A ; fully-qualified # 🌺 E0.6 hibiscus +1F33B ; fully-qualified # 🌻 E0.6 sunflower +1F33C ; fully-qualified # 🌼 E0.6 blossom +1F337 ; fully-qualified # 🌷 E0.6 tulip +1FABB ; fully-qualified # 🪻 E15.0 hyacinth + +# subgroup: plant-other +1F331 ; fully-qualified # 🌱 E0.6 seedling +1FAB4 ; fully-qualified # 🪴 E13.0 potted plant +1F332 ; fully-qualified # 🌲 E1.0 evergreen tree +1F333 ; fully-qualified # 🌳 E1.0 deciduous tree +1F334 ; fully-qualified # 🌴 E0.6 palm tree +1F335 ; fully-qualified # 🌵 E0.6 cactus +1F33E ; fully-qualified # 🌾 E0.6 sheaf of rice +1F33F ; fully-qualified # 🌿 E0.6 herb +2618 FE0F ; fully-qualified # ☘️ E1.0 shamrock +2618 ; unqualified # ☘ E1.0 shamrock +1F340 ; fully-qualified # 🍀 E0.6 four leaf clover +1F341 ; fully-qualified # 🍁 E0.6 maple leaf +1F342 ; fully-qualified # 🍂 E0.6 fallen leaf +1F343 ; fully-qualified # 🍃 E0.6 leaf fluttering in wind +1FAB9 ; fully-qualified # 🪹 E14.0 empty nest +1FABA ; fully-qualified # 🪺 E14.0 nest with eggs +1F344 ; fully-qualified # 🍄 E0.6 mushroom + +# Animals & Nature subtotal: 159 +# Animals & Nature subtotal: 159 w/o modifiers + +# group: Food & Drink + +# subgroup: food-fruit +1F347 ; fully-qualified # 🍇 E0.6 grapes +1F348 ; fully-qualified # 🍈 E0.6 melon +1F349 ; fully-qualified # 🍉 E0.6 watermelon +1F34A ; fully-qualified # 🍊 E0.6 tangerine +1F34B ; fully-qualified # 🍋 E1.0 lemon +1F34C ; fully-qualified # 🍌 E0.6 banana +1F34D ; fully-qualified # 🍍 E0.6 pineapple +1F96D ; fully-qualified # 🥭 E11.0 mango +1F34E ; fully-qualified # 🍎 E0.6 red apple +1F34F ; fully-qualified # 🍏 E0.6 green apple +1F350 ; fully-qualified # 🍐 E1.0 pear +1F351 ; fully-qualified # 🍑 E0.6 peach +1F352 ; fully-qualified # 🍒 E0.6 cherries +1F353 ; fully-qualified # 🍓 E0.6 strawberry +1FAD0 ; fully-qualified # 🫐 E13.0 blueberries +1F95D ; fully-qualified # 🥝 E3.0 kiwi fruit +1F345 ; fully-qualified # 🍅 E0.6 tomato +1FAD2 ; fully-qualified # 🫒 E13.0 olive +1F965 ; fully-qualified # 🥥 E5.0 coconut + +# subgroup: food-vegetable +1F951 ; fully-qualified # 🥑 E3.0 avocado +1F346 ; fully-qualified # 🍆 E0.6 eggplant +1F954 ; fully-qualified # 🥔 E3.0 potato +1F955 ; fully-qualified # 🥕 E3.0 carrot +1F33D ; fully-qualified # 🌽 E0.6 ear of corn +1F336 FE0F ; fully-qualified # 🌶️ E0.7 hot pepper +1F336 ; unqualified # 🌶 E0.7 hot pepper +1FAD1 ; fully-qualified # 🫑 E13.0 bell pepper +1F952 ; fully-qualified # 🥒 E3.0 cucumber +1F96C ; fully-qualified # 🥬 E11.0 leafy green +1F966 ; fully-qualified # 🥦 E5.0 broccoli +1F9C4 ; fully-qualified # 🧄 E12.0 garlic +1F9C5 ; fully-qualified # 🧅 E12.0 onion +1F95C ; fully-qualified # 🥜 E3.0 peanuts +1FAD8 ; fully-qualified # 🫘 E14.0 beans +1F330 ; fully-qualified # 🌰 E0.6 chestnut +1FADA ; fully-qualified # 🫚 E15.0 ginger root +1FADB ; fully-qualified # 🫛 E15.0 pea pod + +# subgroup: food-prepared +1F35E ; fully-qualified # 🍞 E0.6 bread +1F950 ; fully-qualified # 🥐 E3.0 croissant +1F956 ; fully-qualified # 🥖 E3.0 baguette bread +1FAD3 ; fully-qualified # 🫓 E13.0 flatbread +1F968 ; fully-qualified # 🥨 E5.0 pretzel +1F96F ; fully-qualified # 🥯 E11.0 bagel +1F95E ; fully-qualified # 🥞 E3.0 pancakes +1F9C7 ; fully-qualified # 🧇 E12.0 waffle +1F9C0 ; fully-qualified # 🧀 E1.0 cheese wedge +1F356 ; fully-qualified # 🍖 E0.6 meat on bone +1F357 ; fully-qualified # 🍗 E0.6 poultry leg +1F969 ; fully-qualified # 🥩 E5.0 cut of meat +1F953 ; fully-qualified # 🥓 E3.0 bacon +1F354 ; fully-qualified # 🍔 E0.6 hamburger +1F35F ; fully-qualified # 🍟 E0.6 french fries +1F355 ; fully-qualified # 🍕 E0.6 pizza +1F32D ; fully-qualified # 🌭 E1.0 hot dog +1F96A ; fully-qualified # 🥪 E5.0 sandwich +1F32E ; fully-qualified # 🌮 E1.0 taco +1F32F ; fully-qualified # 🌯 E1.0 burrito +1FAD4 ; fully-qualified # 🫔 E13.0 tamale +1F959 ; fully-qualified # 🥙 E3.0 stuffed flatbread +1F9C6 ; fully-qualified # 🧆 E12.0 falafel +1F95A ; fully-qualified # 🥚 E3.0 egg +1F373 ; fully-qualified # 🍳 E0.6 cooking +1F958 ; fully-qualified # 🥘 E3.0 shallow pan of food +1F372 ; fully-qualified # 🍲 E0.6 pot of food +1FAD5 ; fully-qualified # 🫕 E13.0 fondue +1F963 ; fully-qualified # 🥣 E5.0 bowl with spoon +1F957 ; fully-qualified # 🥗 E3.0 green salad +1F37F ; fully-qualified # 🍿 E1.0 popcorn +1F9C8 ; fully-qualified # 🧈 E12.0 butter +1F9C2 ; fully-qualified # 🧂 E11.0 salt +1F96B ; fully-qualified # 🥫 E5.0 canned food + +# subgroup: food-asian +1F371 ; fully-qualified # 🍱 E0.6 bento box +1F358 ; fully-qualified # 🍘 E0.6 rice cracker +1F359 ; fully-qualified # 🍙 E0.6 rice ball +1F35A ; fully-qualified # 🍚 E0.6 cooked rice +1F35B ; fully-qualified # 🍛 E0.6 curry rice +1F35C ; fully-qualified # 🍜 E0.6 steaming bowl +1F35D ; fully-qualified # 🍝 E0.6 spaghetti +1F360 ; fully-qualified # 🍠 E0.6 roasted sweet potato +1F362 ; fully-qualified # 🍢 E0.6 oden +1F363 ; fully-qualified # 🍣 E0.6 sushi +1F364 ; fully-qualified # 🍤 E0.6 fried shrimp +1F365 ; fully-qualified # 🍥 E0.6 fish cake with swirl +1F96E ; fully-qualified # 🥮 E11.0 moon cake +1F361 ; fully-qualified # 🍡 E0.6 dango +1F95F ; fully-qualified # 🥟 E5.0 dumpling +1F960 ; fully-qualified # 🥠 E5.0 fortune cookie +1F961 ; fully-qualified # 🥡 E5.0 takeout box + +# subgroup: food-marine +1F980 ; fully-qualified # 🦀 E1.0 crab +1F99E ; fully-qualified # 🦞 E11.0 lobster +1F990 ; fully-qualified # 🦐 E3.0 shrimp +1F991 ; fully-qualified # 🦑 E3.0 squid +1F9AA ; fully-qualified # 🦪 E12.0 oyster + +# subgroup: food-sweet +1F366 ; fully-qualified # 🍦 E0.6 soft ice cream +1F367 ; fully-qualified # 🍧 E0.6 shaved ice +1F368 ; fully-qualified # 🍨 E0.6 ice cream +1F369 ; fully-qualified # 🍩 E0.6 doughnut +1F36A ; fully-qualified # 🍪 E0.6 cookie +1F382 ; fully-qualified # 🎂 E0.6 birthday cake +1F370 ; fully-qualified # 🍰 E0.6 shortcake +1F9C1 ; fully-qualified # 🧁 E11.0 cupcake +1F967 ; fully-qualified # 🥧 E5.0 pie +1F36B ; fully-qualified # 🍫 E0.6 chocolate bar +1F36C ; fully-qualified # 🍬 E0.6 candy +1F36D ; fully-qualified # 🍭 E0.6 lollipop +1F36E ; fully-qualified # 🍮 E0.6 custard +1F36F ; fully-qualified # 🍯 E0.6 honey pot + +# subgroup: drink +1F37C ; fully-qualified # 🍼 E1.0 baby bottle +1F95B ; fully-qualified # 🥛 E3.0 glass of milk +2615 ; fully-qualified # ☕ E0.6 hot beverage +1FAD6 ; fully-qualified # 🫖 E13.0 teapot +1F375 ; fully-qualified # 🍵 E0.6 teacup without handle +1F376 ; fully-qualified # 🍶 E0.6 sake +1F37E ; fully-qualified # 🍾 E1.0 bottle with popping cork +1F377 ; fully-qualified # 🍷 E0.6 wine glass +1F378 ; fully-qualified # 🍸 E0.6 cocktail glass +1F379 ; fully-qualified # 🍹 E0.6 tropical drink +1F37A ; fully-qualified # 🍺 E0.6 beer mug +1F37B ; fully-qualified # 🍻 E0.6 clinking beer mugs +1F942 ; fully-qualified # 🥂 E3.0 clinking glasses +1F943 ; fully-qualified # 🥃 E3.0 tumbler glass +1FAD7 ; fully-qualified # 🫗 E14.0 pouring liquid +1F964 ; fully-qualified # 🥤 E5.0 cup with straw +1F9CB ; fully-qualified # 🧋 E13.0 bubble tea +1F9C3 ; fully-qualified # 🧃 E12.0 beverage box +1F9C9 ; fully-qualified # 🧉 E12.0 mate +1F9CA ; fully-qualified # 🧊 E12.0 ice + +# subgroup: dishware +1F962 ; fully-qualified # 🥢 E5.0 chopsticks +1F37D FE0F ; fully-qualified # 🍽️ E0.7 fork and knife with plate +1F37D ; unqualified # 🍽 E0.7 fork and knife with plate +1F374 ; fully-qualified # 🍴 E0.6 fork and knife +1F944 ; fully-qualified # 🥄 E3.0 spoon +1F52A ; fully-qualified # 🔪 E0.6 kitchen knife +1FAD9 ; fully-qualified # 🫙 E14.0 jar +1F3FA ; fully-qualified # 🏺 E1.0 amphora + +# Food & Drink subtotal: 135 +# Food & Drink subtotal: 135 w/o modifiers + +# group: Travel & Places + +# subgroup: place-map +1F30D ; fully-qualified # 🌍 E0.7 globe showing Europe-Africa +1F30E ; fully-qualified # 🌎 E0.7 globe showing Americas +1F30F ; fully-qualified # 🌏 E0.6 globe showing Asia-Australia +1F310 ; fully-qualified # 🌐 E1.0 globe with meridians +1F5FA FE0F ; fully-qualified # 🗺️ E0.7 world map +1F5FA ; unqualified # 🗺 E0.7 world map +1F5FE ; fully-qualified # 🗾 E0.6 map of Japan +1F9ED ; fully-qualified # 🧭 E11.0 compass + +# subgroup: place-geographic +1F3D4 FE0F ; fully-qualified # 🏔️ E0.7 snow-capped mountain +1F3D4 ; unqualified # 🏔 E0.7 snow-capped mountain +26F0 FE0F ; fully-qualified # ⛰️ E0.7 mountain +26F0 ; unqualified # ⛰ E0.7 mountain +1F30B ; fully-qualified # 🌋 E0.6 volcano +1F5FB ; fully-qualified # 🗻 E0.6 mount fuji +1F3D5 FE0F ; fully-qualified # 🏕️ E0.7 camping +1F3D5 ; unqualified # 🏕 E0.7 camping +1F3D6 FE0F ; fully-qualified # 🏖️ E0.7 beach with umbrella +1F3D6 ; unqualified # 🏖 E0.7 beach with umbrella +1F3DC FE0F ; fully-qualified # 🏜️ E0.7 desert +1F3DC ; unqualified # 🏜 E0.7 desert +1F3DD FE0F ; fully-qualified # 🏝️ E0.7 desert island +1F3DD ; unqualified # 🏝 E0.7 desert island +1F3DE FE0F ; fully-qualified # 🏞️ E0.7 national park +1F3DE ; unqualified # 🏞 E0.7 national park + +# subgroup: place-building +1F3DF FE0F ; fully-qualified # 🏟️ E0.7 stadium +1F3DF ; unqualified # 🏟 E0.7 stadium +1F3DB FE0F ; fully-qualified # 🏛️ E0.7 classical building +1F3DB ; unqualified # 🏛 E0.7 classical building +1F3D7 FE0F ; fully-qualified # 🏗️ E0.7 building construction +1F3D7 ; unqualified # 🏗 E0.7 building construction +1F9F1 ; fully-qualified # 🧱 E11.0 brick +1FAA8 ; fully-qualified # 🪨 E13.0 rock +1FAB5 ; fully-qualified # 🪵 E13.0 wood +1F6D6 ; fully-qualified # 🛖 E13.0 hut +1F3D8 FE0F ; fully-qualified # 🏘️ E0.7 houses +1F3D8 ; unqualified # 🏘 E0.7 houses +1F3DA FE0F ; fully-qualified # 🏚️ E0.7 derelict house +1F3DA ; unqualified # 🏚 E0.7 derelict house +1F3E0 ; fully-qualified # 🏠 E0.6 house +1F3E1 ; fully-qualified # 🏡 E0.6 house with garden +1F3E2 ; fully-qualified # 🏢 E0.6 office building +1F3E3 ; fully-qualified # 🏣 E0.6 Japanese post office +1F3E4 ; fully-qualified # 🏤 E1.0 post office +1F3E5 ; fully-qualified # 🏥 E0.6 hospital +1F3E6 ; fully-qualified # 🏦 E0.6 bank +1F3E8 ; fully-qualified # 🏨 E0.6 hotel +1F3E9 ; fully-qualified # 🏩 E0.6 love hotel +1F3EA ; fully-qualified # 🏪 E0.6 convenience store +1F3EB ; fully-qualified # 🏫 E0.6 school +1F3EC ; fully-qualified # 🏬 E0.6 department store +1F3ED ; fully-qualified # 🏭 E0.6 factory +1F3EF ; fully-qualified # 🏯 E0.6 Japanese castle +1F3F0 ; fully-qualified # 🏰 E0.6 castle +1F492 ; fully-qualified # 💒 E0.6 wedding +1F5FC ; fully-qualified # 🗼 E0.6 Tokyo tower +1F5FD ; fully-qualified # 🗽 E0.6 Statue of Liberty + +# subgroup: place-religious +26EA ; fully-qualified # ⛪ E0.6 church +1F54C ; fully-qualified # 🕌 E1.0 mosque +1F6D5 ; fully-qualified # 🛕 E12.0 hindu temple +1F54D ; fully-qualified # 🕍 E1.0 synagogue +26E9 FE0F ; fully-qualified # ⛩️ E0.7 shinto shrine +26E9 ; unqualified # ⛩ E0.7 shinto shrine +1F54B ; fully-qualified # 🕋 E1.0 kaaba + +# subgroup: place-other +26F2 ; fully-qualified # ⛲ E0.6 fountain +26FA ; fully-qualified # ⛺ E0.6 tent +1F301 ; fully-qualified # 🌁 E0.6 foggy +1F303 ; fully-qualified # 🌃 E0.6 night with stars +1F3D9 FE0F ; fully-qualified # 🏙️ E0.7 cityscape +1F3D9 ; unqualified # 🏙 E0.7 cityscape +1F304 ; fully-qualified # 🌄 E0.6 sunrise over mountains +1F305 ; fully-qualified # 🌅 E0.6 sunrise +1F306 ; fully-qualified # 🌆 E0.6 cityscape at dusk +1F307 ; fully-qualified # 🌇 E0.6 sunset +1F309 ; fully-qualified # 🌉 E0.6 bridge at night +2668 FE0F ; fully-qualified # ♨️ E0.6 hot springs +2668 ; unqualified # ♨ E0.6 hot springs +1F3A0 ; fully-qualified # 🎠 E0.6 carousel horse +1F6DD ; fully-qualified # 🛝 E14.0 playground slide +1F3A1 ; fully-qualified # 🎡 E0.6 ferris wheel +1F3A2 ; fully-qualified # 🎢 E0.6 roller coaster +1F488 ; fully-qualified # 💈 E0.6 barber pole +1F3AA ; fully-qualified # 🎪 E0.6 circus tent + +# subgroup: transport-ground +1F682 ; fully-qualified # 🚂 E1.0 locomotive +1F683 ; fully-qualified # 🚃 E0.6 railway car +1F684 ; fully-qualified # 🚄 E0.6 high-speed train +1F685 ; fully-qualified # 🚅 E0.6 bullet train +1F686 ; fully-qualified # 🚆 E1.0 train +1F687 ; fully-qualified # 🚇 E0.6 metro +1F688 ; fully-qualified # 🚈 E1.0 light rail +1F689 ; fully-qualified # 🚉 E0.6 station +1F68A ; fully-qualified # 🚊 E1.0 tram +1F69D ; fully-qualified # 🚝 E1.0 monorail +1F69E ; fully-qualified # 🚞 E1.0 mountain railway +1F68B ; fully-qualified # 🚋 E1.0 tram car +1F68C ; fully-qualified # 🚌 E0.6 bus +1F68D ; fully-qualified # 🚍 E0.7 oncoming bus +1F68E ; fully-qualified # 🚎 E1.0 trolleybus +1F690 ; fully-qualified # 🚐 E1.0 minibus +1F691 ; fully-qualified # 🚑 E0.6 ambulance +1F692 ; fully-qualified # 🚒 E0.6 fire engine +1F693 ; fully-qualified # 🚓 E0.6 police car +1F694 ; fully-qualified # 🚔 E0.7 oncoming police car +1F695 ; fully-qualified # 🚕 E0.6 taxi +1F696 ; fully-qualified # 🚖 E1.0 oncoming taxi +1F697 ; fully-qualified # 🚗 E0.6 automobile +1F698 ; fully-qualified # 🚘 E0.7 oncoming automobile +1F699 ; fully-qualified # 🚙 E0.6 sport utility vehicle +1F6FB ; fully-qualified # 🛻 E13.0 pickup truck +1F69A ; fully-qualified # 🚚 E0.6 delivery truck +1F69B ; fully-qualified # 🚛 E1.0 articulated lorry +1F69C ; fully-qualified # 🚜 E1.0 tractor +1F3CE FE0F ; fully-qualified # 🏎️ E0.7 racing car +1F3CE ; unqualified # 🏎 E0.7 racing car +1F3CD FE0F ; fully-qualified # 🏍️ E0.7 motorcycle +1F3CD ; unqualified # 🏍 E0.7 motorcycle +1F6F5 ; fully-qualified # 🛵 E3.0 motor scooter +1F9BD ; fully-qualified # 🦽 E12.0 manual wheelchair +1F9BC ; fully-qualified # 🦼 E12.0 motorized wheelchair +1F6FA ; fully-qualified # 🛺 E12.0 auto rickshaw +1F6B2 ; fully-qualified # 🚲 E0.6 bicycle +1F6F4 ; fully-qualified # 🛴 E3.0 kick scooter +1F6F9 ; fully-qualified # 🛹 E11.0 skateboard +1F6FC ; fully-qualified # 🛼 E13.0 roller skate +1F68F ; fully-qualified # 🚏 E0.6 bus stop +1F6E3 FE0F ; fully-qualified # 🛣️ E0.7 motorway +1F6E3 ; unqualified # 🛣 E0.7 motorway +1F6E4 FE0F ; fully-qualified # 🛤️ E0.7 railway track +1F6E4 ; unqualified # 🛤 E0.7 railway track +1F6E2 FE0F ; fully-qualified # 🛢️ E0.7 oil drum +1F6E2 ; unqualified # 🛢 E0.7 oil drum +26FD ; fully-qualified # ⛽ E0.6 fuel pump +1F6DE ; fully-qualified # 🛞 E14.0 wheel +1F6A8 ; fully-qualified # 🚨 E0.6 police car light +1F6A5 ; fully-qualified # 🚥 E0.6 horizontal traffic light +1F6A6 ; fully-qualified # 🚦 E1.0 vertical traffic light +1F6D1 ; fully-qualified # 🛑 E3.0 stop sign +1F6A7 ; fully-qualified # 🚧 E0.6 construction + +# subgroup: transport-water +2693 ; fully-qualified # ⚓ E0.6 anchor +1F6DF ; fully-qualified # 🛟 E14.0 ring buoy +26F5 ; fully-qualified # ⛵ E0.6 sailboat +1F6F6 ; fully-qualified # 🛶 E3.0 canoe +1F6A4 ; fully-qualified # 🚤 E0.6 speedboat +1F6F3 FE0F ; fully-qualified # 🛳️ E0.7 passenger ship +1F6F3 ; unqualified # 🛳 E0.7 passenger ship +26F4 FE0F ; fully-qualified # ⛴️ E0.7 ferry +26F4 ; unqualified # ⛴ E0.7 ferry +1F6E5 FE0F ; fully-qualified # 🛥️ E0.7 motor boat +1F6E5 ; unqualified # 🛥 E0.7 motor boat +1F6A2 ; fully-qualified # 🚢 E0.6 ship + +# subgroup: transport-air +2708 FE0F ; fully-qualified # ✈️ E0.6 airplane +2708 ; unqualified # ✈ E0.6 airplane +1F6E9 FE0F ; fully-qualified # 🛩️ E0.7 small airplane +1F6E9 ; unqualified # 🛩 E0.7 small airplane +1F6EB ; fully-qualified # 🛫 E1.0 airplane departure +1F6EC ; fully-qualified # 🛬 E1.0 airplane arrival +1FA82 ; fully-qualified # 🪂 E12.0 parachute +1F4BA ; fully-qualified # 💺 E0.6 seat +1F681 ; fully-qualified # 🚁 E1.0 helicopter +1F69F ; fully-qualified # 🚟 E1.0 suspension railway +1F6A0 ; fully-qualified # 🚠 E1.0 mountain cableway +1F6A1 ; fully-qualified # 🚡 E1.0 aerial tramway +1F6F0 FE0F ; fully-qualified # 🛰️ E0.7 satellite +1F6F0 ; unqualified # 🛰 E0.7 satellite +1F680 ; fully-qualified # 🚀 E0.6 rocket +1F6F8 ; fully-qualified # 🛸 E5.0 flying saucer + +# subgroup: hotel +1F6CE FE0F ; fully-qualified # 🛎️ E0.7 bellhop bell +1F6CE ; unqualified # 🛎 E0.7 bellhop bell +1F9F3 ; fully-qualified # 🧳 E11.0 luggage + +# subgroup: time +231B ; fully-qualified # ⌛ E0.6 hourglass done +23F3 ; fully-qualified # ⏳ E0.6 hourglass not done +231A ; fully-qualified # ⌚ E0.6 watch +23F0 ; fully-qualified # ⏰ E0.6 alarm clock +23F1 FE0F ; fully-qualified # ⏱️ E1.0 stopwatch +23F1 ; unqualified # ⏱ E1.0 stopwatch +23F2 FE0F ; fully-qualified # ⏲️ E1.0 timer clock +23F2 ; unqualified # ⏲ E1.0 timer clock +1F570 FE0F ; fully-qualified # 🕰️ E0.7 mantelpiece clock +1F570 ; unqualified # 🕰 E0.7 mantelpiece clock +1F55B ; fully-qualified # 🕛 E0.6 twelve o’clock +1F567 ; fully-qualified # 🕧 E0.7 twelve-thirty +1F550 ; fully-qualified # 🕐 E0.6 one o’clock +1F55C ; fully-qualified # 🕜 E0.7 one-thirty +1F551 ; fully-qualified # 🕑 E0.6 two o’clock +1F55D ; fully-qualified # 🕝 E0.7 two-thirty +1F552 ; fully-qualified # 🕒 E0.6 three o’clock +1F55E ; fully-qualified # 🕞 E0.7 three-thirty +1F553 ; fully-qualified # 🕓 E0.6 four o’clock +1F55F ; fully-qualified # 🕟 E0.7 four-thirty +1F554 ; fully-qualified # 🕔 E0.6 five o’clock +1F560 ; fully-qualified # 🕠 E0.7 five-thirty +1F555 ; fully-qualified # 🕕 E0.6 six o’clock +1F561 ; fully-qualified # 🕡 E0.7 six-thirty +1F556 ; fully-qualified # 🕖 E0.6 seven o’clock +1F562 ; fully-qualified # 🕢 E0.7 seven-thirty +1F557 ; fully-qualified # 🕗 E0.6 eight o’clock +1F563 ; fully-qualified # 🕣 E0.7 eight-thirty +1F558 ; fully-qualified # 🕘 E0.6 nine o’clock +1F564 ; fully-qualified # 🕤 E0.7 nine-thirty +1F559 ; fully-qualified # 🕙 E0.6 ten o’clock +1F565 ; fully-qualified # 🕥 E0.7 ten-thirty +1F55A ; fully-qualified # 🕚 E0.6 eleven o’clock +1F566 ; fully-qualified # 🕦 E0.7 eleven-thirty + +# subgroup: sky & weather +1F311 ; fully-qualified # 🌑 E0.6 new moon +1F312 ; fully-qualified # 🌒 E1.0 waxing crescent moon +1F313 ; fully-qualified # 🌓 E0.6 first quarter moon +1F314 ; fully-qualified # 🌔 E0.6 waxing gibbous moon +1F315 ; fully-qualified # 🌕 E0.6 full moon +1F316 ; fully-qualified # 🌖 E1.0 waning gibbous moon +1F317 ; fully-qualified # 🌗 E1.0 last quarter moon +1F318 ; fully-qualified # 🌘 E1.0 waning crescent moon +1F319 ; fully-qualified # 🌙 E0.6 crescent moon +1F31A ; fully-qualified # 🌚 E1.0 new moon face +1F31B ; fully-qualified # 🌛 E0.6 first quarter moon face +1F31C ; fully-qualified # 🌜 E0.7 last quarter moon face +1F321 FE0F ; fully-qualified # 🌡️ E0.7 thermometer +1F321 ; unqualified # 🌡 E0.7 thermometer +2600 FE0F ; fully-qualified # ☀️ E0.6 sun +2600 ; unqualified # ☀ E0.6 sun +1F31D ; fully-qualified # 🌝 E1.0 full moon face +1F31E ; fully-qualified # 🌞 E1.0 sun with face +1FA90 ; fully-qualified # 🪐 E12.0 ringed planet +2B50 ; fully-qualified # ⭐ E0.6 star +1F31F ; fully-qualified # 🌟 E0.6 glowing star +1F320 ; fully-qualified # 🌠 E0.6 shooting star +1F30C ; fully-qualified # 🌌 E0.6 milky way +2601 FE0F ; fully-qualified # ☁️ E0.6 cloud +2601 ; unqualified # ☁ E0.6 cloud +26C5 ; fully-qualified # ⛅ E0.6 sun behind cloud +26C8 FE0F ; fully-qualified # ⛈️ E0.7 cloud with lightning and rain +26C8 ; unqualified # ⛈ E0.7 cloud with lightning and rain +1F324 FE0F ; fully-qualified # 🌤️ E0.7 sun behind small cloud +1F324 ; unqualified # 🌤 E0.7 sun behind small cloud +1F325 FE0F ; fully-qualified # 🌥️ E0.7 sun behind large cloud +1F325 ; unqualified # 🌥 E0.7 sun behind large cloud +1F326 FE0F ; fully-qualified # 🌦️ E0.7 sun behind rain cloud +1F326 ; unqualified # 🌦 E0.7 sun behind rain cloud +1F327 FE0F ; fully-qualified # 🌧️ E0.7 cloud with rain +1F327 ; unqualified # 🌧 E0.7 cloud with rain +1F328 FE0F ; fully-qualified # 🌨️ E0.7 cloud with snow +1F328 ; unqualified # 🌨 E0.7 cloud with snow +1F329 FE0F ; fully-qualified # 🌩️ E0.7 cloud with lightning +1F329 ; unqualified # 🌩 E0.7 cloud with lightning +1F32A FE0F ; fully-qualified # 🌪️ E0.7 tornado +1F32A ; unqualified # 🌪 E0.7 tornado +1F32B FE0F ; fully-qualified # 🌫️ E0.7 fog +1F32B ; unqualified # 🌫 E0.7 fog +1F32C FE0F ; fully-qualified # 🌬️ E0.7 wind face +1F32C ; unqualified # 🌬 E0.7 wind face +1F300 ; fully-qualified # 🌀 E0.6 cyclone +1F308 ; fully-qualified # 🌈 E0.6 rainbow +1F302 ; fully-qualified # 🌂 E0.6 closed umbrella +2602 FE0F ; fully-qualified # ☂️ E0.7 umbrella +2602 ; unqualified # ☂ E0.7 umbrella +2614 ; fully-qualified # ☔ E0.6 umbrella with rain drops +26F1 FE0F ; fully-qualified # ⛱️ E0.7 umbrella on ground +26F1 ; unqualified # ⛱ E0.7 umbrella on ground +26A1 ; fully-qualified # ⚡ E0.6 high voltage +2744 FE0F ; fully-qualified # ❄️ E0.6 snowflake +2744 ; unqualified # ❄ E0.6 snowflake +2603 FE0F ; fully-qualified # ☃️ E0.7 snowman +2603 ; unqualified # ☃ E0.7 snowman +26C4 ; fully-qualified # ⛄ E0.6 snowman without snow +2604 FE0F ; fully-qualified # ☄️ E1.0 comet +2604 ; unqualified # ☄ E1.0 comet +1F525 ; fully-qualified # 🔥 E0.6 fire +1F4A7 ; fully-qualified # 💧 E0.6 droplet +1F30A ; fully-qualified # 🌊 E0.6 water wave + +# Travel & Places subtotal: 267 +# Travel & Places subtotal: 267 w/o modifiers + +# group: Activities + +# subgroup: event +1F383 ; fully-qualified # 🎃 E0.6 jack-o-lantern +1F384 ; fully-qualified # 🎄 E0.6 Christmas tree +1F386 ; fully-qualified # 🎆 E0.6 fireworks +1F387 ; fully-qualified # 🎇 E0.6 sparkler +1F9E8 ; fully-qualified # 🧨 E11.0 firecracker +2728 ; fully-qualified # ✨ E0.6 sparkles +1F388 ; fully-qualified # 🎈 E0.6 balloon +1F389 ; fully-qualified # 🎉 E0.6 party popper +1F38A ; fully-qualified # 🎊 E0.6 confetti ball +1F38B ; fully-qualified # 🎋 E0.6 tanabata tree +1F38D ; fully-qualified # 🎍 E0.6 pine decoration +1F38E ; fully-qualified # 🎎 E0.6 Japanese dolls +1F38F ; fully-qualified # 🎏 E0.6 carp streamer +1F390 ; fully-qualified # 🎐 E0.6 wind chime +1F391 ; fully-qualified # 🎑 E0.6 moon viewing ceremony +1F9E7 ; fully-qualified # 🧧 E11.0 red envelope +1F380 ; fully-qualified # 🎀 E0.6 ribbon +1F381 ; fully-qualified # 🎁 E0.6 wrapped gift +1F397 FE0F ; fully-qualified # 🎗️ E0.7 reminder ribbon +1F397 ; unqualified # 🎗 E0.7 reminder ribbon +1F39F FE0F ; fully-qualified # 🎟️ E0.7 admission tickets +1F39F ; unqualified # 🎟 E0.7 admission tickets +1F3AB ; fully-qualified # 🎫 E0.6 ticket + +# subgroup: award-medal +1F396 FE0F ; fully-qualified # 🎖️ E0.7 military medal +1F396 ; unqualified # 🎖 E0.7 military medal +1F3C6 ; fully-qualified # 🏆 E0.6 trophy +1F3C5 ; fully-qualified # 🏅 E1.0 sports medal +1F947 ; fully-qualified # 🥇 E3.0 1st place medal +1F948 ; fully-qualified # 🥈 E3.0 2nd place medal +1F949 ; fully-qualified # 🥉 E3.0 3rd place medal + +# subgroup: sport +26BD ; fully-qualified # ⚽ E0.6 soccer ball +26BE ; fully-qualified # ⚾ E0.6 baseball +1F94E ; fully-qualified # 🥎 E11.0 softball +1F3C0 ; fully-qualified # 🏀 E0.6 basketball +1F3D0 ; fully-qualified # 🏐 E1.0 volleyball +1F3C8 ; fully-qualified # 🏈 E0.6 american football +1F3C9 ; fully-qualified # 🏉 E1.0 rugby football +1F3BE ; fully-qualified # 🎾 E0.6 tennis +1F94F ; fully-qualified # 🥏 E11.0 flying disc +1F3B3 ; fully-qualified # 🎳 E0.6 bowling +1F3CF ; fully-qualified # 🏏 E1.0 cricket game +1F3D1 ; fully-qualified # 🏑 E1.0 field hockey +1F3D2 ; fully-qualified # 🏒 E1.0 ice hockey +1F94D ; fully-qualified # 🥍 E11.0 lacrosse +1F3D3 ; fully-qualified # 🏓 E1.0 ping pong +1F3F8 ; fully-qualified # 🏸 E1.0 badminton +1F94A ; fully-qualified # 🥊 E3.0 boxing glove +1F94B ; fully-qualified # 🥋 E3.0 martial arts uniform +1F945 ; fully-qualified # 🥅 E3.0 goal net +26F3 ; fully-qualified # ⛳ E0.6 flag in hole +26F8 FE0F ; fully-qualified # ⛸️ E0.7 ice skate +26F8 ; unqualified # ⛸ E0.7 ice skate +1F3A3 ; fully-qualified # 🎣 E0.6 fishing pole +1F93F ; fully-qualified # 🤿 E12.0 diving mask +1F3BD ; fully-qualified # 🎽 E0.6 running shirt +1F3BF ; fully-qualified # 🎿 E0.6 skis +1F6F7 ; fully-qualified # 🛷 E5.0 sled +1F94C ; fully-qualified # 🥌 E5.0 curling stone + +# subgroup: game +1F3AF ; fully-qualified # 🎯 E0.6 bullseye +1FA80 ; fully-qualified # 🪀 E12.0 yo-yo +1FA81 ; fully-qualified # 🪁 E12.0 kite +1F52B ; fully-qualified # 🔫 E0.6 water pistol +1F3B1 ; fully-qualified # 🎱 E0.6 pool 8 ball +1F52E ; fully-qualified # 🔮 E0.6 crystal ball +1FA84 ; fully-qualified # 🪄 E13.0 magic wand +1F3AE ; fully-qualified # 🎮 E0.6 video game +1F579 FE0F ; fully-qualified # 🕹️ E0.7 joystick +1F579 ; unqualified # 🕹 E0.7 joystick +1F3B0 ; fully-qualified # 🎰 E0.6 slot machine +1F3B2 ; fully-qualified # 🎲 E0.6 game die +1F9E9 ; fully-qualified # 🧩 E11.0 puzzle piece +1F9F8 ; fully-qualified # 🧸 E11.0 teddy bear +1FA85 ; fully-qualified # 🪅 E13.0 piñata +1FAA9 ; fully-qualified # 🪩 E14.0 mirror ball +1FA86 ; fully-qualified # 🪆 E13.0 nesting dolls +2660 FE0F ; fully-qualified # ♠️ E0.6 spade suit +2660 ; unqualified # ♠ E0.6 spade suit +2665 FE0F ; fully-qualified # ♥️ E0.6 heart suit +2665 ; unqualified # ♥ E0.6 heart suit +2666 FE0F ; fully-qualified # ♦️ E0.6 diamond suit +2666 ; unqualified # ♦ E0.6 diamond suit +2663 FE0F ; fully-qualified # ♣️ E0.6 club suit +2663 ; unqualified # ♣ E0.6 club suit +265F FE0F ; fully-qualified # ♟️ E11.0 chess pawn +265F ; unqualified # ♟ E11.0 chess pawn +1F0CF ; fully-qualified # 🃏 E0.6 joker +1F004 ; fully-qualified # 🀄 E0.6 mahjong red dragon +1F3B4 ; fully-qualified # 🎴 E0.6 flower playing cards + +# subgroup: arts & crafts +1F3AD ; fully-qualified # 🎭 E0.6 performing arts +1F5BC FE0F ; fully-qualified # 🖼️ E0.7 framed picture +1F5BC ; unqualified # 🖼 E0.7 framed picture +1F3A8 ; fully-qualified # 🎨 E0.6 artist palette +1F9F5 ; fully-qualified # 🧵 E11.0 thread +1FAA1 ; fully-qualified # 🪡 E13.0 sewing needle +1F9F6 ; fully-qualified # 🧶 E11.0 yarn +1FAA2 ; fully-qualified # 🪢 E13.0 knot + +# Activities subtotal: 96 +# Activities subtotal: 96 w/o modifiers + +# group: Objects + +# subgroup: clothing +1F453 ; fully-qualified # 👓 E0.6 glasses +1F576 FE0F ; fully-qualified # 🕶️ E0.7 sunglasses +1F576 ; unqualified # 🕶 E0.7 sunglasses +1F97D ; fully-qualified # 🥽 E11.0 goggles +1F97C ; fully-qualified # 🥼 E11.0 lab coat +1F9BA ; fully-qualified # 🦺 E12.0 safety vest +1F454 ; fully-qualified # 👔 E0.6 necktie +1F455 ; fully-qualified # 👕 E0.6 t-shirt +1F456 ; fully-qualified # 👖 E0.6 jeans +1F9E3 ; fully-qualified # 🧣 E5.0 scarf +1F9E4 ; fully-qualified # 🧤 E5.0 gloves +1F9E5 ; fully-qualified # 🧥 E5.0 coat +1F9E6 ; fully-qualified # 🧦 E5.0 socks +1F457 ; fully-qualified # 👗 E0.6 dress +1F458 ; fully-qualified # 👘 E0.6 kimono +1F97B ; fully-qualified # 🥻 E12.0 sari +1FA71 ; fully-qualified # 🩱 E12.0 one-piece swimsuit +1FA72 ; fully-qualified # 🩲 E12.0 briefs +1FA73 ; fully-qualified # 🩳 E12.0 shorts +1F459 ; fully-qualified # 👙 E0.6 bikini +1F45A ; fully-qualified # 👚 E0.6 woman’s clothes +1FAAD ; fully-qualified # 🪭 E15.0 folding hand fan +1F45B ; fully-qualified # 👛 E0.6 purse +1F45C ; fully-qualified # 👜 E0.6 handbag +1F45D ; fully-qualified # 👝 E0.6 clutch bag +1F6CD FE0F ; fully-qualified # 🛍️ E0.7 shopping bags +1F6CD ; unqualified # 🛍 E0.7 shopping bags +1F392 ; fully-qualified # 🎒 E0.6 backpack +1FA74 ; fully-qualified # 🩴 E13.0 thong sandal +1F45E ; fully-qualified # 👞 E0.6 man’s shoe +1F45F ; fully-qualified # 👟 E0.6 running shoe +1F97E ; fully-qualified # 🥾 E11.0 hiking boot +1F97F ; fully-qualified # 🥿 E11.0 flat shoe +1F460 ; fully-qualified # 👠 E0.6 high-heeled shoe +1F461 ; fully-qualified # 👡 E0.6 woman’s sandal +1FA70 ; fully-qualified # 🩰 E12.0 ballet shoes +1F462 ; fully-qualified # 👢 E0.6 woman’s boot +1FAAE ; fully-qualified # 🪮 E15.0 hair pick +1F451 ; fully-qualified # 👑 E0.6 crown +1F452 ; fully-qualified # 👒 E0.6 woman’s hat +1F3A9 ; fully-qualified # 🎩 E0.6 top hat +1F393 ; fully-qualified # 🎓 E0.6 graduation cap +1F9E2 ; fully-qualified # 🧢 E5.0 billed cap +1FA96 ; fully-qualified # 🪖 E13.0 military helmet +26D1 FE0F ; fully-qualified # ⛑️ E0.7 rescue worker’s helmet +26D1 ; unqualified # ⛑ E0.7 rescue worker’s helmet +1F4FF ; fully-qualified # 📿 E1.0 prayer beads +1F484 ; fully-qualified # 💄 E0.6 lipstick +1F48D ; fully-qualified # 💍 E0.6 ring +1F48E ; fully-qualified # 💎 E0.6 gem stone + +# subgroup: sound +1F507 ; fully-qualified # 🔇 E1.0 muted speaker +1F508 ; fully-qualified # 🔈 E0.7 speaker low volume +1F509 ; fully-qualified # 🔉 E1.0 speaker medium volume +1F50A ; fully-qualified # 🔊 E0.6 speaker high volume +1F4E2 ; fully-qualified # 📢 E0.6 loudspeaker +1F4E3 ; fully-qualified # 📣 E0.6 megaphone +1F4EF ; fully-qualified # 📯 E1.0 postal horn +1F514 ; fully-qualified # 🔔 E0.6 bell +1F515 ; fully-qualified # 🔕 E1.0 bell with slash + +# subgroup: music +1F3BC ; fully-qualified # 🎼 E0.6 musical score +1F3B5 ; fully-qualified # 🎵 E0.6 musical note +1F3B6 ; fully-qualified # 🎶 E0.6 musical notes +1F399 FE0F ; fully-qualified # 🎙️ E0.7 studio microphone +1F399 ; unqualified # 🎙 E0.7 studio microphone +1F39A FE0F ; fully-qualified # 🎚️ E0.7 level slider +1F39A ; unqualified # 🎚 E0.7 level slider +1F39B FE0F ; fully-qualified # 🎛️ E0.7 control knobs +1F39B ; unqualified # 🎛 E0.7 control knobs +1F3A4 ; fully-qualified # 🎤 E0.6 microphone +1F3A7 ; fully-qualified # 🎧 E0.6 headphone +1F4FB ; fully-qualified # 📻 E0.6 radio + +# subgroup: musical-instrument +1F3B7 ; fully-qualified # 🎷 E0.6 saxophone +1FA97 ; fully-qualified # 🪗 E13.0 accordion +1F3B8 ; fully-qualified # 🎸 E0.6 guitar +1F3B9 ; fully-qualified # 🎹 E0.6 musical keyboard +1F3BA ; fully-qualified # 🎺 E0.6 trumpet +1F3BB ; fully-qualified # 🎻 E0.6 violin +1FA95 ; fully-qualified # 🪕 E12.0 banjo +1F941 ; fully-qualified # 🥁 E3.0 drum +1FA98 ; fully-qualified # 🪘 E13.0 long drum +1FA87 ; fully-qualified # 🪇 E15.0 maracas +1FA88 ; fully-qualified # 🪈 E15.0 flute + +# subgroup: phone +1F4F1 ; fully-qualified # 📱 E0.6 mobile phone +1F4F2 ; fully-qualified # 📲 E0.6 mobile phone with arrow +260E FE0F ; fully-qualified # ☎️ E0.6 telephone +260E ; unqualified # ☎ E0.6 telephone +1F4DE ; fully-qualified # 📞 E0.6 telephone receiver +1F4DF ; fully-qualified # 📟 E0.6 pager +1F4E0 ; fully-qualified # 📠 E0.6 fax machine + +# subgroup: computer +1F50B ; fully-qualified # 🔋 E0.6 battery +1FAAB ; fully-qualified # 🪫 E14.0 low battery +1F50C ; fully-qualified # 🔌 E0.6 electric plug +1F4BB ; fully-qualified # 💻 E0.6 laptop +1F5A5 FE0F ; fully-qualified # 🖥️ E0.7 desktop computer +1F5A5 ; unqualified # 🖥 E0.7 desktop computer +1F5A8 FE0F ; fully-qualified # 🖨️ E0.7 printer +1F5A8 ; unqualified # 🖨 E0.7 printer +2328 FE0F ; fully-qualified # ⌨️ E1.0 keyboard +2328 ; unqualified # ⌨ E1.0 keyboard +1F5B1 FE0F ; fully-qualified # 🖱️ E0.7 computer mouse +1F5B1 ; unqualified # 🖱 E0.7 computer mouse +1F5B2 FE0F ; fully-qualified # 🖲️ E0.7 trackball +1F5B2 ; unqualified # 🖲 E0.7 trackball +1F4BD ; fully-qualified # 💽 E0.6 computer disk +1F4BE ; fully-qualified # 💾 E0.6 floppy disk +1F4BF ; fully-qualified # 💿 E0.6 optical disk +1F4C0 ; fully-qualified # 📀 E0.6 dvd +1F9EE ; fully-qualified # 🧮 E11.0 abacus + +# subgroup: light & video +1F3A5 ; fully-qualified # 🎥 E0.6 movie camera +1F39E FE0F ; fully-qualified # 🎞️ E0.7 film frames +1F39E ; unqualified # 🎞 E0.7 film frames +1F4FD FE0F ; fully-qualified # 📽️ E0.7 film projector +1F4FD ; unqualified # 📽 E0.7 film projector +1F3AC ; fully-qualified # 🎬 E0.6 clapper board +1F4FA ; fully-qualified # 📺 E0.6 television +1F4F7 ; fully-qualified # 📷 E0.6 camera +1F4F8 ; fully-qualified # 📸 E1.0 camera with flash +1F4F9 ; fully-qualified # 📹 E0.6 video camera +1F4FC ; fully-qualified # 📼 E0.6 videocassette +1F50D ; fully-qualified # 🔍 E0.6 magnifying glass tilted left +1F50E ; fully-qualified # 🔎 E0.6 magnifying glass tilted right +1F56F FE0F ; fully-qualified # 🕯️ E0.7 candle +1F56F ; unqualified # 🕯 E0.7 candle +1F4A1 ; fully-qualified # 💡 E0.6 light bulb +1F526 ; fully-qualified # 🔦 E0.6 flashlight +1F3EE ; fully-qualified # 🏮 E0.6 red paper lantern +1FA94 ; fully-qualified # 🪔 E12.0 diya lamp + +# subgroup: book-paper +1F4D4 ; fully-qualified # 📔 E0.6 notebook with decorative cover +1F4D5 ; fully-qualified # 📕 E0.6 closed book +1F4D6 ; fully-qualified # 📖 E0.6 open book +1F4D7 ; fully-qualified # 📗 E0.6 green book +1F4D8 ; fully-qualified # 📘 E0.6 blue book +1F4D9 ; fully-qualified # 📙 E0.6 orange book +1F4DA ; fully-qualified # 📚 E0.6 books +1F4D3 ; fully-qualified # 📓 E0.6 notebook +1F4D2 ; fully-qualified # 📒 E0.6 ledger +1F4C3 ; fully-qualified # 📃 E0.6 page with curl +1F4DC ; fully-qualified # 📜 E0.6 scroll +1F4C4 ; fully-qualified # 📄 E0.6 page facing up +1F4F0 ; fully-qualified # 📰 E0.6 newspaper +1F5DE FE0F ; fully-qualified # 🗞️ E0.7 rolled-up newspaper +1F5DE ; unqualified # 🗞 E0.7 rolled-up newspaper +1F4D1 ; fully-qualified # 📑 E0.6 bookmark tabs +1F516 ; fully-qualified # 🔖 E0.6 bookmark +1F3F7 FE0F ; fully-qualified # 🏷️ E0.7 label +1F3F7 ; unqualified # 🏷 E0.7 label + +# subgroup: money +1F4B0 ; fully-qualified # 💰 E0.6 money bag +1FA99 ; fully-qualified # 🪙 E13.0 coin +1F4B4 ; fully-qualified # 💴 E0.6 yen banknote +1F4B5 ; fully-qualified # 💵 E0.6 dollar banknote +1F4B6 ; fully-qualified # 💶 E1.0 euro banknote +1F4B7 ; fully-qualified # 💷 E1.0 pound banknote +1F4B8 ; fully-qualified # 💸 E0.6 money with wings +1F4B3 ; fully-qualified # 💳 E0.6 credit card +1F9FE ; fully-qualified # 🧾 E11.0 receipt +1F4B9 ; fully-qualified # 💹 E0.6 chart increasing with yen + +# subgroup: mail +2709 FE0F ; fully-qualified # ✉️ E0.6 envelope +2709 ; unqualified # ✉ E0.6 envelope +1F4E7 ; fully-qualified # 📧 E0.6 e-mail +1F4E8 ; fully-qualified # 📨 E0.6 incoming envelope +1F4E9 ; fully-qualified # 📩 E0.6 envelope with arrow +1F4E4 ; fully-qualified # 📤 E0.6 outbox tray +1F4E5 ; fully-qualified # 📥 E0.6 inbox tray +1F4E6 ; fully-qualified # 📦 E0.6 package +1F4EB ; fully-qualified # 📫 E0.6 closed mailbox with raised flag +1F4EA ; fully-qualified # 📪 E0.6 closed mailbox with lowered flag +1F4EC ; fully-qualified # 📬 E0.7 open mailbox with raised flag +1F4ED ; fully-qualified # 📭 E0.7 open mailbox with lowered flag +1F4EE ; fully-qualified # 📮 E0.6 postbox +1F5F3 FE0F ; fully-qualified # 🗳️ E0.7 ballot box with ballot +1F5F3 ; unqualified # 🗳 E0.7 ballot box with ballot + +# subgroup: writing +270F FE0F ; fully-qualified # ✏️ E0.6 pencil +270F ; unqualified # ✏ E0.6 pencil +2712 FE0F ; fully-qualified # ✒️ E0.6 black nib +2712 ; unqualified # ✒ E0.6 black nib +1F58B FE0F ; fully-qualified # 🖋️ E0.7 fountain pen +1F58B ; unqualified # 🖋 E0.7 fountain pen +1F58A FE0F ; fully-qualified # 🖊️ E0.7 pen +1F58A ; unqualified # 🖊 E0.7 pen +1F58C FE0F ; fully-qualified # 🖌️ E0.7 paintbrush +1F58C ; unqualified # 🖌 E0.7 paintbrush +1F58D FE0F ; fully-qualified # 🖍️ E0.7 crayon +1F58D ; unqualified # 🖍 E0.7 crayon +1F4DD ; fully-qualified # 📝 E0.6 memo + +# subgroup: office +1F4BC ; fully-qualified # 💼 E0.6 briefcase +1F4C1 ; fully-qualified # 📁 E0.6 file folder +1F4C2 ; fully-qualified # 📂 E0.6 open file folder +1F5C2 FE0F ; fully-qualified # 🗂️ E0.7 card index dividers +1F5C2 ; unqualified # 🗂 E0.7 card index dividers +1F4C5 ; fully-qualified # 📅 E0.6 calendar +1F4C6 ; fully-qualified # 📆 E0.6 tear-off calendar +1F5D2 FE0F ; fully-qualified # 🗒️ E0.7 spiral notepad +1F5D2 ; unqualified # 🗒 E0.7 spiral notepad +1F5D3 FE0F ; fully-qualified # 🗓️ E0.7 spiral calendar +1F5D3 ; unqualified # 🗓 E0.7 spiral calendar +1F4C7 ; fully-qualified # 📇 E0.6 card index +1F4C8 ; fully-qualified # 📈 E0.6 chart increasing +1F4C9 ; fully-qualified # 📉 E0.6 chart decreasing +1F4CA ; fully-qualified # 📊 E0.6 bar chart +1F4CB ; fully-qualified # 📋 E0.6 clipboard +1F4CC ; fully-qualified # 📌 E0.6 pushpin +1F4CD ; fully-qualified # 📍 E0.6 round pushpin +1F4CE ; fully-qualified # 📎 E0.6 paperclip +1F587 FE0F ; fully-qualified # 🖇️ E0.7 linked paperclips +1F587 ; unqualified # 🖇 E0.7 linked paperclips +1F4CF ; fully-qualified # 📏 E0.6 straight ruler +1F4D0 ; fully-qualified # 📐 E0.6 triangular ruler +2702 FE0F ; fully-qualified # ✂️ E0.6 scissors +2702 ; unqualified # ✂ E0.6 scissors +1F5C3 FE0F ; fully-qualified # 🗃️ E0.7 card file box +1F5C3 ; unqualified # 🗃 E0.7 card file box +1F5C4 FE0F ; fully-qualified # 🗄️ E0.7 file cabinet +1F5C4 ; unqualified # 🗄 E0.7 file cabinet +1F5D1 FE0F ; fully-qualified # 🗑️ E0.7 wastebasket +1F5D1 ; unqualified # 🗑 E0.7 wastebasket + +# subgroup: lock +1F512 ; fully-qualified # 🔒 E0.6 locked +1F513 ; fully-qualified # 🔓 E0.6 unlocked +1F50F ; fully-qualified # 🔏 E0.6 locked with pen +1F510 ; fully-qualified # 🔐 E0.6 locked with key +1F511 ; fully-qualified # 🔑 E0.6 key +1F5DD FE0F ; fully-qualified # 🗝️ E0.7 old key +1F5DD ; unqualified # 🗝 E0.7 old key + +# subgroup: tool +1F528 ; fully-qualified # 🔨 E0.6 hammer +1FA93 ; fully-qualified # 🪓 E12.0 axe +26CF FE0F ; fully-qualified # ⛏️ E0.7 pick +26CF ; unqualified # ⛏ E0.7 pick +2692 FE0F ; fully-qualified # ⚒️ E1.0 hammer and pick +2692 ; unqualified # ⚒ E1.0 hammer and pick +1F6E0 FE0F ; fully-qualified # 🛠️ E0.7 hammer and wrench +1F6E0 ; unqualified # 🛠 E0.7 hammer and wrench +1F5E1 FE0F ; fully-qualified # 🗡️ E0.7 dagger +1F5E1 ; unqualified # 🗡 E0.7 dagger +2694 FE0F ; fully-qualified # ⚔️ E1.0 crossed swords +2694 ; unqualified # ⚔ E1.0 crossed swords +1F4A3 ; fully-qualified # 💣 E0.6 bomb +1FA83 ; fully-qualified # 🪃 E13.0 boomerang +1F3F9 ; fully-qualified # 🏹 E1.0 bow and arrow +1F6E1 FE0F ; fully-qualified # 🛡️ E0.7 shield +1F6E1 ; unqualified # 🛡 E0.7 shield +1FA9A ; fully-qualified # 🪚 E13.0 carpentry saw +1F527 ; fully-qualified # 🔧 E0.6 wrench +1FA9B ; fully-qualified # 🪛 E13.0 screwdriver +1F529 ; fully-qualified # 🔩 E0.6 nut and bolt +2699 FE0F ; fully-qualified # ⚙️ E1.0 gear +2699 ; unqualified # ⚙ E1.0 gear +1F5DC FE0F ; fully-qualified # 🗜️ E0.7 clamp +1F5DC ; unqualified # 🗜 E0.7 clamp +2696 FE0F ; fully-qualified # ⚖️ E1.0 balance scale +2696 ; unqualified # ⚖ E1.0 balance scale +1F9AF ; fully-qualified # 🦯 E12.0 white cane +1F517 ; fully-qualified # 🔗 E0.6 link +26D3 FE0F ; fully-qualified # ⛓️ E0.7 chains +26D3 ; unqualified # ⛓ E0.7 chains +1FA9D ; fully-qualified # 🪝 E13.0 hook +1F9F0 ; fully-qualified # 🧰 E11.0 toolbox +1F9F2 ; fully-qualified # 🧲 E11.0 magnet +1FA9C ; fully-qualified # 🪜 E13.0 ladder + +# subgroup: science +2697 FE0F ; fully-qualified # ⚗️ E1.0 alembic +2697 ; unqualified # ⚗ E1.0 alembic +1F9EA ; fully-qualified # 🧪 E11.0 test tube +1F9EB ; fully-qualified # 🧫 E11.0 petri dish +1F9EC ; fully-qualified # 🧬 E11.0 dna +1F52C ; fully-qualified # 🔬 E1.0 microscope +1F52D ; fully-qualified # 🔭 E1.0 telescope +1F4E1 ; fully-qualified # 📡 E0.6 satellite antenna + +# subgroup: medical +1F489 ; fully-qualified # 💉 E0.6 syringe +1FA78 ; fully-qualified # 🩸 E12.0 drop of blood +1F48A ; fully-qualified # 💊 E0.6 pill +1FA79 ; fully-qualified # 🩹 E12.0 adhesive bandage +1FA7C ; fully-qualified # 🩼 E14.0 crutch +1FA7A ; fully-qualified # 🩺 E12.0 stethoscope +1FA7B ; fully-qualified # 🩻 E14.0 x-ray + +# subgroup: household +1F6AA ; fully-qualified # 🚪 E0.6 door +1F6D7 ; fully-qualified # 🛗 E13.0 elevator +1FA9E ; fully-qualified # 🪞 E13.0 mirror +1FA9F ; fully-qualified # 🪟 E13.0 window +1F6CF FE0F ; fully-qualified # 🛏️ E0.7 bed +1F6CF ; unqualified # 🛏 E0.7 bed +1F6CB FE0F ; fully-qualified # 🛋️ E0.7 couch and lamp +1F6CB ; unqualified # 🛋 E0.7 couch and lamp +1FA91 ; fully-qualified # 🪑 E12.0 chair +1F6BD ; fully-qualified # 🚽 E0.6 toilet +1FAA0 ; fully-qualified # 🪠 E13.0 plunger +1F6BF ; fully-qualified # 🚿 E1.0 shower +1F6C1 ; fully-qualified # 🛁 E1.0 bathtub +1FAA4 ; fully-qualified # 🪤 E13.0 mouse trap +1FA92 ; fully-qualified # 🪒 E12.0 razor +1F9F4 ; fully-qualified # 🧴 E11.0 lotion bottle +1F9F7 ; fully-qualified # 🧷 E11.0 safety pin +1F9F9 ; fully-qualified # 🧹 E11.0 broom +1F9FA ; fully-qualified # 🧺 E11.0 basket +1F9FB ; fully-qualified # 🧻 E11.0 roll of paper +1FAA3 ; fully-qualified # 🪣 E13.0 bucket +1F9FC ; fully-qualified # 🧼 E11.0 soap +1FAE7 ; fully-qualified # 🫧 E14.0 bubbles +1FAA5 ; fully-qualified # 🪥 E13.0 toothbrush +1F9FD ; fully-qualified # 🧽 E11.0 sponge +1F9EF ; fully-qualified # 🧯 E11.0 fire extinguisher +1F6D2 ; fully-qualified # 🛒 E3.0 shopping cart + +# subgroup: other-object +1F6AC ; fully-qualified # 🚬 E0.6 cigarette +26B0 FE0F ; fully-qualified # ⚰️ E1.0 coffin +26B0 ; unqualified # ⚰ E1.0 coffin +1FAA6 ; fully-qualified # 🪦 E13.0 headstone +26B1 FE0F ; fully-qualified # ⚱️ E1.0 funeral urn +26B1 ; unqualified # ⚱ E1.0 funeral urn +1F9FF ; fully-qualified # 🧿 E11.0 nazar amulet +1FAAC ; fully-qualified # 🪬 E14.0 hamsa +1F5FF ; fully-qualified # 🗿 E0.6 moai +1FAA7 ; fully-qualified # 🪧 E13.0 placard +1FAAA ; fully-qualified # 🪪 E14.0 identification card + +# Objects subtotal: 310 +# Objects subtotal: 310 w/o modifiers + +# group: Symbols + +# subgroup: transport-sign +1F3E7 ; fully-qualified # 🏧 E0.6 ATM sign +1F6AE ; fully-qualified # 🚮 E1.0 litter in bin sign +1F6B0 ; fully-qualified # 🚰 E1.0 potable water +267F ; fully-qualified # ♿ E0.6 wheelchair symbol +1F6B9 ; fully-qualified # 🚹 E0.6 men’s room +1F6BA ; fully-qualified # 🚺 E0.6 women’s room +1F6BB ; fully-qualified # 🚻 E0.6 restroom +1F6BC ; fully-qualified # 🚼 E0.6 baby symbol +1F6BE ; fully-qualified # 🚾 E0.6 water closet +1F6C2 ; fully-qualified # 🛂 E1.0 passport control +1F6C3 ; fully-qualified # 🛃 E1.0 customs +1F6C4 ; fully-qualified # 🛄 E1.0 baggage claim +1F6C5 ; fully-qualified # 🛅 E1.0 left luggage + +# subgroup: warning +26A0 FE0F ; fully-qualified # ⚠️ E0.6 warning +26A0 ; unqualified # ⚠ E0.6 warning +1F6B8 ; fully-qualified # 🚸 E1.0 children crossing +26D4 ; fully-qualified # ⛔ E0.6 no entry +1F6AB ; fully-qualified # 🚫 E0.6 prohibited +1F6B3 ; fully-qualified # 🚳 E1.0 no bicycles +1F6AD ; fully-qualified # 🚭 E0.6 no smoking +1F6AF ; fully-qualified # 🚯 E1.0 no littering +1F6B1 ; fully-qualified # 🚱 E1.0 non-potable water +1F6B7 ; fully-qualified # 🚷 E1.0 no pedestrians +1F4F5 ; fully-qualified # 📵 E1.0 no mobile phones +1F51E ; fully-qualified # 🔞 E0.6 no one under eighteen +2622 FE0F ; fully-qualified # ☢️ E1.0 radioactive +2622 ; unqualified # ☢ E1.0 radioactive +2623 FE0F ; fully-qualified # ☣️ E1.0 biohazard +2623 ; unqualified # ☣ E1.0 biohazard + +# subgroup: arrow +2B06 FE0F ; fully-qualified # ⬆️ E0.6 up arrow +2B06 ; unqualified # ⬆ E0.6 up arrow +2197 FE0F ; fully-qualified # ↗️ E0.6 up-right arrow +2197 ; unqualified # ↗ E0.6 up-right arrow +27A1 FE0F ; fully-qualified # ➡️ E0.6 right arrow +27A1 ; unqualified # ➡ E0.6 right arrow +2198 FE0F ; fully-qualified # ↘️ E0.6 down-right arrow +2198 ; unqualified # ↘ E0.6 down-right arrow +2B07 FE0F ; fully-qualified # ⬇️ E0.6 down arrow +2B07 ; unqualified # ⬇ E0.6 down arrow +2199 FE0F ; fully-qualified # ↙️ E0.6 down-left arrow +2199 ; unqualified # ↙ E0.6 down-left arrow +2B05 FE0F ; fully-qualified # ⬅️ E0.6 left arrow +2B05 ; unqualified # ⬅ E0.6 left arrow +2196 FE0F ; fully-qualified # ↖️ E0.6 up-left arrow +2196 ; unqualified # ↖ E0.6 up-left arrow +2195 FE0F ; fully-qualified # ↕️ E0.6 up-down arrow +2195 ; unqualified # ↕ E0.6 up-down arrow +2194 FE0F ; fully-qualified # ↔️ E0.6 left-right arrow +2194 ; unqualified # ↔ E0.6 left-right arrow +21A9 FE0F ; fully-qualified # ↩️ E0.6 right arrow curving left +21A9 ; unqualified # ↩ E0.6 right arrow curving left +21AA FE0F ; fully-qualified # ↪️ E0.6 left arrow curving right +21AA ; unqualified # ↪ E0.6 left arrow curving right +2934 FE0F ; fully-qualified # ⤴️ E0.6 right arrow curving up +2934 ; unqualified # ⤴ E0.6 right arrow curving up +2935 FE0F ; fully-qualified # ⤵️ E0.6 right arrow curving down +2935 ; unqualified # ⤵ E0.6 right arrow curving down +1F503 ; fully-qualified # 🔃 E0.6 clockwise vertical arrows +1F504 ; fully-qualified # 🔄 E1.0 counterclockwise arrows button +1F519 ; fully-qualified # 🔙 E0.6 BACK arrow +1F51A ; fully-qualified # 🔚 E0.6 END arrow +1F51B ; fully-qualified # 🔛 E0.6 ON! arrow +1F51C ; fully-qualified # 🔜 E0.6 SOON arrow +1F51D ; fully-qualified # 🔝 E0.6 TOP arrow + +# subgroup: religion +1F6D0 ; fully-qualified # 🛐 E1.0 place of worship +269B FE0F ; fully-qualified # ⚛️ E1.0 atom symbol +269B ; unqualified # ⚛ E1.0 atom symbol +1F549 FE0F ; fully-qualified # 🕉️ E0.7 om +1F549 ; unqualified # 🕉 E0.7 om +2721 FE0F ; fully-qualified # ✡️ E0.7 star of David +2721 ; unqualified # ✡ E0.7 star of David +2638 FE0F ; fully-qualified # ☸️ E0.7 wheel of dharma +2638 ; unqualified # ☸ E0.7 wheel of dharma +262F FE0F ; fully-qualified # ☯️ E0.7 yin yang +262F ; unqualified # ☯ E0.7 yin yang +271D FE0F ; fully-qualified # ✝️ E0.7 latin cross +271D ; unqualified # ✝ E0.7 latin cross +2626 FE0F ; fully-qualified # ☦️ E1.0 orthodox cross +2626 ; unqualified # ☦ E1.0 orthodox cross +262A FE0F ; fully-qualified # ☪️ E0.7 star and crescent +262A ; unqualified # ☪ E0.7 star and crescent +262E FE0F ; fully-qualified # ☮️ E1.0 peace symbol +262E ; unqualified # ☮ E1.0 peace symbol +1F54E ; fully-qualified # 🕎 E1.0 menorah +1F52F ; fully-qualified # 🔯 E0.6 dotted six-pointed star +1FAAF ; fully-qualified # 🪯 E15.0 khanda + +# subgroup: zodiac +2648 ; fully-qualified # ♈ E0.6 Aries +2649 ; fully-qualified # ♉ E0.6 Taurus +264A ; fully-qualified # ♊ E0.6 Gemini +264B ; fully-qualified # ♋ E0.6 Cancer +264C ; fully-qualified # ♌ E0.6 Leo +264D ; fully-qualified # ♍ E0.6 Virgo +264E ; fully-qualified # ♎ E0.6 Libra +264F ; fully-qualified # ♏ E0.6 Scorpio +2650 ; fully-qualified # ♐ E0.6 Sagittarius +2651 ; fully-qualified # ♑ E0.6 Capricorn +2652 ; fully-qualified # ♒ E0.6 Aquarius +2653 ; fully-qualified # ♓ E0.6 Pisces +26CE ; fully-qualified # ⛎ E0.6 Ophiuchus + +# subgroup: av-symbol +1F500 ; fully-qualified # 🔀 E1.0 shuffle tracks button +1F501 ; fully-qualified # 🔁 E1.0 repeat button +1F502 ; fully-qualified # 🔂 E1.0 repeat single button +25B6 FE0F ; fully-qualified # ▶️ E0.6 play button +25B6 ; unqualified # ▶ E0.6 play button +23E9 ; fully-qualified # ⏩ E0.6 fast-forward button +23ED FE0F ; fully-qualified # ⏭️ E0.7 next track button +23ED ; unqualified # ⏭ E0.7 next track button +23EF FE0F ; fully-qualified # ⏯️ E1.0 play or pause button +23EF ; unqualified # ⏯ E1.0 play or pause button +25C0 FE0F ; fully-qualified # ◀️ E0.6 reverse button +25C0 ; unqualified # ◀ E0.6 reverse button +23EA ; fully-qualified # ⏪ E0.6 fast reverse button +23EE FE0F ; fully-qualified # ⏮️ E0.7 last track button +23EE ; unqualified # ⏮ E0.7 last track button +1F53C ; fully-qualified # 🔼 E0.6 upwards button +23EB ; fully-qualified # ⏫ E0.6 fast up button +1F53D ; fully-qualified # 🔽 E0.6 downwards button +23EC ; fully-qualified # ⏬ E0.6 fast down button +23F8 FE0F ; fully-qualified # ⏸️ E0.7 pause button +23F8 ; unqualified # ⏸ E0.7 pause button +23F9 FE0F ; fully-qualified # ⏹️ E0.7 stop button +23F9 ; unqualified # ⏹ E0.7 stop button +23FA FE0F ; fully-qualified # ⏺️ E0.7 record button +23FA ; unqualified # ⏺ E0.7 record button +23CF FE0F ; fully-qualified # ⏏️ E1.0 eject button +23CF ; unqualified # ⏏ E1.0 eject button +1F3A6 ; fully-qualified # 🎦 E0.6 cinema +1F505 ; fully-qualified # 🔅 E1.0 dim button +1F506 ; fully-qualified # 🔆 E1.0 bright button +1F4F6 ; fully-qualified # 📶 E0.6 antenna bars +1F6DC ; fully-qualified # 🛜 E15.0 wireless +1F4F3 ; fully-qualified # 📳 E0.6 vibration mode +1F4F4 ; fully-qualified # 📴 E0.6 mobile phone off + +# subgroup: gender +2640 FE0F ; fully-qualified # ♀️ E4.0 female sign +2640 ; unqualified # ♀ E4.0 female sign +2642 FE0F ; fully-qualified # ♂️ E4.0 male sign +2642 ; unqualified # ♂ E4.0 male sign +26A7 FE0F ; fully-qualified # ⚧️ E13.0 transgender symbol +26A7 ; unqualified # ⚧ E13.0 transgender symbol + +# subgroup: math +2716 FE0F ; fully-qualified # ✖️ E0.6 multiply +2716 ; unqualified # ✖ E0.6 multiply +2795 ; fully-qualified # ➕ E0.6 plus +2796 ; fully-qualified # ➖ E0.6 minus +2797 ; fully-qualified # ➗ E0.6 divide +1F7F0 ; fully-qualified # 🟰 E14.0 heavy equals sign +267E FE0F ; fully-qualified # ♾️ E11.0 infinity +267E ; unqualified # ♾ E11.0 infinity + +# subgroup: punctuation +203C FE0F ; fully-qualified # ‼️ E0.6 double exclamation mark +203C ; unqualified # ‼ E0.6 double exclamation mark +2049 FE0F ; fully-qualified # ⁉️ E0.6 exclamation question mark +2049 ; unqualified # ⁉ E0.6 exclamation question mark +2753 ; fully-qualified # ❓ E0.6 red question mark +2754 ; fully-qualified # ❔ E0.6 white question mark +2755 ; fully-qualified # ❕ E0.6 white exclamation mark +2757 ; fully-qualified # ❗ E0.6 red exclamation mark +3030 FE0F ; fully-qualified # 〰️ E0.6 wavy dash +3030 ; unqualified # 〰 E0.6 wavy dash + +# subgroup: currency +1F4B1 ; fully-qualified # 💱 E0.6 currency exchange +1F4B2 ; fully-qualified # 💲 E0.6 heavy dollar sign + +# subgroup: other-symbol +2695 FE0F ; fully-qualified # ⚕️ E4.0 medical symbol +2695 ; unqualified # ⚕ E4.0 medical symbol +267B FE0F ; fully-qualified # ♻️ E0.6 recycling symbol +267B ; unqualified # ♻ E0.6 recycling symbol +269C FE0F ; fully-qualified # ⚜️ E1.0 fleur-de-lis +269C ; unqualified # ⚜ E1.0 fleur-de-lis +1F531 ; fully-qualified # 🔱 E0.6 trident emblem +1F4DB ; fully-qualified # 📛 E0.6 name badge +1F530 ; fully-qualified # 🔰 E0.6 Japanese symbol for beginner +2B55 ; fully-qualified # ⭕ E0.6 hollow red circle +2705 ; fully-qualified # ✅ E0.6 check mark button +2611 FE0F ; fully-qualified # ☑️ E0.6 check box with check +2611 ; unqualified # ☑ E0.6 check box with check +2714 FE0F ; fully-qualified # ✔️ E0.6 check mark +2714 ; unqualified # ✔ E0.6 check mark +274C ; fully-qualified # ❌ E0.6 cross mark +274E ; fully-qualified # ❎ E0.6 cross mark button +27B0 ; fully-qualified # ➰ E0.6 curly loop +27BF ; fully-qualified # ➿ E1.0 double curly loop +303D FE0F ; fully-qualified # 〽️ E0.6 part alternation mark +303D ; unqualified # 〽 E0.6 part alternation mark +2733 FE0F ; fully-qualified # ✳️ E0.6 eight-spoked asterisk +2733 ; unqualified # ✳ E0.6 eight-spoked asterisk +2734 FE0F ; fully-qualified # ✴️ E0.6 eight-pointed star +2734 ; unqualified # ✴ E0.6 eight-pointed star +2747 FE0F ; fully-qualified # ❇️ E0.6 sparkle +2747 ; unqualified # ❇ E0.6 sparkle +00A9 FE0F ; fully-qualified # ©️ E0.6 copyright +00A9 ; unqualified # © E0.6 copyright +00AE FE0F ; fully-qualified # ®️ E0.6 registered +00AE ; unqualified # ® E0.6 registered +2122 FE0F ; fully-qualified # ™️ E0.6 trade mark +2122 ; unqualified # ™ E0.6 trade mark + +# subgroup: keycap +0023 FE0F 20E3 ; fully-qualified # #️⃣ E0.6 keycap: # +0023 20E3 ; unqualified # #⃣ E0.6 keycap: # +002A FE0F 20E3 ; fully-qualified # *️⃣ E2.0 keycap: * +002A 20E3 ; unqualified # *⃣ E2.0 keycap: * +0030 FE0F 20E3 ; fully-qualified # 0️⃣ E0.6 keycap: 0 +0030 20E3 ; unqualified # 0⃣ E0.6 keycap: 0 +0031 FE0F 20E3 ; fully-qualified # 1️⃣ E0.6 keycap: 1 +0031 20E3 ; unqualified # 1⃣ E0.6 keycap: 1 +0032 FE0F 20E3 ; fully-qualified # 2️⃣ E0.6 keycap: 2 +0032 20E3 ; unqualified # 2⃣ E0.6 keycap: 2 +0033 FE0F 20E3 ; fully-qualified # 3️⃣ E0.6 keycap: 3 +0033 20E3 ; unqualified # 3⃣ E0.6 keycap: 3 +0034 FE0F 20E3 ; fully-qualified # 4️⃣ E0.6 keycap: 4 +0034 20E3 ; unqualified # 4⃣ E0.6 keycap: 4 +0035 FE0F 20E3 ; fully-qualified # 5️⃣ E0.6 keycap: 5 +0035 20E3 ; unqualified # 5⃣ E0.6 keycap: 5 +0036 FE0F 20E3 ; fully-qualified # 6️⃣ E0.6 keycap: 6 +0036 20E3 ; unqualified # 6⃣ E0.6 keycap: 6 +0037 FE0F 20E3 ; fully-qualified # 7️⃣ E0.6 keycap: 7 +0037 20E3 ; unqualified # 7⃣ E0.6 keycap: 7 +0038 FE0F 20E3 ; fully-qualified # 8️⃣ E0.6 keycap: 8 +0038 20E3 ; unqualified # 8⃣ E0.6 keycap: 8 +0039 FE0F 20E3 ; fully-qualified # 9️⃣ E0.6 keycap: 9 +0039 20E3 ; unqualified # 9⃣ E0.6 keycap: 9 +1F51F ; fully-qualified # 🔟 E0.6 keycap: 10 + +# subgroup: alphanum +1F520 ; fully-qualified # 🔠 E0.6 input latin uppercase +1F521 ; fully-qualified # 🔡 E0.6 input latin lowercase +1F522 ; fully-qualified # 🔢 E0.6 input numbers +1F523 ; fully-qualified # 🔣 E0.6 input symbols +1F524 ; fully-qualified # 🔤 E0.6 input latin letters +1F170 FE0F ; fully-qualified # 🅰️ E0.6 A button (blood type) +1F170 ; unqualified # 🅰 E0.6 A button (blood type) +1F18E ; fully-qualified # 🆎 E0.6 AB button (blood type) +1F171 FE0F ; fully-qualified # 🅱️ E0.6 B button (blood type) +1F171 ; unqualified # 🅱 E0.6 B button (blood type) +1F191 ; fully-qualified # 🆑 E0.6 CL button +1F192 ; fully-qualified # 🆒 E0.6 COOL button +1F193 ; fully-qualified # 🆓 E0.6 FREE button +2139 FE0F ; fully-qualified # ℹ️ E0.6 information +2139 ; unqualified # ℹ E0.6 information +1F194 ; fully-qualified # 🆔 E0.6 ID button +24C2 FE0F ; fully-qualified # Ⓜ️ E0.6 circled M +24C2 ; unqualified # Ⓜ E0.6 circled M +1F195 ; fully-qualified # 🆕 E0.6 NEW button +1F196 ; fully-qualified # 🆖 E0.6 NG button +1F17E FE0F ; fully-qualified # 🅾️ E0.6 O button (blood type) +1F17E ; unqualified # 🅾 E0.6 O button (blood type) +1F197 ; fully-qualified # 🆗 E0.6 OK button +1F17F FE0F ; fully-qualified # 🅿️ E0.6 P button +1F17F ; unqualified # 🅿 E0.6 P button +1F198 ; fully-qualified # 🆘 E0.6 SOS button +1F199 ; fully-qualified # 🆙 E0.6 UP! button +1F19A ; fully-qualified # 🆚 E0.6 VS button +1F201 ; fully-qualified # 🈁 E0.6 Japanese “here” button +1F202 FE0F ; fully-qualified # 🈂️ E0.6 Japanese “service charge” button +1F202 ; unqualified # 🈂 E0.6 Japanese “service charge” button +1F237 FE0F ; fully-qualified # 🈷️ E0.6 Japanese “monthly amount” button +1F237 ; unqualified # 🈷 E0.6 Japanese “monthly amount” button +1F236 ; fully-qualified # 🈶 E0.6 Japanese “not free of charge” button +1F22F ; fully-qualified # 🈯 E0.6 Japanese “reserved” button +1F250 ; fully-qualified # 🉐 E0.6 Japanese “bargain” button +1F239 ; fully-qualified # 🈹 E0.6 Japanese “discount” button +1F21A ; fully-qualified # 🈚 E0.6 Japanese “free of charge” button +1F232 ; fully-qualified # 🈲 E0.6 Japanese “prohibited” button +1F251 ; fully-qualified # 🉑 E0.6 Japanese “acceptable” button +1F238 ; fully-qualified # 🈸 E0.6 Japanese “application” button +1F234 ; fully-qualified # 🈴 E0.6 Japanese “passing grade” button +1F233 ; fully-qualified # 🈳 E0.6 Japanese “vacancy” button +3297 FE0F ; fully-qualified # ㊗️ E0.6 Japanese “congratulations” button +3297 ; unqualified # ㊗ E0.6 Japanese “congratulations” button +3299 FE0F ; fully-qualified # ㊙️ E0.6 Japanese “secret” button +3299 ; unqualified # ㊙ E0.6 Japanese “secret” button +1F23A ; fully-qualified # 🈺 E0.6 Japanese “open for business” button +1F235 ; fully-qualified # 🈵 E0.6 Japanese “no vacancy” button + +# subgroup: geometric +1F534 ; fully-qualified # 🔴 E0.6 red circle +1F7E0 ; fully-qualified # 🟠 E12.0 orange circle +1F7E1 ; fully-qualified # 🟡 E12.0 yellow circle +1F7E2 ; fully-qualified # 🟢 E12.0 green circle +1F535 ; fully-qualified # 🔵 E0.6 blue circle +1F7E3 ; fully-qualified # 🟣 E12.0 purple circle +1F7E4 ; fully-qualified # 🟤 E12.0 brown circle +26AB ; fully-qualified # ⚫ E0.6 black circle +26AA ; fully-qualified # ⚪ E0.6 white circle +1F7E5 ; fully-qualified # 🟥 E12.0 red square +1F7E7 ; fully-qualified # 🟧 E12.0 orange square +1F7E8 ; fully-qualified # 🟨 E12.0 yellow square +1F7E9 ; fully-qualified # 🟩 E12.0 green square +1F7E6 ; fully-qualified # 🟦 E12.0 blue square +1F7EA ; fully-qualified # 🟪 E12.0 purple square +1F7EB ; fully-qualified # 🟫 E12.0 brown square +2B1B ; fully-qualified # ⬛ E0.6 black large square +2B1C ; fully-qualified # ⬜ E0.6 white large square +25FC FE0F ; fully-qualified # ◼️ E0.6 black medium square +25FC ; unqualified # ◼ E0.6 black medium square +25FB FE0F ; fully-qualified # ◻️ E0.6 white medium square +25FB ; unqualified # ◻ E0.6 white medium square +25FE ; fully-qualified # ◾ E0.6 black medium-small square +25FD ; fully-qualified # ◽ E0.6 white medium-small square +25AA FE0F ; fully-qualified # ▪️ E0.6 black small square +25AA ; unqualified # ▪ E0.6 black small square +25AB FE0F ; fully-qualified # ▫️ E0.6 white small square +25AB ; unqualified # ▫ E0.6 white small square +1F536 ; fully-qualified # 🔶 E0.6 large orange diamond +1F537 ; fully-qualified # 🔷 E0.6 large blue diamond +1F538 ; fully-qualified # 🔸 E0.6 small orange diamond +1F539 ; fully-qualified # 🔹 E0.6 small blue diamond +1F53A ; fully-qualified # 🔺 E0.6 red triangle pointed up +1F53B ; fully-qualified # 🔻 E0.6 red triangle pointed down +1F4A0 ; fully-qualified # 💠 E0.6 diamond with a dot +1F518 ; fully-qualified # 🔘 E0.6 radio button +1F533 ; fully-qualified # 🔳 E0.6 white square button +1F532 ; fully-qualified # 🔲 E0.6 black square button + +# Symbols subtotal: 304 +# Symbols subtotal: 304 w/o modifiers + +# group: Flags + +# subgroup: flag +1F3C1 ; fully-qualified # 🏁 E0.6 chequered flag +1F6A9 ; fully-qualified # 🚩 E0.6 triangular flag +1F38C ; fully-qualified # 🎌 E0.6 crossed flags +1F3F4 ; fully-qualified # 🏴 E1.0 black flag +1F3F3 FE0F ; fully-qualified # 🏳️ E0.7 white flag +1F3F3 ; unqualified # 🏳 E0.7 white flag +1F3F3 FE0F 200D 1F308 ; fully-qualified # 🏳️‍🌈 E4.0 rainbow flag +1F3F3 200D 1F308 ; unqualified # 🏳‍🌈 E4.0 rainbow flag +1F3F3 FE0F 200D 26A7 FE0F ; fully-qualified # 🏳️‍⚧️ E13.0 transgender flag +1F3F3 200D 26A7 FE0F ; unqualified # 🏳‍⚧️ E13.0 transgender flag +1F3F3 FE0F 200D 26A7 ; minimally-qualified # 🏳️‍⚧ E13.0 transgender flag +1F3F3 200D 26A7 ; unqualified # 🏳‍⚧ E13.0 transgender flag +1F3F4 200D 2620 FE0F ; fully-qualified # 🏴‍☠️ E11.0 pirate flag +1F3F4 200D 2620 ; minimally-qualified # 🏴‍☠ E11.0 pirate flag + +# subgroup: country-flag +1F1E6 1F1E8 ; fully-qualified # 🇦🇨 E2.0 flag: Ascension Island +1F1E6 1F1E9 ; fully-qualified # 🇦🇩 E2.0 flag: Andorra +1F1E6 1F1EA ; fully-qualified # 🇦🇪 E2.0 flag: United Arab Emirates +1F1E6 1F1EB ; fully-qualified # 🇦🇫 E2.0 flag: Afghanistan +1F1E6 1F1EC ; fully-qualified # 🇦🇬 E2.0 flag: Antigua & Barbuda +1F1E6 1F1EE ; fully-qualified # 🇦🇮 E2.0 flag: Anguilla +1F1E6 1F1F1 ; fully-qualified # 🇦🇱 E2.0 flag: Albania +1F1E6 1F1F2 ; fully-qualified # 🇦🇲 E2.0 flag: Armenia +1F1E6 1F1F4 ; fully-qualified # 🇦🇴 E2.0 flag: Angola +1F1E6 1F1F6 ; fully-qualified # 🇦🇶 E2.0 flag: Antarctica +1F1E6 1F1F7 ; fully-qualified # 🇦🇷 E2.0 flag: Argentina +1F1E6 1F1F8 ; fully-qualified # 🇦🇸 E2.0 flag: American Samoa +1F1E6 1F1F9 ; fully-qualified # 🇦🇹 E2.0 flag: Austria +1F1E6 1F1FA ; fully-qualified # 🇦🇺 E2.0 flag: Australia +1F1E6 1F1FC ; fully-qualified # 🇦🇼 E2.0 flag: Aruba +1F1E6 1F1FD ; fully-qualified # 🇦🇽 E2.0 flag: Åland Islands +1F1E6 1F1FF ; fully-qualified # 🇦🇿 E2.0 flag: Azerbaijan +1F1E7 1F1E6 ; fully-qualified # 🇧🇦 E2.0 flag: Bosnia & Herzegovina +1F1E7 1F1E7 ; fully-qualified # 🇧🇧 E2.0 flag: Barbados +1F1E7 1F1E9 ; fully-qualified # 🇧🇩 E2.0 flag: Bangladesh +1F1E7 1F1EA ; fully-qualified # 🇧🇪 E2.0 flag: Belgium +1F1E7 1F1EB ; fully-qualified # 🇧🇫 E2.0 flag: Burkina Faso +1F1E7 1F1EC ; fully-qualified # 🇧🇬 E2.0 flag: Bulgaria +1F1E7 1F1ED ; fully-qualified # 🇧🇭 E2.0 flag: Bahrain +1F1E7 1F1EE ; fully-qualified # 🇧🇮 E2.0 flag: Burundi +1F1E7 1F1EF ; fully-qualified # 🇧🇯 E2.0 flag: Benin +1F1E7 1F1F1 ; fully-qualified # 🇧🇱 E2.0 flag: St. Barthélemy +1F1E7 1F1F2 ; fully-qualified # 🇧🇲 E2.0 flag: Bermuda +1F1E7 1F1F3 ; fully-qualified # 🇧🇳 E2.0 flag: Brunei +1F1E7 1F1F4 ; fully-qualified # 🇧🇴 E2.0 flag: Bolivia +1F1E7 1F1F6 ; fully-qualified # 🇧🇶 E2.0 flag: Caribbean Netherlands +1F1E7 1F1F7 ; fully-qualified # 🇧🇷 E2.0 flag: Brazil +1F1E7 1F1F8 ; fully-qualified # 🇧🇸 E2.0 flag: Bahamas +1F1E7 1F1F9 ; fully-qualified # 🇧🇹 E2.0 flag: Bhutan +1F1E7 1F1FB ; fully-qualified # 🇧🇻 E2.0 flag: Bouvet Island +1F1E7 1F1FC ; fully-qualified # 🇧🇼 E2.0 flag: Botswana +1F1E7 1F1FE ; fully-qualified # 🇧🇾 E2.0 flag: Belarus +1F1E7 1F1FF ; fully-qualified # 🇧🇿 E2.0 flag: Belize +1F1E8 1F1E6 ; fully-qualified # 🇨🇦 E2.0 flag: Canada +1F1E8 1F1E8 ; fully-qualified # 🇨🇨 E2.0 flag: Cocos (Keeling) Islands +1F1E8 1F1E9 ; fully-qualified # 🇨🇩 E2.0 flag: Congo - Kinshasa +1F1E8 1F1EB ; fully-qualified # 🇨🇫 E2.0 flag: Central African Republic +1F1E8 1F1EC ; fully-qualified # 🇨🇬 E2.0 flag: Congo - Brazzaville +1F1E8 1F1ED ; fully-qualified # 🇨🇭 E2.0 flag: Switzerland +1F1E8 1F1EE ; fully-qualified # 🇨🇮 E2.0 flag: Côte d’Ivoire +1F1E8 1F1F0 ; fully-qualified # 🇨🇰 E2.0 flag: Cook Islands +1F1E8 1F1F1 ; fully-qualified # 🇨🇱 E2.0 flag: Chile +1F1E8 1F1F2 ; fully-qualified # 🇨🇲 E2.0 flag: Cameroon +1F1E8 1F1F3 ; fully-qualified # 🇨🇳 E0.6 flag: China +1F1E8 1F1F4 ; fully-qualified # 🇨🇴 E2.0 flag: Colombia +1F1E8 1F1F5 ; fully-qualified # 🇨🇵 E2.0 flag: Clipperton Island +1F1E8 1F1F7 ; fully-qualified # 🇨🇷 E2.0 flag: Costa Rica +1F1E8 1F1FA ; fully-qualified # 🇨🇺 E2.0 flag: Cuba +1F1E8 1F1FB ; fully-qualified # 🇨🇻 E2.0 flag: Cape Verde +1F1E8 1F1FC ; fully-qualified # 🇨🇼 E2.0 flag: Curaçao +1F1E8 1F1FD ; fully-qualified # 🇨🇽 E2.0 flag: Christmas Island +1F1E8 1F1FE ; fully-qualified # 🇨🇾 E2.0 flag: Cyprus +1F1E8 1F1FF ; fully-qualified # 🇨🇿 E2.0 flag: Czechia +1F1E9 1F1EA ; fully-qualified # 🇩🇪 E0.6 flag: Germany +1F1E9 1F1EC ; fully-qualified # 🇩🇬 E2.0 flag: Diego Garcia +1F1E9 1F1EF ; fully-qualified # 🇩🇯 E2.0 flag: Djibouti +1F1E9 1F1F0 ; fully-qualified # 🇩🇰 E2.0 flag: Denmark +1F1E9 1F1F2 ; fully-qualified # 🇩🇲 E2.0 flag: Dominica +1F1E9 1F1F4 ; fully-qualified # 🇩🇴 E2.0 flag: Dominican Republic +1F1E9 1F1FF ; fully-qualified # 🇩🇿 E2.0 flag: Algeria +1F1EA 1F1E6 ; fully-qualified # 🇪🇦 E2.0 flag: Ceuta & Melilla +1F1EA 1F1E8 ; fully-qualified # 🇪🇨 E2.0 flag: Ecuador +1F1EA 1F1EA ; fully-qualified # 🇪🇪 E2.0 flag: Estonia +1F1EA 1F1EC ; fully-qualified # 🇪🇬 E2.0 flag: Egypt +1F1EA 1F1ED ; fully-qualified # 🇪🇭 E2.0 flag: Western Sahara +1F1EA 1F1F7 ; fully-qualified # 🇪🇷 E2.0 flag: Eritrea +1F1EA 1F1F8 ; fully-qualified # 🇪🇸 E0.6 flag: Spain +1F1EA 1F1F9 ; fully-qualified # 🇪🇹 E2.0 flag: Ethiopia +1F1EA 1F1FA ; fully-qualified # 🇪🇺 E2.0 flag: European Union +1F1EB 1F1EE ; fully-qualified # 🇫🇮 E2.0 flag: Finland +1F1EB 1F1EF ; fully-qualified # 🇫🇯 E2.0 flag: Fiji +1F1EB 1F1F0 ; fully-qualified # 🇫🇰 E2.0 flag: Falkland Islands +1F1EB 1F1F2 ; fully-qualified # 🇫🇲 E2.0 flag: Micronesia +1F1EB 1F1F4 ; fully-qualified # 🇫🇴 E2.0 flag: Faroe Islands +1F1EB 1F1F7 ; fully-qualified # 🇫🇷 E0.6 flag: France +1F1EC 1F1E6 ; fully-qualified # 🇬🇦 E2.0 flag: Gabon +1F1EC 1F1E7 ; fully-qualified # 🇬🇧 E0.6 flag: United Kingdom +1F1EC 1F1E9 ; fully-qualified # 🇬🇩 E2.0 flag: Grenada +1F1EC 1F1EA ; fully-qualified # 🇬🇪 E2.0 flag: Georgia +1F1EC 1F1EB ; fully-qualified # 🇬🇫 E2.0 flag: French Guiana +1F1EC 1F1EC ; fully-qualified # 🇬🇬 E2.0 flag: Guernsey +1F1EC 1F1ED ; fully-qualified # 🇬🇭 E2.0 flag: Ghana +1F1EC 1F1EE ; fully-qualified # 🇬🇮 E2.0 flag: Gibraltar +1F1EC 1F1F1 ; fully-qualified # 🇬🇱 E2.0 flag: Greenland +1F1EC 1F1F2 ; fully-qualified # 🇬🇲 E2.0 flag: Gambia +1F1EC 1F1F3 ; fully-qualified # 🇬🇳 E2.0 flag: Guinea +1F1EC 1F1F5 ; fully-qualified # 🇬🇵 E2.0 flag: Guadeloupe +1F1EC 1F1F6 ; fully-qualified # 🇬🇶 E2.0 flag: Equatorial Guinea +1F1EC 1F1F7 ; fully-qualified # 🇬🇷 E2.0 flag: Greece +1F1EC 1F1F8 ; fully-qualified # 🇬🇸 E2.0 flag: South Georgia & South Sandwich Islands +1F1EC 1F1F9 ; fully-qualified # 🇬🇹 E2.0 flag: Guatemala +1F1EC 1F1FA ; fully-qualified # 🇬🇺 E2.0 flag: Guam +1F1EC 1F1FC ; fully-qualified # 🇬🇼 E2.0 flag: Guinea-Bissau +1F1EC 1F1FE ; fully-qualified # 🇬🇾 E2.0 flag: Guyana +1F1ED 1F1F0 ; fully-qualified # 🇭🇰 E2.0 flag: Hong Kong SAR China +1F1ED 1F1F2 ; fully-qualified # 🇭🇲 E2.0 flag: Heard & McDonald Islands +1F1ED 1F1F3 ; fully-qualified # 🇭🇳 E2.0 flag: Honduras +1F1ED 1F1F7 ; fully-qualified # 🇭🇷 E2.0 flag: Croatia +1F1ED 1F1F9 ; fully-qualified # 🇭🇹 E2.0 flag: Haiti +1F1ED 1F1FA ; fully-qualified # 🇭🇺 E2.0 flag: Hungary +1F1EE 1F1E8 ; fully-qualified # 🇮🇨 E2.0 flag: Canary Islands +1F1EE 1F1E9 ; fully-qualified # 🇮🇩 E2.0 flag: Indonesia +1F1EE 1F1EA ; fully-qualified # 🇮🇪 E2.0 flag: Ireland +1F1EE 1F1F1 ; fully-qualified # 🇮🇱 E2.0 flag: Israel +1F1EE 1F1F2 ; fully-qualified # 🇮🇲 E2.0 flag: Isle of Man +1F1EE 1F1F3 ; fully-qualified # 🇮🇳 E2.0 flag: India +1F1EE 1F1F4 ; fully-qualified # 🇮🇴 E2.0 flag: British Indian Ocean Territory +1F1EE 1F1F6 ; fully-qualified # 🇮🇶 E2.0 flag: Iraq +1F1EE 1F1F7 ; fully-qualified # 🇮🇷 E2.0 flag: Iran +1F1EE 1F1F8 ; fully-qualified # 🇮🇸 E2.0 flag: Iceland +1F1EE 1F1F9 ; fully-qualified # 🇮🇹 E0.6 flag: Italy +1F1EF 1F1EA ; fully-qualified # 🇯🇪 E2.0 flag: Jersey +1F1EF 1F1F2 ; fully-qualified # 🇯🇲 E2.0 flag: Jamaica +1F1EF 1F1F4 ; fully-qualified # 🇯🇴 E2.0 flag: Jordan +1F1EF 1F1F5 ; fully-qualified # 🇯🇵 E0.6 flag: Japan +1F1F0 1F1EA ; fully-qualified # 🇰🇪 E2.0 flag: Kenya +1F1F0 1F1EC ; fully-qualified # 🇰🇬 E2.0 flag: Kyrgyzstan +1F1F0 1F1ED ; fully-qualified # 🇰🇭 E2.0 flag: Cambodia +1F1F0 1F1EE ; fully-qualified # 🇰🇮 E2.0 flag: Kiribati +1F1F0 1F1F2 ; fully-qualified # 🇰🇲 E2.0 flag: Comoros +1F1F0 1F1F3 ; fully-qualified # 🇰🇳 E2.0 flag: St. Kitts & Nevis +1F1F0 1F1F5 ; fully-qualified # 🇰🇵 E2.0 flag: North Korea +1F1F0 1F1F7 ; fully-qualified # 🇰🇷 E0.6 flag: South Korea +1F1F0 1F1FC ; fully-qualified # 🇰🇼 E2.0 flag: Kuwait +1F1F0 1F1FE ; fully-qualified # 🇰🇾 E2.0 flag: Cayman Islands +1F1F0 1F1FF ; fully-qualified # 🇰🇿 E2.0 flag: Kazakhstan +1F1F1 1F1E6 ; fully-qualified # 🇱🇦 E2.0 flag: Laos +1F1F1 1F1E7 ; fully-qualified # 🇱🇧 E2.0 flag: Lebanon +1F1F1 1F1E8 ; fully-qualified # 🇱🇨 E2.0 flag: St. Lucia +1F1F1 1F1EE ; fully-qualified # 🇱🇮 E2.0 flag: Liechtenstein +1F1F1 1F1F0 ; fully-qualified # 🇱🇰 E2.0 flag: Sri Lanka +1F1F1 1F1F7 ; fully-qualified # 🇱🇷 E2.0 flag: Liberia +1F1F1 1F1F8 ; fully-qualified # 🇱🇸 E2.0 flag: Lesotho +1F1F1 1F1F9 ; fully-qualified # 🇱🇹 E2.0 flag: Lithuania +1F1F1 1F1FA ; fully-qualified # 🇱🇺 E2.0 flag: Luxembourg +1F1F1 1F1FB ; fully-qualified # 🇱🇻 E2.0 flag: Latvia +1F1F1 1F1FE ; fully-qualified # 🇱🇾 E2.0 flag: Libya +1F1F2 1F1E6 ; fully-qualified # 🇲🇦 E2.0 flag: Morocco +1F1F2 1F1E8 ; fully-qualified # 🇲🇨 E2.0 flag: Monaco +1F1F2 1F1E9 ; fully-qualified # 🇲🇩 E2.0 flag: Moldova +1F1F2 1F1EA ; fully-qualified # 🇲🇪 E2.0 flag: Montenegro +1F1F2 1F1EB ; fully-qualified # 🇲🇫 E2.0 flag: St. Martin +1F1F2 1F1EC ; fully-qualified # 🇲🇬 E2.0 flag: Madagascar +1F1F2 1F1ED ; fully-qualified # 🇲🇭 E2.0 flag: Marshall Islands +1F1F2 1F1F0 ; fully-qualified # 🇲🇰 E2.0 flag: North Macedonia +1F1F2 1F1F1 ; fully-qualified # 🇲🇱 E2.0 flag: Mali +1F1F2 1F1F2 ; fully-qualified # 🇲🇲 E2.0 flag: Myanmar (Burma) +1F1F2 1F1F3 ; fully-qualified # 🇲🇳 E2.0 flag: Mongolia +1F1F2 1F1F4 ; fully-qualified # 🇲🇴 E2.0 flag: Macao SAR China +1F1F2 1F1F5 ; fully-qualified # 🇲🇵 E2.0 flag: Northern Mariana Islands +1F1F2 1F1F6 ; fully-qualified # 🇲🇶 E2.0 flag: Martinique +1F1F2 1F1F7 ; fully-qualified # 🇲🇷 E2.0 flag: Mauritania +1F1F2 1F1F8 ; fully-qualified # 🇲🇸 E2.0 flag: Montserrat +1F1F2 1F1F9 ; fully-qualified # 🇲🇹 E2.0 flag: Malta +1F1F2 1F1FA ; fully-qualified # 🇲🇺 E2.0 flag: Mauritius +1F1F2 1F1FB ; fully-qualified # 🇲🇻 E2.0 flag: Maldives +1F1F2 1F1FC ; fully-qualified # 🇲🇼 E2.0 flag: Malawi +1F1F2 1F1FD ; fully-qualified # 🇲🇽 E2.0 flag: Mexico +1F1F2 1F1FE ; fully-qualified # 🇲🇾 E2.0 flag: Malaysia +1F1F2 1F1FF ; fully-qualified # 🇲🇿 E2.0 flag: Mozambique +1F1F3 1F1E6 ; fully-qualified # 🇳🇦 E2.0 flag: Namibia +1F1F3 1F1E8 ; fully-qualified # 🇳🇨 E2.0 flag: New Caledonia +1F1F3 1F1EA ; fully-qualified # 🇳🇪 E2.0 flag: Niger +1F1F3 1F1EB ; fully-qualified # 🇳🇫 E2.0 flag: Norfolk Island +1F1F3 1F1EC ; fully-qualified # 🇳🇬 E2.0 flag: Nigeria +1F1F3 1F1EE ; fully-qualified # 🇳🇮 E2.0 flag: Nicaragua +1F1F3 1F1F1 ; fully-qualified # 🇳🇱 E2.0 flag: Netherlands +1F1F3 1F1F4 ; fully-qualified # 🇳🇴 E2.0 flag: Norway +1F1F3 1F1F5 ; fully-qualified # 🇳🇵 E2.0 flag: Nepal +1F1F3 1F1F7 ; fully-qualified # 🇳🇷 E2.0 flag: Nauru +1F1F3 1F1FA ; fully-qualified # 🇳🇺 E2.0 flag: Niue +1F1F3 1F1FF ; fully-qualified # 🇳🇿 E2.0 flag: New Zealand +1F1F4 1F1F2 ; fully-qualified # 🇴🇲 E2.0 flag: Oman +1F1F5 1F1E6 ; fully-qualified # 🇵🇦 E2.0 flag: Panama +1F1F5 1F1EA ; fully-qualified # 🇵🇪 E2.0 flag: Peru +1F1F5 1F1EB ; fully-qualified # 🇵🇫 E2.0 flag: French Polynesia +1F1F5 1F1EC ; fully-qualified # 🇵🇬 E2.0 flag: Papua New Guinea +1F1F5 1F1ED ; fully-qualified # 🇵🇭 E2.0 flag: Philippines +1F1F5 1F1F0 ; fully-qualified # 🇵🇰 E2.0 flag: Pakistan +1F1F5 1F1F1 ; fully-qualified # 🇵🇱 E2.0 flag: Poland +1F1F5 1F1F2 ; fully-qualified # 🇵🇲 E2.0 flag: St. Pierre & Miquelon +1F1F5 1F1F3 ; fully-qualified # 🇵🇳 E2.0 flag: Pitcairn Islands +1F1F5 1F1F7 ; fully-qualified # 🇵🇷 E2.0 flag: Puerto Rico +1F1F5 1F1F8 ; fully-qualified # 🇵🇸 E2.0 flag: Palestinian Territories +1F1F5 1F1F9 ; fully-qualified # 🇵🇹 E2.0 flag: Portugal +1F1F5 1F1FC ; fully-qualified # 🇵🇼 E2.0 flag: Palau +1F1F5 1F1FE ; fully-qualified # 🇵🇾 E2.0 flag: Paraguay +1F1F6 1F1E6 ; fully-qualified # 🇶🇦 E2.0 flag: Qatar +1F1F7 1F1EA ; fully-qualified # 🇷🇪 E2.0 flag: Réunion +1F1F7 1F1F4 ; fully-qualified # 🇷🇴 E2.0 flag: Romania +1F1F7 1F1F8 ; fully-qualified # 🇷🇸 E2.0 flag: Serbia +1F1F7 1F1FA ; fully-qualified # 🇷🇺 E0.6 flag: Russia +1F1F7 1F1FC ; fully-qualified # 🇷🇼 E2.0 flag: Rwanda +1F1F8 1F1E6 ; fully-qualified # 🇸🇦 E2.0 flag: Saudi Arabia +1F1F8 1F1E7 ; fully-qualified # 🇸🇧 E2.0 flag: Solomon Islands +1F1F8 1F1E8 ; fully-qualified # 🇸🇨 E2.0 flag: Seychelles +1F1F8 1F1E9 ; fully-qualified # 🇸🇩 E2.0 flag: Sudan +1F1F8 1F1EA ; fully-qualified # 🇸🇪 E2.0 flag: Sweden +1F1F8 1F1EC ; fully-qualified # 🇸🇬 E2.0 flag: Singapore +1F1F8 1F1ED ; fully-qualified # 🇸🇭 E2.0 flag: St. Helena +1F1F8 1F1EE ; fully-qualified # 🇸🇮 E2.0 flag: Slovenia +1F1F8 1F1EF ; fully-qualified # 🇸🇯 E2.0 flag: Svalbard & Jan Mayen +1F1F8 1F1F0 ; fully-qualified # 🇸🇰 E2.0 flag: Slovakia +1F1F8 1F1F1 ; fully-qualified # 🇸🇱 E2.0 flag: Sierra Leone +1F1F8 1F1F2 ; fully-qualified # 🇸🇲 E2.0 flag: San Marino +1F1F8 1F1F3 ; fully-qualified # 🇸🇳 E2.0 flag: Senegal +1F1F8 1F1F4 ; fully-qualified # 🇸🇴 E2.0 flag: Somalia +1F1F8 1F1F7 ; fully-qualified # 🇸🇷 E2.0 flag: Suriname +1F1F8 1F1F8 ; fully-qualified # 🇸🇸 E2.0 flag: South Sudan +1F1F8 1F1F9 ; fully-qualified # 🇸🇹 E2.0 flag: São Tomé & Príncipe +1F1F8 1F1FB ; fully-qualified # 🇸🇻 E2.0 flag: El Salvador +1F1F8 1F1FD ; fully-qualified # 🇸🇽 E2.0 flag: Sint Maarten +1F1F8 1F1FE ; fully-qualified # 🇸🇾 E2.0 flag: Syria +1F1F8 1F1FF ; fully-qualified # 🇸🇿 E2.0 flag: Eswatini +1F1F9 1F1E6 ; fully-qualified # 🇹🇦 E2.0 flag: Tristan da Cunha +1F1F9 1F1E8 ; fully-qualified # 🇹🇨 E2.0 flag: Turks & Caicos Islands +1F1F9 1F1E9 ; fully-qualified # 🇹🇩 E2.0 flag: Chad +1F1F9 1F1EB ; fully-qualified # 🇹🇫 E2.0 flag: French Southern Territories +1F1F9 1F1EC ; fully-qualified # 🇹🇬 E2.0 flag: Togo +1F1F9 1F1ED ; fully-qualified # 🇹🇭 E2.0 flag: Thailand +1F1F9 1F1EF ; fully-qualified # 🇹🇯 E2.0 flag: Tajikistan +1F1F9 1F1F0 ; fully-qualified # 🇹🇰 E2.0 flag: Tokelau +1F1F9 1F1F1 ; fully-qualified # 🇹🇱 E2.0 flag: Timor-Leste +1F1F9 1F1F2 ; fully-qualified # 🇹🇲 E2.0 flag: Turkmenistan +1F1F9 1F1F3 ; fully-qualified # 🇹🇳 E2.0 flag: Tunisia +1F1F9 1F1F4 ; fully-qualified # 🇹🇴 E2.0 flag: Tonga +1F1F9 1F1F7 ; fully-qualified # 🇹🇷 E2.0 flag: Turkey +1F1F9 1F1F9 ; fully-qualified # 🇹🇹 E2.0 flag: Trinidad & Tobago +1F1F9 1F1FB ; fully-qualified # 🇹🇻 E2.0 flag: Tuvalu +1F1F9 1F1FC ; fully-qualified # 🇹🇼 E2.0 flag: Taiwan +1F1F9 1F1FF ; fully-qualified # 🇹🇿 E2.0 flag: Tanzania +1F1FA 1F1E6 ; fully-qualified # 🇺🇦 E2.0 flag: Ukraine +1F1FA 1F1EC ; fully-qualified # 🇺🇬 E2.0 flag: Uganda +1F1FA 1F1F2 ; fully-qualified # 🇺🇲 E2.0 flag: U.S. Outlying Islands +1F1FA 1F1F3 ; fully-qualified # 🇺🇳 E4.0 flag: United Nations +1F1FA 1F1F8 ; fully-qualified # 🇺🇸 E0.6 flag: United States +1F1FA 1F1FE ; fully-qualified # 🇺🇾 E2.0 flag: Uruguay +1F1FA 1F1FF ; fully-qualified # 🇺🇿 E2.0 flag: Uzbekistan +1F1FB 1F1E6 ; fully-qualified # 🇻🇦 E2.0 flag: Vatican City +1F1FB 1F1E8 ; fully-qualified # 🇻🇨 E2.0 flag: St. Vincent & Grenadines +1F1FB 1F1EA ; fully-qualified # 🇻🇪 E2.0 flag: Venezuela +1F1FB 1F1EC ; fully-qualified # 🇻🇬 E2.0 flag: British Virgin Islands +1F1FB 1F1EE ; fully-qualified # 🇻🇮 E2.0 flag: U.S. Virgin Islands +1F1FB 1F1F3 ; fully-qualified # 🇻🇳 E2.0 flag: Vietnam +1F1FB 1F1FA ; fully-qualified # 🇻🇺 E2.0 flag: Vanuatu +1F1FC 1F1EB ; fully-qualified # 🇼🇫 E2.0 flag: Wallis & Futuna +1F1FC 1F1F8 ; fully-qualified # 🇼🇸 E2.0 flag: Samoa +1F1FD 1F1F0 ; fully-qualified # 🇽🇰 E2.0 flag: Kosovo +1F1FE 1F1EA ; fully-qualified # 🇾🇪 E2.0 flag: Yemen +1F1FE 1F1F9 ; fully-qualified # 🇾🇹 E2.0 flag: Mayotte +1F1FF 1F1E6 ; fully-qualified # 🇿🇦 E2.0 flag: South Africa +1F1FF 1F1F2 ; fully-qualified # 🇿🇲 E2.0 flag: Zambia +1F1FF 1F1FC ; fully-qualified # 🇿🇼 E2.0 flag: Zimbabwe + +# subgroup: subdivision-flag +1F3F4 E0067 E0062 E0065 E006E E0067 E007F ; fully-qualified # 🏴󠁧󠁢󠁥󠁮󠁧󠁿 E5.0 flag: England +1F3F4 E0067 E0062 E0073 E0063 E0074 E007F ; fully-qualified # 🏴󠁧󠁢󠁳󠁣󠁴󠁿 E5.0 flag: Scotland +1F3F4 E0067 E0062 E0077 E006C E0073 E007F ; fully-qualified # 🏴󠁧󠁢󠁷󠁬󠁳󠁿 E5.0 flag: Wales + +# Flags subtotal: 275 +# Flags subtotal: 275 w/o modifiers + +# Status Counts +# fully-qualified : 3655 +# minimally-qualified : 827 +# unqualified : 242 +# component : 9 + +#EOF diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex new file mode 100644 index 0000000..43a3447 --- /dev/null +++ b/lib/pleroma/emoji.ex @@ -0,0 +1,207 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emoji do + @moduledoc """ + This GenServer stores in an ETS table the list of the loaded emojis, + and also allows to reload the list at runtime. + """ + use GenServer + + alias Pleroma.Emoji.Combinations + alias Pleroma.Emoji.Loader + + require Logger + + @ets __MODULE__.Ets + @ets_options [ + :ordered_set, + :protected, + :named_table, + {:read_concurrency, true} + ] + + defstruct [:code, :file, :tags, :safe_code, :safe_file] + + @doc "Build emoji struct" + def build({code, file, tags}) do + %__MODULE__{ + code: code, + file: file, + tags: tags, + safe_code: Pleroma.HTML.strip_tags(code), + safe_file: Pleroma.HTML.strip_tags(file) + } + end + + def build({code, file}), do: build({code, file, []}) + + @doc false + def start_link(_) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @doc "Reloads the emojis from disk." + @spec reload() :: :ok + def reload do + GenServer.call(__MODULE__, :reload) + end + + @doc "Returns the path of the emoji `name`." + @spec get(String.t()) :: String.t() | nil + def get(name) do + name = maybe_strip_name(name) + + case :ets.lookup(@ets, name) do + [{_, path}] -> path + _ -> nil + end + end + + @spec exist?(String.t()) :: boolean() + def exist?(name), do: not is_nil(get(name)) + + @doc "Returns all the emojos!!" + @spec get_all() :: list({String.t(), String.t(), String.t()}) + def get_all do + :ets.tab2list(@ets) + end + + @doc "Clear out old emojis" + def clear_all, do: :ets.delete_all_objects(@ets) + + @doc false + def init(_) do + @ets = :ets.new(@ets, @ets_options) + GenServer.cast(self(), :reload) + {:ok, nil} + end + + @doc false + def handle_cast(:reload, state) do + update_emojis(Loader.load()) + {:noreply, state} + end + + @doc false + def handle_call(:reload, _from, state) do + update_emojis(Loader.load()) + {:reply, :ok, state} + end + + @doc false + def terminate(_, _) do + :ok + end + + @doc false + def code_change(_old_vsn, state, _extra) do + update_emojis(Loader.load()) + {:ok, state} + end + + defp update_emojis(emojis) do + :ets.insert(@ets, emojis) + end + + @external_resource "lib/pleroma/emoji-test.txt" + + regional_indicators = + Enum.map(127_462..127_487, fn codepoint -> + <> + end) + + emojis = + @external_resource + |> File.read!() + |> String.split("\n") + |> Enum.filter(fn line -> + line != "" and not String.starts_with?(line, "#") and + String.contains?(line, "fully-qualified") + end) + |> Enum.map(fn line -> + line + |> String.split(";", parts: 2) + |> hd() + |> String.trim() + |> String.split() + |> Enum.map(fn codepoint -> + <> + end) + |> Enum.join() + end) + |> Enum.uniq() + + emojis = emojis ++ regional_indicators + + for emoji <- emojis do + def is_unicode_emoji?(unquote(emoji)), do: true + end + + def is_unicode_emoji?(_), do: false + + @emoji_regex ~r/:[A-Za-z0-9_-]+(@.+)?:/ + + def is_custom_emoji?(s) when is_binary(s), do: Regex.match?(@emoji_regex, s) + + def is_custom_emoji?(_), do: false + + def maybe_strip_name(name) when is_binary(name), do: String.trim(name, ":") + + def maybe_strip_name(name), do: name + + def maybe_quote(name) when is_binary(name) do + if is_unicode_emoji?(name) do + name + else + if String.starts_with?(name, ":") do + name + else + ":#{name}:" + end + end + end + + def maybe_quote(name), do: name + + def emoji_url(%{"type" => "EmojiReact", "content" => _, "tag" => []}), do: nil + + def emoji_url(%{"type" => "EmojiReact", "content" => emoji, "tag" => tags}) do + emoji = maybe_strip_name(emoji) + + tag = + tags + |> Enum.find(fn tag -> + tag["type"] == "Emoji" && !is_nil(tag["name"]) && tag["name"] == emoji + end) + + if is_nil(tag) do + nil + else + tag + |> Map.get("icon") + |> Map.get("url") + end + end + + def emoji_url(_), do: nil + + def emoji_name_with_instance(name, url) do + url = url |> URI.parse() |> Map.get(:host) + "#{name}@#{url}" + end + + emoji_qualification_map = + emojis + |> Enum.filter(&String.contains?(&1, "\uFE0F")) + |> Combinations.variate_emoji_qualification() + + for {qualified, unqualified_list} <- emoji_qualification_map do + for unqualified <- unqualified_list do + def fully_qualify_emoji(unquote(unqualified)), do: unquote(qualified) + end + end + + def fully_qualify_emoji(emoji), do: emoji +end diff --git a/lib/pleroma/emoji/combinations.ex b/lib/pleroma/emoji/combinations.ex new file mode 100644 index 0000000..981c735 --- /dev/null +++ b/lib/pleroma/emoji/combinations.ex @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emoji.Combinations do + # FE0F is the emoji variation sequence. It is used for fully-qualifying + # emoji, and that includes emoji combinations. + # This code generates combinations per emoji: for each FE0F, all possible + # combinations of the character being removed or staying will be generated. + # This is made as an attempt to find all partially-qualified and unqualified + # versions of a fully-qualified emoji. + # I have found *no cases* for which this would be a problem, after browsing + # the entire emoji list in emoji-test.txt. This is safe, and, sadly, most + # likely sane too. + + defp qualification_combinations(codepoints) do + qualification_combinations([[]], codepoints) + end + + defp qualification_combinations(acc, []), do: acc + + defp qualification_combinations(acc, ["\uFE0F" | tail]) do + acc + |> Enum.flat_map(fn x -> [x, x ++ ["\uFE0F"]] end) + |> qualification_combinations(tail) + end + + defp qualification_combinations(acc, [codepoint | tail]) do + acc + |> Enum.map(&Kernel.++(&1, [codepoint])) + |> qualification_combinations(tail) + end + + def variate_emoji_qualification(emoji) when is_binary(emoji) do + emoji + |> String.codepoints() + |> qualification_combinations() + |> Enum.map(&List.to_string/1) + end + + def variate_emoji_qualification(emoji) when is_list(emoji) do + emoji + |> Enum.map(fn emoji -> {emoji, variate_emoji_qualification(emoji)} end) + end +end diff --git a/lib/pleroma/emoji/formatter.ex b/lib/pleroma/emoji/formatter.ex new file mode 100644 index 0000000..87fd35f --- /dev/null +++ b/lib/pleroma/emoji/formatter.ex @@ -0,0 +1,52 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emoji.Formatter do + alias Pleroma.Emoji + alias Pleroma.HTML + alias Pleroma.Web.Endpoint + alias Pleroma.Web.MediaProxy + + def emojify(text) do + emojify(text, Emoji.get_all()) + end + + def emojify(text, nil), do: text + + def emojify(text, emoji, strip \\ false) do + Enum.reduce(emoji, text, fn + {_, %Emoji{safe_code: emoji, safe_file: file}}, text -> + String.replace(text, ":#{emoji}:", prepare_emoji_html(emoji, file, strip)) + + {unsafe_emoji, unsafe_file}, text -> + emoji = HTML.strip_tags(unsafe_emoji) + file = HTML.strip_tags(unsafe_file) + String.replace(text, ":#{emoji}:", prepare_emoji_html(emoji, file, strip)) + end) + |> HTML.filter_tags() + end + + defp prepare_emoji_html(_emoji, _file, true), do: "" + + defp prepare_emoji_html(emoji, file, _strip) do + "#{emoji}" + end + + def demojify(text) do + emojify(text, Emoji.get_all(), true) + end + + def demojify(text, nil), do: text + + @doc "Outputs a list of the emoji-Maps in a text" + def get_emoji_map(text) when is_binary(text) do + Emoji.get_all() + |> Enum.filter(fn {emoji, %Emoji{}} -> String.contains?(text, ":#{emoji}:") end) + |> Enum.reduce(%{}, fn {name, %Emoji{file: file}}, acc -> + Map.put(acc, name, to_string(URI.merge(Endpoint.url(), file))) + end) + end + + def get_emoji_map(_), do: %{} +end diff --git a/lib/pleroma/emoji/loader.ex b/lib/pleroma/emoji/loader.ex new file mode 100644 index 0000000..eb6f681 --- /dev/null +++ b/lib/pleroma/emoji/loader.ex @@ -0,0 +1,233 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emoji.Loader do + @moduledoc """ + The Loader emoji from: + + * emoji packs in INSTANCE-DIR/emoji + * the files: `config/emoji.txt` and `config/custom_emoji.txt` + * glob paths, nested folder is used as tag name for grouping e.g. priv/static/emoji/custom/nested_folder + """ + alias Pleroma.Config + alias Pleroma.Emoji + + require Logger + + @mix_env Mix.env() + + @type pattern :: Regex.t() | module() | String.t() + @type patterns :: pattern() | [pattern()] + @type group_patterns :: keyword(patterns()) + @type emoji :: {String.t(), Emoji.t()} + + @doc """ + Loads emojis from files/packs. + + returns list emojis in format: + `{"000", "/emoji/freespeechextremist.com/000.png", ["Custom"]}` + """ + @spec load() :: list(emoji) + def load do + emoji_dir_path = Path.join(Config.get!([:instance, :static_dir]), "emoji") + + emoji_groups = Config.get([:emoji, :groups]) + + emojis = + case File.ls(emoji_dir_path) do + {:error, :enoent} -> + # The custom emoji directory doesn't exist, + # don't do anything + [] + + {:error, e} -> + # There was some other error + Logger.error("Could not access the custom emoji directory #{emoji_dir_path}: #{e}") + [] + + {:ok, results} -> + grouped = + Enum.group_by(results, fn file -> + File.dir?(Path.join(emoji_dir_path, file)) + end) + + packs = grouped[true] || [] + files = grouped[false] || [] + + # Print the packs we've found + Logger.info("Found emoji packs: #{Enum.join(packs, ", ")}") + + if not Enum.empty?(files) do + Logger.warning( + "Found files in the emoji folder. These will be ignored, please move them to a subdirectory\nFound files: #{Enum.join(files, ", ")}" + ) + end + + emojis = + Enum.flat_map(packs, fn pack -> + load_pack(Path.join(emoji_dir_path, pack), emoji_groups) + end) + + Emoji.clear_all() + emojis + end + + # Compat thing for old custom emoji handling & default emoji, + # it should run even if there are no emoji packs + shortcode_globs = Config.get([:emoji, :shortcode_globs], []) + + # for testing emoji.txt entries we do not want exposed in normal operation + test_emoji = + if @mix_env == :test do + load_from_file("test/config/emoji.txt", emoji_groups) + else + [] + end + + emojis_txt = + (load_from_file("config/emoji.txt", emoji_groups) ++ + load_from_file("config/custom_emoji.txt", emoji_groups) ++ + load_from_globs(shortcode_globs, emoji_groups) ++ + test_emoji) + |> Enum.reject(fn value -> value == nil end) + + Enum.map(emojis ++ emojis_txt, &prepare_emoji/1) + end + + defp prepare_emoji({code, _, _} = emoji), do: {code, Emoji.build(emoji)} + + defp load_pack(pack_dir, emoji_groups) do + pack_name = Path.basename(pack_dir) + + pack_file = Path.join(pack_dir, "pack.json") + + if File.exists?(pack_file) do + Logger.info("Loading emoji pack from JSON: #{pack_file}") + contents = Jason.decode!(File.read!(pack_file)) + + contents["files"] + |> Enum.map(fn {name, rel_file} -> + filename = Path.join("/emoji/#{pack_name}", rel_file) + {name, filename, ["pack:#{pack_name}"]} + end) + else + # Load from emoji.txt / all files + emoji_txt = Path.join(pack_dir, "emoji.txt") + + if File.exists?(emoji_txt) do + Logger.info("Loading emoji pack from emoji.txt: #{emoji_txt}") + load_from_file(emoji_txt, emoji_groups) + else + extensions = Config.get([:emoji, :pack_extensions]) + + Logger.info( + "No emoji.txt found for pack \"#{pack_name}\", assuming all #{Enum.join(extensions, ", ")} files are emoji" + ) + + make_shortcode_to_file_map(pack_dir, extensions) + |> Enum.map(fn {shortcode, rel_file} -> + filename = Path.join("/emoji/#{pack_name}", rel_file) + + {shortcode, filename, [to_string(match_extra(emoji_groups, filename))]} + end) + end + end + end + + def make_shortcode_to_file_map(pack_dir, exts) do + find_all_emoji(pack_dir, exts) + |> Enum.map(&Path.relative_to(&1, pack_dir)) + |> Enum.map(fn f -> {f |> Path.basename() |> Path.rootname(), f} end) + |> Enum.into(%{}) + end + + def find_all_emoji(dir, exts) do + dir + |> File.ls!() + |> Enum.flat_map(fn f -> + filepath = Path.join(dir, f) + + if File.dir?(filepath) do + find_all_emoji(filepath, exts) + else + [filepath] + end + end) + |> Enum.filter(fn f -> Path.extname(f) in exts end) + end + + defp load_from_file(file, emoji_groups) do + if File.exists?(file) do + load_from_file_stream(File.stream!(file), emoji_groups) + else + [] + end + end + + defp load_from_file_stream(stream, emoji_groups) do + stream + |> Stream.map(&String.trim/1) + |> Stream.map(fn line -> + case String.split(line, ~r/,\s*/) do + [name, file] -> + {name, file, [to_string(match_extra(emoji_groups, file))]} + + [name, file | tags] -> + {name, file, tags} + + _ -> + nil + end + end) + |> Enum.to_list() + end + + defp load_from_globs(globs, emoji_groups) do + static_path = Path.join(:code.priv_dir(:pleroma), "static") + + paths = + Enum.map(globs, fn glob -> + Path.join(static_path, glob) + |> Path.wildcard() + end) + |> Enum.concat() + + Enum.map(paths, fn path -> + tag = match_extra(emoji_groups, Path.join("/", Path.relative_to(path, static_path))) + shortcode = Path.basename(path, Path.extname(path)) + external_path = Path.join("/", Path.relative_to(path, static_path)) + {shortcode, external_path, [to_string(tag)]} + end) + end + + @doc """ + Finds a matching group for the given emoji filename + """ + @spec match_extra(group_patterns(), String.t()) :: atom() | nil + def match_extra(group_patterns, filename) do + match_group_patterns(group_patterns, fn pattern -> + case pattern do + %Regex{} = regex -> Regex.match?(regex, filename) + string when is_binary(string) -> filename == string + end + end) + end + + defp match_group_patterns(group_patterns, matcher) do + Enum.find_value(group_patterns, fn {group, patterns} -> + patterns = + patterns + |> List.wrap() + |> Enum.map(fn pattern -> + if String.contains?(pattern, "*") do + ~r(#{String.replace(pattern, "*", ".*")}) + else + pattern + end + end) + + Enum.any?(patterns, matcher) && group + end) + end +end diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex new file mode 100644 index 0000000..6b18025 --- /dev/null +++ b/lib/pleroma/emoji/pack.ex @@ -0,0 +1,669 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emoji.Pack do + @derive {Jason.Encoder, only: [:files, :pack, :files_count]} + defstruct files: %{}, + files_count: 0, + pack_file: nil, + path: nil, + pack: %{}, + name: nil + + @type t() :: %__MODULE__{ + files: %{String.t() => Path.t()}, + files_count: non_neg_integer(), + pack_file: Path.t(), + path: Path.t(), + pack: map(), + name: String.t() + } + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + + alias Pleroma.Emoji + alias Pleroma.Emoji.Pack + alias Pleroma.Utils + + @spec create(String.t()) :: {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} + def create(name) do + name = safe_path(name) + + with :ok <- validate_not_empty([name]), + dir <- Path.join(emoji_path(), name), + :ok <- File.mkdir(dir) do + save_pack(%__MODULE__{pack_file: Path.join(dir, "pack.json")}) + end + end + + defp paginate(entities, 1, page_size), do: Enum.take(entities, page_size) + + defp paginate(entities, page, page_size) do + entities + |> Enum.chunk_every(page_size) + |> Enum.at(page - 1) + end + + @spec show(keyword()) :: {:ok, t()} | {:error, atom()} + def show(opts) do + name = opts[:name] + + with :ok <- validate_not_empty([name]), + {:ok, pack} <- load_pack(name) do + shortcodes = + pack.files + |> Map.keys() + |> Enum.sort() + |> paginate(opts[:page], opts[:page_size]) + + pack = Map.put(pack, :files, Map.take(pack.files, shortcodes)) + + {:ok, validate_pack(pack)} + end + end + + @spec delete(String.t()) :: + {:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values} + def delete(name) do + with :ok <- validate_not_empty([name]), + pack_path <- Path.join(emoji_path(), name) do + File.rm_rf(pack_path) + end + end + + @spec unpack_zip_emojies(list(tuple())) :: list(map()) + defp unpack_zip_emojies(zip_files) do + Enum.reduce(zip_files, [], fn + {_, path, s, _, _, _}, acc when elem(s, 2) == :regular -> + with( + filename <- Path.basename(path), + shortcode <- Path.basename(filename, Path.extname(filename)), + false <- Emoji.exist?(shortcode) + ) do + [%{path: path, filename: path, shortcode: shortcode} | acc] + else + _ -> acc + end + + _, acc -> + acc + end) + end + + @spec add_file(t(), String.t(), Path.t(), Plug.Upload.t()) :: + {:ok, t()} + | {:error, File.posix() | atom()} + def add_file(%Pack{} = pack, _, _, %Plug.Upload{content_type: "application/zip"} = file) do + with {:ok, zip_files} <- :zip.table(to_charlist(file.path)), + [_ | _] = emojies <- unpack_zip_emojies(zip_files), + {:ok, tmp_dir} <- Utils.tmp_dir("emoji") do + try do + {:ok, _emoji_files} = + :zip.unzip( + to_charlist(file.path), + [{:file_list, Enum.map(emojies, & &1[:path])}, {:cwd, tmp_dir}] + ) + + {_, updated_pack} = + Enum.map_reduce(emojies, pack, fn item, emoji_pack -> + emoji_file = %Plug.Upload{ + filename: item[:filename], + path: Path.join(tmp_dir, item[:path]) + } + + {:ok, updated_pack} = + do_add_file( + emoji_pack, + item[:shortcode], + to_string(item[:filename]), + emoji_file + ) + + {item, updated_pack} + end) + + Emoji.reload() + + {:ok, updated_pack} + after + File.rm_rf(tmp_dir) + end + else + {:error, _} = error -> + error + + _ -> + {:ok, pack} + end + end + + def add_file(%Pack{} = pack, shortcode, filename, %Plug.Upload{} = file) do + with :ok <- validate_not_empty([shortcode, filename]), + :ok <- validate_emoji_not_exists(shortcode), + {:ok, updated_pack} <- do_add_file(pack, shortcode, filename, file) do + Emoji.reload() + {:ok, updated_pack} + end + end + + defp do_add_file(pack, shortcode, filename, file) do + with :ok <- save_file(file, pack, filename) do + pack + |> put_emoji(shortcode, filename) + |> save_pack() + end + end + + @spec delete_file(t(), String.t()) :: + {:ok, t()} | {:error, File.posix() | atom()} + def delete_file(%Pack{} = pack, shortcode) do + with :ok <- validate_not_empty([shortcode]), + :ok <- remove_file(pack, shortcode), + {:ok, updated_pack} <- pack |> delete_emoji(shortcode) |> save_pack() do + Emoji.reload() + {:ok, updated_pack} + end + end + + @spec update_file(t(), String.t(), String.t(), String.t(), boolean()) :: + {:ok, t()} | {:error, File.posix() | atom()} + def update_file(%Pack{} = pack, shortcode, new_shortcode, new_filename, force) do + with :ok <- validate_not_empty([shortcode, new_shortcode, new_filename]), + {:ok, filename} <- get_filename(pack, shortcode), + :ok <- validate_emoji_not_exists(new_shortcode, force), + :ok <- rename_file(pack, filename, new_filename), + {:ok, updated_pack} <- + pack + |> delete_emoji(shortcode) + |> put_emoji(new_shortcode, new_filename) + |> save_pack() do + Emoji.reload() + {:ok, updated_pack} + end + end + + @spec import_from_filesystem() :: {:ok, [String.t()]} | {:error, File.posix() | atom()} + def import_from_filesystem do + emoji_path = emoji_path() + + with {:ok, %{access: :read_write}} <- File.stat(emoji_path), + {:ok, results} <- File.ls(emoji_path) do + names = + results + |> Enum.map(&Path.join(emoji_path, &1)) + |> Enum.reject(fn path -> + File.dir?(path) and File.exists?(Path.join(path, "pack.json")) + end) + |> Enum.map(&write_pack_contents/1) + |> Enum.reject(&is_nil/1) + + {:ok, names} + else + {:ok, %{access: _}} -> {:error, :no_read_write} + e -> e + end + end + + @spec list_remote(keyword()) :: {:ok, map()} | {:error, atom()} + def list_remote(opts) do + uri = opts[:url] |> String.trim() |> URI.parse() + + with :ok <- validate_shareable_packs_available(uri) do + uri + |> URI.merge("/api/pleroma/emoji/packs?page=#{opts[:page]}&page_size=#{opts[:page_size]}") + |> http_get() + end + end + + @spec list_local(keyword()) :: {:ok, map(), non_neg_integer()} + def list_local(opts) do + with {:ok, results} <- list_packs_dir() do + all_packs = + results + |> Enum.map(fn name -> + case load_pack(name) do + {:ok, pack} -> pack + _ -> nil + end + end) + |> Enum.reject(&is_nil/1) + + packs = + all_packs + |> paginate(opts[:page], opts[:page_size]) + |> Map.new(fn pack -> {pack.name, validate_pack(pack)} end) + + {:ok, packs, length(all_packs)} + end + end + + @spec get_archive(String.t()) :: {:ok, binary()} | {:error, atom()} + def get_archive(name) do + with {:ok, pack} <- load_pack(name), + :ok <- validate_downloadable(pack) do + {:ok, fetch_archive(pack)} + end + end + + @spec download(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, atom()} + def download(name, url, as) do + uri = url |> String.trim() |> URI.parse() + + with :ok <- validate_shareable_packs_available(uri), + {:ok, remote_pack} <- + uri |> URI.merge("/api/pleroma/emoji/pack?name=#{name}") |> http_get(), + {:ok, %{sha: sha, url: url} = pack_info} <- fetch_pack_info(remote_pack, uri, name), + {:ok, archive} <- download_archive(url, sha), + pack <- copy_as(remote_pack, as || name), + {:ok, _} = unzip(archive, pack_info, remote_pack, pack) do + # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256 + # in it to depend on itself + if pack_info[:fallback] do + save_pack(pack) + else + {:ok, pack} + end + end + end + + @spec save_metadata(map(), t()) :: {:ok, t()} | {:error, File.posix()} + def save_metadata(metadata, %__MODULE__{} = pack) do + pack + |> Map.put(:pack, metadata) + |> save_pack() + end + + @spec update_metadata(String.t(), map()) :: {:ok, t()} | {:error, File.posix()} + def update_metadata(name, data) do + with {:ok, pack} <- load_pack(name) do + if fallback_sha_changed?(pack, data) do + update_sha_and_save_metadata(pack, data) + else + save_metadata(data, pack) + end + end + end + + @spec load_pack(String.t()) :: {:ok, t()} | {:error, :file.posix()} + def load_pack(name) do + name = Path.basename(name) + pack_file = Path.join([emoji_path(), name, "pack.json"]) + + with {:ok, _} <- File.stat(pack_file), + {:ok, pack_data} <- File.read(pack_file) do + pack = + from_json( + pack_data, + %{ + pack_file: pack_file, + path: Path.dirname(pack_file), + name: name + } + ) + + files_count = + pack.files + |> Map.keys() + |> length() + + {:ok, Map.put(pack, :files_count, files_count)} + end + end + + @spec emoji_path() :: Path.t() + defp emoji_path do + [:instance, :static_dir] + |> Pleroma.Config.get!() + |> Path.join("emoji") + end + + defp validate_emoji_not_exists(shortcode, force \\ false) + defp validate_emoji_not_exists(_shortcode, true), do: :ok + + defp validate_emoji_not_exists(shortcode, _) do + if Emoji.exist?(shortcode) do + {:error, :already_exists} + else + :ok + end + end + + defp write_pack_contents(path) do + pack = %__MODULE__{ + files: files_from_path(path), + path: path, + pack_file: Path.join(path, "pack.json") + } + + case save_pack(pack) do + {:ok, _pack} -> Path.basename(path) + _ -> nil + end + end + + defp files_from_path(path) do + txt_path = Path.join(path, "emoji.txt") + + if File.exists?(txt_path) do + # There's an emoji.txt file, it's likely from a pack installed by the pack manager. + # Make a pack.json file from the contents of that emoji.txt file + + # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2 + + # Create a map of shortcodes to filenames from emoji.txt + txt_path + |> File.read!() + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.map(fn line -> + case String.split(line, ~r/,\s*/) do + # This matches both strings with and without tags + # and we don't care about tags here + [name, file | _] -> + file_dir_name = Path.dirname(file) + + if String.ends_with?(path, file_dir_name) do + {name, Path.basename(file)} + else + {name, file} + end + + _ -> + nil + end + end) + |> Enum.reject(&is_nil/1) + |> Map.new() + else + # If there's no emoji.txt, assume all files + # that are of certain extensions from the config are emojis and import them all + pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions]) + Emoji.Loader.make_shortcode_to_file_map(path, pack_extensions) + end + end + + defp validate_pack(pack) do + info = + if downloadable?(pack) do + archive = fetch_archive(pack) + archive_sha = :crypto.hash(:sha256, archive) |> Base.encode16() + + pack.pack + |> Map.put("can-download", true) + |> Map.put("download-sha256", archive_sha) + else + Map.put(pack.pack, "can-download", false) + end + + Map.put(pack, :pack, info) + end + + defp downloadable?(pack) do + # If the pack is set as shared, check if it can be downloaded + # That means that when asked, the pack can be packed and sent to the remote + # Otherwise, they'd have to download it from external-src + pack.pack["share-files"] && + Enum.all?(pack.files, fn {_, file} -> + pack.path + |> Path.join(file) + |> File.exists?() + end) + end + + defp create_archive_and_cache(pack, hash) do + files = ['pack.json' | Enum.map(pack.files, fn {_, file} -> to_charlist(file) end)] + + {:ok, {_, result}} = + :zip.zip('#{pack.name}.zip', files, [:memory, cwd: to_charlist(pack.path)]) + + ttl_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file]) + overall_ttl = :timer.seconds(ttl_per_file * Enum.count(files)) + + @cachex.put( + :emoji_packs_cache, + pack.name, + # if pack.json MD5 changes, the cache is not valid anymore + %{hash: hash, pack_data: result}, + # Add a minute to cache time for every file in the pack + ttl: overall_ttl + ) + + result + end + + defp save_pack(pack) do + with {:ok, json} <- Jason.encode(pack, pretty: true), + :ok <- File.write(pack.pack_file, json) do + {:ok, pack} + end + end + + defp from_json(json, attrs) do + map = Jason.decode!(json) + + pack_attrs = + attrs + |> Map.merge(%{ + files: map["files"], + pack: map["pack"] + }) + + struct(__MODULE__, pack_attrs) + end + + defp validate_shareable_packs_available(uri) do + with {:ok, %{"links" => links}} <- uri |> URI.merge("/.well-known/nodeinfo") |> http_get(), + # Get the actual nodeinfo address and fetch it + {:ok, %{"metadata" => %{"features" => features}}} <- + links |> List.last() |> Map.get("href") |> http_get() do + if Enum.member?(features, "shareable_emoji_packs") do + :ok + else + {:error, :not_shareable} + end + end + end + + defp validate_not_empty(list) do + if Enum.all?(list, fn i -> is_binary(i) and i != "" end) do + :ok + else + {:error, :empty_values} + end + end + + defp save_file(%Plug.Upload{path: upload_path}, pack, filename) do + filename = safe_path(filename) + file_path = Path.join(pack.path, filename) + create_subdirs(file_path) + + with {:ok, _} <- File.copy(upload_path, file_path) do + :ok + end + end + + defp put_emoji(pack, shortcode, filename) do + files = Map.put(pack.files, shortcode, filename) + %{pack | files: files, files_count: length(Map.keys(files))} + end + + defp delete_emoji(pack, shortcode) do + files = Map.delete(pack.files, shortcode) + %{pack | files: files} + end + + defp rename_file(pack, filename, new_filename) do + filename = safe_path(filename) + new_filename = safe_path(new_filename) + old_path = Path.join(pack.path, filename) + new_path = Path.join(pack.path, new_filename) + create_subdirs(new_path) + + with :ok <- File.rename(old_path, new_path) do + remove_dir_if_empty(old_path, filename) + end + end + + defp create_subdirs(file_path) do + with true <- String.contains?(file_path, "/"), + path <- Path.dirname(file_path), + false <- File.exists?(path) do + File.mkdir_p!(path) + end + end + + defp remove_file(pack, shortcode) do + with {:ok, filename} <- get_filename(pack, shortcode), + emoji <- Path.join(pack.path, filename), + :ok <- File.rm(emoji) do + remove_dir_if_empty(emoji, filename) + end + end + + defp remove_dir_if_empty(emoji, filename) do + dir = Path.dirname(emoji) + + if String.contains?(filename, "/") and File.ls!(dir) == [] do + File.rmdir!(dir) + else + :ok + end + end + + defp get_filename(pack, shortcode) do + with %{^shortcode => filename} when is_binary(filename) <- pack.files, + file_path <- Path.join(pack.path, filename), + {:ok, _} <- File.stat(file_path) do + {:ok, filename} + else + {:error, _} = error -> + error + + _ -> + {:error, :doesnt_exist} + end + end + + defp http_get(%URI{} = url), do: url |> to_string() |> http_get() + + defp http_get(url) do + with {:ok, %{body: body}} <- Pleroma.HTTP.get(url, [], pool: :default) do + Jason.decode(body) + end + end + + defp list_packs_dir do + emoji_path = emoji_path() + # Create the directory first if it does not exist. This is probably the first request made + # with the API so it should be sufficient + with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_path)}, + {:ls, {:ok, results}} <- {:ls, File.ls(emoji_path)} do + {:ok, Enum.sort(results)} + else + {:create_dir, {:error, e}} -> {:error, :create_dir, e} + {:ls, {:error, e}} -> {:error, :ls, e} + end + end + + defp validate_downloadable(pack) do + if downloadable?(pack), do: :ok, else: {:error, :cant_download} + end + + defp copy_as(remote_pack, local_name) do + path = Path.join(emoji_path(), local_name) + + %__MODULE__{ + name: local_name, + path: path, + files: remote_pack["files"], + pack_file: Path.join(path, "pack.json") + } + end + + defp unzip(archive, pack_info, remote_pack, local_pack) do + with :ok <- File.mkdir_p!(local_pack.path) do + files = Enum.map(remote_pack["files"], fn {_, path} -> to_charlist(path) end) + # Fallback cannot contain a pack.json file + files = if pack_info[:fallback], do: files, else: ['pack.json' | files] + + :zip.unzip(archive, cwd: to_charlist(local_pack.path), file_list: files) + end + end + + defp fetch_pack_info(remote_pack, uri, name) do + case remote_pack["pack"] do + %{"share-files" => true, "can-download" => true, "download-sha256" => sha} -> + {:ok, + %{ + sha: sha, + url: URI.merge(uri, "/api/pleroma/emoji/packs/archive?name=#{name}") |> to_string() + }} + + %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> + {:ok, + %{ + sha: sha, + url: src, + fallback: true + }} + + _ -> + {:error, "The pack was not set as shared and there is no fallback src to download from"} + end + end + + defp download_archive(url, sha) do + with {:ok, %{body: archive}} <- Pleroma.HTTP.get(url) do + if Base.decode16!(sha) == :crypto.hash(:sha256, archive) do + {:ok, archive} + else + {:error, :invalid_checksum} + end + end + end + + defp fetch_archive(pack) do + hash = :crypto.hash(:md5, File.read!(pack.pack_file)) + + case @cachex.get!(:emoji_packs_cache, pack.name) do + %{hash: ^hash, pack_data: archive} -> archive + _ -> create_archive_and_cache(pack, hash) + end + end + + defp fallback_sha_changed?(pack, data) do + is_binary(data[:"fallback-src"]) and data[:"fallback-src"] != pack.pack["fallback-src"] + end + + defp update_sha_and_save_metadata(pack, data) do + with {:ok, %{body: zip}} <- Pleroma.HTTP.get(data[:"fallback-src"]), + :ok <- validate_has_all_files(pack, zip) do + fallback_sha = :sha256 |> :crypto.hash(zip) |> Base.encode16() + + data + |> Map.put("fallback-src-sha256", fallback_sha) + |> save_metadata(pack) + end + end + + defp validate_has_all_files(pack, zip) do + with {:ok, f_list} <- :zip.unzip(zip, [:memory]) do + # Check if all files from the pack.json are in the archive + pack.files + |> Enum.all?(fn {_, from_manifest} -> + List.keyfind(f_list, to_charlist(from_manifest), 0) + end) + |> if(do: :ok, else: {:error, :incomplete}) + end + end + + defp safe_path(path) do + elems = Path.split(path) |> Enum.reject(fn x -> x == ".." end) + + case length(elems) do + x when x < 2 -> Enum.join(elems) + _ -> Path.join(elems) + end + end +end diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex new file mode 100644 index 0000000..db88bc0 --- /dev/null +++ b/lib/pleroma/filter.ex @@ -0,0 +1,226 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Filter do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.Repo + alias Pleroma.User + + @type t() :: %__MODULE__{} + @type format() :: :postgres | :re + + schema "filters" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + field(:filter_id, :integer) + field(:hide, :boolean, default: false) + field(:whole_word, :boolean, default: true) + field(:phrase, :string) + field(:context, {:array, :string}) + field(:expires_at, :naive_datetime) + + timestamps() + end + + @spec get(integer() | String.t(), User.t()) :: t() | nil + def get(id, %{id: user_id} = _user) do + query = + from( + f in __MODULE__, + where: f.filter_id == ^id, + where: f.user_id == ^user_id + ) + + Repo.one(query) + end + + @spec get_active(Ecto.Query.t() | module()) :: Ecto.Query.t() + def get_active(query) do + from(f in query, where: is_nil(f.expires_at) or f.expires_at > ^NaiveDateTime.utc_now()) + end + + @spec get_irreversible(Ecto.Query.t()) :: Ecto.Query.t() + def get_irreversible(query) do + from(f in query, where: f.hide) + end + + @spec get_filters(Ecto.Query.t() | module(), User.t()) :: [t()] + def get_filters(query \\ __MODULE__, %User{id: user_id}) do + query = + from( + f in query, + where: f.user_id == ^user_id, + order_by: [desc: :id] + ) + + Repo.all(query) + end + + @spec create(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} + def create(attrs \\ %{}) do + Repo.transaction(fn -> create_with_expiration(attrs) end) + end + + defp create_with_expiration(attrs) do + with {:ok, filter} <- do_create(attrs), + {:ok, _} <- maybe_add_expiration_job(filter) do + filter + else + {:error, error} -> Repo.rollback(error) + end + end + + defp do_create(attrs) do + %__MODULE__{} + |> cast(attrs, [:phrase, :context, :hide, :expires_at, :whole_word, :user_id, :filter_id]) + |> maybe_add_filter_id() + |> validate_required([:phrase, :context, :user_id, :filter_id]) + |> maybe_add_expires_at(attrs) + |> Repo.insert() + end + + defp maybe_add_filter_id(%{changes: %{filter_id: _}} = changeset), do: changeset + + defp maybe_add_filter_id(%{changes: %{user_id: user_id}} = changeset) do + # If filter_id wasn't given, use the max filter_id for this user plus 1. + # XXX This could result in a race condition if a user tries to add two + # different filters for their account from two different clients at the + # same time, but that should be unlikely. + + max_id_query = + from( + f in __MODULE__, + where: f.user_id == ^user_id, + select: max(f.filter_id) + ) + + filter_id = + case Repo.one(max_id_query) do + # Start allocating from 1 + nil -> + 1 + + max_id -> + max_id + 1 + end + + change(changeset, filter_id: filter_id) + end + + # don't override expires_at, if passed expires_at and expires_in + defp maybe_add_expires_at(%{changes: %{expires_at: %NaiveDateTime{} = _}} = changeset, _) do + changeset + end + + defp maybe_add_expires_at(changeset, %{expires_in: expires_in}) + when is_integer(expires_in) and expires_in > 0 do + expires_at = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(expires_in) + |> NaiveDateTime.truncate(:second) + + change(changeset, expires_at: expires_at) + end + + defp maybe_add_expires_at(changeset, %{expires_in: nil}) do + change(changeset, expires_at: nil) + end + + defp maybe_add_expires_at(changeset, _), do: changeset + + defp maybe_add_expiration_job(%{expires_at: %NaiveDateTime{} = expires_at} = filter) do + Pleroma.Workers.PurgeExpiredFilter.enqueue(%{ + filter_id: filter.id, + expires_at: DateTime.from_naive!(expires_at, "Etc/UTC") + }) + end + + defp maybe_add_expiration_job(_), do: {:ok, nil} + + @spec delete(t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} + def delete(%__MODULE__{} = filter) do + Repo.transaction(fn -> delete_with_expiration(filter) end) + end + + defp delete_with_expiration(filter) do + with {:ok, _} <- maybe_delete_old_expiration_job(filter, nil), + {:ok, filter} <- Repo.delete(filter) do + filter + else + {:error, error} -> Repo.rollback(error) + end + end + + @spec update(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} + def update(%__MODULE__{} = filter, params) do + Repo.transaction(fn -> update_with_expiration(filter, params) end) + end + + defp update_with_expiration(filter, params) do + with {:ok, updated} <- do_update(filter, params), + {:ok, _} <- maybe_delete_old_expiration_job(filter, updated), + {:ok, _} <- + maybe_add_expiration_job(updated) do + updated + else + {:error, error} -> Repo.rollback(error) + end + end + + defp do_update(filter, params) do + filter + |> cast(params, [:phrase, :context, :hide, :expires_at, :whole_word]) + |> validate_required([:phrase, :context]) + |> maybe_add_expires_at(params) + |> Repo.update() + end + + defp maybe_delete_old_expiration_job(%{expires_at: nil}, _), do: {:ok, nil} + + defp maybe_delete_old_expiration_job(%{expires_at: expires_at}, %{expires_at: expires_at}) do + {:ok, nil} + end + + defp maybe_delete_old_expiration_job(%{id: id}, _) do + with %Oban.Job{} = job <- Pleroma.Workers.PurgeExpiredFilter.get_expiration(id) do + Repo.delete(job) + else + nil -> {:ok, nil} + end + end + + @spec compose_regex(User.t() | [t()], format()) :: String.t() | Regex.t() | nil + def compose_regex(user_or_filters, format \\ :postgres) + + def compose_regex(%User{} = user, format) do + __MODULE__ + |> get_active() + |> get_irreversible() + |> get_filters(user) + |> compose_regex(format) + end + + def compose_regex([_ | _] = filters, format) do + phrases = + filters + |> Enum.map(& &1.phrase) + |> Enum.join("|") + + case format do + :postgres -> + "\\y(#{phrases})\\y" + + :re -> + ~r/\b#{phrases}\b/i + + _ -> + nil + end + end + + def compose_regex(_, _), do: nil +end diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex new file mode 100644 index 0000000..15664c8 --- /dev/null +++ b/lib/pleroma/following_relationship.ex @@ -0,0 +1,300 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.FollowingRelationship do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Ecto.Changeset + alias FlakeId.Ecto.CompatType + alias Pleroma.FollowingRelationship.State + alias Pleroma.Repo + alias Pleroma.User + + schema "following_relationships" do + field(:state, State, default: :follow_pending) + + belongs_to(:follower, User, type: CompatType) + belongs_to(:following, User, type: CompatType) + + timestamps() + end + + @doc "Returns underlying integer code for state atom" + def state_int_code(state_atom), do: State.__enum_map__() |> Keyword.fetch!(state_atom) + + def accept_state_code, do: state_int_code(:follow_accept) + + def changeset(%__MODULE__{} = following_relationship, attrs) do + following_relationship + |> cast(attrs, [:state]) + |> put_assoc(:follower, attrs.follower) + |> put_assoc(:following, attrs.following) + |> validate_required([:state, :follower, :following]) + |> unique_constraint(:follower_id, + name: :following_relationships_follower_id_following_id_index + ) + |> validate_not_self_relationship() + end + + def state_to_enum(state) when state in ["pending", "accept", "reject"] do + String.to_existing_atom("follow_#{state}") + end + + def state_to_enum(state) do + raise "State is not convertible to Pleroma.FollowingRelationship.State: #{state}" + end + + def get(%User{} = follower, %User{} = following) do + __MODULE__ + |> where(follower_id: ^follower.id, following_id: ^following.id) + |> Repo.one() + end + + def update(follower, following, :follow_reject), do: unfollow(follower, following) + + def update(%User{} = follower, %User{} = following, state) do + case get(follower, following) do + nil -> + follow(follower, following, state) + + following_relationship -> + with {:ok, _following_relationship} <- + following_relationship + |> cast(%{state: state}, [:state]) + |> validate_required([:state]) + |> Repo.update() do + after_update(state, follower, following) + end + end + end + + def follow(%User{} = follower, %User{} = following, state \\ :follow_accept) do + with {:ok, _following_relationship} <- + %__MODULE__{} + |> changeset(%{follower: follower, following: following, state: state}) + |> Repo.insert(on_conflict: :nothing) do + after_update(state, follower, following) + end + end + + def unfollow(%User{} = follower, %User{} = following) do + case get(follower, following) do + %__MODULE__{} = following_relationship -> + with {:ok, _following_relationship} <- Repo.delete(following_relationship) do + after_update(:unfollow, follower, following) + end + + _ -> + {:ok, nil} + end + end + + defp after_update(state, %User{} = follower, %User{} = following) do + with {:ok, following} <- User.update_follower_count(following), + {:ok, follower} <- User.update_following_count(follower) do + Pleroma.Web.Streamer.stream("follow_relationship", %{ + state: state, + following: following, + follower: follower + }) + + {:ok, follower, following} + end + end + + def follower_count(%User{} = user) do + %{followers: user, deactivated: false} + |> User.Query.build() + |> Repo.aggregate(:count, :id) + end + + def followers_query(%User{} = user) do + __MODULE__ + |> join(:inner, [r], u in User, on: r.follower_id == u.id) + |> where([r], r.following_id == ^user.id) + |> where([r], r.state == ^:follow_accept) + end + + def followers_ap_ids(user, from_ap_ids \\ nil) + + def followers_ap_ids(_, []), do: [] + + def followers_ap_ids(%User{} = user, from_ap_ids) do + query = + user + |> followers_query() + |> select([r, u], u.ap_id) + + query = + if from_ap_ids do + where(query, [r, u], u.ap_id in ^from_ap_ids) + else + query + end + + Repo.all(query) + end + + def following_count(%User{id: nil}), do: 0 + + def following_count(%User{} = user) do + %{friends: user, deactivated: false} + |> User.Query.build() + |> Repo.aggregate(:count, :id) + end + + def get_follow_requests(%User{id: id}) do + __MODULE__ + |> join(:inner, [r], f in assoc(r, :follower)) + |> where([r], r.state == ^:follow_pending) + |> where([r], r.following_id == ^id) + |> where([r, f], f.is_active == true) + |> select([r, f], f) + |> Repo.all() + end + + def following?(%User{id: follower_id}, %User{id: followed_id}) do + __MODULE__ + |> where(follower_id: ^follower_id, following_id: ^followed_id, state: ^:follow_accept) + |> Repo.exists?() + end + + def following_query(%User{} = user) do + __MODULE__ + |> join(:inner, [r], u in User, on: r.following_id == u.id) + |> where([r], r.follower_id == ^user.id) + |> where([r], r.state == ^:follow_accept) + end + + def outgoing_pending_follow_requests_query(%User{} = follower) do + __MODULE__ + |> where([r], r.follower_id == ^follower.id) + |> where([r], r.state == ^:follow_pending) + end + + def following(%User{} = user) do + following = + following_query(user) + |> select([r, u], u.follower_address) + |> Repo.all() + + if not user.local or user.invisible do + following + else + [user.follower_address | following] + end + end + + def move_following(origin, target) do + __MODULE__ + |> join(:inner, [r], f in assoc(r, :follower)) + |> where(following_id: ^origin.id) + |> where([r, f], f.allow_following_move == true) + |> where([r, f], f.local == true) + |> limit(50) + |> preload([:follower]) + |> Repo.all() + |> Enum.map(fn following_relationship -> + Pleroma.Web.CommonAPI.follow(following_relationship.follower, target) + Pleroma.Web.CommonAPI.unfollow(following_relationship.follower, origin) + end) + |> case do + [] -> + User.update_follower_count(origin) + :ok + + _ -> + move_following(origin, target) + end + end + + def all_between_user_sets( + source_users, + target_users + ) + when is_list(source_users) and is_list(target_users) do + source_user_ids = User.binary_id(source_users) + target_user_ids = User.binary_id(target_users) + + __MODULE__ + |> where( + fragment( + "(follower_id = ANY(?) AND following_id = ANY(?)) OR \ + (follower_id = ANY(?) AND following_id = ANY(?))", + ^source_user_ids, + ^target_user_ids, + ^target_user_ids, + ^source_user_ids + ) + ) + |> Repo.all() + end + + def find(following_relationships, follower, following) do + Enum.find(following_relationships, fn + fr -> fr.follower_id == follower.id and fr.following_id == following.id + end) + end + + @doc """ + For a query with joined activity, + keeps rows where activity's actor is followed by user -or- is NOT domain-blocked by user. + """ + def keep_following_or_not_domain_blocked(query, user) do + where( + query, + [_, activity], + fragment( + # "(actor's domain NOT in domain_blocks) OR (actor IS in followed AP IDs)" + """ + NOT (substring(? from '.*://([^/]*)') = ANY(?)) OR + ? = ANY(SELECT ap_id FROM users AS u INNER JOIN following_relationships AS fr + ON u.id = fr.following_id WHERE fr.follower_id = ? AND fr.state = ?) + """, + activity.actor, + ^user.domain_blocks, + activity.actor, + ^User.binary_id(user.id), + ^accept_state_code() + ) + ) + end + + defp validate_not_self_relationship(%Changeset{} = changeset) do + changeset + |> validate_follower_id_following_id_inequality() + |> validate_following_id_follower_id_inequality() + end + + defp validate_follower_id_following_id_inequality(%Changeset{} = changeset) do + validate_change(changeset, :follower_id, fn _, follower_id -> + if follower_id == get_field(changeset, :following_id) do + [source_id: "can't be equal to following_id"] + else + [] + end + end) + end + + defp validate_following_id_follower_id_inequality(%Changeset{} = changeset) do + validate_change(changeset, :following_id, fn _, following_id -> + if following_id == get_field(changeset, :follower_id) do + [target_id: "can't be equal to follower_id"] + else + [] + end + end) + end + + @spec following_ap_ids(User.t()) :: [String.t()] + def following_ap_ids(%User{} = user) do + user + |> following_query() + |> select([r, u], u.ap_id) + |> Repo.all() + end +end diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex new file mode 100644 index 0000000..11d5af2 --- /dev/null +++ b/lib/pleroma/formatter.ex @@ -0,0 +1,161 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Formatter do + alias Pleroma.HTML + alias Pleroma.User + + @safe_mention_regex ~r/^(\s*(?(@.+?\s+){1,})+)(?.*)/s + @link_regex ~r"((?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~%:/?#[\]@!\$&'\(\)\*\+,;=.]+)|[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+"ui + @markdown_characters_regex ~r/(`|\*|_|{|}|[|]|\(|\)|#|\+|-|\.|!)/ + + defp linkify_opts do + Pleroma.Config.get(Pleroma.Formatter) ++ + [ + hashtag: true, + hashtag_handler: &Pleroma.Formatter.hashtag_handler/4, + mention: true, + mention_handler: &Pleroma.Formatter.mention_handler/4 + ] + end + + def escape_mention_handler("@" <> nickname = mention, buffer, _, _) do + case User.get_cached_by_nickname(nickname) do + %User{} -> + # escape markdown characters with `\\` + # (we don't want something like @user__name to be parsed by markdown) + String.replace(mention, @markdown_characters_regex, "\\\\\\1") + + _ -> + buffer + end + end + + def mention_handler("@" <> nickname, buffer, opts, acc) do + case User.get_cached_by_nickname(nickname) do + %User{} = user -> + {mention_from_user(user, opts), + %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, user})}} + + _ -> + {buffer, acc} + end + end + + def mention_from_user(%User{id: id} = user, opts \\ %{mentions_format: :full}) do + user_url = user.uri || user.ap_id + nickname_text = get_nickname_text(user.nickname, opts) + + Phoenix.HTML.Tag.content_tag( + :span, + Phoenix.HTML.Tag.content_tag( + :a, + ["@", Phoenix.HTML.Tag.content_tag(:span, nickname_text)], + "data-user": id, + class: "u-url mention", + href: user_url, + rel: "ugc" + ), + class: "h-card" + ) + |> Phoenix.HTML.safe_to_string() + end + + def hashtag_handler("#" <> tag = tag_text, _buffer, _opts, acc) do + tag = String.downcase(tag) + url = "#{Pleroma.Web.Endpoint.url()}/tag/#{tag}" + + link = + Phoenix.HTML.Tag.content_tag(:a, tag_text, + class: "hashtag", + "data-tag": tag, + href: url, + rel: "tag ugc" + ) + |> Phoenix.HTML.safe_to_string() + + {link, %{acc | tags: MapSet.put(acc.tags, {tag_text, tag})}} + end + + @doc """ + Parses a text and replace plain text links with HTML. Returns a tuple with a result text, mentions, and hashtags. + + If the 'safe_mention' option is given, only consecutive mentions at the start the post are actually mentioned. + """ + @spec linkify(String.t(), keyword()) :: + {String.t(), [{String.t(), User.t()}], [{String.t(), String.t()}]} + def linkify(text, options \\ []) do + options = linkify_opts() ++ options + + if options[:safe_mention] && Regex.named_captures(@safe_mention_regex, text) do + %{"mentions" => mentions, "rest" => rest} = Regex.named_captures(@safe_mention_regex, text) + acc = %{mentions: MapSet.new(), tags: MapSet.new()} + + {text_mentions, %{mentions: mentions}} = Linkify.link_map(mentions, acc, options) + {text_rest, %{tags: tags}} = Linkify.link_map(rest, acc, options) + + {text_mentions <> text_rest, MapSet.to_list(mentions), MapSet.to_list(tags)} + else + acc = %{mentions: MapSet.new(), tags: MapSet.new()} + {text, %{mentions: mentions, tags: tags}} = Linkify.link_map(text, acc, options) + + {text, MapSet.to_list(mentions), MapSet.to_list(tags)} + end + end + + @doc """ + Escapes a special characters in mention names. + """ + def mentions_escape(text, options \\ []) do + options = + Keyword.merge(options, + mention: true, + url: false, + mention_handler: &Pleroma.Formatter.escape_mention_handler/4 + ) + + if options[:safe_mention] && Regex.named_captures(@safe_mention_regex, text) do + %{"mentions" => mentions, "rest" => rest} = Regex.named_captures(@safe_mention_regex, text) + Linkify.link(mentions, options) <> Linkify.link(rest, options) + else + Linkify.link(text, options) + end + end + + def markdown_to_html(text) do + Earmark.as_html!(text, %Earmark.Options{compact_output: true, smartypants: false}) + end + + def html_escape({text, mentions, hashtags}, type) do + {html_escape(text, type), mentions, hashtags} + end + + def html_escape(text, "text/html") do + HTML.filter_tags(text) + end + + def html_escape(text, "text/plain") do + Regex.split(@link_regex, text, include_captures: true) + |> Enum.map_every(2, fn chunk -> + {:safe, part} = Phoenix.HTML.html_escape(chunk) + part + end) + |> Enum.join("") + end + + def truncate(text, max_length \\ 200, omission \\ "...") do + # Remove trailing whitespace + text = Regex.replace(~r/([^ \t\r\n])([ \t]+$)/u, text, "\\g{1}") + + if String.length(text) < max_length do + text + else + length_with_omission = max_length - String.length(omission) + String.slice(text, 0, length_with_omission) <> omission + end + end + + defp get_nickname_text(nickname, %{mentions_format: :full}), do: User.full_nickname(nickname) + defp get_nickname_text(nickname, _), do: User.local_nickname(nickname) +end diff --git a/lib/pleroma/frontend.ex b/lib/pleroma/frontend.ex new file mode 100644 index 0000000..143769b --- /dev/null +++ b/lib/pleroma/frontend.ex @@ -0,0 +1,185 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Frontend do + alias Pleroma.Config + alias Pleroma.ConfigDB + alias Pleroma.Frontend + + require Logger + + @unknown_name "unknown" + @frontend_types [:admin, :primary] + + defstruct [:name, :ref, :git, :build_url, :build_dir, :file, :"custom-http-headers"] + + def install(%Frontend{} = frontend) do + frontend + |> maybe_put_name() + |> hydrate() + |> validate!() + |> do_install() + end + + defp maybe_put_name(%{name: nil} = fe), do: Map.put(fe, :name, @unknown_name) + defp maybe_put_name(fe), do: fe + + # Merges a named frontend with the provided one + defp hydrate(%Frontend{name: name} = frontend) do + get_named_frontend(name) + |> merge(frontend) + end + + defp do_install(%Frontend{ref: ref, name: name} = frontend) do + dest = Path.join([dir(), name, ref]) + + label = "#{name} (#{ref})" + tmp_dir = Path.join(dir(), "tmp") + + with {_, :ok} <- {:download_or_unzip, download_or_unzip(frontend, tmp_dir)}, + Logger.info("Installing #{label} to #{dest}"), + :ok <- install_frontend(frontend, tmp_dir, dest) do + File.rm_rf!(tmp_dir) + Logger.info("Frontend #{label} installed to #{dest}") + frontend + else + {:download_or_unzip, _} -> + Logger.info("Could not download or unzip the frontend") + {:error, "Could not download or unzip the frontend"} + + _e -> + Logger.info("Could not install the frontend") + {:error, "Could not install the frontend"} + end + end + + def enable(%Frontend{} = frontend, frontend_type) when frontend_type in @frontend_types do + with {:config_db, true} <- {:config_db, Config.get(:configurable_from_database)} do + frontend + |> maybe_put_name() + |> hydrate() + |> validate!() + |> do_enable(frontend_type) + else + {:config_db, _} -> + map = to_map(frontend) + + raise """ + Can't enable frontend; database configuration is disabled. + Enable the frontend by manually adding this line to your config: + + config :pleroma, :frontends, #{to_string(frontend_type)}: #{inspect(map)} + + Alternatively, enable database configuration: + + config :pleroma, configurable_from_database: true + """ + end + end + + def do_enable(%Frontend{name: name} = frontend, frontend_type) do + value = Keyword.put([], frontend_type, to_map(frontend)) + params = %{group: :pleroma, key: :frontends, value: value} + + with {:ok, _} <- ConfigDB.update_or_create(params), + :ok <- Config.TransferTask.load_and_update_env([], false) do + Logger.info("Frontend #{name} successfully enabled") + frontend + end + end + + def dir do + Config.get!([:instance, :static_dir]) + |> Path.join("frontends") + end + + defp download_or_unzip(%Frontend{build_url: build_url} = frontend, dest) + when is_binary(build_url), + do: download_build(frontend, dest) + + defp download_or_unzip(%Frontend{file: file}, dest) when is_binary(file) do + with {:ok, zip} <- File.read(Path.expand(file)) do + unzip(zip, dest) + end + end + + def unzip(zip, dest) do + with {:ok, unzipped} <- :zip.unzip(zip, [:memory]) do + File.rm_rf!(dest) + File.mkdir_p!(dest) + + Enum.each(unzipped, fn {filename, data} -> + path = filename + + new_file_path = Path.join(dest, path) + + new_file_path + |> Path.dirname() + |> File.mkdir_p!() + + File.write!(new_file_path, data) + end) + end + end + + def parse_build_url(%Frontend{ref: ref, build_url: build_url}) do + String.replace(build_url, "${ref}", ref) + end + + defp download_build(%Frontend{name: name} = frontend, dest) do + Logger.info("Downloading pre-built bundle for #{name}") + url = parse_build_url(frontend) + + with {:ok, %{status: 200, body: zip_body}} <- + Pleroma.HTTP.get(url, [], pool: :media, recv_timeout: 120_000) do + unzip(zip_body, dest) + else + {:error, e} -> {:error, e} + e -> {:error, e} + end + end + + defp install_frontend(%Frontend{} = frontend, source, dest) do + from = frontend.build_dir || "dist" + File.rm_rf!(dest) + File.mkdir_p!(dest) + File.cp_r!(Path.join([source, from]), dest) + :ok + end + + # Converts a named frontend into a %Frontend{} struct + def get_named_frontend(name) do + [:frontends, :available, name] + |> Config.get(%{}) + |> from_map() + end + + def merge(%Frontend{} = fe1, %Frontend{} = fe2) do + Map.merge(fe1, fe2, fn _key, v1, v2 -> + # This only overrides things that are actually set + v1 || v2 + end) + end + + def validate!(%Frontend{ref: ref} = fe) when is_binary(ref), do: fe + def validate!(_), do: raise("No ref given or configured") + + def from_map(frontend) when is_map(frontend) do + struct(Frontend, atomize_keys(frontend)) + end + + def to_map(%Frontend{} = frontend) do + frontend + |> Map.from_struct() + |> stringify_keys() + end + + defp atomize_keys(map) do + Map.new(map, fn {k, v} -> {String.to_existing_atom(k), v} end) + end + + defp stringify_keys(map) do + Map.new(map, fn {k, v} -> {to_string(k), v} end) + end +end diff --git a/lib/pleroma/gopher/server.ex b/lib/pleroma/gopher/server.ex new file mode 100644 index 0000000..0fde0ad --- /dev/null +++ b/lib/pleroma/gopher/server.ex @@ -0,0 +1,162 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Gopher.Server do + use GenServer + require Logger + + def start_link(_) do + config = Pleroma.Config.get(:gopher, []) + ip = Keyword.get(config, :ip, {0, 0, 0, 0}) + port = Keyword.get(config, :port, 1234) + + if Keyword.get(config, :enabled, false) do + GenServer.start_link(__MODULE__, [ip, port], []) + else + Logger.info("Gopher server disabled") + :ignore + end + end + + def init([ip, port]) do + Logger.info("Starting gopher server on #{port}") + + :ranch.start_listener( + :gopher, + 100, + :ranch_tcp, + [ip: ip, port: port], + __MODULE__.ProtocolHandler, + [] + ) + + {:ok, %{ip: ip, port: port}} + end +end + +defmodule Pleroma.Gopher.Server.ProtocolHandler do + alias Pleroma.Activity + alias Pleroma.HTML + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility + + def start_link(ref, socket, transport, opts) do + pid = spawn_link(__MODULE__, :init, [ref, socket, transport, opts]) + {:ok, pid} + end + + def init(ref, socket, transport, [] = _Opts) do + :ok = :ranch.accept_ack(ref) + loop(socket, transport) + end + + def info(text) do + text = String.replace(text, ~r/[\t\n]/, "") + + String.split(text, "\r") + |> Enum.map(fn text -> + "i#{text}\tfake\t(NULL)\t0\r\n" + end) + |> Enum.join("") + end + + def link(name, selector, type \\ 1) do + address = Pleroma.Web.Endpoint.host() + port = Pleroma.Config.get([:gopher, :port], 1234) + dstport = Pleroma.Config.get([:gopher, :dstport], port) + "#{type}#{name}\t#{selector}\t#{address}\t#{dstport}\r\n" + end + + def render_activities(activities) do + activities + |> Enum.reverse() + |> Enum.map(fn activity -> + user = User.get_cached_by_ap_id(activity.data["actor"]) + + object = Object.normalize(activity, fetch: false) + like_count = object.data["like_count"] || 0 + announcement_count = object.data["announcement_count"] || 0 + + link("Post ##{activity.id} by #{user.nickname}", "/notices/#{activity.id}") <> + info("#{like_count} likes, #{announcement_count} repeats") <> + "i\tfake\t(NULL)\t0\r\n" <> + info(HTML.strip_tags(String.replace(object.data["content"], "
    ", "\r"))) + end) + |> Enum.join("i\tfake\t(NULL)\t0\r\n") + end + + def response("") do + info("Welcome to #{Pleroma.Config.get([:instance, :name], "Pleroma")}!") <> + link("Public Timeline", "/main/public") <> + link("Federated Timeline", "/main/all") <> ".\r\n" + end + + def response("/main/public") do + posts = + %{type: ["Create"], local_only: true} + |> ActivityPub.fetch_public_activities() + |> render_activities() + + info("Welcome to the Public Timeline!") <> posts <> ".\r\n" + end + + def response("/main/all") do + posts = + %{type: ["Create"]} + |> ActivityPub.fetch_public_activities() + |> render_activities() + + info("Welcome to the Federated Timeline!") <> posts <> ".\r\n" + end + + def response("/notices/" <> id) do + with %Activity{} = activity <- Activity.get_by_id(id), + true <- Visibility.is_public?(activity) do + activities = + ActivityPub.fetch_activities_for_context(activity.data["context"]) + |> render_activities + + user = User.get_cached_by_ap_id(activity.data["actor"]) + + info("Post #{activity.id} by #{user.nickname}") <> + link("More posts by #{user.nickname}", "/users/#{user.nickname}") <> activities <> ".\r\n" + else + _e -> + info("Not public") <> ".\r\n" + end + end + + def response("/users/" <> nickname) do + with %User{} = user <- User.get_cached_by_nickname(nickname) do + params = %{ + type: ["Create"], + actor_id: user.ap_id + } + + activities = + params + |> ActivityPub.fetch_public_activities() + |> render_activities() + + info("Posts by #{user.nickname}") <> activities <> ".\r\n" + else + _e -> + info("No such user") <> ".\r\n" + end + end + + def loop(socket, transport) do + case transport.recv(socket, 0, 5000) do + {:ok, data} -> + data = String.trim_trailing(data, "\r\n") + transport.send(socket, response(data)) + :ok = transport.close(socket) + + _ -> + :ok = transport.close(socket) + end + end +end diff --git a/lib/pleroma/gun.ex b/lib/pleroma/gun.ex new file mode 100644 index 0000000..c2d6b4b --- /dev/null +++ b/lib/pleroma/gun.ex @@ -0,0 +1,29 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Gun do + @callback open(charlist(), pos_integer(), map()) :: {:ok, pid()} + @callback info(pid()) :: map() + @callback close(pid()) :: :ok + @callback await_up(pid, pos_integer()) :: {:ok, atom()} | {:error, atom()} + @callback connect(pid(), map()) :: reference() + @callback await(pid(), reference()) :: {:response, :fin, 200, []} + @callback set_owner(pid(), pid()) :: :ok + + defp api, do: Pleroma.Config.get([Pleroma.Gun], Pleroma.Gun.API) + + def open(host, port, opts), do: api().open(host, port, opts) + + def info(pid), do: api().info(pid) + + def close(pid), do: api().close(pid) + + def await_up(pid, timeout \\ 5_000), do: api().await_up(pid, timeout) + + def connect(pid, opts), do: api().connect(pid, opts) + + def await(pid, ref), do: api().await(pid, ref) + + def set_owner(pid, owner), do: api().set_owner(pid, owner) +end diff --git a/lib/pleroma/gun/api.ex b/lib/pleroma/gun/api.ex new file mode 100644 index 0000000..ff21006 --- /dev/null +++ b/lib/pleroma/gun/api.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Gun.API do + @behaviour Pleroma.Gun + + alias Pleroma.Gun + + @gun_keys [ + :connect_timeout, + :http_opts, + :http2_opts, + :protocols, + :retry, + :retry_timeout, + :trace, + :transport, + :tls_opts, + :tcp_opts, + :socks_opts, + :ws_opts, + :supervise + ] + + @impl Gun + def open(host, port, opts \\ %{}), do: :gun.open(host, port, Map.take(opts, @gun_keys)) + + @impl Gun + defdelegate info(pid), to: :gun + + @impl Gun + defdelegate close(pid), to: :gun + + @impl Gun + defdelegate await_up(pid, timeout \\ 5_000), to: :gun + + @impl Gun + defdelegate connect(pid, opts), to: :gun + + @impl Gun + defdelegate await(pid, ref), to: :gun + + @impl Gun + defdelegate set_owner(pid, owner), to: :gun +end diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex new file mode 100644 index 0000000..804cd11 --- /dev/null +++ b/lib/pleroma/gun/conn.ex @@ -0,0 +1,131 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Gun.Conn do + alias Pleroma.Gun + + require Logger + + def open(%URI{} = uri, opts) do + pool_opts = Pleroma.Config.get([:connections_pool], []) + + opts = + opts + |> Enum.into(%{}) + |> Map.put_new(:connect_timeout, pool_opts[:connect_timeout] || 5_000) + |> Map.put_new(:supervise, false) + |> maybe_add_tls_opts(uri) + + do_open(uri, opts) + end + + defp maybe_add_tls_opts(opts, %URI{scheme: "http"}), do: opts + + defp maybe_add_tls_opts(opts, %URI{scheme: "https"}) do + tls_opts = [ + verify: :verify_peer, + cacertfile: CAStore.file_path(), + depth: 20, + reuse_sessions: false, + log_level: :warning, + customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)] + ] + + tls_opts = + if Keyword.keyword?(opts[:tls_opts]) do + Keyword.merge(tls_opts, opts[:tls_opts]) + else + tls_opts + end + + Map.put(opts, :tls_opts, tls_opts) + end + + defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do + connect_opts = + uri + |> destination_opts() + |> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, [])) + + with open_opts <- Map.delete(opts, :tls_opts), + {:ok, conn} <- Gun.open(proxy_host, proxy_port, open_opts), + {:ok, protocol} <- Gun.await_up(conn, opts[:connect_timeout]), + stream <- Gun.connect(conn, connect_opts), + {:response, :fin, 200, _} <- Gun.await(conn, stream) do + {:ok, conn, protocol} + else + error -> + Logger.warning( + "Opening proxied connection to #{compose_uri_log(uri)} failed with error #{inspect(error)}" + ) + + error + end + end + + defp do_open(uri, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do + version = + proxy_type + |> to_string() + |> String.last() + |> case do + "4" -> 4 + _ -> 5 + end + + socks_opts = + uri + |> destination_opts() + |> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, [])) + |> Map.put(:version, version) + + opts = + opts + |> Map.put(:protocols, [:socks]) + |> Map.put(:socks_opts, socks_opts) + + with {:ok, conn} <- Gun.open(proxy_host, proxy_port, opts), + {:ok, protocol} <- Gun.await_up(conn, opts[:connect_timeout]) do + {:ok, conn, protocol} + else + error -> + Logger.warning( + "Opening socks proxied connection to #{compose_uri_log(uri)} failed with error #{inspect(error)}" + ) + + error + end + end + + defp do_open(%URI{host: host, port: port} = uri, opts) do + host = Pleroma.HTTP.AdapterHelper.parse_host(host) + + with {:ok, conn} <- Gun.open(host, port, opts), + {:ok, protocol} <- Gun.await_up(conn, opts[:connect_timeout]) do + {:ok, conn, protocol} + else + error -> + Logger.warning( + "Opening connection to #{compose_uri_log(uri)} failed with error #{inspect(error)}" + ) + + error + end + end + + defp destination_opts(%URI{host: host, port: port}) do + host = Pleroma.HTTP.AdapterHelper.parse_host(host) + %{host: host, port: port} + end + + defp add_http2_opts(opts, "https", tls_opts) do + Map.merge(opts, %{protocols: [:http2], transport: :tls, tls_opts: tls_opts}) + end + + defp add_http2_opts(opts, _, _), do: opts + + def compose_uri_log(%URI{scheme: scheme, host: host, path: path}) do + "#{scheme}://#{host}#{path}" + end +end diff --git a/lib/pleroma/gun/connection_pool.ex b/lib/pleroma/gun/connection_pool.ex new file mode 100644 index 0000000..2e851de --- /dev/null +++ b/lib/pleroma/gun/connection_pool.ex @@ -0,0 +1,86 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Gun.ConnectionPool do + @registry __MODULE__ + + alias Pleroma.Gun.ConnectionPool.WorkerSupervisor + + def children do + [ + {Registry, keys: :unique, name: @registry}, + Pleroma.Gun.ConnectionPool.WorkerSupervisor + ] + end + + @spec get_conn(URI.t(), keyword()) :: {:ok, pid()} | {:error, term()} + def get_conn(uri, opts) do + key = "#{uri.scheme}:#{uri.host}:#{uri.port}" + + case Registry.lookup(@registry, key) do + # The key has already been registered, but connection is not up yet + [{worker_pid, nil}] -> + get_gun_pid_from_worker(worker_pid, true) + + [{worker_pid, {gun_pid, _used_by, _crf, _last_reference}}] -> + GenServer.call(worker_pid, :add_client) + {:ok, gun_pid} + + [] -> + # :gun.set_owner fails in :connected state for whatevever reason, + # so we open the connection in the process directly and send it's pid back + # We trust gun to handle timeouts by itself + case WorkerSupervisor.start_worker([key, uri, opts, self()]) do + {:ok, worker_pid} -> + get_gun_pid_from_worker(worker_pid, false) + + {:error, {:already_started, worker_pid}} -> + get_gun_pid_from_worker(worker_pid, true) + + err -> + err + end + end + end + + defp get_gun_pid_from_worker(worker_pid, register) do + # GenServer.call will block the process for timeout length if + # the server crashes on startup (which will happen if gun fails to connect) + # so instead we use cast + monitor + + ref = Process.monitor(worker_pid) + if register, do: GenServer.cast(worker_pid, {:add_client, self()}) + + receive do + {:conn_pid, pid} -> + Process.demonitor(ref) + {:ok, pid} + + {:DOWN, ^ref, :process, ^worker_pid, reason} -> + case reason do + {:shutdown, {:error, _} = error} -> error + {:shutdown, error} -> {:error, error} + _ -> {:error, reason} + end + end + end + + @spec release_conn(pid()) :: :ok + def release_conn(conn_pid) do + # :ets.fun2ms(fn {_, {worker_pid, {gun_pid, _, _, _}}} when gun_pid == conn_pid -> + # worker_pid end) + query_result = + Registry.select(@registry, [ + {{:_, :"$1", {:"$2", :_, :_, :_}}, [{:==, :"$2", conn_pid}], [:"$1"]} + ]) + + case query_result do + [worker_pid] -> + GenServer.call(worker_pid, :remove_client) + + [] -> + :ok + end + end +end diff --git a/lib/pleroma/gun/connection_pool/reclaimer.ex b/lib/pleroma/gun/connection_pool/reclaimer.ex new file mode 100644 index 0000000..efd5c9f --- /dev/null +++ b/lib/pleroma/gun/connection_pool/reclaimer.ex @@ -0,0 +1,89 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Gun.ConnectionPool.Reclaimer do + use GenServer, restart: :temporary + + defp registry, do: Pleroma.Gun.ConnectionPool + + def start_monitor do + pid = + case :gen_server.start(__MODULE__, [], name: {:via, Registry, {registry(), "reclaimer"}}) do + {:ok, pid} -> + pid + + {:error, {:already_registered, pid}} -> + pid + end + + {pid, Process.monitor(pid)} + end + + @impl true + def init(_) do + {:ok, nil, {:continue, :reclaim}} + end + + @impl true + def handle_continue(:reclaim, _) do + max_connections = Pleroma.Config.get([:connections_pool, :max_connections]) + + reclaim_max = + [:connections_pool, :reclaim_multiplier] + |> Pleroma.Config.get() + |> Kernel.*(max_connections) + |> round + |> max(1) + + :telemetry.execute([:pleroma, :connection_pool, :reclaim, :start], %{}, %{ + max_connections: max_connections, + reclaim_max: reclaim_max + }) + + # :ets.fun2ms( + # fn {_, {worker_pid, {_, used_by, crf, last_reference}}} when used_by == [] -> + # {worker_pid, crf, last_reference} end) + unused_conns = + Registry.select( + registry(), + [ + {{:_, :"$1", {:_, :"$2", :"$3", :"$4"}}, [{:==, :"$2", []}], [{{:"$1", :"$3", :"$4"}}]} + ] + ) + + case unused_conns do + [] -> + :telemetry.execute( + [:pleroma, :connection_pool, :reclaim, :stop], + %{reclaimed_count: 0}, + %{ + max_connections: max_connections + } + ) + + {:stop, :no_unused_conns, nil} + + unused_conns -> + reclaimed = + unused_conns + |> Enum.sort(fn {_pid1, crf1, last_reference1}, {_pid2, crf2, last_reference2} -> + crf1 <= crf2 and last_reference1 <= last_reference2 + end) + |> Enum.take(reclaim_max) + + reclaimed + |> Enum.each(fn {pid, _, _} -> + DynamicSupervisor.terminate_child(Pleroma.Gun.ConnectionPool.WorkerSupervisor, pid) + end) + + :telemetry.execute( + [:pleroma, :connection_pool, :reclaim, :stop], + %{reclaimed_count: Enum.count(reclaimed)}, + %{max_connections: max_connections} + ) + + {:stop, :normal, nil} + end + end +end diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex new file mode 100644 index 0000000..38527ec --- /dev/null +++ b/lib/pleroma/gun/connection_pool/worker.ex @@ -0,0 +1,153 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Gun.ConnectionPool.Worker do + alias Pleroma.Gun + use GenServer, restart: :temporary + + defp registry, do: Pleroma.Gun.ConnectionPool + + def start_link([key | _] = opts) do + GenServer.start_link(__MODULE__, opts, name: {:via, Registry, {registry(), key}}) + end + + @impl true + def init([_key, _uri, _opts, _client_pid] = opts) do + {:ok, nil, {:continue, {:connect, opts}}} + end + + @impl true + def handle_continue({:connect, [key, uri, opts, client_pid]}, _) do + with {:ok, conn_pid, protocol} <- Gun.Conn.open(uri, opts), + Process.link(conn_pid) do + time = :erlang.monotonic_time(:millisecond) + + {_, _} = + Registry.update_value(registry(), key, fn _ -> + {conn_pid, [client_pid], 1, time} + end) + + send(client_pid, {:conn_pid, conn_pid}) + + {:noreply, + %{ + key: key, + timer: nil, + client_monitors: %{client_pid => Process.monitor(client_pid)}, + protocol: protocol + }, :hibernate} + else + err -> + {:stop, {:shutdown, err}, nil} + end + end + + @impl true + def handle_cast({:add_client, client_pid}, state) do + case handle_call(:add_client, {client_pid, nil}, state) do + {:reply, conn_pid, state, :hibernate} -> + send(client_pid, {:conn_pid, conn_pid}) + {:noreply, state, :hibernate} + end + end + + @impl true + def handle_cast({:remove_client, client_pid}, state) do + case handle_call(:remove_client, {client_pid, nil}, state) do + {:reply, _, state, :hibernate} -> + {:noreply, state, :hibernate} + end + end + + @impl true + def handle_call(:add_client, {client_pid, _}, %{key: key, protocol: protocol} = state) do + time = :erlang.monotonic_time(:millisecond) + + {{conn_pid, used_by, _, _}, _} = + Registry.update_value(registry(), key, fn {conn_pid, used_by, crf, last_reference} -> + {conn_pid, [client_pid | used_by], crf(time - last_reference, crf), time} + end) + + :telemetry.execute( + [:pleroma, :connection_pool, :client, :add], + %{client_pid: client_pid, clients: used_by}, + %{key: state.key, protocol: protocol} + ) + + state = + if state.timer != nil do + Process.cancel_timer(state[:timer]) + %{state | timer: nil} + else + state + end + + ref = Process.monitor(client_pid) + + state = put_in(state.client_monitors[client_pid], ref) + {:reply, conn_pid, state, :hibernate} + end + + @impl true + def handle_call(:remove_client, {client_pid, _}, %{key: key} = state) do + {{_conn_pid, used_by, _crf, _last_reference}, _} = + Registry.update_value(registry(), key, fn {conn_pid, used_by, crf, last_reference} -> + {conn_pid, List.delete(used_by, client_pid), crf, last_reference} + end) + + {ref, state} = pop_in(state.client_monitors[client_pid]) + + Process.demonitor(ref, [:flush]) + + timer = + if used_by == [] do + max_idle = Pleroma.Config.get([:connections_pool, :max_idle_time], 30_000) + Process.send_after(self(), :idle_close, max_idle) + else + nil + end + + {:reply, :ok, %{state | timer: timer}, :hibernate} + end + + @impl true + def handle_info(:idle_close, state) do + # Gun monitors the owner process, and will close the connection automatically + # when it's terminated + {:stop, :normal, state} + end + + @impl true + def handle_info({:gun_up, _pid, _protocol}, state) do + {:noreply, state, :hibernate} + end + + # Gracefully shutdown if the connection got closed without any streams left + @impl true + def handle_info({:gun_down, _pid, _protocol, _reason, []}, state) do + {:stop, :normal, state} + end + + # Otherwise, wait for retry + @impl true + def handle_info({:gun_down, _pid, _protocol, _reason, _killed_streams}, state) do + {:noreply, state, :hibernate} + end + + @impl true + def handle_info({:DOWN, _ref, :process, pid, reason}, state) do + :telemetry.execute( + [:pleroma, :connection_pool, :client, :dead], + %{client_pid: pid, reason: reason}, + %{key: state.key} + ) + + handle_cast({:remove_client, pid}, state) + end + + # LRFU policy: https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.55.1478 + defp crf(time_delta, prev_crf) do + 1 + :math.pow(0.5, 0.0001 * time_delta) * prev_crf + end +end diff --git a/lib/pleroma/gun/connection_pool/worker_supervisor.ex b/lib/pleroma/gun/connection_pool/worker_supervisor.ex new file mode 100644 index 0000000..d26a70b --- /dev/null +++ b/lib/pleroma/gun/connection_pool/worker_supervisor.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Gun.ConnectionPool.WorkerSupervisor do + @moduledoc "Supervisor for pool workers. Does not do anything except enforce max connection limit" + + use DynamicSupervisor + + def start_link(opts) do + DynamicSupervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(_opts) do + DynamicSupervisor.init( + strategy: :one_for_one, + max_children: Pleroma.Config.get([:connections_pool, :max_connections]) + ) + end + + def start_worker(opts, retry \\ false) do + case DynamicSupervisor.start_child(__MODULE__, {Pleroma.Gun.ConnectionPool.Worker, opts}) do + {:error, :max_children} -> + if retry or free_pool() == :error do + :telemetry.execute([:pleroma, :connection_pool, :provision_failure], %{opts: opts}) + {:error, :pool_full} + else + start_worker(opts, true) + end + + res -> + res + end + end + + defp free_pool do + wait_for_reclaimer_finish(Pleroma.Gun.ConnectionPool.Reclaimer.start_monitor()) + end + + defp wait_for_reclaimer_finish({pid, mon}) do + receive do + {:DOWN, ^mon, :process, ^pid, :no_unused_conns} -> + :error + + {:DOWN, ^mon, :process, ^pid, :normal} -> + :ok + end + end +end diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex new file mode 100644 index 0000000..a43d882 --- /dev/null +++ b/lib/pleroma/hashtag.ex @@ -0,0 +1,106 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Hashtag do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Ecto.Multi + alias Pleroma.Hashtag + alias Pleroma.Object + alias Pleroma.Repo + + schema "hashtags" do + field(:name, :string) + + many_to_many(:objects, Object, join_through: "hashtags_objects", on_replace: :delete) + + timestamps() + end + + def normalize_name(name) do + name + |> String.downcase() + |> String.trim() + end + + def get_or_create_by_name(name) do + changeset = changeset(%Hashtag{}, %{name: name}) + + Repo.insert( + changeset, + on_conflict: [set: [name: get_field(changeset, :name)]], + conflict_target: :name, + returning: true + ) + end + + def get_or_create_by_names(names) when is_list(names) do + names = Enum.map(names, &normalize_name/1) + timestamp = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + + structs = + Enum.map(names, fn name -> + %Hashtag{} + |> changeset(%{name: name}) + |> Map.get(:changes) + |> Map.merge(%{inserted_at: timestamp, updated_at: timestamp}) + end) + + try do + with {:ok, %{query_op: hashtags}} <- + Multi.new() + |> Multi.insert_all(:insert_all_op, Hashtag, structs, + on_conflict: :nothing, + conflict_target: :name + ) + |> Multi.run(:query_op, fn _repo, _changes -> + {:ok, Repo.all(from(ht in Hashtag, where: ht.name in ^names))} + end) + |> Repo.transaction() do + {:ok, hashtags} + else + {:error, _name, value, _changes_so_far} -> {:error, value} + end + rescue + e -> {:error, e} + end + end + + def changeset(%Hashtag{} = struct, params) do + struct + |> cast(params, [:name]) + |> update_change(:name, &normalize_name/1) + |> validate_required([:name]) + |> unique_constraint(:name) + end + + def unlink(%Object{id: object_id}) do + with {_, hashtag_ids} <- + from(hto in "hashtags_objects", + where: hto.object_id == ^object_id, + select: hto.hashtag_id + ) + |> Repo.delete_all(), + {:ok, unreferenced_count} <- delete_unreferenced(hashtag_ids) do + {:ok, length(hashtag_ids), unreferenced_count} + end + end + + @delete_unreferenced_query """ + DELETE FROM hashtags WHERE id IN + (SELECT hashtags.id FROM hashtags + LEFT OUTER JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id + WHERE hashtags_objects.hashtag_id IS NULL AND hashtags.id = ANY($1)); + """ + + def delete_unreferenced(ids) do + with {:ok, %{num_rows: deleted_count}} <- Repo.query(@delete_unreferenced_query, [ids]) do + {:ok, deleted_count} + end + end +end diff --git a/lib/pleroma/healthcheck.ex b/lib/pleroma/healthcheck.ex new file mode 100644 index 0000000..8e9ab82 --- /dev/null +++ b/lib/pleroma/healthcheck.ex @@ -0,0 +1,73 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Healthcheck do + @moduledoc """ + Module collects metrics about app and assign healthy status. + """ + alias Pleroma.Healthcheck + alias Pleroma.Repo + + @derive Jason.Encoder + defstruct pool_size: 0, + active: 0, + idle: 0, + memory_used: 0, + job_queue_stats: nil, + healthy: true + + @type t :: %__MODULE__{ + pool_size: non_neg_integer(), + active: non_neg_integer(), + idle: non_neg_integer(), + memory_used: number(), + job_queue_stats: map(), + healthy: boolean() + } + + @spec system_info() :: t() + def system_info do + %Healthcheck{ + memory_used: Float.round(:recon_alloc.memory(:allocated) / 1024 / 1024, 2) + } + |> assign_db_info() + |> assign_job_queue_stats() + |> check_health() + end + + defp assign_db_info(healthcheck) do + database = Pleroma.Config.get([Repo, :database]) + + query = + "select state, count(pid) from pg_stat_activity where datname = '#{database}' group by state;" + + result = Repo.query!(query) + pool_size = Pleroma.Config.get([Repo, :pool_size]) + + db_info = + Enum.reduce(result.rows, %{active: 0, idle: 0}, fn [state, cnt], states -> + if state == "active" do + Map.put(states, :active, states.active + cnt) + else + Map.put(states, :idle, states.idle + cnt) + end + end) + |> Map.put(:pool_size, pool_size) + + Map.merge(healthcheck, db_info) + end + + defp assign_job_queue_stats(healthcheck) do + stats = Pleroma.JobQueueMonitor.stats() + Map.put(healthcheck, :job_queue_stats, stats) + end + + @spec check_health(Healthcheck.t()) :: Healthcheck.t() + def check_health(%{pool_size: pool_size, active: active} = check) + when active >= pool_size do + %{check | healthy: false} + end + + def check_health(check), do: check +end diff --git a/lib/pleroma/helpers/auth_helper.ex b/lib/pleroma/helpers/auth_helper.ex new file mode 100644 index 0000000..61599e7 --- /dev/null +++ b/lib/pleroma/helpers/auth_helper.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Helpers.AuthHelper do + alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Plug.Conn + + import Plug.Conn + + @oauth_token_session_key :oauth_token + + @doc """ + Skips OAuth permissions (scopes) checks, assigns nil `:token`. + Intended to be used with explicit authentication and only when OAuth token cannot be determined. + """ + def skip_oauth(conn) do + conn + |> assign(:token, nil) + |> OAuthScopesPlug.skip_plug() + end + + @doc "Drops authentication info from connection" + def drop_auth_info(conn) do + # To simplify debugging, setting a private variable on `conn` if auth info is dropped + conn + |> assign(:user, nil) + |> assign(:token, nil) + |> put_private(:authentication_ignored, true) + end + + @doc "Gets OAuth token string from session" + def get_session_token(%Conn{} = conn) do + get_session(conn, @oauth_token_session_key) + end + + @doc "Updates OAuth token string in session" + def put_session_token(%Conn{} = conn, token) when is_binary(token) do + put_session(conn, @oauth_token_session_key, token) + end + + @doc "Deletes OAuth token string from session" + def delete_session_token(%Conn{} = conn) do + delete_session(conn, @oauth_token_session_key) + end +end diff --git a/lib/pleroma/helpers/inet_helper.ex b/lib/pleroma/helpers/inet_helper.ex new file mode 100644 index 0000000..704d37f --- /dev/null +++ b/lib/pleroma/helpers/inet_helper.ex @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Helpers.InetHelper do + def parse_address(ip) when is_tuple(ip) do + {:ok, ip} + end + + def parse_address(ip) when is_binary(ip) do + ip + |> String.to_charlist() + |> parse_address() + end + + def parse_address(ip) do + :inet.parse_address(ip) + end +end diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex new file mode 100644 index 0000000..24c845f --- /dev/null +++ b/lib/pleroma/helpers/media_helper.ex @@ -0,0 +1,162 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Helpers.MediaHelper do + @moduledoc """ + Handles common media-related operations. + """ + + alias Pleroma.HTTP + + require Logger + + def missing_dependencies do + Enum.reduce([imagemagick: "convert", ffmpeg: "ffmpeg"], [], fn {sym, executable}, acc -> + if Pleroma.Utils.command_available?(executable) do + acc + else + [sym | acc] + end + end) + end + + def image_resize(url, options) do + with executable when is_binary(executable) <- System.find_executable("convert"), + {:ok, args} <- prepare_image_resize_args(options), + {:ok, env} <- HTTP.get(url, [], pool: :media), + {:ok, fifo_path} <- mkfifo() do + args = List.flatten([fifo_path, args]) + run_fifo(fifo_path, env, executable, args) + else + nil -> {:error, {:convert, :command_not_found}} + {:error, _} = error -> error + end + end + + defp prepare_image_resize_args( + %{max_width: max_width, max_height: max_height, format: "png"} = options + ) do + quality = options[:quality] || 85 + resize = Enum.join([max_width, "x", max_height, ">"]) + + args = [ + "-resize", + resize, + "-quality", + to_string(quality), + "png:-" + ] + + {:ok, args} + end + + defp prepare_image_resize_args(%{max_width: max_width, max_height: max_height} = options) do + quality = options[:quality] || 85 + resize = Enum.join([max_width, "x", max_height, ">"]) + + args = [ + "-interlace", + "Plane", + "-resize", + resize, + "-quality", + to_string(quality), + "jpg:-" + ] + + {:ok, args} + end + + defp prepare_image_resize_args(_), do: {:error, :missing_options} + + # Note: video thumbnail is intentionally not resized (always has original dimensions) + def video_framegrab(url) do + with executable when is_binary(executable) <- System.find_executable("ffmpeg"), + {:ok, env} <- HTTP.get(url, [], pool: :media), + {:ok, fifo_path} <- mkfifo(), + args = [ + "-y", + "-i", + fifo_path, + "-vframes", + "1", + "-f", + "mjpeg", + "-loglevel", + "error", + "-" + ] do + run_fifo(fifo_path, env, executable, args) + else + nil -> {:error, {:ffmpeg, :command_not_found}} + {:error, _} = error -> error + end + end + + defp run_fifo(fifo_path, env, executable, args) do + pid = + Port.open({:spawn_executable, executable}, [ + :use_stdio, + :stream, + :exit_status, + :binary, + args: args + ]) + + fifo = Port.open(to_charlist(fifo_path), [:eof, :binary, :stream, :out]) + fix = Pleroma.Helpers.QtFastStart.fix(env.body) + true = Port.command(fifo, fix) + :erlang.port_close(fifo) + loop_recv(pid) + after + File.rm(fifo_path) + end + + defp mkfifo do + path = Path.join(System.tmp_dir!(), "pleroma-media-preview-pipe-#{Ecto.UUID.generate()}") + + case System.cmd("mkfifo", [path]) do + {_, 0} -> + spawn(fifo_guard(path)) + {:ok, path} + + {_, err} -> + {:error, {:fifo_failed, err}} + end + end + + defp fifo_guard(path) do + pid = self() + + fn -> + ref = Process.monitor(pid) + + receive do + {:DOWN, ^ref, :process, ^pid, _} -> + File.rm(path) + end + end + end + + defp loop_recv(pid) do + loop_recv(pid, <<>>) + end + + defp loop_recv(pid, acc) do + receive do + {^pid, {:data, data}} -> + loop_recv(pid, acc <> data) + + {^pid, {:exit_status, 0}} -> + {:ok, acc} + + {^pid, {:exit_status, status}} -> + {:error, status} + after + 5000 -> + :erlang.port_close(pid) + {:error, :timeout} + end + end +end diff --git a/lib/pleroma/helpers/qt_fast_start.ex b/lib/pleroma/helpers/qt_fast_start.ex new file mode 100644 index 0000000..5711c71 --- /dev/null +++ b/lib/pleroma/helpers/qt_fast_start.ex @@ -0,0 +1,131 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Helpers.QtFastStart do + @moduledoc """ + (WIP) Converts a "slow start" (data before metadatas) mov/mp4 file to a "fast start" one (metadatas before data). + """ + + # TODO: Cleanup and optimizations + # Inspirations: https://www.ffmpeg.org/doxygen/3.4/qt-faststart_8c_source.html + # https://github.com/danielgtaylor/qtfaststart/blob/master/qtfaststart/processor.py + # ISO/IEC 14496-12:2015, ISO/IEC 15444-12:2015 + # Paracetamol + + def fix(<<0x00, 0x00, 0x00, _, 0x66, 0x74, 0x79, 0x70, _::bits>> = binary) do + index = fix(binary, 0, nil, nil, []) + + case index do + :abort -> binary + [{"ftyp", _, _, _, _}, {"mdat", _, _, _, _} | _] -> faststart(index) + [{"ftyp", _, _, _, _}, {"free", _, _, _, _}, {"mdat", _, _, _, _} | _] -> faststart(index) + _ -> binary + end + end + + def fix(binary) do + binary + end + + # MOOV have been seen before MDAT- abort + defp fix(<<_::bits>>, _, true, false, _) do + :abort + end + + defp fix( + <>, + pos, + got_moov, + got_mdat, + acc + ) do + full_size = (size - 8) * 8 + <> = rest + + acc = [ + {fourcc, pos, pos + size, size, + <>} + | acc + ] + + fix(rest, pos + size, got_moov || fourcc == "moov", got_mdat || fourcc == "mdat", acc) + end + + defp fix(<<>>, _pos, _, _, acc) do + :lists.reverse(acc) + end + + defp faststart(index) do + {{_ftyp, _, _, _, ftyp}, index} = List.keytake(index, "ftyp", 0) + + # Skip re-writing the free fourcc as it's kind of useless. + # Why stream useless bytes when you can do without? + {free_size, index} = + case List.keytake(index, "free", 0) do + {{_, _, _, size, _}, index} -> {size, index} + _ -> {0, index} + end + + {{_moov, _, _, moov_size, moov}, index} = List.keytake(index, "moov", 0) + offset = -free_size + moov_size + rest = for {_, _, _, _, data} <- index, do: data, into: [] + <> = moov + [ftyp, moov_head, fix_moov(moov_data, offset, []), rest] + end + + defp fix_moov( + <>, + offset, + acc + ) do + full_size = (size - 8) * 8 + <> = rest + + data = + cond do + fourcc in ["trak", "mdia", "minf", "stbl"] -> + # Theses contains sto or co64 part + [<>, fix_moov(data, offset, [])] + + fourcc in ["stco", "co64"] -> + # fix the damn thing + <> = data + + entry_size = + case fourcc do + "stco" -> 32 + "co64" -> 64 + end + + [ + <>, + rewrite_entries(entry_size, offset, rest, []) + ] + + true -> + [<>, data] + end + + acc = [acc | data] + fix_moov(rest, offset, acc) + end + + defp fix_moov(<<>>, _, acc), do: acc + + for size <- [32, 64] do + defp rewrite_entries( + unquote(size), + offset, + <>, + acc + ) do + rewrite_entries(unquote(size), offset, rest, [ + acc | <> + ]) + end + end + + defp rewrite_entries(_, _, <<>>, acc), do: acc +end diff --git a/lib/pleroma/helpers/uri_helper.ex b/lib/pleroma/helpers/uri_helper.ex new file mode 100644 index 0000000..c8d10d3 --- /dev/null +++ b/lib/pleroma/helpers/uri_helper.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Helpers.UriHelper do + def modify_uri_params(uri, overridden_params, deleted_params \\ []) do + uri = URI.parse(uri) + + existing_params = URI.query_decoder(uri.query || "") |> Map.new() + overridden_params = Map.new(overridden_params, fn {k, v} -> {to_string(k), v} end) + deleted_params = Enum.map(deleted_params, &to_string/1) + + updated_params = + existing_params + |> Map.merge(overridden_params) + |> Map.drop(deleted_params) + + uri + |> Map.put(:query, URI.encode_query(updated_params)) + |> URI.to_string() + |> String.replace_suffix("?", "") + end + + def maybe_add_base("/" <> uri, base), do: Path.join([base, uri]) + def maybe_add_base(uri, _base), do: uri +end diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex new file mode 100644 index 0000000..5bf735c --- /dev/null +++ b/lib/pleroma/html.ex @@ -0,0 +1,93 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTML do + # Scrubbers are compiled on boot so they can be configured in OTP releases + # @on_load :compile_scrubbers + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + + def compile_scrubbers do + dir = Path.join(:code.priv_dir(:pleroma), "scrubbers") + + dir + |> Pleroma.Utils.compile_dir() + |> case do + {:error, _errors, _warnings} -> + raise "Compiling scrubbers failed" + + {:ok, _modules, _warnings} -> + :ok + end + end + + defp get_scrubbers(scrubber) when is_atom(scrubber), do: [scrubber] + defp get_scrubbers(scrubbers) when is_list(scrubbers), do: scrubbers + defp get_scrubbers(_), do: [Pleroma.HTML.Scrubber.Default] + + def get_scrubbers do + Pleroma.Config.get([:markup, :scrub_policy]) + |> get_scrubbers + end + + def filter_tags(html, nil) do + filter_tags(html, get_scrubbers()) + end + + def filter_tags(html, scrubbers) when is_list(scrubbers) do + Enum.reduce(scrubbers, html, fn scrubber, html -> + filter_tags(html, scrubber) + end) + end + + def filter_tags(html, scrubber) do + {:ok, content} = FastSanitize.Sanitizer.scrub(html, scrubber) + content + end + + def filter_tags(html), do: filter_tags(html, nil) + def strip_tags(html), do: filter_tags(html, FastSanitize.Sanitizer.StripTags) + + def ensure_scrubbed_html( + content, + scrubbers, + fake, + callback + ) do + content = + content + |> filter_tags(scrubbers) + |> callback.() + + if fake do + {:ignore, content} + else + {:commit, content} + end + end + + def extract_first_external_url_from_object(%{data: %{"content" => content}} = object) + when is_binary(content) do + unless object.data["fake"] do + key = "URL|#{object.id}" + + @cachex.fetch!(:scrubber_cache, key, fn _key -> + {:commit, {:ok, extract_first_external_url(content)}} + end) + else + {:ok, extract_first_external_url(content)} + end + end + + def extract_first_external_url_from_object(_), do: {:error, :no_content} + + def extract_first_external_url(content) do + content + |> Floki.parse_fragment!() + |> Floki.find("a:not(.mention,.hashtag,.attachment,[rel~=\"tag\"])") + |> Enum.take(1) + |> Floki.attribute("href") + |> Enum.at(0) + end +end diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex new file mode 100644 index 0000000..d410615 --- /dev/null +++ b/lib/pleroma/http.ex @@ -0,0 +1,117 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP do + @moduledoc """ + Wrapper for `Tesla.request/2`. + """ + + alias Pleroma.HTTP.AdapterHelper + alias Pleroma.HTTP.Request + alias Pleroma.HTTP.RequestBuilder, as: Builder + alias Tesla.Client + alias Tesla.Env + + require Logger + + @type t :: __MODULE__ + @type method() :: :get | :post | :put | :delete | :head + + @doc """ + Performs GET request. + + See `Pleroma.HTTP.request/5` + """ + @spec get(Request.url() | nil, Request.headers(), keyword()) :: + nil | {:ok, Env.t()} | {:error, any()} + def get(url, headers \\ [], options \\ []) + def get(nil, _, _), do: nil + def get(url, headers, options), do: request(:get, url, "", headers, options) + + @spec head(Request.url(), Request.headers(), keyword()) :: {:ok, Env.t()} | {:error, any()} + def head(url, headers \\ [], options \\ []), do: request(:head, url, "", headers, options) + + @doc """ + Performs POST request. + + See `Pleroma.HTTP.request/5` + """ + @spec post(Request.url(), String.t(), Request.headers(), keyword()) :: + {:ok, Env.t()} | {:error, any()} + def post(url, body, headers \\ [], options \\ []), + do: request(:post, url, body, headers, options) + + @doc """ + Builds and performs http request. + + # Arguments: + `method` - :get, :post, :put, :delete, :head + `url` - full url + `body` - request body + `headers` - a keyworld list of headers, e.g. `[{"content-type", "text/plain"}]` + `options` - custom, per-request middleware or adapter options + + # Returns: + `{:ok, %Tesla.Env{}}` or `{:error, error}` + + """ + @spec request(method(), Request.url(), String.t(), Request.headers(), keyword()) :: + {:ok, Env.t()} | {:error, any()} + def request(method, url, body, headers, options) when is_binary(url) do + uri = URI.parse(url) + adapter_opts = AdapterHelper.options(uri, options || []) + + options = put_in(options[:adapter], adapter_opts) + params = options[:params] || [] + request = build_request(method, headers, options, url, body, params) + + adapter = Application.get_env(:tesla, :adapter) + + client = Tesla.client(adapter_middlewares(adapter), adapter) + + maybe_limit( + fn -> + request(client, request) + end, + adapter, + adapter_opts + ) + end + + @spec request(Client.t(), keyword()) :: {:ok, Env.t()} | {:error, any()} + def request(client, request), do: Tesla.request(client, request) + + defp build_request(method, headers, options, url, body, params) do + Builder.new() + |> Builder.method(method) + |> Builder.headers(headers) + |> Builder.opts(options) + |> Builder.url(url) + |> Builder.add_param(:body, :body, body) + |> Builder.add_param(:query, :query, params) + |> Builder.convert_to_keyword() + end + + @prefix Pleroma.Gun.ConnectionPool + defp maybe_limit(fun, Tesla.Adapter.Gun, opts) do + ConcurrentLimiter.limit(:"#{@prefix}.#{opts[:pool] || :default}", fun) + end + + defp maybe_limit(fun, _, _) do + fun.() + end + + defp adapter_middlewares(Tesla.Adapter.Gun) do + [Tesla.Middleware.FollowRedirects, Pleroma.Tesla.Middleware.ConnectionPool] + end + + defp adapter_middlewares(_) do + if Pleroma.Config.get(:env) == :test do + # Emulate redirects in test env, which are handled by adapters in other environments + [Tesla.Middleware.FollowRedirects] + else + [] + end + end +end diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex new file mode 100644 index 0000000..e9bb202 --- /dev/null +++ b/lib/pleroma/http/adapter_helper.ex @@ -0,0 +1,121 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.AdapterHelper do + @moduledoc """ + Configure Tesla.Client with default and customized adapter options. + """ + @defaults [pool: :federation, connect_timeout: 5_000, recv_timeout: 5_000] + + @type proxy_type() :: :socks4 | :socks5 + @type host() :: charlist() | :inet.ip_address() + + alias Pleroma.HTTP.AdapterHelper + require Logger + + @type proxy :: + {Connection.host(), pos_integer()} + | {Connection.proxy_type(), Connection.host(), pos_integer()} + + @callback options(keyword(), URI.t()) :: keyword() + + @spec format_proxy(String.t() | tuple() | nil) :: proxy() | nil + def format_proxy(nil), do: nil + + def format_proxy(proxy_url) do + case parse_proxy(proxy_url) do + {:ok, host, port} -> {host, port} + {:ok, type, host, port} -> {type, host, port} + _ -> nil + end + end + + @spec maybe_add_proxy(keyword(), proxy() | nil) :: keyword() + def maybe_add_proxy(opts, nil), do: opts + def maybe_add_proxy(opts, proxy), do: Keyword.put_new(opts, :proxy, proxy) + + @doc """ + Merge default connection & adapter options with received ones. + """ + + @spec options(URI.t(), keyword()) :: keyword() + def options(%URI{} = uri, opts \\ []) do + @defaults + |> Keyword.merge(opts) + |> adapter_helper().options(uri) + end + + defp adapter, do: Application.get_env(:tesla, :adapter) + + defp adapter_helper do + case adapter() do + Tesla.Adapter.Gun -> AdapterHelper.Gun + Tesla.Adapter.Hackney -> AdapterHelper.Hackney + _ -> AdapterHelper.Default + end + end + + @spec parse_proxy(String.t() | tuple() | nil) :: + {:ok, host(), pos_integer()} + | {:ok, proxy_type(), host(), pos_integer()} + | {:error, atom()} + | nil + + def parse_proxy(nil), do: nil + + def parse_proxy(proxy) when is_binary(proxy) do + with [host, port] <- String.split(proxy, ":"), + {port, ""} <- Integer.parse(port) do + {:ok, parse_host(host), port} + else + {_, _} -> + Logger.warning("Parsing port failed #{inspect(proxy)}") + {:error, :invalid_proxy_port} + + :error -> + Logger.warning("Parsing port failed #{inspect(proxy)}") + {:error, :invalid_proxy_port} + + _ -> + Logger.warning("Parsing proxy failed #{inspect(proxy)}") + {:error, :invalid_proxy} + end + end + + def parse_proxy(proxy) when is_tuple(proxy) do + with {type, host, port} <- proxy do + {:ok, type, parse_host(host), port} + else + _ -> + Logger.warning("Parsing proxy failed #{inspect(proxy)}") + {:error, :invalid_proxy} + end + end + + @spec parse_host(String.t() | atom() | charlist()) :: charlist() | :inet.ip_address() + def parse_host(host) when is_list(host), do: host + def parse_host(host) when is_atom(host), do: to_charlist(host) + + def parse_host(host) when is_binary(host) do + host = to_charlist(host) + + case :inet.parse_address(host) do + {:error, :einval} -> host + {:ok, ip} -> ip + end + end + + @spec format_host(String.t()) :: charlist() + def format_host(host) do + host_charlist = to_charlist(host) + + case :inet.parse_address(host_charlist) do + {:error, :einval} -> + :idna.encode(host_charlist) + + {:ok, _ip} -> + host_charlist + end + end +end diff --git a/lib/pleroma/http/adapter_helper/default.ex b/lib/pleroma/http/adapter_helper/default.ex new file mode 100644 index 0000000..9c94147 --- /dev/null +++ b/lib/pleroma/http/adapter_helper/default.ex @@ -0,0 +1,18 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.AdapterHelper.Default do + alias Pleroma.HTTP.AdapterHelper + + @behaviour Pleroma.HTTP.AdapterHelper + + @spec options(keyword(), URI.t()) :: keyword() + def options(opts, _uri) do + proxy = Pleroma.Config.get([:http, :proxy_url], nil) + AdapterHelper.maybe_add_proxy(opts, AdapterHelper.format_proxy(proxy)) + end + + @spec get_conn(URI.t(), keyword()) :: {:ok, keyword()} + def get_conn(_uri, opts), do: {:ok, opts} +end diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex new file mode 100644 index 0000000..74ab985 --- /dev/null +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -0,0 +1,82 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.AdapterHelper.Gun do + @behaviour Pleroma.HTTP.AdapterHelper + + alias Pleroma.Config + alias Pleroma.HTTP.AdapterHelper + + require Logger + + @defaults [ + retry: 1, + retry_timeout: 1_000 + ] + + @type pool() :: :federation | :upload | :media | :default + + @spec options(keyword(), URI.t()) :: keyword() + def options(incoming_opts \\ [], %URI{} = uri) do + proxy = + [:http, :proxy_url] + |> Config.get() + |> AdapterHelper.format_proxy() + + config_opts = Config.get([:http, :adapter], []) + + @defaults + |> Keyword.merge(config_opts) + |> add_scheme_opts(uri) + |> AdapterHelper.maybe_add_proxy(proxy) + |> Keyword.merge(incoming_opts) + |> put_timeout() + end + + defp add_scheme_opts(opts, %{scheme: "http"}), do: opts + + defp add_scheme_opts(opts, %{scheme: "https"}) do + Keyword.put(opts, :certificates_verification, true) + end + + defp put_timeout(opts) do + {recv_timeout, opts} = Keyword.pop(opts, :recv_timeout, pool_timeout(opts[:pool])) + # this is the timeout to receive a message from Gun + # `:timeout` key is used in Tesla + Keyword.put(opts, :timeout, recv_timeout) + end + + @spec pool_timeout(pool()) :: non_neg_integer() + def pool_timeout(pool) do + default = Config.get([:pools, :default, :recv_timeout], 5_000) + + Config.get([:pools, pool, :recv_timeout], default) + end + + def limiter_setup do + prefix = Pleroma.Gun.ConnectionPool + wait = Config.get([:connections_pool, :connection_acquisition_wait]) + retries = Config.get([:connections_pool, :connection_acquisition_retries]) + + :pools + |> Config.get([]) + |> Enum.each(fn {name, opts} -> + max_running = Keyword.get(opts, :size, 50) + max_waiting = Keyword.get(opts, :max_waiting, 10) + + result = + ConcurrentLimiter.new(:"#{prefix}.#{name}", max_running, max_waiting, + wait: wait, + max_retries: retries + ) + + case result do + :ok -> :ok + {:error, :existing} -> :ok + end + end) + + :ok + end +end diff --git a/lib/pleroma/http/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex new file mode 100644 index 0000000..f3be1f3 --- /dev/null +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.AdapterHelper.Hackney do + @behaviour Pleroma.HTTP.AdapterHelper + + @defaults [ + follow_redirect: true, + force_redirect: true + ] + + @spec options(keyword(), URI.t()) :: keyword() + def options(connection_opts \\ [], %URI{} = uri) do + proxy = Pleroma.Config.get([:http, :proxy_url]) + + config_opts = Pleroma.Config.get([:http, :adapter], []) + + @defaults + |> Keyword.merge(config_opts) + |> Keyword.merge(connection_opts) + |> add_scheme_opts(uri) + |> maybe_add_with_body() + |> Pleroma.HTTP.AdapterHelper.maybe_add_proxy(proxy) + end + + defp add_scheme_opts(opts, _), do: opts + + defp maybe_add_with_body(opts) do + if opts[:max_body] do + Keyword.put(opts, :with_body, true) + else + opts + end + end +end diff --git a/lib/pleroma/http/ex_aws.ex b/lib/pleroma/http/ex_aws.ex new file mode 100644 index 0000000..469c138 --- /dev/null +++ b/lib/pleroma/http/ex_aws.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.ExAws do + @moduledoc false + + @behaviour ExAws.Request.HttpClient + + alias Pleroma.HTTP + + @impl true + def request(method, url, body \\ "", headers \\ [], http_opts \\ []) do + http_opts = Keyword.put_new(http_opts, :pool, :upload) + + case HTTP.request(method, url, body, headers, http_opts) do + {:ok, env} -> + {:ok, %{status_code: env.status, headers: env.headers, body: env.body}} + + {:error, reason} -> + {:error, %{reason: reason}} + end + end +end diff --git a/lib/pleroma/http/request.ex b/lib/pleroma/http/request.ex new file mode 100644 index 0000000..01045f8 --- /dev/null +++ b/lib/pleroma/http/request.ex @@ -0,0 +1,23 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.Request do + @moduledoc """ + Request struct. + """ + defstruct method: :get, url: "", query: [], headers: [], body: "", opts: [] + + @type method :: :head | :get | :delete | :trace | :options | :post | :put | :patch + @type url :: String.t() + @type headers :: [{String.t(), String.t()}] + + @type t :: %__MODULE__{ + method: method(), + url: url(), + query: keyword(), + headers: headers(), + body: String.t(), + opts: keyword() + } +end diff --git a/lib/pleroma/http/request_builder.ex b/lib/pleroma/http/request_builder.ex new file mode 100644 index 0000000..f16fb3b --- /dev/null +++ b/lib/pleroma/http/request_builder.ex @@ -0,0 +1,95 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.RequestBuilder do + @moduledoc """ + Helper functions for building Tesla requests + """ + + alias Pleroma.HTTP.Request + alias Tesla.Multipart + + @doc """ + Creates new request + """ + @spec new(Request.t()) :: Request.t() + def new(%Request{} = request \\ %Request{}), do: request + + @doc """ + Specify the request method when building a request + """ + @spec method(Request.t(), Request.method()) :: Request.t() + def method(request, m), do: %{request | method: m} + + @doc """ + Specify the request method when building a request + """ + @spec url(Request.t(), Request.url()) :: Request.t() + def url(request, u), do: %{request | url: u} + + @doc """ + Add headers to the request + """ + @spec headers(Request.t(), Request.headers()) :: Request.t() + def headers(request, headers) do + headers_list = + with true <- Pleroma.Config.get([:http, :send_user_agent]), + nil <- Enum.find(headers, fn {key, _val} -> String.downcase(key) == "user-agent" end) do + [{"user-agent", Pleroma.Application.user_agent()} | headers] + else + _ -> + headers + end + + %{request | headers: headers_list} + end + + @doc """ + Add custom, per-request middleware or adapter options to the request + """ + @spec opts(Request.t(), keyword()) :: Request.t() + def opts(request, options), do: %{request | opts: options} + + @doc """ + Add optional parameters to the request + """ + @spec add_param(Request.t(), atom(), atom(), any()) :: Request.t() + def add_param(request, :query, :query, values), do: %{request | query: values} + + def add_param(request, :body, :body, value), do: %{request | body: value} + + def add_param(request, :body, key, value) do + request + |> Map.put(:body, Multipart.new()) + |> Map.update!( + :body, + &Multipart.add_field( + &1, + key, + Jason.encode!(value), + headers: [{"content-type", "application/json"}] + ) + ) + end + + def add_param(request, :file, name, path) do + request + |> Map.put(:body, Multipart.new()) + |> Map.update!(:body, &Multipart.add_file(&1, path, name: name)) + end + + def add_param(request, :form, name, value) do + Map.update(request, :body, %{name => value}, &Map.put(&1, name, value)) + end + + def add_param(request, location, key, value) do + Map.update(request, location, [{key, value}], &(&1 ++ [{key, value}])) + end + + def convert_to_keyword(request) do + request + |> Map.from_struct() + |> Enum.into([]) + end +end diff --git a/lib/pleroma/http/tzdata.ex b/lib/pleroma/http/tzdata.ex new file mode 100644 index 0000000..5d2529c --- /dev/null +++ b/lib/pleroma/http/tzdata.ex @@ -0,0 +1,29 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.Tzdata do + @moduledoc false + + @behaviour Tzdata.HTTPClient + + alias Pleroma.HTTP + + @impl true + def get(url, headers, options) do + options = Keyword.put_new(options, :pool, :default) + + with {:ok, %Tesla.Env{} = env} <- HTTP.get(url, headers, options) do + {:ok, {env.status, env.headers, env.body}} + end + end + + @impl true + def head(url, headers, options) do + options = Keyword.put_new(options, :pool, :default) + + with {:ok, %Tesla.Env{} = env} <- HTTP.head(url, headers, options) do + {:ok, {env.status, env.headers}} + end + end +end diff --git a/lib/pleroma/http/web_push.ex b/lib/pleroma/http/web_push.ex new file mode 100644 index 0000000..ca399b6 --- /dev/null +++ b/lib/pleroma/http/web_push.ex @@ -0,0 +1,12 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.WebPush do + @moduledoc false + + def post(url, payload, headers, options \\ []) do + list_headers = Map.to_list(headers) + Pleroma.HTTP.post(url, payload, list_headers, options) + end +end diff --git a/lib/pleroma/instances.ex b/lib/pleroma/instances.ex new file mode 100644 index 0000000..782948f --- /dev/null +++ b/lib/pleroma/instances.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Instances do + @moduledoc "Instances context." + + alias Pleroma.Instances.Instance + + def filter_reachable(urls_or_hosts), do: Instance.filter_reachable(urls_or_hosts) + + def reachable?(url_or_host), do: Instance.reachable?(url_or_host) + + def set_reachable(url_or_host), do: Instance.set_reachable(url_or_host) + + def set_unreachable(url_or_host, unreachable_since \\ nil), + do: Instance.set_unreachable(url_or_host, unreachable_since) + + def get_consistently_unreachable, do: Instance.get_consistently_unreachable() + + def set_consistently_unreachable(url_or_host), + do: set_unreachable(url_or_host, reachability_datetime_threshold()) + + def reachability_datetime_threshold do + federation_reachability_timeout_days = + Pleroma.Config.get([:instance, :federation_reachability_timeout_days], 0) + + if federation_reachability_timeout_days > 0 do + NaiveDateTime.add( + NaiveDateTime.utc_now(), + -federation_reachability_timeout_days * 24 * 3600, + :second + ) + else + ~N[0000-01-01 00:00:00] + end + end + + def host(url_or_host) when is_binary(url_or_host) do + if url_or_host =~ ~r/^http/i do + URI.parse(url_or_host).host + else + url_or_host + end + end +end diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex new file mode 100644 index 0000000..7a4a06f --- /dev/null +++ b/lib/pleroma/instances/instance.ex @@ -0,0 +1,318 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Instances.Instance do + @moduledoc "Instance." + + alias Pleroma.Instances + alias Pleroma.Instances.Instance + alias Pleroma.Maps + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Workers.BackgroundWorker + + use Ecto.Schema + + import Ecto.Query + import Ecto.Changeset + + require Logger + + schema "instances" do + field(:host, :string) + field(:unreachable_since, :naive_datetime_usec) + field(:favicon, :string) + field(:favicon_updated_at, :naive_datetime) + + embeds_one :metadata, Pleroma.Instances.Metadata, primary_key: false do + field(:software_name, :string) + field(:software_version, :string) + field(:software_repository, :string) + end + + field(:metadata_updated_at, :utc_datetime) + + timestamps() + end + + defdelegate host(url_or_host), to: Instances + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, __schema__(:fields) -- [:metadata]) + |> cast_embed(:metadata, with: &metadata_changeset/2) + |> validate_required([:host]) + |> unique_constraint(:host) + end + + def metadata_changeset(struct, params \\ %{}) do + struct + |> cast(params, [:software_name, :software_version, :software_repository]) + end + + def filter_reachable([]), do: %{} + + def filter_reachable(urls_or_hosts) when is_list(urls_or_hosts) do + hosts = + urls_or_hosts + |> Enum.map(&(&1 && host(&1))) + |> Enum.filter(&(to_string(&1) != "")) + + unreachable_since_by_host = + Repo.all( + from(i in Instance, + where: i.host in ^hosts, + select: {i.host, i.unreachable_since} + ) + ) + |> Map.new(& &1) + + reachability_datetime_threshold = Instances.reachability_datetime_threshold() + + for entry <- Enum.filter(urls_or_hosts, &is_binary/1) do + host = host(entry) + unreachable_since = unreachable_since_by_host[host] + + if !unreachable_since || + NaiveDateTime.compare(unreachable_since, reachability_datetime_threshold) == :gt do + {entry, unreachable_since} + end + end + |> Enum.filter(& &1) + |> Map.new(& &1) + end + + def reachable?(url_or_host) when is_binary(url_or_host) do + !Repo.one( + from(i in Instance, + where: + i.host == ^host(url_or_host) and + i.unreachable_since <= ^Instances.reachability_datetime_threshold(), + select: true + ) + ) + end + + def reachable?(url_or_host) when is_binary(url_or_host), do: true + + def set_reachable(url_or_host) when is_binary(url_or_host) do + with host <- host(url_or_host), + %Instance{} = existing_record <- Repo.get_by(Instance, %{host: host}) do + {:ok, _instance} = + existing_record + |> changeset(%{unreachable_since: nil}) + |> Repo.update() + end + end + + def set_reachable(_), do: {:error, nil} + + def set_unreachable(url_or_host, unreachable_since \\ nil) + + def set_unreachable(url_or_host, unreachable_since) when is_binary(url_or_host) do + unreachable_since = parse_datetime(unreachable_since) || NaiveDateTime.utc_now() + host = host(url_or_host) + existing_record = Repo.get_by(Instance, %{host: host}) + + changes = %{unreachable_since: unreachable_since} + + cond do + is_nil(existing_record) -> + %Instance{} + |> changeset(Map.put(changes, :host, host)) + |> Repo.insert() + + existing_record.unreachable_since && + NaiveDateTime.compare(existing_record.unreachable_since, unreachable_since) != :gt -> + {:ok, existing_record} + + true -> + existing_record + |> changeset(changes) + |> Repo.update() + end + end + + def set_unreachable(_, _), do: {:error, nil} + + def get_consistently_unreachable do + reachability_datetime_threshold = Instances.reachability_datetime_threshold() + + from(i in Instance, + where: ^reachability_datetime_threshold > i.unreachable_since, + order_by: i.unreachable_since, + select: {i.host, i.unreachable_since} + ) + |> Repo.all() + end + + defp parse_datetime(datetime) when is_binary(datetime) do + NaiveDateTime.from_iso8601(datetime) + end + + defp parse_datetime(datetime), do: datetime + + def get_or_update_favicon(%URI{host: host} = instance_uri) do + existing_record = Repo.get_by(Instance, %{host: host}) + now = NaiveDateTime.utc_now() + + if existing_record && existing_record.favicon_updated_at && + NaiveDateTime.diff(now, existing_record.favicon_updated_at) < 86_400 do + existing_record.favicon + else + favicon = scrape_favicon(instance_uri) + + if existing_record do + existing_record + |> changeset(%{favicon: favicon, favicon_updated_at: now}) + |> Repo.update() + else + %Instance{} + |> changeset(%{host: host, favicon: favicon, favicon_updated_at: now}) + |> Repo.insert() + end + + favicon + end + rescue + e -> + Logger.warning("Instance.get_or_update_favicon(\"#{host}\") error: #{inspect(e)}") + nil + end + + defp scrape_favicon(%URI{} = instance_uri) do + try do + with {_, true} <- {:reachable, reachable?(instance_uri.host)}, + {:ok, %Tesla.Env{body: html}} <- + Pleroma.HTTP.get(to_string(instance_uri), [{"accept", "text/html"}], pool: :media), + {_, [favicon_rel | _]} when is_binary(favicon_rel) <- + {:parse, + html |> Floki.parse_document!() |> Floki.attribute("link[rel=icon]", "href")}, + {_, favicon} when is_binary(favicon) <- + {:merge, URI.merge(instance_uri, favicon_rel) |> to_string()} do + favicon + else + {:reachable, false} -> + Logger.debug( + "Instance.scrape_favicon(\"#{to_string(instance_uri)}\") ignored unreachable host" + ) + + nil + + _ -> + nil + end + rescue + e -> + Logger.warning( + "Instance.scrape_favicon(\"#{to_string(instance_uri)}\") error: #{inspect(e)}" + ) + + nil + end + end + + def get_or_update_metadata(%URI{host: host} = instance_uri) do + existing_record = Repo.get_by(Instance, %{host: host}) + now = NaiveDateTime.utc_now() + + if existing_record && existing_record.metadata_updated_at && + NaiveDateTime.diff(now, existing_record.metadata_updated_at) < 86_400 do + existing_record.metadata + else + metadata = scrape_metadata(instance_uri) + + if existing_record do + existing_record + |> changeset(%{metadata: metadata, metadata_updated_at: now}) + |> Repo.update() + else + %Instance{} + |> changeset(%{host: host, metadata: metadata, metadata_updated_at: now}) + |> Repo.insert() + end + + metadata + end + end + + defp get_nodeinfo_uri(well_known) do + links = Map.get(well_known, "links", []) + + nodeinfo21 = + Enum.find(links, &(&1["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.1"))["href"] + + nodeinfo20 = + Enum.find(links, &(&1["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.0"))["href"] + + cond do + is_binary(nodeinfo21) -> {:ok, nodeinfo21} + is_binary(nodeinfo20) -> {:ok, nodeinfo20} + true -> {:error, :no_links} + end + end + + defp scrape_metadata(%URI{} = instance_uri) do + try do + with {_, true} <- {:reachable, reachable?(instance_uri.host)}, + {:ok, %Tesla.Env{body: well_known_body}} <- + instance_uri + |> URI.merge("/.well-known/nodeinfo") + |> to_string() + |> Pleroma.HTTP.get([{"accept", "application/json"}]), + {:ok, well_known_json} <- Jason.decode(well_known_body), + {:ok, nodeinfo_uri} <- get_nodeinfo_uri(well_known_json), + {:ok, %Tesla.Env{body: nodeinfo_body}} <- + Pleroma.HTTP.get(nodeinfo_uri, [{"accept", "application/json"}]), + {:ok, nodeinfo} <- Jason.decode(nodeinfo_body) do + # Can extract more metadata from NodeInfo but need to be careful about it's size, + # can't just dump the entire thing + software = Map.get(nodeinfo, "software", %{}) + + %{ + software_name: software["name"], + software_version: software["version"] + } + |> Maps.put_if_present(:software_repository, software["repository"]) + else + {:reachable, false} -> + Logger.debug( + "Instance.scrape_metadata(\"#{to_string(instance_uri)}\") ignored unreachable host" + ) + + nil + + _ -> + nil + end + rescue + e -> + Logger.warning( + "Instance.scrape_metadata(\"#{to_string(instance_uri)}\") error: #{inspect(e)}" + ) + + nil + end + end + + @doc """ + Deletes all users from an instance in a background task, thus also deleting + all of those users' activities and notifications. + """ + def delete_users_and_activities(host) when is_binary(host) do + BackgroundWorker.enqueue("delete_instance", %{"host" => host}) + end + + def perform(:delete_instance, host) when is_binary(host) do + User.Query.build(%{nickname: "@#{host}"}) + |> Repo.chunk_stream(100, :batches) + |> Stream.each(fn users -> + users + |> Enum.each(fn user -> + User.perform(:delete, user) + end) + end) + |> Stream.run() + end +end diff --git a/lib/pleroma/job_queue_monitor.ex b/lib/pleroma/job_queue_monitor.ex new file mode 100644 index 0000000..6233cdc --- /dev/null +++ b/lib/pleroma/job_queue_monitor.ex @@ -0,0 +1,84 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.JobQueueMonitor do + use GenServer + + @initial_state %{workers: %{}, queues: %{}, processed_jobs: 0} + @queue %{processed_jobs: 0, success: 0, failure: 0} + @operation %{processed_jobs: 0, success: 0, failure: 0} + + def start_link(_) do + GenServer.start_link(__MODULE__, @initial_state, name: __MODULE__) + end + + @impl true + def init(state) do + :telemetry.attach("oban-monitor-failure", [:oban, :job, :exception], &handle_event/4, nil) + :telemetry.attach("oban-monitor-success", [:oban, :job, :stop], &handle_event/4, nil) + + {:ok, state} + end + + def stats do + GenServer.call(__MODULE__, :stats) + end + + def handle_event([:oban, :job, event], %{duration: duration}, meta, _) do + GenServer.cast( + __MODULE__, + {:process_event, mapping_status(event), duration, meta} + ) + end + + @impl true + def handle_call(:stats, _from, state) do + {:reply, state, state} + end + + @impl true + def handle_cast({:process_event, status, duration, meta}, state) do + state = + state + |> Map.update!(:workers, fn workers -> + workers + |> Map.put_new(meta.worker, %{}) + |> Map.update!(meta.worker, &update_worker(&1, status, meta, duration)) + end) + |> Map.update!(:queues, fn workers -> + workers + |> Map.put_new(meta.queue, @queue) + |> Map.update!(meta.queue, &update_queue(&1, status, meta, duration)) + end) + |> Map.update!(:processed_jobs, &(&1 + 1)) + + {:noreply, state} + end + + defp update_worker(worker, status, meta, duration) do + worker + |> Map.put_new(meta.args["op"], @operation) + |> Map.update!(meta.args["op"], &update_op(&1, status, meta, duration)) + end + + defp update_op(op, :enqueue, _meta, _duration) do + op + |> Map.update!(:enqueued, &(&1 + 1)) + end + + defp update_op(op, status, _meta, _duration) do + op + |> Map.update!(:processed_jobs, &(&1 + 1)) + |> Map.update!(status, &(&1 + 1)) + end + + defp update_queue(queue, status, _meta, _duration) do + queue + |> Map.update!(:processed_jobs, &(&1 + 1)) + |> Map.update!(status, &(&1 + 1)) + end + + defp mapping_status(:stop), do: :success + defp mapping_status(:exception), do: :failure +end diff --git a/lib/pleroma/jwt.ex b/lib/pleroma/jwt.ex new file mode 100644 index 0000000..7ec6245 --- /dev/null +++ b/lib/pleroma/jwt.ex @@ -0,0 +1,13 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.JWT do + use Joken.Config + + @impl true + def token_config do + default_claims(skip: [:aud]) + |> add_claim("aud", &Pleroma.Web.Endpoint.url/0, &(&1 == Pleroma.Web.Endpoint.url())) + end +end diff --git a/lib/pleroma/keys.ex b/lib/pleroma/keys.ex new file mode 100644 index 0000000..496d20a --- /dev/null +++ b/lib/pleroma/keys.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Keys do + # Native generation of RSA keys is only available since OTP 20+ and in default build conditions + # We try at compile time to generate natively an RSA key otherwise we fallback on the old way. + try do + _ = :public_key.generate_key({:rsa, 2048, 65_537}) + + def generate_rsa_pem do + key = :public_key.generate_key({:rsa, 2048, 65_537}) + entry = :public_key.pem_entry_encode(:RSAPrivateKey, key) + pem = :public_key.pem_encode([entry]) |> String.trim_trailing() + {:ok, pem} + end + rescue + _ -> + def generate_rsa_pem do + port = Port.open({:spawn, "openssl genrsa"}, [:binary]) + + {:ok, pem} = + receive do + {^port, {:data, pem}} -> {:ok, pem} + end + + Port.close(port) + + if Regex.match?(~r/RSA PRIVATE KEY/, pem) do + {:ok, pem} + else + :error + end + end + end + + def keys_from_pem(pem) do + with [private_key_code] <- :public_key.pem_decode(pem), + private_key <- :public_key.pem_entry_decode(private_key_code), + {:RSAPrivateKey, _, modulus, exponent, _, _, _, _, _, _, _} <- private_key do + {:ok, private_key, {:RSAPublicKey, modulus, exponent}} + else + error -> {:error, error} + end + end +end diff --git a/lib/pleroma/language/language_detector.ex b/lib/pleroma/language/language_detector.ex new file mode 100644 index 0000000..42d200a --- /dev/null +++ b/lib/pleroma/language/language_detector.ex @@ -0,0 +1,44 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.LanguageDetector do + @words_threshold 4 + + def missing_dependencies do + provider = get_provider() + + if provider do + provider.missing_dependencies() + else + [] + end + end + + # Strip tags from text, etc. + defp prepare_text(text) do + text + |> Floki.parse_fragment!() + |> Floki.filter_out( + ".h-card, .mention, .hashtag, .u-url, .quote-inline, .recipients-inline, code, pre" + ) + |> Floki.text() + end + + def detect(text) do + provider = get_provider() + + text = prepare_text(text) + word_count = text |> String.split(~r/\s+/) |> Enum.count() + + if word_count < @words_threshold or !provider or !provider.configured? do + nil + else + provider.detect(text) + end + end + + defp get_provider do + Pleroma.Config.get([__MODULE__, :provider]) + end +end diff --git a/lib/pleroma/language/language_detector/fasttext.ex b/lib/pleroma/language/language_detector/fasttext.ex new file mode 100644 index 0000000..0f621a0 --- /dev/null +++ b/lib/pleroma/language/language_detector/fasttext.ex @@ -0,0 +1,47 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.LanguageDetector.Fasttext do + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] + + alias Pleroma.Language.LanguageDetector.Provider + + @behaviour Provider + + @impl Provider + def missing_dependencies do + if Pleroma.Utils.command_available?("fasttext") do + [] + else + ["fasttext"] + end + end + + @impl Provider + def configured?, do: not_empty_string(get_model()) + + @impl Provider + def detect(text) do + text_path = Path.join(System.tmp_dir!(), "fasttext-#{Ecto.UUID.generate()}") + + File.write(text_path, text |> String.replace(~r/\s+/, " ")) + + detected_language = + case System.cmd("fasttext", ["predict", get_model(), text_path]) do + {"__label__" <> language, _} -> + language |> String.trim() + + _ -> + nil + end + + File.rm(text_path) + + detected_language + end + + defp get_model do + Pleroma.Config.get([__MODULE__, :model]) + end +end diff --git a/lib/pleroma/language/language_detector/provider.ex b/lib/pleroma/language/language_detector/provider.ex new file mode 100644 index 0000000..08e7c8e --- /dev/null +++ b/lib/pleroma/language/language_detector/provider.ex @@ -0,0 +1,11 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.LanguageDetector.Provider do + @callback missing_dependencies() :: [String.t()] + + @callback configured?() :: boolean() + + @callback detect(text :: String.t()) :: String.t() | nil +end diff --git a/lib/pleroma/language/translation.ex b/lib/pleroma/language/translation.ex new file mode 100644 index 0000000..be79680 --- /dev/null +++ b/lib/pleroma/language/translation.ex @@ -0,0 +1,115 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.Translation do + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + + def configured? do + provider = get_provider() + + !!provider and provider.configured? + end + + def missing_dependencies do + provider = get_provider() + + if provider do + provider.missing_dependencies() + else + [] + end + end + + def translate(text, source_language, target_language) do + cache_key = get_cache_key(text, source_language, target_language) + + case @cachex.get(:translations_cache, cache_key) do + {:ok, nil} -> + provider = get_provider() + + result = + if !configured?() do + {:error, :not_found} + else + provider.translate(text, source_language, target_language) + end + + store_result(result, cache_key) + + result + + {:ok, result} -> + {:ok, result} + + {:error, error} -> + {:error, error} + end + end + + def supported_languages(type) when type in [:source, :target] do + provider = get_provider() + + cache_key = "#{type}_languages/#{provider.name()}" + + case @cachex.get(:translations_cache, cache_key) do + {:ok, nil} -> + result = + if !configured?() do + {:error, :not_found} + else + provider.supported_languages(type) + end + + store_result(result, cache_key) + + result + + {:ok, result} -> + {:ok, result} + + {:error, error} -> + {:error, error} + end + end + + def languages_matrix do + provider = get_provider() + + cache_key = "languages_matrix/#{provider.name()}" + + case @cachex.get(:translations_cache, cache_key) do + {:ok, nil} -> + result = + if !configured?() do + {:error, :not_found} + else + provider.languages_matrix() + end + + store_result(result, cache_key) + + result + + {:ok, result} -> + {:ok, result} + + {:error, error} -> + {:error, error} + end + end + + defp get_provider, do: Pleroma.Config.get([__MODULE__, :provider]) + + defp get_cache_key(text, source_language, target_language) do + "#{source_language}/#{target_language}/#{content_hash(text)}" + end + + defp store_result({:ok, result}, cache_key) do + @cachex.put(:translations_cache, cache_key, result) + end + + defp store_result(_, _), do: nil + + defp content_hash(text), do: :crypto.hash(:sha256, text) |> Base.encode64() +end diff --git a/lib/pleroma/language/translation/deepl.ex b/lib/pleroma/language/translation/deepl.ex new file mode 100644 index 0000000..e027035 --- /dev/null +++ b/lib/pleroma/language/translation/deepl.ex @@ -0,0 +1,123 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.Translation.Deepl do + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] + + alias Pleroma.Language.Translation.Provider + + use Provider + + @behaviour Provider + + @name "DeepL" + + @impl Provider + def configured?, do: not_empty_string(base_url()) and not_empty_string(api_key()) + + @impl Provider + def translate(content, source_language, target_language) do + endpoint = + base_url() + |> URI.merge("/v2/translate") + |> URI.to_string() + + case Pleroma.HTTP.post( + endpoint <> + "?" <> + URI.encode_query(%{ + text: content, + source_lang: source_language |> String.upcase(), + target_lang: target_language, + tag_handling: "html" + }), + "", + [ + {"Content-Type", "application/x-www-form-urlencoded"}, + {"Authorization", "DeepL-Auth-Key #{api_key()}"} + ] + ) do + {:ok, %{status: 429}} -> + {:error, :too_many_requests} + + {:ok, %{status: 456}} -> + {:error, :quota_exceeded} + + {:ok, %{status: 200} = res} -> + %{ + "translations" => [ + %{"text" => content, "detected_source_language" => detected_source_language} + ] + } = Jason.decode!(res.body) + + {:ok, + %{ + content: content, + detected_source_language: detected_source_language, + provider: @name + }} + + _ -> + {:error, :internal_server_error} + end + end + + @impl Provider + def supported_languages(type) when type in [:source, :target] do + endpoint = + base_url() + |> URI.merge("/v2/languages") + |> URI.to_string() + + case Pleroma.HTTP.post( + endpoint <> "?" <> URI.encode_query(%{type: type}), + "", + [ + {"Content-Type", "application/x-www-form-urlencoded"}, + {"Authorization", "DeepL-Auth-Key #{api_key()}"} + ] + ) do + {:ok, %{status: 200} = res} -> + languages = + Jason.decode!(res.body) + |> Enum.map(fn %{"language" => language} -> language |> String.downcase() end) + |> Enum.map(fn language -> + if String.contains?(language, "-") do + [language, language |> String.split("-") |> Enum.at(0)] + else + language + end + end) + |> List.flatten() + |> Enum.uniq() + + {:ok, languages} + + _ -> + {:error, :internal_server_error} + end + end + + @impl Provider + def languages_matrix do + with {:ok, source_languages} <- supported_languages(:source), + {:ok, target_languages} <- supported_languages(:target) do + {:ok, + Map.new(source_languages, fn language -> {language, target_languages -- [language]} end)} + else + {:error, error} -> {:error, error} + end + end + + @impl Provider + def name, do: @name + + defp base_url do + Pleroma.Config.get([__MODULE__, :base_url]) + end + + defp api_key do + Pleroma.Config.get([__MODULE__, :api_key]) + end +end diff --git a/lib/pleroma/language/translation/libretranslate.ex b/lib/pleroma/language/translation/libretranslate.ex new file mode 100644 index 0000000..69ecf23 --- /dev/null +++ b/lib/pleroma/language/translation/libretranslate.ex @@ -0,0 +1,93 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.Translation.Libretranslate do + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] + + alias Pleroma.Language.Translation.Provider + + use Provider + + @behaviour Provider + + @name "LibreTranslate" + + @impl Provider + def configured?, do: not_empty_string(base_url()) and not_empty_string(api_key()) + + @impl Provider + def translate(content, source_language, target_language) do + case Pleroma.HTTP.post( + base_url() <> "/translate", + Jason.encode!(%{ + q: content, + source: source_language |> String.upcase(), + target: target_language, + format: "html", + api_key: api_key() + }), + [ + {"Content-Type", "application/json"} + ] + ) do + {:ok, %{status: 429}} -> + {:error, :too_many_requests} + + {:ok, %{status: 403}} -> + {:error, :quota_exceeded} + + {:ok, %{status: 200} = res} -> + %{ + "translatedText" => content + } = Jason.decode!(res.body) + + {:ok, + %{ + content: content, + detected_source_language: source_language, + provider: "LibreTranslate" + }} + + _ -> + {:error, :internal_server_error} + end + end + + @impl Provider + def supported_languages(_) do + case Pleroma.HTTP.get(base_url() <> "/languages") do + {:ok, %{status: 200} = res} -> + languages = + Jason.decode!(res.body) + |> Enum.map(fn %{"code" => code} -> code end) + + {:ok, languages} + + _ -> + {:error, :internal_server_error} + end + end + + @impl Provider + def languages_matrix do + with {:ok, source_languages} <- supported_languages(:source), + {:ok, target_languages} <- supported_languages(:target) do + {:ok, + Map.new(source_languages, fn language -> {language, target_languages -- [language]} end)} + else + {:error, error} -> {:error, error} + end + end + + @impl Provider + def name, do: @name + + defp base_url do + Pleroma.Config.get([__MODULE__, :base_url]) + end + + defp api_key do + Pleroma.Config.get([__MODULE__, :api_key], "") + end +end diff --git a/lib/pleroma/language/translation/provider.ex b/lib/pleroma/language/translation/provider.ex new file mode 100644 index 0000000..533b535 --- /dev/null +++ b/lib/pleroma/language/translation/provider.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.Translation.Provider do + alias Pleroma.Language.Translation.Provider + + @callback missing_dependencies() :: [String.t()] + + @callback configured?() :: boolean() + + @callback translate( + content :: String.t(), + source_language :: String.t(), + target_language :: String.t() + ) :: + {:ok, + %{ + content: String.t(), + detected_source_language: String.t(), + provider: String.t() + }} + | {:error, atom()} + + @callback supported_languages(type :: :string | :target) :: + {:ok, [String.t()]} | {:error, atom()} + + @callback languages_matrix() :: {:ok, Map.t()} | {:error, atom()} + + @callback name() :: String.t() + + defmacro __using__(_opts) do + quote do + @impl Provider + def missing_dependencies, do: [] + + defoverridable missing_dependencies: 0 + end + end +end diff --git a/lib/pleroma/language/translation/translate_locally.ex b/lib/pleroma/language/translation/translate_locally.ex new file mode 100644 index 0000000..7eaa95e --- /dev/null +++ b/lib/pleroma/language/translation/translate_locally.ex @@ -0,0 +1,129 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.Translation.TranslateLocally do + alias Pleroma.Language.Translation.Provider + + use Provider + + @behaviour Provider + + @name "translateLocally" + + @impl Provider + def missing_dependencies do + if Pleroma.Utils.command_available?("translateLocally") do + [] + else + ["translateLocally"] + end + end + + @impl Provider + def configured?, do: is_map(models()) + + @impl Provider + def translate(content, source_language, target_language) do + model = + models() + |> Map.get(source_language, %{}) + |> Map.get(target_language) + + models = + if model do + [model] + else + [ + models() + |> Map.get(source_language, %{}) + |> Map.get(intermediary_language()), + models() + |> Map.get(intermediary_language(), %{}) + |> Map.get(target_language) + ] + end + + translated_content = + Enum.reduce(models, content, fn model, content -> + text_path = Path.join(System.tmp_dir!(), "translateLocally-#{Ecto.UUID.generate()}") + + File.write(text_path, content) + + translated_content = + case System.cmd("translateLocally", ["-m", model, "-i", text_path, "--html"]) do + {content, _} -> content + _ -> nil + end + + File.rm(text_path) + + translated_content + end) + + {:ok, + %{ + content: translated_content, + detected_source_language: source_language, + provider: @name + }} + end + + @impl Provider + def supported_languages(:source) do + languages = + languages_matrix() + |> elem(1) + |> Map.keys() + + {:ok, languages} + end + + @impl Provider + def supported_languages(:target) do + languages = + languages_matrix() + |> elem(1) + |> Map.values() + |> List.flatten() + |> Enum.uniq() + + {:ok, languages} + end + + @impl Provider + def languages_matrix do + languages = + models() + |> Map.to_list() + |> Enum.map(fn {key, value} -> {key, Map.keys(value)} end) + |> Enum.into(%{}) + + matrix = + if intermediary_language() do + languages + |> Map.to_list() + |> Enum.map(fn {key, value} -> + with_intermediary = + (((value ++ languages[intermediary_language()]) + |> Enum.uniq()) -- + [key]) + |> Enum.sort() + + {key, with_intermediary} + end) + |> Enum.into(%{}) + else + languages + end + + {:ok, matrix} + end + + @impl Provider + def name, do: @name + + defp models, do: Pleroma.Config.get([__MODULE__, :models]) + + defp intermediary_language, do: Pleroma.Config.get([__MODULE__, :intermediary_language]) +end diff --git a/lib/pleroma/list.ex b/lib/pleroma/list.ex new file mode 100644 index 0000000..b446b91 --- /dev/null +++ b/lib/pleroma/list.ex @@ -0,0 +1,152 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.List do + use Ecto.Schema + + import Ecto.Query + import Ecto.Changeset + + alias Pleroma.Activity + alias Pleroma.Repo + alias Pleroma.User + + schema "lists" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + field(:title, :string) + field(:following, {:array, :string}, default: []) + field(:ap_id, :string) + + timestamps() + end + + def title_changeset(list, attrs \\ %{}) do + list + |> cast(attrs, [:title]) + |> validate_required([:title]) + end + + def follow_changeset(list, attrs \\ %{}) do + list + |> cast(attrs, [:following]) + |> validate_required([:following]) + end + + def for_user(user, _opts) do + query = + from( + l in Pleroma.List, + where: l.user_id == ^user.id, + order_by: [desc: l.id], + limit: 50 + ) + + Repo.all(query) + end + + def get(id, %{id: user_id} = _user) do + query = + from( + l in Pleroma.List, + where: l.id == ^id, + where: l.user_id == ^user_id + ) + + Repo.one(query) + end + + def get_by_ap_id(ap_id) do + Repo.get_by(__MODULE__, ap_id: ap_id) + end + + def get_following(%Pleroma.List{following: following} = _list) do + q = + from( + u in User, + where: u.follower_address in ^following + ) + + {:ok, Repo.all(q)} + end + + # Get lists the activity should be streamed to. + def get_lists_from_activity(%Activity{actor: ap_id}) do + actor = User.get_cached_by_ap_id(ap_id) + + query = + from( + l in Pleroma.List, + where: fragment("? && ?", l.following, ^[actor.follower_address]) + ) + + Repo.all(query) + end + + # Get lists to which the account belongs. + def get_lists_account_belongs(%User{} = owner, user) do + Pleroma.List + |> where([l], l.user_id == ^owner.id) + |> where([l], fragment("? = ANY(?)", ^user.follower_address, l.following)) + |> Repo.all() + end + + def rename(%Pleroma.List{} = list, title) do + list + |> title_changeset(%{title: title}) + |> Repo.update() + end + + def create(title, %User{} = creator) do + changeset = title_changeset(%Pleroma.List{user_id: creator.id}, %{title: title}) + + if changeset.valid? do + Repo.transaction(fn -> + list = Repo.insert!(changeset) + + list + |> change(ap_id: "#{creator.ap_id}/lists/#{list.id}") + |> Repo.update!() + end) + else + {:error, changeset} + end + end + + def follow(%Pleroma.List{id: id}, %User{} = followed) do + list = Repo.get(Pleroma.List, id) + %{following: following} = list + update_follows(list, %{following: Enum.uniq([followed.follower_address | following])}) + end + + def unfollow(%Pleroma.List{id: id}, %User{} = unfollowed) do + list = Repo.get(Pleroma.List, id) + %{following: following} = list + update_follows(list, %{following: List.delete(following, unfollowed.follower_address)}) + end + + def delete(%Pleroma.List{} = list) do + Repo.delete(list) + end + + def update_follows(%Pleroma.List{} = list, attrs) do + list + |> follow_changeset(attrs) + |> Repo.update() + end + + def memberships(%User{follower_address: follower_address}) do + Pleroma.List + |> where([l], ^follower_address in l.following) + |> select([l], l.ap_id) + |> Repo.all() + end + + def memberships(_), do: [] + + def member?(%Pleroma.List{following: following}, %User{follower_address: follower_address}) do + Enum.member?(following, follower_address) + end + + def member?(_, _), do: false +end diff --git a/lib/pleroma/logging.ex b/lib/pleroma/logging.ex new file mode 100644 index 0000000..ac09c04 --- /dev/null +++ b/lib/pleroma/logging.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Logging do + @callback error(String.t()) :: any() +end diff --git a/lib/pleroma/maintenance.ex b/lib/pleroma/maintenance.ex new file mode 100644 index 0000000..1e39b03 --- /dev/null +++ b/lib/pleroma/maintenance.ex @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Maintenance do + alias Pleroma.Repo + require Logger + + def vacuum(args) do + case args do + "analyze" -> + Logger.info("Running VACUUM ANALYZE.") + + Repo.query!( + "vacuum analyze;", + [], + timeout: :infinity + ) + + "full" -> + Logger.info("Running VACUUM FULL.") + + Logger.warning( + "Re-packing your entire database may take a while and will consume extra disk space during the process." + ) + + Repo.query!( + "vacuum full;", + [], + timeout: :infinity + ) + + _ -> + Logger.error("Error: invalid vacuum argument.") + end + end +end diff --git a/lib/pleroma/maps.ex b/lib/pleroma/maps.ex new file mode 100644 index 0000000..6d586e5 --- /dev/null +++ b/lib/pleroma/maps.ex @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Maps do + def put_if_present(map, key, value, value_function \\ &{:ok, &1}) when is_map(map) do + with false <- is_nil(key), + false <- is_nil(value), + {:ok, new_value} <- value_function.(value) do + Map.put(map, key, new_value) + else + _ -> map + end + end + + def safe_put_in(data, keys, value) when is_map(data) and is_list(keys) do + Kernel.put_in(data, keys, value) + rescue + _ -> data + end +end diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex new file mode 100644 index 0000000..68b054e --- /dev/null +++ b/lib/pleroma/marker.ex @@ -0,0 +1,117 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Marker do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Ecto.Multi + alias Pleroma.Notification + alias Pleroma.Repo + alias Pleroma.User + alias __MODULE__ + + @timelines ["notifications"] + @type t :: %__MODULE__{} + + schema "markers" do + field(:last_read_id, :string, default: "") + field(:timeline, :string, default: "") + field(:lock_version, :integer, default: 0) + field(:unread_count, :integer, default: 0, virtual: true) + + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + timestamps() + end + + @doc "Gets markers by user and timeline." + @spec get_markers(User.t(), list(String)) :: list(t()) + def get_markers(user, timelines \\ []) do + user + |> get_query(timelines) + |> unread_count_query() + |> Repo.all() + end + + @spec upsert(User.t(), map()) :: {:ok | :error, any()} + def upsert(%User{} = user, attrs) do + attrs + |> Map.take(@timelines) + |> Enum.reduce(Multi.new(), fn {timeline, timeline_attrs}, multi -> + marker = + user + |> get_marker(timeline) + |> changeset(timeline_attrs) + + Multi.insert(multi, timeline, marker, + returning: true, + on_conflict: {:replace, [:last_read_id]}, + conflict_target: [:user_id, :timeline] + ) + end) + |> Repo.transaction() + end + + @spec multi_set_last_read_id(Multi.t(), User.t(), String.t()) :: Multi.t() + def multi_set_last_read_id(multi, %User{} = user, "notifications") do + multi + |> Multi.run(:counters, fn _repo, _changes -> + {:ok, %{last_read_id: Repo.one(Notification.last_read_query(user))}} + end) + |> Multi.insert( + :marker, + fn %{counters: attrs} -> + %Marker{timeline: "notifications", user_id: user.id} + |> struct(attrs) + |> Ecto.Changeset.change() + end, + returning: true, + on_conflict: {:replace, [:last_read_id]}, + conflict_target: [:user_id, :timeline] + ) + end + + def multi_set_last_read_id(multi, _, _), do: multi + + defp get_marker(user, timeline) do + case Repo.find_resource(get_query(user, timeline)) do + {:ok, marker} -> %__MODULE__{marker | user: user} + _ -> %__MODULE__{timeline: timeline, user_id: user.id} + end + end + + @doc false + defp changeset(marker, attrs) do + marker + |> cast(attrs, [:last_read_id]) + |> validate_required([:user_id, :timeline, :last_read_id]) + |> validate_inclusion(:timeline, @timelines) + end + + defp by_timeline(query, timeline) do + from(m in query, where: m.timeline in ^List.wrap(timeline)) + end + + defp by_user_id(query, id), do: from(m in query, where: m.user_id == ^id) + + defp get_query(user, timelines) do + __MODULE__ + |> by_user_id(user.id) + |> by_timeline(timelines) + end + + defp unread_count_query(query) do + from( + q in query, + left_join: n in "notifications", + on: n.user_id == q.user_id and n.seen == false, + group_by: [:id], + select_merge: %{ + unread_count: fragment("count(?)", n.id) + } + ) + end +end diff --git a/lib/pleroma/mfa.ex b/lib/pleroma/mfa.ex new file mode 100644 index 0000000..01b730c --- /dev/null +++ b/lib/pleroma/mfa.ex @@ -0,0 +1,155 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA do + @moduledoc """ + The MFA context. + """ + + alias Pleroma.User + + alias Pleroma.MFA.BackupCodes + alias Pleroma.MFA.Changeset + alias Pleroma.MFA.Settings + alias Pleroma.MFA.TOTP + + @doc """ + Returns MFA methods the user has enabled. + + ## Examples + + iex> Pleroma.MFA.supported_method(User) + "totp, u2f" + """ + @spec supported_methods(User.t()) :: String.t() + def supported_methods(user) do + settings = fetch_settings(user) + + Settings.mfa_methods() + |> Enum.reduce([], fn m, acc -> + if method_enabled?(m, settings) do + acc ++ [m] + else + acc + end + end) + |> Enum.join(",") + end + + @doc "Checks that user enabled MFA" + def require?(user) do + fetch_settings(user).enabled + end + + @doc """ + Display MFA settings of user + """ + def mfa_settings(user) do + settings = fetch_settings(user) + + Settings.mfa_methods() + |> Enum.map(fn m -> [m, method_enabled?(m, settings)] end) + |> Enum.into(%{enabled: settings.enabled}, fn [a, b] -> {a, b} end) + end + + @doc false + def fetch_settings(%User{} = user) do + user.multi_factor_authentication_settings || %Settings{} + end + + @doc "clears backup codes" + def invalidate_backup_code(%User{} = user, hash_code) do + %{backup_codes: codes} = fetch_settings(user) + + user + |> Changeset.cast_backup_codes(codes -- [hash_code]) + |> User.update_and_set_cache() + end + + @doc "generates backup codes" + @spec generate_backup_codes(User.t()) :: {:ok, list(binary)} | {:error, String.t()} + def generate_backup_codes(%User{} = user) do + with codes <- BackupCodes.generate(), + hashed_codes <- Enum.map(codes, &Pleroma.Password.Pbkdf2.hash_pwd_salt/1), + changeset <- Changeset.cast_backup_codes(user, hashed_codes), + {:ok, _} <- User.update_and_set_cache(changeset) do + {:ok, codes} + else + {:error, msg} -> + %{error: msg} + end + end + + @doc """ + Generates secret key and set delivery_type to 'app' for TOTP method. + """ + @spec setup_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def setup_totp(user) do + user + |> Changeset.setup_totp(%{secret: TOTP.generate_secret(), delivery_type: "app"}) + |> User.update_and_set_cache() + end + + @doc """ + Confirms the TOTP method for user. + + `attrs`: + `password` - current user password + `code` - TOTP token + """ + @spec confirm_totp(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t() | atom()} + def confirm_totp(%User{} = user, attrs) do + with settings <- user.multi_factor_authentication_settings.totp, + {:ok, :pass} <- TOTP.validate_token(settings.secret, attrs["code"]) do + user + |> Changeset.confirm_totp() + |> User.update_and_set_cache() + end + end + + @doc """ + Disables the TOTP method for user. + + `attrs`: + `password` - current user password + """ + @spec disable_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def disable_totp(%User{} = user) do + user + |> Changeset.disable_totp() + |> Changeset.disable() + |> User.update_and_set_cache() + end + + @doc """ + Force disables all MFA methods for user. + """ + @spec disable(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def disable(%User{} = user) do + user + |> Changeset.disable_totp() + |> Changeset.disable(true) + |> User.update_and_set_cache() + end + + @doc """ + Checks if the user has MFA method enabled. + """ + def method_enabled?(method, settings) do + with {:ok, %{confirmed: true} = _} <- Map.fetch(settings, method) do + true + else + _ -> false + end + end + + @doc """ + Checks if the user has enabled at least one MFA method. + """ + def enabled?(settings) do + Settings.mfa_methods() + |> Enum.map(fn m -> method_enabled?(m, settings) end) + |> Enum.any?() + end +end diff --git a/lib/pleroma/mfa/backup_codes.ex b/lib/pleroma/mfa/backup_codes.ex new file mode 100644 index 0000000..2f6962c --- /dev/null +++ b/lib/pleroma/mfa/backup_codes.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.BackupCodes do + @moduledoc """ + This module contains functions for generating backup codes. + """ + alias Pleroma.Config + + @config_ns [:instance, :multi_factor_authentication, :backup_codes] + + @doc """ + Generates backup codes. + """ + @spec generate(Keyword.t()) :: list(String.t()) + def generate(opts \\ []) do + number_of_codes = Keyword.get(opts, :number_of_codes, default_backup_codes_number()) + code_length = Keyword.get(opts, :length, default_backup_codes_code_length()) + + Enum.map(1..number_of_codes, fn _ -> + :crypto.strong_rand_bytes(div(code_length, 2)) + |> Base.encode16(case: :lower) + end) + end + + defp default_backup_codes_number, do: Config.get(@config_ns ++ [:number], 5) + + defp default_backup_codes_code_length, + do: Config.get(@config_ns ++ [:length], 16) +end diff --git a/lib/pleroma/mfa/changeset.ex b/lib/pleroma/mfa/changeset.ex new file mode 100644 index 0000000..3ec3cfe --- /dev/null +++ b/lib/pleroma/mfa/changeset.ex @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.Changeset do + alias Pleroma.MFA + alias Pleroma.MFA.Settings + alias Pleroma.User + + def disable(%Ecto.Changeset{} = changeset, force \\ false) do + settings = + changeset + |> Ecto.Changeset.apply_changes() + |> MFA.fetch_settings() + + if force || not MFA.enabled?(settings) do + put_change(changeset, %Settings{settings | enabled: false}) + else + changeset + end + end + + def disable_totp(%User{multi_factor_authentication_settings: settings} = user) do + user + |> put_change(%Settings{settings | totp: %Settings.TOTP{}}) + end + + def confirm_totp(%User{multi_factor_authentication_settings: settings} = user) do + totp_settings = %Settings.TOTP{settings.totp | confirmed: true} + + user + |> put_change(%Settings{settings | totp: totp_settings, enabled: true}) + end + + def setup_totp(%User{} = user, attrs) do + mfa_settings = MFA.fetch_settings(user) + + totp_settings = + %Settings.TOTP{} + |> Ecto.Changeset.cast(attrs, [:secret, :delivery_type]) + + user + |> put_change(%Settings{mfa_settings | totp: Ecto.Changeset.apply_changes(totp_settings)}) + end + + def cast_backup_codes(%User{} = user, codes) do + user + |> put_change(%Settings{ + user.multi_factor_authentication_settings + | backup_codes: codes + }) + end + + defp put_change(%User{} = user, settings) do + user + |> Ecto.Changeset.change() + |> put_change(settings) + end + + defp put_change(%Ecto.Changeset{} = changeset, settings) do + changeset + |> Ecto.Changeset.put_change(:multi_factor_authentication_settings, settings) + end +end diff --git a/lib/pleroma/mfa/settings.ex b/lib/pleroma/mfa/settings.ex new file mode 100644 index 0000000..2c7f13e --- /dev/null +++ b/lib/pleroma/mfa/settings.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.Settings do + use Ecto.Schema + + @primary_key false + + @mfa_methods [:totp] + embedded_schema do + field(:enabled, :boolean, default: false) + field(:backup_codes, {:array, :string}, default: []) + + embeds_one :totp, TOTP, on_replace: :delete, primary_key: false do + field(:secret, :string) + # app | sms + field(:delivery_type, :string, default: "app") + field(:confirmed, :boolean, default: false) + end + end + + def mfa_methods, do: @mfa_methods +end diff --git a/lib/pleroma/mfa/token.ex b/lib/pleroma/mfa/token.ex new file mode 100644 index 0000000..57bc11e --- /dev/null +++ b/lib/pleroma/mfa/token.ex @@ -0,0 +1,105 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.Token do + use Ecto.Schema + import Ecto.Query + import Ecto.Changeset + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.OAuth.Authorization + + @expires 300 + + @type t() :: %__MODULE__{} + + schema "mfa_tokens" do + field(:token, :string) + field(:valid_until, :naive_datetime_usec) + + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:authorization, Authorization) + + timestamps() + end + + @spec get_by_token(String.t()) :: {:ok, t()} | {:error, :not_found} + def get_by_token(token) do + from( + t in __MODULE__, + where: t.token == ^token, + preload: [:user, :authorization] + ) + |> Repo.find_resource() + end + + @spec validate(String.t()) :: {:ok, t()} | {:error, :not_found} | {:error, :expired_token} + def validate(token_str) do + with {:ok, token} <- get_by_token(token_str), + false <- expired?(token) do + {:ok, token} + end + end + + defp expired?(%__MODULE__{valid_until: valid_until}) do + with true <- NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) > 0 do + {:error, :expired_token} + end + end + + @spec create(User.t(), Authorization.t() | nil) :: {:ok, t()} | {:error, Ecto.Changeset.t()} + def create(user, authorization \\ nil) do + with {:ok, token} <- do_create(user, authorization) do + Pleroma.Workers.PurgeExpiredToken.enqueue(%{ + token_id: token.id, + valid_until: DateTime.from_naive!(token.valid_until, "Etc/UTC"), + mod: __MODULE__ + }) + + {:ok, token} + end + end + + defp do_create(user, authorization) do + %__MODULE__{} + |> change() + |> assign_user(user) + |> maybe_assign_authorization(authorization) + |> put_token() + |> put_valid_until() + |> Repo.insert() + end + + defp assign_user(changeset, user) do + changeset + |> put_assoc(:user, user) + |> validate_required([:user]) + end + + defp maybe_assign_authorization(changeset, %Authorization{} = authorization) do + changeset + |> put_assoc(:authorization, authorization) + |> validate_required([:authorization]) + end + + defp maybe_assign_authorization(changeset, _), do: changeset + + defp put_token(changeset) do + token = Pleroma.Web.OAuth.Token.Utils.generate_token() + + changeset + |> change(%{token: token}) + |> validate_required([:token]) + |> unique_constraint(:token) + end + + defp put_valid_until(changeset) do + expires_in = NaiveDateTime.add(NaiveDateTime.utc_now(), @expires) + + changeset + |> change(%{valid_until: expires_in}) + |> validate_required([:valid_until]) + end +end diff --git a/lib/pleroma/mfa/totp.ex b/lib/pleroma/mfa/totp.ex new file mode 100644 index 0000000..429c4b7 --- /dev/null +++ b/lib/pleroma/mfa/totp.ex @@ -0,0 +1,86 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.TOTP do + @moduledoc """ + This module represents functions to create secrets for + TOTP Application as well as validate them with a time based token. + """ + alias Pleroma.Config + + @config_ns [:instance, :multi_factor_authentication, :totp] + + @doc """ + https://github.com/google/google-authenticator/wiki/Key-Uri-Format + """ + def provisioning_uri(secret, label, opts \\ []) do + query = + %{ + secret: secret, + issuer: Keyword.get(opts, :issuer, default_issuer()), + digits: Keyword.get(opts, :digits, default_digits()), + period: Keyword.get(opts, :period, default_period()) + } + |> Enum.filter(fn {_, v} -> not is_nil(v) end) + |> Enum.into(%{}) + |> URI.encode_query() + + %URI{scheme: "otpauth", host: "totp", path: "/" <> label, query: query} + |> URI.to_string() + end + + defp default_period, do: Config.get(@config_ns ++ [:period]) + defp default_digits, do: Config.get(@config_ns ++ [:digits]) + + defp default_issuer, + do: Config.get(@config_ns ++ [:issuer], Config.get([:instance, :name])) + + @doc "Creates a random Base 32 encoded string" + def generate_secret do + Base.encode32(:crypto.strong_rand_bytes(10)) + end + + @doc "Generates a valid token based on a secret" + def generate_token(secret) do + :pot.totp(secret) + end + + @doc """ + Validates a given token based on a secret. + + optional parameters: + `token_length` default `6` + `interval_length` default `30` + `window` default 0 + + Returns {:ok, :pass} if the token is valid and + {:error, :invalid_token} if it is not. + """ + @spec validate_token(String.t(), String.t()) :: + {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token} + def validate_token(secret, token) + when is_binary(secret) and is_binary(token) do + opts = [ + token_length: default_digits(), + interval_length: default_period() + ] + + validate_token(secret, token, opts) + end + + def validate_token(_, _), do: {:error, :invalid_secret_and_token} + + @doc "See `validate_token/2`" + @spec validate_token(String.t(), String.t(), Keyword.t()) :: + {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token} + def validate_token(secret, token, options) + when is_binary(secret) and is_binary(token) do + case :pot.valid_totp(token, secret, options) do + true -> {:ok, :pass} + false -> {:error, :invalid_token} + end + end + + def validate_token(_, _, _), do: {:error, :invalid_secret_and_token} +end diff --git a/lib/pleroma/migration_helper/notification_backfill.ex b/lib/pleroma/migration_helper/notification_backfill.ex new file mode 100644 index 0000000..9f4976d --- /dev/null +++ b/lib/pleroma/migration_helper/notification_backfill.ex @@ -0,0 +1,92 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MigrationHelper.NotificationBackfill do + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + + import Ecto.Query + + def fill_in_notification_types do + query = + from(n in Pleroma.Notification, + where: is_nil(n.type), + preload: :activity + ) + + query + |> Repo.chunk_stream(100) + |> Enum.each(fn notification -> + if notification.activity do + type = type_from_activity(notification.activity) + + notification + |> Ecto.Changeset.change(%{type: type}) + |> Repo.update() + end + end) + end + + defp get_by_ap_id(ap_id) do + q = + from(u in User, + select: u.id + ) + + Repo.get_by(q, ap_id: ap_id) + end + + # This is copied over from Notifications to keep this stable. + defp type_from_activity(%{data: %{"type" => type}} = activity) do + case type do + "Follow" -> + accepted_function = fn activity -> + with %User{} = follower <- get_by_ap_id(activity.data["actor"]), + %User{} = followed <- get_by_ap_id(activity.data["object"]) do + Pleroma.FollowingRelationship.following?(follower, followed) + end + end + + if accepted_function.(activity) do + "follow" + else + "follow_request" + end + + "Announce" -> + "reblog" + + "Like" -> + "favourite" + + "Move" -> + "move" + + "EmojiReact" -> + "pleroma:emoji_reaction" + + # Compatibility with old reactions + "EmojiReaction" -> + "pleroma:emoji_reaction" + + "Create" -> + type_from_activity_object(activity) + + t -> + raise "No notification type for activity type #{t}" + end + end + + defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), do: "mention" + + defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do + object = Object.get_by_ap_id(activity.data["object"]) + + case object && object.data["type"] do + "ChatMessage" -> "pleroma:chat_mention" + _ -> "mention" + end + end +end diff --git a/lib/pleroma/migrators/context_objects_deletion_migrator.ex b/lib/pleroma/migrators/context_objects_deletion_migrator.ex new file mode 100644 index 0000000..fb22479 --- /dev/null +++ b/lib/pleroma/migrators/context_objects_deletion_migrator.ex @@ -0,0 +1,139 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Migrators.ContextObjectsDeletionMigrator do + defmodule State do + use Pleroma.Migrators.Support.BaseMigratorState + + @impl Pleroma.Migrators.Support.BaseMigratorState + defdelegate data_migration(), to: Pleroma.DataMigration, as: :delete_context_objects + end + + use Pleroma.Migrators.Support.BaseMigrator + + alias Pleroma.Migrators.Support.BaseMigrator + alias Pleroma.Object + + @doc "This migration removes objects created exclusively for contexts, containing only an `id` field." + + @impl BaseMigrator + def feature_config_path, do: [:features, :delete_context_objects] + + @impl BaseMigrator + def fault_rate_allowance, do: Config.get([:delete_context_objects, :fault_rate_allowance], 0) + + @impl BaseMigrator + def perform do + data_migration_id = data_migration_id() + max_processed_id = get_stat(:max_processed_id, 0) + + Logger.info("Deleting context objects from `objects` (from oid: #{max_processed_id})...") + + query() + |> where([object], object.id > ^max_processed_id) + |> Repo.chunk_stream(100, :batches, timeout: :infinity) + |> Stream.each(fn objects -> + object_ids = Enum.map(objects, & &1.id) + + results = Enum.map(object_ids, &delete_context_object(&1)) + + failed_ids = + results + |> Enum.filter(&(elem(&1, 0) == :error)) + |> Enum.map(&elem(&1, 1)) + + chunk_affected_count = + results + |> Enum.filter(&(elem(&1, 0) == :ok)) + |> length() + + for failed_id <- failed_ids do + _ = + Repo.query( + "INSERT INTO data_migration_failed_ids(data_migration_id, record_id) " <> + "VALUES ($1, $2) ON CONFLICT DO NOTHING;", + [data_migration_id, failed_id] + ) + end + + _ = + Repo.query( + "DELETE FROM data_migration_failed_ids " <> + "WHERE data_migration_id = $1 AND record_id = ANY($2)", + [data_migration_id, object_ids -- failed_ids] + ) + + max_object_id = Enum.at(object_ids, -1) + + put_stat(:max_processed_id, max_object_id) + increment_stat(:iteration_processed_count, length(object_ids)) + increment_stat(:processed_count, length(object_ids)) + increment_stat(:failed_count, length(failed_ids)) + increment_stat(:affected_count, chunk_affected_count) + put_stat(:records_per_second, records_per_second()) + persist_state() + + # A quick and dirty approach to controlling the load this background migration imposes + sleep_interval = Config.get([:delete_context_objects, :sleep_interval_ms], 0) + Process.sleep(sleep_interval) + end) + |> Stream.run() + end + + @impl BaseMigrator + def query do + # Context objects have no activity type, and only one field, `id`. + # Only those context objects are without types. + from( + object in Object, + where: fragment("(?)->'type' IS NULL", object.data), + select: %{ + id: object.id + } + ) + end + + @spec delete_context_object(integer()) :: {:ok | :error, integer()} + defp delete_context_object(id) do + result = + %Object{id: id} + |> Repo.delete() + |> elem(0) + + {result, id} + end + + @impl BaseMigrator + def retry_failed do + data_migration_id = data_migration_id() + + failed_objects_query() + |> Repo.chunk_stream(100, :one) + |> Stream.each(fn object -> + with {res, _} when res != :error <- delete_context_object(object.id) do + _ = + Repo.query( + "DELETE FROM data_migration_failed_ids " <> + "WHERE data_migration_id = $1 AND record_id = $2", + [data_migration_id, object.id] + ) + end + end) + |> Stream.run() + + put_stat(:failed_count, failures_count()) + persist_state() + + force_continue() + end + + defp failed_objects_query do + from(o in Object) + |> join(:inner, [o], dmf in fragment("SELECT * FROM data_migration_failed_ids"), + on: dmf.record_id == o.id + ) + |> where([_o, dmf], dmf.data_migration_id == ^data_migration_id()) + |> order_by([o], asc: o.id) + end +end diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex new file mode 100644 index 0000000..dca4bfa --- /dev/null +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -0,0 +1,208 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Migrators.HashtagsTableMigrator do + defmodule State do + use Pleroma.Migrators.Support.BaseMigratorState + + @impl Pleroma.Migrators.Support.BaseMigratorState + defdelegate data_migration(), to: Pleroma.DataMigration, as: :populate_hashtags_table + end + + use Pleroma.Migrators.Support.BaseMigrator + + alias Pleroma.Hashtag + alias Pleroma.Migrators.Support.BaseMigrator + alias Pleroma.Object + + @impl BaseMigrator + def feature_config_path, do: [:features, :improved_hashtag_timeline] + + @impl BaseMigrator + def fault_rate_allowance, do: Config.get([:populate_hashtags_table, :fault_rate_allowance], 0) + + @impl BaseMigrator + def perform do + data_migration_id = data_migration_id() + max_processed_id = get_stat(:max_processed_id, 0) + + Logger.info("Transferring embedded hashtags to `hashtags` (from oid: #{max_processed_id})...") + + query() + |> where([object], object.id > ^max_processed_id) + |> Repo.chunk_stream(100, :batches, timeout: :infinity) + |> Stream.each(fn objects -> + object_ids = Enum.map(objects, & &1.id) + + results = Enum.map(objects, &transfer_object_hashtags(&1)) + + failed_ids = + results + |> Enum.filter(&(elem(&1, 0) == :error)) + |> Enum.map(&elem(&1, 1)) + + # Count of objects with hashtags: `{:noop, id}` is returned for objects having other AS2 tags + chunk_affected_count = + results + |> Enum.filter(&(elem(&1, 0) == :ok)) + |> length() + + for failed_id <- failed_ids do + _ = + Repo.query( + "INSERT INTO data_migration_failed_ids(data_migration_id, record_id) " <> + "VALUES ($1, $2) ON CONFLICT DO NOTHING;", + [data_migration_id, failed_id] + ) + end + + _ = + Repo.query( + "DELETE FROM data_migration_failed_ids " <> + "WHERE data_migration_id = $1 AND record_id = ANY($2)", + [data_migration_id, object_ids -- failed_ids] + ) + + max_object_id = Enum.at(object_ids, -1) + + put_stat(:max_processed_id, max_object_id) + increment_stat(:iteration_processed_count, length(object_ids)) + increment_stat(:processed_count, length(object_ids)) + increment_stat(:failed_count, length(failed_ids)) + increment_stat(:affected_count, chunk_affected_count) + put_stat(:records_per_second, records_per_second()) + persist_state() + + # A quick and dirty approach to controlling the load this background migration imposes + sleep_interval = Config.get([:populate_hashtags_table, :sleep_interval_ms], 0) + Process.sleep(sleep_interval) + end) + |> Stream.run() + end + + @impl BaseMigrator + def query do + # Note: most objects have Mention-type AS2 tags and no hashtags (but we can't filter them out) + # Note: not checking activity type, expecting remove_non_create_objects_hashtags/_ to clean up + from( + object in Object, + where: + fragment("(?)->'tag' IS NOT NULL AND (?)->'tag' != '[]'::jsonb", object.data, object.data), + select: %{ + id: object.id, + tag: fragment("(?)->'tag'", object.data) + } + ) + |> join(:left, [o], hashtags_objects in fragment("SELECT object_id FROM hashtags_objects"), + on: hashtags_objects.object_id == o.id + ) + |> where([_o, hashtags_objects], is_nil(hashtags_objects.object_id)) + end + + @spec transfer_object_hashtags(Map.t()) :: {:noop | :ok | :error, integer()} + defp transfer_object_hashtags(object) do + embedded_tags = if Map.has_key?(object, :tag), do: object.tag, else: object.data["tag"] + hashtags = Object.object_data_hashtags(%{"tag" => embedded_tags}) + + if Enum.any?(hashtags) do + transfer_object_hashtags(object, hashtags) + else + {:noop, object.id} + end + end + + defp transfer_object_hashtags(object, hashtags) do + Repo.transaction(fn -> + with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do + maps = Enum.map(hashtag_records, &%{hashtag_id: &1.id, object_id: object.id}) + base_error = "ERROR when inserting hashtags_objects for object with id #{object.id}" + + try do + with {rows_count, _} when is_integer(rows_count) <- + Repo.insert_all("hashtags_objects", maps, on_conflict: :nothing) do + object.id + else + e -> + Logger.error("#{base_error}: #{inspect(e)}") + Repo.rollback(object.id) + end + rescue + e -> + Logger.error("#{base_error}: #{inspect(e)}") + Repo.rollback(object.id) + end + else + e -> + error = "ERROR: could not create hashtags for object #{object.id}: #{inspect(e)}" + Logger.error(error) + Repo.rollback(object.id) + end + end) + end + + @impl BaseMigrator + def retry_failed do + data_migration_id = data_migration_id() + + failed_objects_query() + |> Repo.chunk_stream(100, :one) + |> Stream.each(fn object -> + with {res, _} when res != :error <- transfer_object_hashtags(object) do + _ = + Repo.query( + "DELETE FROM data_migration_failed_ids " <> + "WHERE data_migration_id = $1 AND record_id = $2", + [data_migration_id, object.id] + ) + end + end) + |> Stream.run() + + put_stat(:failed_count, failures_count()) + persist_state() + + force_continue() + end + + defp failed_objects_query do + from(o in Object) + |> join(:inner, [o], dmf in fragment("SELECT * FROM data_migration_failed_ids"), + on: dmf.record_id == o.id + ) + |> where([_o, dmf], dmf.data_migration_id == ^data_migration_id()) + |> order_by([o], asc: o.id) + end + + @doc """ + Service func to delete `hashtags_objects` for legacy objects not associated with Create activity. + Also deletes unreferenced `hashtags` records (might occur after deletion of `hashtags_objects`). + """ + def delete_non_create_activities_hashtags do + hashtags_objects_cleanup_query = """ + DELETE FROM hashtags_objects WHERE object_id IN + (SELECT DISTINCT objects.id FROM objects + JOIN hashtags_objects ON hashtags_objects.object_id = objects.id LEFT JOIN activities + ON associated_object_id(activities) = + (objects.data->>'id') + AND activities.data->>'type' = 'Create' + WHERE activities.id IS NULL); + """ + + hashtags_cleanup_query = """ + DELETE FROM hashtags WHERE id IN + (SELECT hashtags.id FROM hashtags + LEFT OUTER JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id + WHERE hashtags_objects.hashtag_id IS NULL); + """ + + {:ok, %{num_rows: hashtags_objects_count}} = + Repo.query(hashtags_objects_cleanup_query, [], timeout: :infinity) + + {:ok, %{num_rows: hashtags_count}} = + Repo.query(hashtags_cleanup_query, [], timeout: :infinity) + + {:ok, hashtags_objects_count, hashtags_count} + end +end diff --git a/lib/pleroma/migrators/support/base_migrator.ex b/lib/pleroma/migrators/support/base_migrator.ex new file mode 100644 index 0000000..ce88caa --- /dev/null +++ b/lib/pleroma/migrators/support/base_migrator.ex @@ -0,0 +1,210 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Migrators.Support.BaseMigrator do + @moduledoc """ + Base background migrator functionality. + """ + + @callback perform() :: any() + @callback retry_failed() :: any() + @callback feature_config_path() :: list(atom()) + @callback query() :: Ecto.Query.t() + @callback fault_rate_allowance() :: integer() | float() + + defmacro __using__(_opts) do + quote do + use GenServer + + require Logger + + import Ecto.Query + + alias __MODULE__.State + alias Pleroma.Config + alias Pleroma.Repo + + @behaviour Pleroma.Migrators.Support.BaseMigrator + + defdelegate data_migration(), to: State + defdelegate data_migration_id(), to: State + defdelegate state(), to: State + defdelegate persist_state(), to: State, as: :persist_to_db + defdelegate get_stat(key, value \\ nil), to: State, as: :get_data_key + defdelegate put_stat(key, value), to: State, as: :put_data_key + defdelegate increment_stat(key, increment), to: State, as: :increment_data_key + + @reg_name {:global, __MODULE__} + + def whereis, do: GenServer.whereis(@reg_name) + + def start_link(_) do + case whereis() do + nil -> + GenServer.start_link(__MODULE__, nil, name: @reg_name) + + pid -> + {:ok, pid} + end + end + + @impl true + def init(_) do + {:ok, nil, {:continue, :init_state}} + end + + @impl true + def handle_continue(:init_state, _state) do + {:ok, _} = State.start_link(nil) + + data_migration = data_migration() + manual_migrations = Config.get([:instance, :manual_data_migrations], []) + + cond do + Config.get(:env) == :test -> + update_status(:noop) + + is_nil(data_migration) -> + message = "Data migration does not exist." + update_status(:failed, message) + Logger.error("#{__MODULE__}: #{message}") + + data_migration.state == :manual or data_migration.name in manual_migrations -> + message = "Data migration is in manual execution or manual fix mode." + update_status(:manual, message) + Logger.warning("#{__MODULE__}: #{message}") + + data_migration.state == :complete -> + on_complete(data_migration) + + true -> + send(self(), :perform) + end + + {:noreply, nil} + end + + @impl true + def handle_info(:perform, state) do + State.reinit() + + update_status(:running) + put_stat(:iteration_processed_count, 0) + put_stat(:started_at, NaiveDateTime.utc_now()) + + perform() + + fault_rate = fault_rate() + put_stat(:fault_rate, fault_rate) + fault_rate_allowance = fault_rate_allowance() + + cond do + fault_rate == 0 -> + set_complete() + + is_float(fault_rate) and fault_rate <= fault_rate_allowance -> + message = """ + Done with fault rate of #{fault_rate} which doesn't exceed #{fault_rate_allowance}. + Putting data migration to manual fix mode. Try running `#{__MODULE__}.retry_failed/0`. + """ + + Logger.warning("#{__MODULE__}: #{message}") + update_status(:manual, message) + on_complete(data_migration()) + + true -> + message = "Too many failures. Try running `#{__MODULE__}.retry_failed/0`." + Logger.error("#{__MODULE__}: #{message}") + update_status(:failed, message) + end + + persist_state() + {:noreply, state} + end + + defp on_complete(data_migration) do + if data_migration.feature_lock || feature_state() == :disabled do + Logger.warning( + "#{__MODULE__}: migration complete but feature is locked; consider enabling." + ) + + :noop + else + Config.put(feature_config_path(), :enabled) + :ok + end + end + + @doc "Approximate count for current iteration (including processed records count)" + def count(force \\ false, timeout \\ :infinity) do + stored_count = get_stat(:count) + + if stored_count && !force do + stored_count + else + processed_count = get_stat(:processed_count, 0) + max_processed_id = get_stat(:max_processed_id, 0) + query = where(query(), [entity], entity.id > ^max_processed_id) + + count = Repo.aggregate(query, :count, :id, timeout: timeout) + processed_count + put_stat(:count, count) + persist_state() + + count + end + end + + def failures_count do + with {:ok, %{rows: [[count]]}} <- + Repo.query( + "SELECT COUNT(record_id) FROM data_migration_failed_ids WHERE data_migration_id = $1;", + [data_migration_id()] + ) do + count + end + end + + def feature_state, do: Config.get(feature_config_path()) + + def force_continue do + send(whereis(), :perform) + end + + def force_restart do + :ok = State.reset() + force_continue() + end + + def set_complete do + update_status(:complete) + persist_state() + on_complete(data_migration()) + end + + defp update_status(status, message \\ nil) do + put_stat(:state, status) + put_stat(:message, message) + end + + defp fault_rate do + with failures_count when is_integer(failures_count) <- failures_count() do + failures_count / Enum.max([get_stat(:affected_count, 0), 1]) + else + _ -> :error + end + end + + defp records_per_second do + get_stat(:iteration_processed_count, 0) / Enum.max([running_time(), 1]) + end + + defp running_time do + NaiveDateTime.diff( + NaiveDateTime.utc_now(), + get_stat(:started_at, NaiveDateTime.utc_now()) + ) + end + end + end +end diff --git a/lib/pleroma/migrators/support/base_migrator_state.ex b/lib/pleroma/migrators/support/base_migrator_state.ex new file mode 100644 index 0000000..3d7769f --- /dev/null +++ b/lib/pleroma/migrators/support/base_migrator_state.ex @@ -0,0 +1,117 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Migrators.Support.BaseMigratorState do + @moduledoc """ + Base background migrator state functionality. + """ + + @callback data_migration() :: Pleroma.DataMigration.t() + + defmacro __using__(_opts) do + quote do + use Agent + + alias Pleroma.DataMigration + + @behaviour Pleroma.Migrators.Support.BaseMigratorState + @reg_name {:global, __MODULE__} + + def start_link(_) do + Agent.start_link(fn -> load_state_from_db() end, name: @reg_name) + end + + def data_migration, do: raise("data_migration/0 is not implemented") + defoverridable data_migration: 0 + + defp load_state_from_db do + data_migration = data_migration() + + data = + if data_migration do + Map.new(data_migration.data, fn {k, v} -> {String.to_atom(k), v} end) + else + %{} + end + + %{ + data_migration_id: data_migration && data_migration.id, + data: data + } + end + + def persist_to_db do + %{data_migration_id: data_migration_id, data: data} = state() + + if data_migration_id do + DataMigration.update_one_by_id(data_migration_id, data: data) + else + {:error, :nil_data_migration_id} + end + end + + def reset do + %{data_migration_id: data_migration_id} = state() + + with false <- is_nil(data_migration_id), + :ok <- + DataMigration.update_one_by_id(data_migration_id, + state: :pending, + data: %{} + ) do + reinit() + else + true -> {:error, :nil_data_migration_id} + e -> e + end + end + + def reinit do + Agent.update(@reg_name, fn _state -> load_state_from_db() end) + end + + def state do + Agent.get(@reg_name, & &1) + end + + def get_data_key(key, default \\ nil) do + get_in(state(), [:data, key]) || default + end + + def put_data_key(key, value) do + _ = persist_non_data_change(key, value) + + Agent.update(@reg_name, fn state -> + put_in(state, [:data, key], value) + end) + end + + def increment_data_key(key, increment \\ 1) do + Agent.update(@reg_name, fn state -> + initial_value = get_in(state, [:data, key]) || 0 + updated_value = initial_value + increment + put_in(state, [:data, key], updated_value) + end) + end + + defp persist_non_data_change(:state, value) do + with true <- get_data_key(:state) != value, + true <- value in Pleroma.DataMigration.State.__valid_values__(), + %{data_migration_id: data_migration_id} when not is_nil(data_migration_id) <- + state() do + DataMigration.update_one_by_id(data_migration_id, state: value) + else + false -> :ok + _ -> {:error, :nil_data_migration_id} + end + end + + defp persist_non_data_change(_, _) do + nil + end + + def data_migration_id, do: Map.get(state(), :data_migration_id) + end + end +end diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex new file mode 100644 index 0000000..a656d76 --- /dev/null +++ b/lib/pleroma/moderation_log.ex @@ -0,0 +1,646 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ModerationLog do + use Ecto.Schema + + alias Pleroma.Activity + alias Pleroma.ModerationLog + alias Pleroma.Repo + alias Pleroma.User + + import Ecto.Query + + @type t :: %__MODULE__{} + @type log_subject :: Activity.t() | User.t() | list(User.t()) + @type log_params :: %{ + required(:actor) => User.t(), + required(:action) => String.t(), + optional(:subject) => log_subject(), + optional(:subject_actor) => User.t(), + optional(:subject_id) => String.t(), + optional(:subjects) => list(User.t()), + optional(:permission) => String.t(), + optional(:text) => String.t(), + optional(:sensitive) => String.t(), + optional(:visibility) => String.t(), + optional(:followed) => User.t(), + optional(:follower) => User.t(), + optional(:nicknames) => list(String.t()), + optional(:tags) => list(String.t()), + optional(:target) => String.t() + } + + schema "moderation_log" do + field(:data, :map) + + timestamps() + end + + def get_all(params) do + base_query = + get_all_query() + |> maybe_filter_by_date(params) + |> maybe_filter_by_user(params) + |> maybe_filter_by_search(params) + + query_with_pagination = base_query |> paginate_query(params) + + %{ + items: Repo.all(query_with_pagination), + count: Repo.aggregate(base_query, :count, :id) + } + end + + defp maybe_filter_by_date(query, %{start_date: nil, end_date: nil}), do: query + + defp maybe_filter_by_date(query, %{start_date: start_date, end_date: nil}) do + from(q in query, + where: q.inserted_at >= ^parse_datetime(start_date) + ) + end + + defp maybe_filter_by_date(query, %{start_date: nil, end_date: end_date}) do + from(q in query, + where: q.inserted_at <= ^parse_datetime(end_date) + ) + end + + defp maybe_filter_by_date(query, %{start_date: start_date, end_date: end_date}) do + from(q in query, + where: q.inserted_at >= ^parse_datetime(start_date), + where: q.inserted_at <= ^parse_datetime(end_date) + ) + end + + defp maybe_filter_by_user(query, %{user_id: nil}), do: query + + defp maybe_filter_by_user(query, %{user_id: user_id}) do + from(q in query, + where: fragment("(?)->'actor'->>'id' = ?", q.data, ^user_id) + ) + end + + defp maybe_filter_by_search(query, %{search: search}) when is_nil(search) or search == "", + do: query + + defp maybe_filter_by_search(query, %{search: search}) do + from(q in query, + where: fragment("(?)->>'message' ILIKE ?", q.data, ^"%#{search}%") + ) + end + + defp paginate_query(query, %{page: page, page_size: page_size}) do + from(q in query, + limit: ^page_size, + offset: ^((page - 1) * page_size) + ) + end + + defp get_all_query do + from(q in __MODULE__, + order_by: [desc: q.inserted_at] + ) + end + + defp parse_datetime(datetime) do + {:ok, parsed_datetime, _} = DateTime.from_iso8601(datetime) + + parsed_datetime + end + + defp prepare_log_data(%{actor: actor, action: action} = attrs) do + %{ + "actor" => user_to_map(actor), + "action" => action, + "message" => "" + } + |> Pleroma.Maps.put_if_present("subject_actor", user_to_map(attrs[:subject_actor])) + end + + defp prepare_log_data(attrs), do: attrs + + @spec insert_log(log_params()) :: {:ok, ModerationLog} | {:error, any} + def insert_log(%{actor: %User{}, subject: subjects, permission: permission} = attrs) do + data = + attrs + |> prepare_log_data + |> Map.merge(%{"subject" => user_to_map(subjects), "permission" => permission}) + + insert_log_entry_with_message(%ModerationLog{data: data}) + end + + def insert_log(%{actor: %User{}, action: action, subject: %Activity{} = subject} = attrs) + when action in [ + "report_note_delete", + "report_update", + "report_note", + "report_unassigned", + "report_assigned" + ] do + data = + attrs + |> prepare_log_data + |> Pleroma.Maps.put_if_present("text", attrs[:text]) + |> Pleroma.Maps.put_if_present("assigned_account", attrs[:assigned_account]) + |> Map.merge(%{"subject" => report_to_map(subject)}) + + insert_log_entry_with_message(%ModerationLog{data: data}) + end + + def insert_log( + %{ + actor: %User{}, + action: action, + subject: %Activity{} = subject, + sensitive: sensitive, + visibility: visibility + } = attrs + ) + when action == "status_update" do + data = + attrs + |> prepare_log_data + |> Map.merge(%{ + "subject" => status_to_map(subject), + "sensitive" => sensitive, + "visibility" => visibility + }) + + insert_log_entry_with_message(%ModerationLog{data: data}) + end + + def insert_log(%{actor: %User{}, action: action, subject_id: subject_id} = attrs) + when action == "status_delete" do + data = + attrs + |> prepare_log_data + |> Map.merge(%{"subject_id" => subject_id}) + + insert_log_entry_with_message(%ModerationLog{data: data}) + end + + def insert_log(%{actor: %User{}, subject: subject, action: _action} = attrs) do + data = + attrs + |> prepare_log_data + |> Map.merge(%{"subject" => user_to_map(subject)}) + + insert_log_entry_with_message(%ModerationLog{data: data}) + end + + def insert_log(%{actor: %User{}, subjects: subjects, action: _action} = attrs) do + data = + attrs + |> prepare_log_data + |> Map.merge(%{"subjects" => user_to_map(subjects)}) + + insert_log_entry_with_message(%ModerationLog{data: data}) + end + + def insert_log( + %{ + actor: %User{}, + followed: %User{} = followed, + follower: %User{} = follower, + action: action + } = attrs + ) + when action in ["unfollow", "follow"] do + data = + attrs + |> prepare_log_data + |> Map.merge(%{"followed" => user_to_map(followed), "follower" => user_to_map(follower)}) + + insert_log_entry_with_message(%ModerationLog{data: data}) + end + + def insert_log(%{ + actor: %User{} = actor, + nicknames: nicknames, + tags: tags, + action: action + }) do + %ModerationLog{ + data: %{ + "actor" => user_to_map(actor), + "nicknames" => nicknames, + "tags" => tags, + "action" => action, + "message" => "" + } + } + |> insert_log_entry_with_message() + end + + def insert_log(%{actor: %User{}, action: action, target: target} = attrs) + when action in ["relay_follow", "relay_unfollow"] do + data = + attrs + |> prepare_log_data + |> Map.merge(%{"target" => target}) + + insert_log_entry_with_message(%ModerationLog{data: data}) + end + + def insert_log(%{actor: %User{} = actor, action: "chat_message_delete", subject_id: subject_id}) do + %ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor.nickname}, + "action" => "chat_message_delete", + "subject_id" => subject_id + } + } + |> insert_log_entry_with_message() + end + + @spec insert_log_entry_with_message(ModerationLog) :: {:ok, ModerationLog} | {:error, any} + defp insert_log_entry_with_message(entry) do + entry.data["message"] + |> put_in(get_log_entry_message(entry)) + |> Repo.insert() + end + + defp user_to_map(users) when is_list(users) do + Enum.map(users, &user_to_map/1) + end + + defp user_to_map(%User{} = user) do + user + |> Map.take([:id, :nickname]) + |> Map.new(fn {k, v} -> {Atom.to_string(k), v} end) + |> Map.put("type", "user") + end + + defp user_to_map(_), do: nil + + defp report_to_map(%Activity{} = report) do + %{"type" => "report", "id" => report.id, "state" => report.data["state"]} + end + + defp status_to_map(%Activity{} = status) do + %{"type" => "status", "id" => status.id} + end + + @spec get_log_entry_message(ModerationLog.t()) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => action, + "followed" => %{"nickname" => followed_nickname}, + "follower" => %{"nickname" => follower_nickname} + } + }) do + "@#{actor_nickname} made @#{follower_nickname} #{action} @#{followed_nickname}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "delete", + "subject" => subjects + } + }) do + "@#{actor_nickname} deleted users: #{users_to_nicknames_string(subjects)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "create", + "subjects" => subjects + } + }) do + "@#{actor_nickname} created users: #{users_to_nicknames_string(subjects)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "activate", + "subject" => users + } + }) do + "@#{actor_nickname} activated users: #{users_to_nicknames_string(users)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "deactivate", + "subject" => users + } + }) do + "@#{actor_nickname} deactivated users: #{users_to_nicknames_string(users)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "approve", + "subject" => users + } + }) do + "@#{actor_nickname} approved users: #{users_to_nicknames_string(users)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "reject", + "subject" => users + } + }) do + "@#{actor_nickname} rejected users: #{users_to_nicknames_string(users)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "add_suggestion", + "subject" => users + } + }) do + "@#{actor_nickname} added suggested users: #{users_to_nicknames_string(users)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "remove_suggestion", + "subject" => users + } + }) do + "@#{actor_nickname} removed suggested users: #{users_to_nicknames_string(users)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "nicknames" => nicknames, + "tags" => tags, + "action" => "tag" + } + }) do + tags_string = tags |> Enum.join(", ") + + "@#{actor_nickname} added tags: #{tags_string} to users: #{nicknames_to_string(nicknames)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "nicknames" => nicknames, + "tags" => tags, + "action" => "untag" + } + }) do + tags_string = tags |> Enum.join(", ") + + "@#{actor_nickname} removed tags: #{tags_string} from users: #{nicknames_to_string(nicknames)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "grant", + "subject" => users, + "permission" => permission + } + }) do + "@#{actor_nickname} made #{users_to_nicknames_string(users)} #{permission}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "revoke", + "subject" => users, + "permission" => permission + } + }) do + "@#{actor_nickname} revoked #{permission} role from #{users_to_nicknames_string(users)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "relay_follow", + "target" => target + } + }) do + "@#{actor_nickname} followed relay: #{target}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "relay_unfollow", + "target" => target + } + }) do + "@#{actor_nickname} unfollowed relay: #{target}" + end + + def get_log_entry_message( + %ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "report_update", + "subject" => %{"id" => subject_id, "state" => state, "type" => "report"} + } + } = log + ) do + "@#{actor_nickname} updated report ##{subject_id}" <> + subject_actor_nickname(log, " (on user ", ")") <> + " with '#{state}' state" + end + + def get_log_entry_message( + %ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "report_assigned", + "subject" => %{"id" => subject_id, "type" => "report"}, + "assigned_account" => assigned_account + } + } = log + ) do + "@#{actor_nickname} assigned report ##{subject_id}" <> + subject_actor_nickname(log, " (on user ", ")") <> + " to user #{assigned_account}" + end + + def get_log_entry_message( + %ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "report_unassigned", + "subject" => %{"id" => subject_id, "type" => "report"} + } + } = log + ) do + "@#{actor_nickname} unassigned report ##{subject_id}" <> + subject_actor_nickname(log, " (on user ", ")") <> + " from a user" + end + + def get_log_entry_message( + %ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "report_note", + "subject" => %{"id" => subject_id, "type" => "report"}, + "text" => text + } + } = log + ) do + "@#{actor_nickname} added note '#{text}' to report ##{subject_id}" <> + subject_actor_nickname(log, " on user ") + end + + def get_log_entry_message( + %ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "report_note_delete", + "subject" => %{"id" => subject_id, "type" => "report"}, + "text" => text + } + } = log + ) do + "@#{actor_nickname} deleted note '#{text}' from report ##{subject_id}" <> + subject_actor_nickname(log, " on user ") + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "status_update", + "subject" => %{"id" => subject_id, "type" => "status"}, + "sensitive" => nil, + "visibility" => visibility + } + }) do + "@#{actor_nickname} updated status ##{subject_id}, set visibility: '#{visibility}'" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "status_update", + "subject" => %{"id" => subject_id, "type" => "status"}, + "sensitive" => sensitive, + "visibility" => nil + } + }) do + "@#{actor_nickname} updated status ##{subject_id}, set sensitive: '#{sensitive}'" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "status_update", + "subject" => %{"id" => subject_id, "type" => "status"}, + "sensitive" => sensitive, + "visibility" => visibility + } + }) do + "@#{actor_nickname} updated status ##{subject_id}, set sensitive: '#{sensitive}', visibility: '#{visibility}'" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "status_delete", + "subject_id" => subject_id + } + }) do + "@#{actor_nickname} deleted status ##{subject_id}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "force_password_reset", + "subject" => subjects + } + }) do + "@#{actor_nickname} forced password reset for users: #{users_to_nicknames_string(subjects)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "confirm_email", + "subject" => subjects + } + }) do + "@#{actor_nickname} confirmed email for users: #{users_to_nicknames_string(subjects)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "resend_confirmation_email", + "subject" => subjects + } + }) do + "@#{actor_nickname} re-sent confirmation email for users: #{users_to_nicknames_string(subjects)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "updated_users", + "subject" => subjects + } + }) do + "@#{actor_nickname} updated users: #{users_to_nicknames_string(subjects)}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "chat_message_delete", + "subject_id" => subject_id + } + }) do + "@#{actor_nickname} deleted chat message ##{subject_id}" + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "create_backup", + "subject" => %{"nickname" => user_nickname} + } + }) do + "@#{actor_nickname} requested account backup for @#{user_nickname}" + end + + defp nicknames_to_string(nicknames) do + nicknames + |> Enum.map(&"@#{&1}") + |> Enum.join(", ") + end + + defp users_to_nicknames_string(users) do + users + |> Enum.map(&"@#{&1["nickname"]}") + |> Enum.join(", ") + end + + defp subject_actor_nickname(%ModerationLog{data: data}, prefix_msg, postfix_msg \\ "") do + case data do + %{"subject_actor" => %{"nickname" => subject_actor}} -> + [prefix_msg, "@#{subject_actor}", postfix_msg] + |> Enum.reject(&(&1 == "")) + |> Enum.join() + + _ -> + "" + end + end +end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex new file mode 100644 index 0000000..2833570 --- /dev/null +++ b/lib/pleroma/notification.ex @@ -0,0 +1,888 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Notification do + use Ecto.Schema + + alias Ecto.Multi + alias Pleroma.Activity + alias Pleroma.FollowingRelationship + alias Pleroma.Marker + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Pagination + alias Pleroma.Repo + alias Pleroma.ThreadMute + alias Pleroma.User + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.Push + alias Pleroma.Web.Streamer + + import Ecto.Query + import Ecto.Changeset + + require Logger + + @type t :: %__MODULE__{} + + @include_muted_option :with_muted + + schema "notifications" do + field(:seen, :boolean, default: false) + # This is an enum type in the database. If you add a new notification type, + # remember to add a migration to add it to the `notifications_type` enum + # as well. + field(:type, :string) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) + + timestamps() + end + + def update_notification_type(user, activity) do + with %__MODULE__{} = notification <- + Repo.get_by(__MODULE__, user_id: user.id, activity_id: activity.id) do + type = + activity + |> type_from_activity() + + notification + |> changeset(%{type: type}) + |> Repo.update() + end + end + + @spec unread_notifications_count(User.t()) :: integer() + def unread_notifications_count(%User{id: user_id}) do + from(q in __MODULE__, + where: q.user_id == ^user_id and q.seen == false + ) + |> Repo.aggregate(:count, :id) + end + + @notification_types ~w{ + favourite + follow + follow_request + mention + move + pleroma:chat_mention + pleroma:emoji_reaction + pleroma:report + reblog + poll + status + pleroma:participation_accepted + pleroma:participation_request + pleroma:event_reminder + pleroma:event_update + } + + def changeset(%Notification{} = notification, attrs) do + notification + |> cast(attrs, [:seen, :type]) + |> validate_inclusion(:type, @notification_types) + end + + @spec last_read_query(User.t()) :: Ecto.Queryable.t() + def last_read_query(user) do + from(q in Pleroma.Notification, + where: q.user_id == ^user.id, + where: q.seen == true, + select: type(q.id, :string), + limit: 1, + order_by: fragment("? desc nulls last", q.id) + ) + end + + defp for_user_query_ap_id_opts(user, opts) do + ap_id_relationships = + [:block] ++ + if opts[@include_muted_option], do: [], else: [:notification_mute] + + preloaded_ap_ids = User.outgoing_relationships_ap_ids(user, ap_id_relationships) + + exclude_blocked_opts = Map.merge(%{blocked_users_ap_ids: preloaded_ap_ids[:block]}, opts) + + exclude_notification_muted_opts = + Map.merge(%{notification_muted_users_ap_ids: preloaded_ap_ids[:notification_mute]}, opts) + + {exclude_blocked_opts, exclude_notification_muted_opts} + end + + def for_user_query(user, opts \\ %{}) do + {exclude_blocked_opts, exclude_notification_muted_opts} = + for_user_query_ap_id_opts(user, opts) + + Notification + |> where(user_id: ^user.id) + |> join(:inner, [n], activity in assoc(n, :activity)) + |> join(:left, [n, a], object in Object, + on: + fragment( + "(?->>'id') = associated_object_id(?)", + object.data, + a.data + ) + ) + |> join(:inner, [_n, a], u in User, on: u.ap_id == a.actor, as: :user_actor) + |> preload([n, a, o], activity: {a, object: o}) + |> where([user_actor: user_actor], user_actor.is_active) + |> exclude_notification_muted(user, exclude_notification_muted_opts) + |> exclude_blocked(user, exclude_blocked_opts) + |> exclude_blockers(user) + |> exclude_filtered(user) + |> exclude_visibility(opts) + end + + # Excludes blocked users and non-followed domain-blocked users + defp exclude_blocked(query, user, opts) do + blocked_ap_ids = opts[:blocked_users_ap_ids] || User.blocked_users_ap_ids(user) + + query + |> where([n, a], a.actor not in ^blocked_ap_ids) + |> FollowingRelationship.keep_following_or_not_domain_blocked(user) + end + + defp exclude_blockers(query, user) do + if Pleroma.Config.get([:activitypub, :blockers_visible]) == true do + query + else + blocker_ap_ids = User.incoming_relationships_ungrouped_ap_ids(user, [:block]) + + query + |> where([n, a], a.actor not in ^blocker_ap_ids) + end + end + + defp exclude_notification_muted(query, _, %{@include_muted_option => true}) do + query + end + + defp exclude_notification_muted(query, user, opts) do + notification_muted_ap_ids = + opts[:notification_muted_users_ap_ids] || User.notification_muted_users_ap_ids(user) + + query + |> where([n, a], a.actor not in ^notification_muted_ap_ids) + |> join(:left, [n, a], tm in ThreadMute, + on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data), + as: :thread_mute + ) + |> where([thread_mute: thread_mute], is_nil(thread_mute.user_id)) + end + + defp exclude_filtered(query, user) do + case Pleroma.Filter.compose_regex(user) do + nil -> + query + + regex -> + from([_n, a, o] in query, + where: + fragment("not(?->>'content' ~* ?)", o.data, ^regex) or + fragment("?->>'content' is null", o.data) or + fragment("?->>'actor' = ?", o.data, ^user.ap_id) + ) + end + end + + @valid_visibilities ~w[direct unlisted public private] + + defp exclude_visibility(query, %{exclude_visibilities: visibility}) + when is_list(visibility) do + if Enum.all?(visibility, &(&1 in @valid_visibilities)) do + query + |> join(:left, [n, a], mutated_activity in Pleroma.Activity, + on: + fragment( + "associated_object_id(?)", + a.data + ) == + fragment( + "associated_object_id(?)", + mutated_activity.data + ) and + fragment("(?->>'type' = 'Like' or ?->>'type' = 'Announce')", a.data, a.data) and + fragment("?->>'type'", mutated_activity.data) == "Create", + as: :mutated_activity + ) + |> where( + [n, a, mutated_activity: mutated_activity], + not fragment( + """ + CASE WHEN (?->>'type') = 'Like' or (?->>'type') = 'Announce' + THEN (activity_visibility(?, ?, ?) = ANY (?)) + ELSE (activity_visibility(?, ?, ?) = ANY (?)) END + """, + a.data, + a.data, + mutated_activity.actor, + mutated_activity.recipients, + mutated_activity.data, + ^visibility, + a.actor, + a.recipients, + a.data, + ^visibility + ) + ) + else + Logger.error("Could not exclude visibility to #{visibility}") + query + end + end + + defp exclude_visibility(query, %{exclude_visibilities: visibility}) + when visibility in @valid_visibilities do + exclude_visibility(query, [visibility]) + end + + defp exclude_visibility(query, %{exclude_visibilities: visibility}) + when visibility not in @valid_visibilities do + Logger.error("Could not exclude visibility to #{visibility}") + query + end + + defp exclude_visibility(query, _visibility), do: query + + def for_user(user, opts \\ %{}) do + user + |> for_user_query(opts) + |> Pagination.fetch_paginated(opts) + end + + @doc """ + Returns notifications for user received since given date. + + ## Examples + + iex> Pleroma.Notification.for_user_since(%Pleroma.User{}, ~N[2019-04-13 11:22:33]) + [%Pleroma.Notification{}, %Pleroma.Notification{}] + + iex> Pleroma.Notification.for_user_since(%Pleroma.User{}, ~N[2019-04-15 11:22:33]) + [] + """ + @spec for_user_since(Pleroma.User.t(), NaiveDateTime.t()) :: [t()] + def for_user_since(user, date) do + from(n in for_user_query(user), + where: n.updated_at > ^date + ) + |> Repo.all() + end + + def set_read_up_to(%{id: user_id} = user, id) do + query = + from( + n in Notification, + where: n.user_id == ^user_id, + where: n.id <= ^id, + where: n.seen == false, + # Ideally we would preload object and activities here + # but Ecto does not support preloads in update_all + select: n.id + ) + + {:ok, %{ids: {_, notification_ids}, marker: marker}} = + Multi.new() + |> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()]) + |> Marker.multi_set_last_read_id(user, "notifications") + |> Repo.transaction() + + Streamer.stream(["user", "user:notification"], marker) + + for_user_query(user) + |> where([n], n.id in ^notification_ids) + |> Repo.all() + end + + @spec read_one(User.t(), String.t()) :: + {:ok, Notification.t()} | {:error, Ecto.Changeset.t()} | nil + def read_one(%User{} = user, notification_id) do + with {:ok, %Notification{} = notification} <- get(user, notification_id) do + Multi.new() + |> Multi.update(:update, changeset(notification, %{seen: true})) + |> Marker.multi_set_last_read_id(user, "notifications") + |> Repo.transaction() + |> case do + {:ok, %{update: notification}} -> {:ok, notification} + {:error, :update, changeset, _} -> {:error, changeset} + end + end + end + + def get(%{id: user_id} = _user, id) do + query = + from( + n in Notification, + where: n.id == ^id, + join: activity in assoc(n, :activity), + preload: [activity: activity] + ) + + notification = Repo.one(query) + + case notification do + %{user_id: ^user_id} -> + {:ok, notification} + + _ -> + {:error, "Cannot get notification"} + end + end + + def clear(user) do + from(n in Notification, where: n.user_id == ^user.id) + |> Repo.delete_all() + end + + def destroy_multiple(%{id: user_id} = _user, ids) do + from(n in Notification, + where: n.id in ^ids, + where: n.user_id == ^user_id + ) + |> Repo.delete_all() + end + + def dismiss(%Pleroma.Activity{} = activity) do + Notification + |> where([n], n.activity_id == ^activity.id) + |> Repo.delete_all() + |> case do + {_, notifications} -> {:ok, notifications} + _ -> {:error, "Cannot dismiss notification"} + end + end + + def dismiss(%{id: user_id} = _user, id) do + notification = Repo.get(Notification, id) + + case notification do + %{user_id: ^user_id} -> + Repo.delete(notification) + + _ -> + {:error, "Cannot dismiss notification"} + end + end + + @spec create_notifications(Activity.t(), keyword()) :: {:ok, [Notification.t()] | []} + def create_notifications(activity, options \\ []) + + def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity, options) do + object = Object.normalize(activity, fetch: false) + + if object && object.data["type"] == "Answer" do + {:ok, []} + else + do_create_notifications(activity, options) + end + end + + def create_notifications(%Activity{data: %{"type" => type}} = activity, options) + when type in [ + "Follow", + "Like", + "Announce", + "Move", + "EmojiReact", + "Flag", + "Update", + "Accept", + "Join" + ] do + do_create_notifications(activity, options) + end + + def create_notifications(_, _), do: {:ok, []} + + defp do_create_notifications(%Activity{} = activity, options) do + do_send = Keyword.get(options, :do_send, true) + + {enabled_participants, disabled_participants} = + get_notified_participants_from_activity(activity) + + potential_participants = enabled_participants ++ disabled_participants + + {enabled_receivers, disabled_receivers} = get_notified_from_activity(activity) + + potential_receivers = (enabled_receivers ++ disabled_receivers) -- potential_participants + + {enabled_subscribers, disabled_subscribers} = get_notified_subscribers_from_activity(activity) + + potential_subscribers = + (enabled_subscribers ++ disabled_subscribers) -- + (potential_participants ++ potential_receivers) + + notifications = + (Enum.map(potential_receivers, fn user -> + do_send = do_send && user in enabled_receivers + create_notification(activity, user, do_send: do_send) + end) ++ + Enum.map(potential_subscribers, fn user -> + do_send = do_send && user in enabled_subscribers + create_notification(activity, user, do_send: do_send, type: "status") + end) ++ + Enum.map(potential_participants, fn user -> + do_send = do_send && user in enabled_participants + create_notification(activity, user, do_send: do_send, type: "pleroma:event_update") + end)) + |> Enum.reject(&is_nil/1) + + {:ok, notifications} + end + + defp type_from_activity(%{data: %{"type" => type}} = activity) do + case type do + "Follow" -> + if Activity.follow_accepted?(activity) do + "follow" + else + "follow_request" + end + + "Announce" -> + "reblog" + + "Like" -> + "favourite" + + "Move" -> + "move" + + "EmojiReact" -> + "pleroma:emoji_reaction" + + "Flag" -> + "pleroma:report" + + # Compatibility with old reactions + "EmojiReaction" -> + "pleroma:emoji_reaction" + + "Create" -> + activity + |> type_from_activity_object() + + "Update" -> + "update" + + "Accept" -> + "pleroma:participation_accepted" + + "Join" -> + "pleroma:participation_request" + + t -> + raise "No notification type for activity type #{t}" + end + end + + defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), do: "mention" + + defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do + object = Object.get_by_ap_id(activity.data["object"]) + + case object && object.data["type"] do + "ChatMessage" -> "pleroma:chat_mention" + _ -> "mention" + end + end + + # TODO move to sql, too. + def create_notification(%Activity{} = activity, %User{} = user, opts \\ []) do + do_send = Keyword.get(opts, :do_send, true) + type = Keyword.get(opts, :type, type_from_activity(activity)) + + unless skip?(activity, user, opts) do + {:ok, %{notification: notification}} = + Multi.new() + |> Multi.insert(:notification, %Notification{ + user_id: user.id, + activity: activity, + seen: mark_as_read?(activity, user), + type: type + }) + |> Marker.multi_set_last_read_id(user, "notifications") + |> Repo.transaction() + + if do_send do + Streamer.stream(["user", "user:notification"], notification) + Push.send(notification) + end + + notification + end + end + + def create_poll_notifications(%Activity{} = activity) do + with %Object{data: %{"type" => "Question", "actor" => actor} = data} <- + Object.normalize(activity) do + voters = + case data do + %{"voters" => voters} when is_list(voters) -> voters + _ -> [] + end + + notifications = + Enum.reduce([actor | voters], [], fn ap_id, acc -> + with %User{local: true} = user <- User.get_by_ap_id(ap_id) do + [create_notification(activity, user, type: "poll") | acc] + else + _ -> acc + end + end) + + {:ok, notifications} + end + end + + def create_event_notifications(%Activity{} = activity) do + with %Object{data: %{"type" => "Event", "actor" => actor} = data} <- + Object.normalize(activity) do + participations = + case data do + %{"participations" => participations} when is_list(participations) -> participations + _ -> [] + end + + notifications = + Enum.reduce([actor | participations], [], fn ap_id, acc -> + with %User{local: true} = user <- User.get_by_ap_id(ap_id) do + [create_notification(activity, user, type: "pleroma:event_reminder") | acc] + else + _ -> acc + end + end) + + {:ok, notifications} + end + end + + @doc """ + Returns a tuple with 2 elements: + {notification-enabled receivers, currently disabled receivers (blocking / [thread] muting)} + + NOTE: might be called for FAKE Activities, see ActivityPub.Utils.get_notified_from_object/1 + """ + @spec get_notified_from_activity(Activity.t(), boolean()) :: {list(User.t()), list(User.t())} + def get_notified_from_activity(activity, local_only \\ true) + + def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only) + when type in [ + "Create", + "Like", + "Announce", + "Follow", + "Move", + "EmojiReact", + "Flag", + "Update", + "Accept", + "Join" + ] do + potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity) + + potential_receivers = + User.get_users_from_set(potential_receiver_ap_ids, local_only: local_only) + + notification_enabled_ap_ids = + potential_receiver_ap_ids + |> exclude_domain_blocker_ap_ids(activity, potential_receivers) + |> exclude_relationship_restricted_ap_ids(activity) + |> exclude_thread_muter_ap_ids(activity) + + notification_enabled_users = + Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end) + + {notification_enabled_users, potential_receivers -- notification_enabled_users} + end + + def get_notified_from_activity(_, _local_only), do: {[], []} + + def get_notified_subscribers_from_activity(activity, local_only \\ true) + + def get_notified_subscribers_from_activity( + %Activity{data: %{"type" => "Create"}} = activity, + local_only + ) do + notification_enabled_ap_ids = + [] + |> Utils.maybe_notify_subscribers(activity) + + potential_receivers = + User.get_users_from_set(notification_enabled_ap_ids, local_only: local_only) + + notification_enabled_users = + Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end) + + {notification_enabled_users, potential_receivers -- notification_enabled_users} + end + + def get_notified_subscribers_from_activity(_, _), do: {[], []} + + def get_notified_participants_from_activity(activity, local_only \\ true) + + def get_notified_participants_from_activity( + %Activity{data: %{"type" => "Update"}} = activity, + local_only + ) do + notification_enabled_ap_ids = + [] + |> Utils.maybe_notify_participants(activity) + + potential_receivers = + User.get_users_from_set(notification_enabled_ap_ids, local_only: local_only) + + notification_enabled_users = + Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end) + + {notification_enabled_users, potential_receivers -- notification_enabled_users} + end + + def get_notified_participants_from_activity(_, _), do: {[], []} + + # For some activities, only notify the author of the object + def get_potential_receiver_ap_ids(%{data: %{"type" => type, "object" => object_id}}) + when type in ~w{Like Announce EmojiReact} do + case Object.get_cached_by_ap_id(object_id) do + %Object{data: %{"actor" => actor}} -> + [actor] + + _ -> + [] + end + end + + def get_potential_receiver_ap_ids(%{data: %{"type" => "Accept", "object" => join_id}}) do + case Activity.get_by_ap_id_with_object(join_id) do + %Activity{ + data: %{"type" => "Join"}, + object: %Object{data: %{"type" => "Event", "joinMode" => "free"}} + } -> + [] + + %Activity{data: %{"type" => "Join", "actor" => actor_id}} -> + [actor_id] + + _ -> + [] + end + end + + def get_potential_receiver_ap_ids(%{data: %{"type" => "Join", "object" => object_id}}) do + case Object.get_by_ap_id(object_id) do + %Object{data: %{"type" => "Event", "joinMode" => "free"}} -> + [] + + %Object{data: %{"type" => "Event", "actor" => actor_id}} -> + [actor_id] + + _ -> + [] + end + end + + def get_potential_receiver_ap_ids(%{data: %{"type" => "Follow", "object" => object_id}}) do + [object_id] + end + + def get_potential_receiver_ap_ids(%{data: %{"type" => "Flag", "actor" => actor}}) do + (User.all_users_with_privilege(:reports_manage_reports) + |> Enum.map(fn user -> user.ap_id end)) -- + [actor] + end + + # Update activity: notify all who repeated this + def get_potential_receiver_ap_ids(%{data: %{"type" => "Update", "actor" => actor}} = activity) do + with %Object{data: %{"id" => object_id}} <- Object.normalize(activity, fetch: false) do + repeaters = + Activity.Queries.by_type("Announce") + |> Activity.Queries.by_object_id(object_id) + |> Activity.with_joined_user_actor() + |> where([a, u], u.local) + |> select([a, u], u.ap_id) + |> Repo.all() + + repeaters -- [actor] + end + end + + def get_potential_receiver_ap_ids(activity) do + [] + |> Utils.maybe_notify_to_recipients(activity) + |> Utils.maybe_notify_mentioned_recipients(activity) + |> Utils.maybe_notify_followers(activity) + |> Enum.uniq() + end + + @doc "Filters out AP IDs domain-blocking and not following the activity's actor" + def exclude_domain_blocker_ap_ids(ap_ids, activity, preloaded_users \\ []) + + def exclude_domain_blocker_ap_ids([], _activity, _preloaded_users), do: [] + + def exclude_domain_blocker_ap_ids(ap_ids, %Activity{} = activity, preloaded_users) do + activity_actor_domain = activity.actor && URI.parse(activity.actor).host + + users = + ap_ids + |> Enum.map(fn ap_id -> + Enum.find(preloaded_users, &(&1.ap_id == ap_id)) || + User.get_cached_by_ap_id(ap_id) + end) + |> Enum.filter(& &1) + + domain_blocker_ap_ids = for u <- users, activity_actor_domain in u.domain_blocks, do: u.ap_id + + domain_blocker_follower_ap_ids = + if Enum.any?(domain_blocker_ap_ids) do + activity + |> Activity.user_actor() + |> FollowingRelationship.followers_ap_ids(domain_blocker_ap_ids) + else + [] + end + + ap_ids + |> Kernel.--(domain_blocker_ap_ids) + |> Kernel.++(domain_blocker_follower_ap_ids) + end + + @doc "Filters out AP IDs of users basing on their relationships with activity actor user" + def exclude_relationship_restricted_ap_ids([], _activity), do: [] + + def exclude_relationship_restricted_ap_ids(ap_ids, %Activity{} = activity) do + relationship_restricted_ap_ids = + activity + |> Activity.user_actor() + |> User.incoming_relationships_ungrouped_ap_ids([ + :block, + :notification_mute + ]) + + Enum.uniq(ap_ids) -- relationship_restricted_ap_ids + end + + @doc "Filters out AP IDs of users who mute activity thread" + def exclude_thread_muter_ap_ids([], _activity), do: [] + + def exclude_thread_muter_ap_ids(ap_ids, %Activity{} = activity) do + thread_muter_ap_ids = ThreadMute.muter_ap_ids(activity.data["context"]) + + Enum.uniq(ap_ids) -- thread_muter_ap_ids + end + + def skip?(activity, user, opts \\ []) + + @spec skip?(Activity.t(), User.t(), Keyword.t()) :: boolean() + def skip?(%Activity{} = activity, %User{} = user, opts) do + [ + :self, + :invisible, + :block_from_strangers, + :recently_followed, + :filtered + ] + |> Enum.find(&skip?(&1, activity, user, opts)) + end + + def skip?(_activity, _user, _opts), do: false + + @spec skip?(atom(), Activity.t(), User.t(), Keyword.t()) :: boolean() + def skip?(:self, %Activity{} = activity, %User{} = user, opts) do + cond do + opts[:type] == "poll" -> false + activity.data["actor"] == user.ap_id -> true + true -> false + end + end + + def skip?(:invisible, %Activity{} = activity, _user, _opts) do + actor = activity.data["actor"] + user = User.get_cached_by_ap_id(actor) + User.invisible?(user) + end + + def skip?( + :block_from_strangers, + %Activity{} = activity, + %User{notification_settings: %{block_from_strangers: true}} = user, + opts + ) do + actor = activity.data["actor"] + follower = User.get_cached_by_ap_id(actor) + + cond do + opts[:type] == "poll" -> false + user.ap_id == actor -> false + !User.following?(user, follower) -> true + true -> false + end + end + + # To do: consider defining recency in hours and checking FollowingRelationship with a single SQL + def skip?( + :recently_followed, + %Activity{data: %{"type" => "Follow"}} = activity, + %User{} = user, + _opts + ) do + actor = activity.data["actor"] + + Notification.for_user(user) + |> Enum.any?(fn + %{activity: %{data: %{"type" => "Follow", "actor" => ^actor}}} -> true + _ -> false + end) + end + + def skip?(:filtered, %{data: %{"type" => type}}, _user, _opts) when type in ["Follow", "Move"], + do: false + + def skip?(:filtered, activity, user, _opts) do + object = Object.normalize(activity, fetch: false) + + cond do + is_nil(object) -> + false + + object.data["actor"] == user.ap_id -> + false + + not is_nil(regex = Pleroma.Filter.compose_regex(user, :re)) -> + Regex.match?(regex, object.data["content"]) + + true -> + false + end + end + + def skip?(_type, _activity, _user, _opts), do: false + + def mark_as_read?(activity, target_user) do + user = Activity.user_actor(activity) + User.mutes_user?(target_user, user) || CommonAPI.thread_muted?(target_user, activity) + end + + def for_user_and_activity(user, activity) do + from(n in __MODULE__, + where: n.user_id == ^user.id, + where: n.activity_id == ^activity.id + ) + |> Repo.one() + end + + @spec mark_context_as_read(User.t(), String.t()) :: {integer(), nil | [term()]} + def mark_context_as_read(%User{id: id}, context) do + from( + n in Notification, + join: a in assoc(n, :activity), + where: n.user_id == ^id, + where: n.seen == false, + where: fragment("?->>'context'", a.data) == ^context + ) + |> Repo.update_all(set: [seen: true]) + end +end diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex new file mode 100644 index 0000000..3fdd4c6 --- /dev/null +++ b/lib/pleroma/object.ex @@ -0,0 +1,500 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Object do + use Ecto.Schema + + import Ecto.Query + import Ecto.Changeset + + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.Hashtag + alias Pleroma.Object + alias Pleroma.Object.Fetcher + alias Pleroma.ObjectTombstone + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Workers.AttachmentsCleanupWorker + + require Logger + + @type t() :: %__MODULE__{} + + @derive {Jason.Encoder, only: [:data]} + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + + schema "objects" do + field(:data, :map) + + many_to_many(:hashtags, Hashtag, join_through: "hashtags_objects", on_replace: :delete) + + timestamps() + end + + def with_joined_activity(query, activity_type \\ "Create", join_type \\ :inner) do + object_position = Map.get(query.aliases, :object, 0) + + join(query, join_type, [{object, object_position}], a in Activity, + on: + fragment( + "associated_object_id(?) = (? ->> 'id') AND (?->>'type' = ?) ", + a.data, + object.data, + a.data, + ^activity_type + ), + as: :object_activity + ) + end + + def create(data) do + %Object{} + |> Object.change(%{data: data}) + |> Repo.insert() + end + + def change(struct, params \\ %{}) do + struct + |> cast(params, [:data]) + |> validate_required([:data]) + |> unique_constraint(:ap_id, name: :objects_unique_apid_index) + # Expecting `maybe_handle_hashtags_change/1` to run last: + |> maybe_handle_hashtags_change(struct) + end + + # Note: not checking activity type (assuming non-legacy objects are associated with Create act.) + defp maybe_handle_hashtags_change(changeset, struct) do + with %Ecto.Changeset{valid?: true} <- changeset, + data_hashtags_change = get_change(changeset, :data), + {_, true} <- {:changed, hashtags_changed?(struct, data_hashtags_change)}, + {:ok, hashtag_records} <- + data_hashtags_change + |> object_data_hashtags() + |> Hashtag.get_or_create_by_names() do + put_assoc(changeset, :hashtags, hashtag_records) + else + %{valid?: false} -> + changeset + + {:changed, false} -> + changeset + + {:error, _} -> + validate_change(changeset, :data, fn _, _ -> + [data: "error referencing hashtags"] + end) + end + end + + defp hashtags_changed?(%Object{} = struct, %{"tag" => _} = data) do + Enum.sort(embedded_hashtags(struct)) != + Enum.sort(object_data_hashtags(data)) + end + + defp hashtags_changed?(_, _), do: false + + def get_by_id(nil), do: nil + def get_by_id(id), do: Repo.get(Object, id) + + def get_by_id_and_maybe_refetch(id, opts \\ []) do + %{updated_at: updated_at} = object = get_by_id(id) + + if opts[:interval] && + NaiveDateTime.diff(NaiveDateTime.utc_now(), updated_at) > opts[:interval] do + case Fetcher.refetch_object(object) do + {:ok, %Object{} = object} -> + object + + e -> + Logger.error("Couldn't refresh #{object.data["id"]}:\n#{inspect(e)}") + object + end + else + object + end + end + + def get_by_ap_id(nil), do: nil + + def get_by_ap_id(ap_id) do + Repo.one(from(object in Object, where: fragment("(?)->>'id' = ?", object.data, ^ap_id))) + end + + @doc """ + Get a single attachment by it's name and href + """ + @spec get_attachment_by_name_and_href(String.t(), String.t()) :: Object.t() | nil + def get_attachment_by_name_and_href(name, href) do + query = + from(o in Object, + where: fragment("(?)->>'name' = ?", o.data, ^name), + where: fragment("(?)->>'href' = ?", o.data, ^href) + ) + + Repo.one(query) + end + + defp warn_on_no_object_preloaded(ap_id) do + "Object.normalize() called without preloaded object (#{inspect(ap_id)}). Consider preloading the object" + |> Logger.debug() + + Logger.debug("Backtrace: #{inspect(Process.info(:erlang.self(), :current_stacktrace))}") + end + + def normalize(_, options \\ [fetch: false, id_only: false]) + + # If we pass an Activity to Object.normalize(), we can try to use the preloaded object. + # Use this whenever possible, especially when walking graphs in an O(N) loop! + def normalize(%Object{} = object, _), do: object + def normalize(%Activity{object: %Object{} = object}, _), do: object + + # A hack for fake activities + def normalize(%Activity{data: %{"object" => %{"fake" => true} = data}}, _) do + %Object{id: "pleroma:fake_object_id", data: data} + end + + # No preloaded object + def normalize(%Activity{data: %{"object" => %{"id" => ap_id}}}, options) do + warn_on_no_object_preloaded(ap_id) + normalize(ap_id, options) + end + + # No preloaded object + def normalize(%Activity{data: %{"object" => ap_id}}, options) do + warn_on_no_object_preloaded(ap_id) + normalize(ap_id, options) + end + + # Old way, try fetching the object through cache. + def normalize(%{"id" => ap_id}, options), do: normalize(ap_id, options) + + def normalize(ap_id, options) when is_binary(ap_id) do + cond do + Keyword.get(options, :id_only) -> + ap_id + + Keyword.get(options, :fetch) -> + Fetcher.fetch_object_from_id!(ap_id, options) + + true -> + get_cached_by_ap_id(ap_id) + end + end + + def normalize(_, _), do: nil + + # Owned objects can only be accessed by their owner + def authorize_access(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}) do + if actor == ap_id do + :ok + else + {:error, :forbidden} + end + end + + # Legacy objects can be accessed by anybody + def authorize_access(%Object{}, %User{}), do: :ok + + @spec get_cached_by_ap_id(String.t()) :: Object.t() | nil + def get_cached_by_ap_id(ap_id) do + key = "object:#{ap_id}" + + with {:ok, nil} <- @cachex.get(:object_cache, key), + object when not is_nil(object) <- get_by_ap_id(ap_id), + {:ok, true} <- @cachex.put(:object_cache, key, object) do + object + else + {:ok, object} -> object + nil -> nil + end + end + + def make_tombstone(%Object{data: %{"id" => id, "type" => type}}, deleted \\ DateTime.utc_now()) do + %ObjectTombstone{ + id: id, + formerType: type, + deleted: deleted + } + |> Map.from_struct() + end + + def swap_object_with_tombstone(object) do + tombstone = make_tombstone(object) + + with {:ok, object} <- + object + |> Object.change(%{data: tombstone}) + |> Repo.update() do + Hashtag.unlink(object) + {:ok, object} + end + end + + def delete(%Object{data: %{"id" => id}} = object) do + with {:ok, _obj} = swap_object_with_tombstone(object), + deleted_activity = Activity.delete_all_by_object_ap_id(id), + {:ok, _} <- invalid_object_cache(object) do + cleanup_attachments( + Config.get([:instance, :cleanup_attachments]), + %{"object" => object} + ) + + {:ok, object, deleted_activity} + end + end + + @spec cleanup_attachments(boolean(), %{required(:object) => map()}) :: + {:ok, Oban.Job.t() | nil} + def cleanup_attachments(true, %{"object" => _} = params) do + AttachmentsCleanupWorker.enqueue("cleanup_attachments", params) + end + + def cleanup_attachments(_, _), do: {:ok, nil} + + def prune(%Object{data: %{"id" => _id}} = object) do + with {:ok, object} <- Repo.delete(object), + {:ok, _} <- invalid_object_cache(object) do + {:ok, object} + end + end + + def invalid_object_cache(%Object{data: %{"id" => id}}) do + with {:ok, true} <- @cachex.del(:object_cache, "object:#{id}") do + @cachex.del(:web_resp_cache, URI.parse(id).path) + end + end + + def set_cache(%Object{data: %{"id" => ap_id}} = object) do + @cachex.put(:object_cache, "object:#{ap_id}", object) + {:ok, object} + end + + def update_and_set_cache(changeset) do + with {:ok, object} <- Repo.update(changeset) do + set_cache(object) + end + end + + def increase_replies_count(ap_id) do + Object + |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id))) + |> update([o], + set: [ + data: + fragment( + """ + safe_jsonb_set(?, '{repliesCount}', + (coalesce((?->>'repliesCount')::int, 0) + 1)::varchar::jsonb, true) + """, + o.data, + o.data + ) + ] + ) + |> Repo.update_all([]) + |> case do + {1, [object]} -> set_cache(object) + _ -> {:error, "Not found"} + end + end + + def decrease_replies_count(ap_id) do + Object + |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id))) + |> update([o], + set: [ + data: + fragment( + """ + safe_jsonb_set(?, '{repliesCount}', + (greatest(0, (?->>'repliesCount')::int - 1))::varchar::jsonb, true) + """, + o.data, + o.data + ) + ] + ) + |> Repo.update_all([]) + |> case do + {1, [object]} -> set_cache(object) + _ -> {:error, "Not found"} + end + end + + def increase_quotes_count(ap_id) do + Object + |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id))) + |> update([o], + set: [ + data: + fragment( + """ + safe_jsonb_set(?, '{quotesCount}', + (coalesce((?->>'quotesCount')::int, 0) + 1)::varchar::jsonb, true) + """, + o.data, + o.data + ) + ] + ) + |> Repo.update_all([]) + |> case do + {1, [object]} -> set_cache(object) + _ -> {:error, "Not found"} + end + end + + def decrease_quotes_count(ap_id) do + Object + |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id))) + |> update([o], + set: [ + data: + fragment( + """ + safe_jsonb_set(?, '{quotesCount}', + (greatest(0, (?->>'quotesCount')::int - 1))::varchar::jsonb, true) + """, + o.data, + o.data + ) + ] + ) + |> Repo.update_all([]) + |> case do + {1, [object]} -> set_cache(object) + _ -> {:error, "Not found"} + end + end + + defp poll_is_multiple?(%Object{data: %{"anyOf" => [_ | _]}}), do: true + + defp poll_is_multiple?(_), do: false + + def increase_vote_count(ap_id, name, actor) do + with %Object{} = object <- Object.normalize(ap_id, fetch: false), + "Question" <- object.data["type"] do + key = if poll_is_multiple?(object), do: "anyOf", else: "oneOf" + + options = + object.data[key] + |> Enum.map(fn + %{"name" => ^name} = option -> + Kernel.update_in(option["replies"]["totalItems"], &(&1 + 1)) + + option -> + option + end) + + voters = [actor | object.data["voters"] || []] |> Enum.uniq() + + data = + object.data + |> Map.put(key, options) + |> Map.put("voters", voters) + + object + |> Object.change(%{data: data}) + |> update_and_set_cache() + else + _ -> :noop + end + end + + @doc "Updates data field of an object" + def update_data(%Object{data: data} = object, attrs \\ %{}) do + object + |> Object.change(%{data: Map.merge(data || %{}, attrs)}) + |> Repo.update() + end + + def local?(%Object{data: %{"id" => id}}) do + String.starts_with?(id, Pleroma.Web.Endpoint.url() <> "/") + end + + def replies(object, opts \\ []) do + object = Object.normalize(object, fetch: false) + + query = + Object + |> where( + [o], + fragment("(?)->>'inReplyTo' = ?", o.data, ^object.data["id"]) + ) + |> order_by([o], asc: o.id) + + if opts[:self_only] do + actor = object.data["actor"] + where(query, [o], fragment("(?)->>'actor' = ?", o.data, ^actor)) + else + query + end + end + + def self_replies(object, opts \\ []), + do: replies(object, Keyword.put(opts, :self_only, true)) + + def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags + + def tags(_), do: [] + + def hashtags(%Object{} = object) do + # Note: always using embedded hashtags regardless whether they are migrated to hashtags table + # (embedded hashtags stay in sync anyways, and we avoid extra joins and preload hassle) + embedded_hashtags(object) + end + + def embedded_hashtags(%Object{data: data}) do + object_data_hashtags(data) + end + + def embedded_hashtags(_), do: [] + + def object_data_hashtags(%{"tag" => tags}) when is_list(tags) do + tags + |> Enum.filter(fn + %{"type" => "Hashtag"} = data -> Map.has_key?(data, "name") + plain_text when is_bitstring(plain_text) -> true + _ -> false + end) + |> Enum.map(fn + %{"name" => "#" <> hashtag} -> String.downcase(hashtag) + %{"name" => hashtag} -> String.downcase(hashtag) + hashtag when is_bitstring(hashtag) -> String.downcase(hashtag) + end) + |> Enum.uniq() + # Note: "" elements (plain text) might occur in `data.tag` for incoming objects + |> Enum.filter(&(&1 not in [nil, ""])) + end + + def object_data_hashtags(_), do: [] + + def get_emoji_reactions(object) do + reactions = object.data["reactions"] + + if is_list(reactions) or is_map(reactions) do + reactions + |> Enum.map(fn + [_emoji, users, _maybe_url] = item when is_list(users) -> + item + + [emoji, users] when is_list(users) -> + [emoji, users, nil] + + # This case is here to process the Map situation, which will happen + # only with the legacy two-value format. + {emoji, users} when is_list(users) -> + [emoji, users, nil] + + _ -> + nil + end) + |> Enum.reject(&is_nil/1) + else + [] + end + end +end diff --git a/lib/pleroma/object/containment.ex b/lib/pleroma/object/containment.ex new file mode 100644 index 0000000..f6106cb --- /dev/null +++ b/lib/pleroma/object/containment.ex @@ -0,0 +1,88 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Object.Containment do + @moduledoc """ + This module contains some useful functions for containing objects to specific + origins and determining those origins. They previously lived in the + ActivityPub `Transmogrifier` module. + + Object containment is an important step in validating remote objects to prevent + spoofing, therefore removal of object containment functions is NOT recommended. + """ + def get_actor(%{"actor" => actor}) when is_binary(actor) do + actor + end + + def get_actor(%{"actor" => actor}) when is_list(actor) do + if is_binary(Enum.at(actor, 0)) do + Enum.at(actor, 0) + else + Enum.find(actor, fn %{"type" => type} -> type in ["Person", "Service", "Application"] end) + |> Map.get("id") + end + end + + def get_actor(%{"actor" => %{"id" => id}}) when is_bitstring(id) do + id + end + + def get_actor(%{"actor" => nil, "attributedTo" => actor}) when not is_nil(actor) do + get_actor(%{"actor" => actor}) + end + + def get_object(%{"object" => id}) when is_binary(id) do + id + end + + def get_object(%{"object" => %{"id" => id}}) when is_binary(id) do + id + end + + def get_object(_) do + nil + end + + defp compare_uris(%URI{host: host} = _id_uri, %URI{host: host} = _other_uri), do: :ok + defp compare_uris(_id_uri, _other_uri), do: :error + + @doc """ + Checks that an imported AP object's actor matches the host it came from. + """ + def contain_origin(_id, %{"actor" => nil}), do: :error + + def contain_origin(id, %{"actor" => _actor} = params) do + id_uri = URI.parse(id) + actor_uri = URI.parse(get_actor(params)) + + compare_uris(actor_uri, id_uri) + end + + def contain_origin(id, %{"attributedTo" => actor} = params), + do: contain_origin(id, Map.put(params, "actor", actor)) + + def contain_origin(_id, _data), do: :error + + def contain_origin_from_id(id, %{"id" => other_id} = _params) when is_binary(other_id) do + id_uri = URI.parse(id) + other_uri = URI.parse(other_id) + + compare_uris(id_uri, other_uri) + end + + # Mastodon pin activities don't have an id, so we check the object field, which will be pinned. + def contain_origin_from_id(id, %{"object" => object}) when is_binary(object) do + id_uri = URI.parse(id) + object_uri = URI.parse(object) + + compare_uris(id_uri, object_uri) + end + + def contain_origin_from_id(_id, _data), do: :error + + def contain_child(%{"object" => %{"id" => id, "attributedTo" => _} = object}), + do: contain_origin(id, object) + + def contain_child(_), do: :ok +end diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex new file mode 100644 index 0000000..cc37725 --- /dev/null +++ b/lib/pleroma/object/fetcher.ex @@ -0,0 +1,243 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Object.Fetcher do + alias Pleroma.HTTP + alias Pleroma.Instances + alias Pleroma.Maps + alias Pleroma.Object + alias Pleroma.Object.Containment + alias Pleroma.Signature + alias Pleroma.Web.ActivityPub.InternalFetchActor + alias Pleroma.Web.ActivityPub.MRF + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.Federator + + require Logger + require Pleroma.Constants + + @spec reinject_object(struct(), map()) :: {:ok, Object.t()} | {:error, any()} + defp reinject_object(%Object{data: %{}} = object, new_data) do + Logger.debug("Reinjecting object #{new_data["id"]}") + + with {:ok, new_data, _} <- ObjectValidator.validate(new_data, %{}), + {:ok, new_data} <- MRF.filter(new_data), + {:ok, new_object, _} <- + Object.Updater.do_update_and_invalidate_cache( + object, + new_data, + _touch_changeset? = true + ) do + {:ok, new_object} + else + e -> + Logger.error("Error while processing object: #{inspect(e)}") + {:error, e} + end + end + + defp reinject_object(_, new_data) do + with {:ok, object, _} <- Pipeline.common_pipeline(new_data, local: false) do + {:ok, object} + else + e -> e + end + end + + def refetch_object(%Object{data: %{"id" => id}} = object) do + with {:local, false} <- {:local, Object.local?(object)}, + {:ok, new_data} <- fetch_and_contain_remote_object_from_id(id), + {:ok, object} <- reinject_object(object, new_data) do + {:ok, object} + else + {:local, true} -> {:ok, object} + e -> {:error, e} + end + end + + # Note: will create a Create activity, which we need internally at the moment. + def fetch_object_from_id(id, options \\ []) do + with {_, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)}, + {_, true} <- {:allowed_depth, Federator.allowed_thread_distance?(options[:depth])}, + {_, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)}, + {_, nil} <- {:normalize, Object.normalize(data, fetch: false)}, + params <- prepare_activity_params(data), + {_, :ok} <- {:containment, Containment.contain_origin(id, params)}, + {_, {:ok, activity}} <- + {:transmogrifier, Transmogrifier.handle_incoming(params, options)}, + {_, _data, %Object{} = object} <- + {:object, data, Object.normalize(activity, fetch: false)} do + {:ok, object} + else + {:allowed_depth, false} -> + {:error, "Max thread distance exceeded."} + + {:containment, _} -> + {:error, "Object containment failed."} + + {:transmogrifier, {:error, {:reject, e}}} -> + {:reject, e} + + {:transmogrifier, {:reject, e}} -> + {:reject, e} + + {:transmogrifier, _} = e -> + {:error, e} + + {:object, data, nil} -> + reinject_object(%Object{}, data) + + {:normalize, object = %Object{}} -> + {:ok, object} + + {:fetch_object, %Object{} = object} -> + {:ok, object} + + {:fetch, {:error, error}} -> + {:error, error} + + e -> + e + end + end + + defp prepare_activity_params(data) do + %{ + "type" => "Create", + # Should we seriously keep this attributedTo thing? + "actor" => data["actor"] || data["attributedTo"], + "object" => data + } + |> Maps.put_if_present("to", data["to"]) + |> Maps.put_if_present("cc", data["cc"]) + |> Maps.put_if_present("bto", data["bto"]) + |> Maps.put_if_present("bcc", data["bcc"]) + end + + def fetch_object_from_id!(id, options \\ []) do + with {:ok, object} <- fetch_object_from_id(id, options) do + object + else + {:error, %Tesla.Mock.Error{}} -> + nil + + {:error, "Object has been deleted"} -> + nil + + {:reject, reason} -> + Logger.info("Rejected #{id} while fetching: #{inspect(reason)}") + nil + + e -> + Logger.error("Error while fetching #{id}: #{inspect(e)}") + nil + end + end + + defp make_signature(id, date) do + uri = URI.parse(id) + + signature = + InternalFetchActor.get_actor() + |> Signature.sign(%{ + "(request-target)": "get #{uri.path}", + host: uri.host, + date: date + }) + + {"signature", signature} + end + + defp sign_fetch(headers, id, date) do + if Pleroma.Config.get([:activitypub, :sign_object_fetches]) do + [make_signature(id, date) | headers] + else + headers + end + end + + defp maybe_date_fetch(headers, date) do + if Pleroma.Config.get([:activitypub, :sign_object_fetches]) do + [{"date", date} | headers] + else + headers + end + end + + def fetch_and_contain_remote_object_from_id(id) + + def fetch_and_contain_remote_object_from_id(%{"id" => id}), + do: fetch_and_contain_remote_object_from_id(id) + + def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do + Logger.debug("Fetching object #{id} via AP") + + with {:scheme, true} <- {:scheme, String.starts_with?(id, "http")}, + {:ok, body} <- get_object(id), + {:ok, data} <- safe_json_decode(body), + :ok <- Containment.contain_origin_from_id(id, data) do + if not Instances.reachable?(id) do + Instances.set_reachable(id) + end + + {:ok, data} + else + {:scheme, _} -> + {:error, "Unsupported URI scheme"} + + {:error, e} -> + {:error, e} + + e -> + {:error, e} + end + end + + def fetch_and_contain_remote_object_from_id(_id), + do: {:error, "id must be a string"} + + defp get_object(id) do + date = Pleroma.Signature.signed_date() + + headers = + [{"accept", "application/activity+json"}] + |> maybe_date_fetch(date) + |> sign_fetch(id, date) + + case HTTP.get(id, headers) do + {:ok, %{body: body, status: code, headers: headers}} when code in 200..299 -> + case List.keyfind(headers, "content-type", 0) do + {_, content_type} -> + case Plug.Conn.Utils.media_type(content_type) do + {:ok, "application", "activity+json", _} -> + {:ok, body} + + {:ok, "application", "ld+json", + %{"profile" => "https://www.w3.org/ns/activitystreams"}} -> + {:ok, body} + + _ -> + {:error, {:content_type, content_type}} + end + + _ -> + {:error, {:content_type, nil}} + end + + {:ok, %{status: code}} when code in [404, 410] -> + {:error, "Object has been deleted"} + + {:error, e} -> + {:error, e} + + e -> + {:error, e} + end + end + + defp safe_json_decode(nil), do: {:ok, nil} + defp safe_json_decode(json), do: Jason.decode(json) +end diff --git a/lib/pleroma/object/updater.ex b/lib/pleroma/object/updater.ex new file mode 100644 index 0000000..39fe789 --- /dev/null +++ b/lib/pleroma/object/updater.ex @@ -0,0 +1,293 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Object.Updater do + require Pleroma.Constants + + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.Workers.EventReminderWorker + + def update_content_fields(orig_object_data, updated_object) do + Pleroma.Constants.status_updatable_fields() + |> Enum.reduce( + %{data: orig_object_data, updated: false}, + fn field, %{data: data, updated: updated} -> + updated = + updated or + (field != "updated" and + Map.get(updated_object, field) != Map.get(orig_object_data, field)) + + data = + if Map.has_key?(updated_object, field) do + Map.put(data, field, updated_object[field]) + else + Map.drop(data, [field]) + end + + %{data: data, updated: updated} + end + ) + end + + def maybe_history(object) do + with history <- Map.get(object, "formerRepresentations"), + true <- is_map(history), + "OrderedCollection" <- Map.get(history, "type"), + true <- is_list(Map.get(history, "orderedItems")), + true <- is_integer(Map.get(history, "totalItems")) do + history + else + _ -> nil + end + end + + def history_for(object) do + with history when not is_nil(history) <- maybe_history(object) do + history + else + _ -> history_skeleton() + end + end + + defp history_skeleton do + %{ + "type" => "OrderedCollection", + "totalItems" => 0, + "orderedItems" => [] + } + end + + def maybe_update_history( + updated_object, + orig_object_data, + opts + ) do + updated = opts[:updated] + use_history_in_new_object? = opts[:use_history_in_new_object?] + + if not updated do + %{updated_object: updated_object, used_history_in_new_object?: false} + else + # Put edit history + # Note that we may have got the edit history by first fetching the object + {new_history, used_history_in_new_object?} = + with true <- use_history_in_new_object?, + updated_history when not is_nil(updated_history) <- maybe_history(opts[:new_data]) do + {updated_history, true} + else + _ -> + history = history_for(orig_object_data) + + latest_history_item = + orig_object_data + |> Map.drop(["id", "formerRepresentations"]) + + updated_history = + history + |> Map.put("orderedItems", [latest_history_item | history["orderedItems"]]) + |> Map.put("totalItems", history["totalItems"] + 1) + + {updated_history, false} + end + + updated_object = + updated_object + |> Map.put("formerRepresentations", new_history) + + %{updated_object: updated_object, used_history_in_new_object?: used_history_in_new_object?} + end + end + + defp maybe_update_poll(to_be_updated, updated_object) do + choice_key = fn + %{"anyOf" => [_ | _]} -> "anyOf" + %{"oneOf" => [_ | _]} -> "oneOf" + _ -> nil + end + + with true <- to_be_updated["type"] == "Question", + key when not is_nil(key) <- choice_key.(updated_object), + true <- key == choice_key.(to_be_updated), + orig_choices <- to_be_updated[key] |> Enum.map(&Map.drop(&1, ["replies"])), + new_choices <- updated_object[key] |> Enum.map(&Map.drop(&1, ["replies"])), + true <- orig_choices == new_choices do + # Choices are the same, but counts are different + to_be_updated + |> Map.put(key, updated_object[key]) + else + # Choices (or vote type) have changed, do not allow this + _ -> to_be_updated + end + end + + # This calculates the data to be sent as the object of an Update. + # new_data's formerRepresentations is not considered. + # formerRepresentations is added to the returned data. + def make_update_object_data(original_data, new_data, date) do + %{data: updated_data, updated: updated} = + original_data + |> update_content_fields(new_data) + + if not updated do + updated_data + else + %{updated_object: updated_data} = + updated_data + |> maybe_update_history(original_data, updated: updated, use_history_in_new_object?: false) + + updated_data + |> Map.put("updated", date) + end + end + + # This calculates the data of the new Object from an Update. + # new_data's formerRepresentations is considered. + def make_new_object_data_from_update_object(original_data, new_data) do + update_is_reasonable = + with {_, updated} when not is_nil(updated) <- {:cur_updated, new_data["updated"]}, + {_, {:ok, updated_time, _}} <- {:cur_updated, DateTime.from_iso8601(updated)}, + {_, last_updated} when not is_nil(last_updated) <- + {:last_updated, original_data["updated"] || original_data["published"]}, + {_, {:ok, last_updated_time, _}} <- + {:last_updated, DateTime.from_iso8601(last_updated)}, + :gt <- DateTime.compare(updated_time, last_updated_time) do + :update_everything + else + # only allow poll updates + {:cur_updated, _} -> :no_content_update + :eq -> :no_content_update + # allow all updates + {:last_updated, _} -> :update_everything + # allow no updates + _ -> false + end + + %{ + updated_object: updated_data, + used_history_in_new_object?: used_history_in_new_object?, + updated: updated + } = + if update_is_reasonable == :update_everything do + %{data: updated_data, updated: updated} = + original_data + |> update_content_fields(new_data) + + updated_data + |> maybe_update_history(original_data, + updated: updated, + use_history_in_new_object?: true, + new_data: new_data + ) + |> Map.put(:updated, updated) + else + %{ + updated_object: original_data, + used_history_in_new_object?: false, + updated: false + } + end + + updated_data = + if update_is_reasonable != false do + updated_data + |> maybe_update_poll(new_data) + else + updated_data + end + + %{ + updated_data: updated_data, + updated: updated, + used_history_in_new_object?: used_history_in_new_object? + } + end + + def for_each_history_item(%{"orderedItems" => items} = history, _object, fun) do + new_items = + Enum.map(items, fun) + |> Enum.reduce_while( + {:ok, []}, + fn + {:ok, item}, {:ok, acc} -> {:cont, {:ok, acc ++ [item]}} + e, _acc -> {:halt, e} + end + ) + + case new_items do + {:ok, items} -> {:ok, Map.put(history, "orderedItems", items)} + e -> e + end + end + + def for_each_history_item(history, _, _) do + {:ok, history} + end + + def do_with_history(object, fun) do + with history <- object["formerRepresentations"], + object <- Map.drop(object, ["formerRepresentations"]), + {_, {:ok, object}} <- {:main_body, fun.(object)}, + {_, {:ok, history}} <- {:history_items, for_each_history_item(history, object, fun)} do + object = + if history do + Map.put(object, "formerRepresentations", history) + else + object + end + + {:ok, object} + else + {:main_body, e} -> e + {:history_items, e} -> e + end + end + + defp maybe_touch_changeset(changeset, true) do + updated_at = + NaiveDateTime.utc_now() + |> NaiveDateTime.truncate(:second) + + Ecto.Changeset.put_change(changeset, :updated_at, updated_at) + end + + defp maybe_touch_changeset(changeset, _), do: changeset + + def do_update_and_invalidate_cache(orig_object, updated_object, touch_changeset? \\ false) do + orig_object_ap_id = updated_object["id"] + orig_object_data = orig_object.data + + %{ + updated_data: updated_object_data, + updated: updated, + used_history_in_new_object?: used_history_in_new_object? + } = make_new_object_data_from_update_object(orig_object_data, updated_object) + + changeset = + orig_object + |> Repo.preload(:hashtags) + |> Object.change(%{data: updated_object_data}) + |> maybe_touch_changeset(touch_changeset?) + + with {:ok, new_object} <- Repo.update(changeset), + {:ok, _} <- Object.invalid_object_cache(new_object), + {:ok, _} <- Object.set_cache(new_object), + # The metadata/utils.ex uses the object id for the cache. + {:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(new_object.id) do + if used_history_in_new_object? do + with create_activity when not is_nil(create_activity) <- + Pleroma.Activity.get_create_by_object_ap_id(orig_object_ap_id), + {:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(create_activity.id) do + nil + else + _ -> nil + end + end + + EventReminderWorker.schedule_event_reminder(orig_object) + + {:ok, new_object, updated} + end + end +end diff --git a/lib/pleroma/object_tombstone.ex b/lib/pleroma/object_tombstone.ex new file mode 100644 index 0000000..8bdc8f6 --- /dev/null +++ b/lib/pleroma/object_tombstone.ex @@ -0,0 +1,8 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ObjectTombstone do + @enforce_keys [:id, :formerType, :deleted] + defstruct [:id, :formerType, :deleted, type: "Tombstone"] +end diff --git a/lib/pleroma/otp_version.ex b/lib/pleroma/otp_version.ex new file mode 100644 index 0000000..80b1527 --- /dev/null +++ b/lib/pleroma/otp_version.ex @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.OTPVersion do + @spec version() :: String.t() | nil + def version do + # OTP Version https://erlang.org/doc/system_principles/versions.html#otp-version + [ + Path.join(:code.root_dir(), "OTP_VERSION"), + Path.join([:code.root_dir(), "releases", :erlang.system_info(:otp_release), "OTP_VERSION"]) + ] + |> get_version_from_files() + end + + @spec get_version_from_files([Path.t()]) :: String.t() | nil + def get_version_from_files([]), do: nil + + def get_version_from_files([path | paths]) do + if File.exists?(path) do + path + |> File.read!() + |> String.replace(~r/\r|\n|\s/, "") + else + get_version_from_files(paths) + end + end +end diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex new file mode 100644 index 0000000..f12ca28 --- /dev/null +++ b/lib/pleroma/pagination.ex @@ -0,0 +1,165 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Pagination do + @moduledoc """ + Implements Mastodon-compatible pagination. + """ + + import Ecto.Query + import Ecto.Changeset + + alias Pleroma.Repo + + @type type :: :keyset | :offset + + @default_limit 20 + @max_limit 40 + @page_keys ["max_id", "min_id", "limit", "since_id", "order"] + + def page_keys, do: @page_keys + + @spec fetch_paginated(Ecto.Query.t(), map(), type(), atom() | nil) :: [Ecto.Schema.t()] + def fetch_paginated(query, params, type \\ :keyset, table_binding \\ nil) + + def fetch_paginated(query, %{total: true} = params, :keyset, table_binding) do + total = Repo.aggregate(query, :count, :id) + + %{ + total: total, + items: fetch_paginated(query, Map.drop(params, [:total]), :keyset, table_binding) + } + end + + def fetch_paginated(query, params, :keyset, table_binding) do + options = cast_params(params) + + query + |> paginate(options, :keyset, table_binding) + |> Repo.all() + |> enforce_order(options) + end + + def fetch_paginated(query, %{total: true} = params, :offset, table_binding) do + total = + query + |> Ecto.Query.exclude(:left_join) + |> Repo.aggregate(:count, :id) + + %{ + total: total, + items: fetch_paginated(query, Map.drop(params, [:total]), :offset, table_binding) + } + end + + def fetch_paginated(query, params, :offset, table_binding) do + options = cast_params(params) + + query + |> paginate(options, :offset, table_binding) + |> Repo.all() + end + + @spec paginate(Ecto.Query.t(), map(), type(), atom() | nil) :: [Ecto.Schema.t()] + def paginate(query, options, method \\ :keyset, table_binding \\ nil) + + def paginate(list, options, _method, _table_binding) when is_list(list) do + offset = options[:offset] || 0 + limit = options[:limit] || 0 + Enum.slice(list, offset, limit) + end + + def paginate(query, options, :keyset, table_binding) do + query + |> restrict(:min_id, options, table_binding) + |> restrict(:since_id, options, table_binding) + |> restrict(:max_id, options, table_binding) + |> restrict(:order, options, table_binding) + |> restrict(:limit, options, table_binding) + end + + def paginate(query, options, :offset, table_binding) do + query + |> restrict(:order, options, table_binding) + |> restrict(:offset, options, table_binding) + |> restrict(:limit, options, table_binding) + end + + defp cast_params(params) do + param_types = %{ + min_id: :string, + since_id: :string, + max_id: :string, + offset: :integer, + limit: :integer, + skip_extra_order: :boolean, + skip_order: :boolean + } + + changeset = cast({%{}, param_types}, params, Map.keys(param_types)) + changeset.changes + end + + defp restrict(query, :min_id, %{min_id: min_id}, table_binding) do + where(query, [{q, table_position(query, table_binding)}], q.id > ^min_id) + end + + defp restrict(query, :since_id, %{since_id: since_id}, table_binding) do + where(query, [{q, table_position(query, table_binding)}], q.id > ^since_id) + end + + defp restrict(query, :max_id, %{max_id: max_id}, table_binding) do + where(query, [{q, table_position(query, table_binding)}], q.id < ^max_id) + end + + defp restrict(query, :order, %{skip_order: true}, _), do: query + + defp restrict(%{order_bys: [_ | _]} = query, :order, %{skip_extra_order: true}, _), do: query + + defp restrict(query, :order, %{min_id: _}, table_binding) do + order_by( + query, + [{u, table_position(query, table_binding)}], + fragment("? asc nulls last", u.id) + ) + end + + defp restrict(query, :order, _options, table_binding) do + order_by( + query, + [{u, table_position(query, table_binding)}], + fragment("? desc nulls last", u.id) + ) + end + + defp restrict(query, :offset, %{offset: offset}, _table_binding) do + offset(query, ^offset) + end + + defp restrict(query, :limit, options, _table_binding) do + limit = + case Map.get(options, :limit, @default_limit) do + limit when limit < @max_limit -> limit + _ -> @max_limit + end + + query + |> limit(^limit) + end + + defp restrict(query, _, _, _), do: query + + defp enforce_order(result, %{min_id: _}) do + result + |> Enum.reverse() + end + + defp enforce_order(result, _), do: result + + defp table_position(%Ecto.Query{} = query, binding_name) do + Map.get(query.aliases, binding_name, 0) + end + + defp table_position(_, _), do: 0 +end diff --git a/lib/pleroma/password/pbkdf2.ex b/lib/pleroma/password/pbkdf2.ex new file mode 100644 index 0000000..92e9e19 --- /dev/null +++ b/lib/pleroma/password/pbkdf2.ex @@ -0,0 +1,55 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Password.Pbkdf2 do + @moduledoc """ + This module implements Pbkdf2 passwords in terms of Plug.Crypto. + """ + + alias Plug.Crypto.KeyGenerator + + def decode64(str) do + str + |> String.replace(".", "+") + |> Base.decode64!(padding: false) + end + + def encode64(bin) do + bin + |> Base.encode64(padding: false) + |> String.replace("+", ".") + end + + def verify_pass(password, hash) do + ["pbkdf2-" <> digest, iterations, salt, hash] = String.split(hash, "$", trim: true) + + salt = decode64(salt) + + iterations = String.to_integer(iterations) + + digest = String.to_atom(digest) + + binary_hash = + KeyGenerator.generate(password, salt, digest: digest, iterations: iterations, length: 64) + + encode64(binary_hash) == hash + end + + def hash_pwd_salt(password, opts \\ []) do + salt = + Keyword.get_lazy(opts, :salt, fn -> + :crypto.strong_rand_bytes(16) + end) + + digest = Keyword.get(opts, :digest, :sha512) + + iterations = + Keyword.get(opts, :iterations, Pleroma.Config.get([:password, :iterations], 160_000)) + + binary_hash = + KeyGenerator.generate(password, salt, digest: digest, iterations: iterations, length: 64) + + "$pbkdf2-#{digest}$#{iterations}$#{encode64(salt)}$#{encode64(binary_hash)}" + end +end diff --git a/lib/pleroma/password_reset_token.ex b/lib/pleroma/password_reset_token.ex new file mode 100644 index 0000000..42a789e --- /dev/null +++ b/lib/pleroma/password_reset_token.ex @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.PasswordResetToken do + use Ecto.Schema + + import Ecto.Changeset + + alias Pleroma.PasswordResetToken + alias Pleroma.Repo + alias Pleroma.User + + schema "password_reset_tokens" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + field(:token, :string) + field(:used, :boolean, default: false) + + timestamps() + end + + def create_token(%User{} = user) do + token = :crypto.strong_rand_bytes(32) |> Base.url_encode64() + + token = %PasswordResetToken{ + user_id: user.id, + used: false, + token: token + } + + Repo.insert(token) + end + + def used_changeset(struct) do + struct + |> cast(%{}, []) + |> put_change(:used, true) + end + + @spec reset_password(binary(), map()) :: {:ok, User.t()} | {:error, binary()} + def reset_password(token, data) do + with %{used: false} = token <- Repo.get_by(PasswordResetToken, %{token: token}), + false <- expired?(token), + %User{} = user <- User.get_cached_by_id(token.user_id), + {:ok, _user} <- User.reset_password(user, data), + {:ok, token} <- Repo.update(used_changeset(token)) do + {:ok, token} + else + _e -> {:error, token} + end + end + + def expired?(%__MODULE__{inserted_at: inserted_at}) do + validity = Pleroma.Config.get([:instance, :password_reset_token_validity], 0) + + now = NaiveDateTime.utc_now() + + difference = NaiveDateTime.diff(now, inserted_at) + + difference > validity + end +end diff --git a/lib/pleroma/prom_ex.ex b/lib/pleroma/prom_ex.ex new file mode 100644 index 0000000..09e290f --- /dev/null +++ b/lib/pleroma/prom_ex.ex @@ -0,0 +1,90 @@ +defmodule Pleroma.PromEx do + @moduledoc """ + Be sure to add the following to finish setting up PromEx: + + 1. Update your configuration (config.exs, dev.exs, prod.exs, releases.exs, etc) to + configure the necessary bit of PromEx. Be sure to check out `PromEx.Config` for + more details regarding configuring PromEx: + ``` + config :pleroma, Pleroma.PromEx, + disabled: false, + manual_metrics_start_delay: :no_delay, + drop_metrics_groups: [], + grafana: :disabled, + metrics_server: :disabled + ``` + + 2. Add this module to your application supervision tree. It should be one of the first + things that is started so that no Telemetry events are missed. For example, if PromEx + is started after your Repo module, you will miss Ecto's init events and the dashboards + will be missing some data points: + ``` + def start(_type, _args) do + children = [ + Pleroma.PromEx, + + ... + ] + + ... + end + ``` + + 3. Update your `endpoint.ex` file to expose your metrics (or configure a standalone + server using the `:metrics_server` config options). Be sure to put this plug before + your `Plug.Telemetry` entry so that you can avoid having calls to your `/metrics` + endpoint create their own metrics and logs which can pollute your logs/metrics given + that Prometheus will scrape at a regular interval and that can get noisy: + ``` + defmodule PleromaWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :pleroma + + ... + + plug PromEx.Plug, prom_ex_module: Pleroma.PromEx + + ... + end + ``` + + 4. Update the list of plugins in the `plugins/0` function return list to reflect your + application's dependencies. Also update the list of dashboards that are to be uploaded + to Grafana in the `dashboards/0` function. + """ + + use PromEx, otp_app: :pleroma + + alias PromEx.Plugins + + @impl true + def plugins do + [ + # PromEx built in plugins + Plugins.Application, + Plugins.Beam, + {Plugins.Phoenix, router: Pleroma.Web.Router, endpoint: Pleroma.Web.Endpoint}, + Plugins.Ecto, + Plugins.Oban + ] + end + + @impl true + def dashboard_assigns do + [ + datasource_id: "Prometheus", + default_selected_interval: "1m" + ] + end + + @impl true + def dashboards do + [ + # PromEx built in Grafana dashboards + {:prom_ex, "application.json"}, + {:prom_ex, "beam.json"}, + {:prom_ex, "phoenix.json"}, + {:prom_ex, "ecto.json"}, + {:prom_ex, "oban.json"} + ] + end +end diff --git a/lib/pleroma/registration.ex b/lib/pleroma/registration.ex new file mode 100644 index 0000000..b043c37 --- /dev/null +++ b/lib/pleroma/registration.ex @@ -0,0 +1,57 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Registration do + use Ecto.Schema + + import Ecto.Changeset + + alias Pleroma.Registration + alias Pleroma.Repo + alias Pleroma.User + + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + + schema "registrations" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + field(:provider, :string) + field(:uid, :string) + field(:info, :map, default: %{}) + + timestamps() + end + + def nickname(registration, default \\ nil), + do: Map.get(registration.info, "nickname", default) + + def email(registration, default \\ nil), + do: Map.get(registration.info, "email", default) + + def name(registration, default \\ nil), + do: Map.get(registration.info, "name", default) + + def description(registration, default \\ nil), + do: Map.get(registration.info, "description", default) + + def changeset(registration, params \\ %{}) do + registration + |> cast(params, [:user_id, :provider, :uid, :info]) + |> validate_required([:provider, :uid]) + |> foreign_key_constraint(:user_id) + |> unique_constraint(:uid, name: :registrations_provider_uid_index) + end + + def bind_to_user(registration, user) do + registration + |> changeset(%{user_id: (user && user.id) || nil}) + |> Repo.update() + end + + def get_by_provider_uid(provider, uid) do + Repo.get_by(Registration, + provider: to_string(provider), + uid: to_string(uid) + ) + end +end diff --git a/lib/pleroma/release_tasks.ex b/lib/pleroma/release_tasks.ex new file mode 100644 index 0000000..f9e8d19 --- /dev/null +++ b/lib/pleroma/release_tasks.ex @@ -0,0 +1,66 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReleaseTasks do + @repo Pleroma.Repo + + def run(args) do + [task | args] = String.split(args) + + case task do + "migrate" -> migrate(args) + "create" -> create() + "rollback" -> rollback(args) + task -> mix_task(task, args) + end + end + + defp mix_task(task, args) do + Application.load(:pleroma) + {:ok, modules} = :application.get_key(:pleroma, :modules) + + module = + Enum.find(modules, fn module -> + module = Module.split(module) + + match?(["Mix", "Tasks", "Pleroma" | _], module) and + String.downcase(List.last(module)) == task + end) + + if module do + module.run(args) + else + IO.puts("The task #{task} does not exist") + end + end + + def migrate(args) do + Mix.Tasks.Pleroma.Ecto.Migrate.run(args) + end + + def rollback(args) do + Mix.Tasks.Pleroma.Ecto.Rollback.run(args) + end + + def create do + Application.load(:pleroma) + + case @repo.__adapter__.storage_up(@repo.config) do + :ok -> + IO.puts("The database for #{inspect(@repo)} has been created") + + {:error, :already_up} -> + IO.puts("The database for #{inspect(@repo)} has already been created") + + {:error, term} when is_binary(term) -> + IO.puts(:stderr, "The database for #{inspect(@repo)} couldn't be created: #{term}") + + {:error, term} -> + IO.puts( + :stderr, + "The database for #{inspect(@repo)} couldn't be created: #{inspect(term)}" + ) + end + end +end diff --git a/lib/pleroma/repo.ex b/lib/pleroma/repo.ex new file mode 100644 index 0000000..515b0c1 --- /dev/null +++ b/lib/pleroma/repo.ex @@ -0,0 +1,97 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Repo do + use Ecto.Repo, + otp_app: :pleroma, + adapter: Ecto.Adapters.Postgres, + migration_timestamps: [type: :naive_datetime_usec] + + import Ecto.Query + require Logger + + defmodule Instrumenter, do: use(Prometheus.EctoInstrumenter) + + @doc """ + Dynamically loads the repository url from the + DATABASE_URL environment variable. + """ + def init(_, opts) do + {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))} + end + + @doc "find resource based on prepared query" + @spec find_resource(Ecto.Query.t()) :: {:ok, struct()} | {:error, :not_found} + def find_resource(%Ecto.Query{} = query) do + case __MODULE__.one(query) do + nil -> {:error, :not_found} + resource -> {:ok, resource} + end + end + + def find_resource(_query), do: {:error, :not_found} + + @doc """ + Gets association from cache or loads if need + + ## Examples + + iex> Repo.get_assoc(token, :user) + %User{} + + """ + @spec get_assoc(struct(), atom()) :: {:ok, struct()} | {:error, :not_found} + def get_assoc(resource, association) do + case __MODULE__.preload(resource, association) do + %{^association => assoc} when not is_nil(assoc) -> {:ok, assoc} + _ -> {:error, :not_found} + end + end + + @doc """ + Returns a lazy enumerable that emits all entries from the data store matching the given query. + + `returns_as` use to group records. use the `batches` option to fetch records in bulk. + + ## Examples + + # fetch records one-by-one + iex> Pleroma.Repo.chunk_stream(Pleroma.Activity.Queries.by_actor(ap_id), 500) + + # fetch records in bulk + iex> Pleroma.Repo.chunk_stream(Pleroma.Activity.Queries.by_actor(ap_id), 500, :batches) + """ + @spec chunk_stream(Ecto.Query.t(), integer(), atom()) :: Enumerable.t() + def chunk_stream(query, chunk_size, returns_as \\ :one, query_options \\ []) do + # We don't actually need start and end functions of resource streaming, + # but it seems to be the only way to not fetch records one-by-one and + # have individual records be the elements of the stream, instead of + # lists of records + Stream.resource( + fn -> 0 end, + fn + last_id -> + query + |> order_by(asc: :id) + |> where([r], r.id > ^last_id) + |> limit(^chunk_size) + |> all(query_options) + |> case do + [] -> + {:halt, last_id} + + records -> + last_id = List.last(records).id + + if returns_as == :one do + {records, last_id} + else + {[records], last_id} + end + end + end, + fn _ -> :ok end + ) + end +end diff --git a/lib/pleroma/report_note.ex b/lib/pleroma/report_note.ex new file mode 100644 index 0000000..f2ad76f --- /dev/null +++ b/lib/pleroma/report_note.ex @@ -0,0 +1,48 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReportNote do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.Activity + alias Pleroma.Repo + alias Pleroma.ReportNote + alias Pleroma.User + + @type t :: %__MODULE__{} + + schema "report_notes" do + field(:content, :string) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) + + timestamps() + end + + @spec create(FlakeId.Ecto.CompatType.t(), FlakeId.Ecto.CompatType.t(), String.t()) :: + {:ok, ReportNote.t()} | {:error, Changeset.t()} + def create(user_id, activity_id, content) do + attrs = %{ + user_id: user_id, + activity_id: activity_id, + content: content + } + + %ReportNote{} + |> cast(attrs, [:user_id, :activity_id, :content]) + |> validate_required([:user_id, :activity_id, :content]) + |> Repo.insert() + end + + @spec destroy(FlakeId.Ecto.CompatType.t()) :: + {:ok, ReportNote.t()} | {:error, Changeset.t()} + def destroy(id) do + from(r in ReportNote, where: r.id == ^id) + |> Repo.one() + |> Repo.delete() + end +end diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex new file mode 100644 index 0000000..880940d --- /dev/null +++ b/lib/pleroma/reverse_proxy.ex @@ -0,0 +1,426 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReverseProxy do + @range_headers ~w(range if-range) + @keep_req_headers ~w(accept accept-encoding cache-control if-modified-since) ++ + ~w(if-unmodified-since if-none-match) ++ @range_headers + @resp_cache_headers ~w(etag date last-modified) + @keep_resp_headers @resp_cache_headers ++ + ~w(content-length content-type content-disposition content-encoding) ++ + ~w(content-range accept-ranges vary) + @default_cache_control_header "public, max-age=1209600" + @valid_resp_codes [200, 206, 304] + @max_read_duration :timer.seconds(30) + @max_body_length :infinity + @failed_request_ttl :timer.seconds(60) + @methods ~w(GET HEAD) + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + + def max_read_duration_default, do: @max_read_duration + def default_cache_control_header, do: @default_cache_control_header + + @moduledoc """ + A reverse proxy. + + Pleroma.ReverseProxy.call(conn, url, options) + + It is not meant to be added into a plug pipeline, but to be called from another plug or controller. + + Supports `#{inspect(@methods)}` HTTP methods, and only allows `#{inspect(@valid_resp_codes)}` status codes. + + Responses are chunked to the client while downloading from the upstream. + + Some request / responses headers are preserved: + + * request: `#{inspect(@keep_req_headers)}` + * response: `#{inspect(@keep_resp_headers)}` + + Options: + + * `redirect_on_failure` (default `false`). Redirects the client to the real remote URL if there's any HTTP + errors. Any error during body processing will not be redirected as the response is chunked. This may expose + remote URL, clients IPs, …. + + * `max_body_length` (default `#{inspect(@max_body_length)}`): limits the content length to be approximately the + specified length. It is validated with the `content-length` header and also verified when proxying. + + * `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total time the connection is allowed to + read from the remote upstream. + + * `failed_request_ttl` (default `#{inspect(@failed_request_ttl)}` ms): the time the failed request is cached and cannot be retried. + + * `inline_content_types`: + * `true` will not alter `content-disposition` (up to the upstream), + * `false` will add `content-disposition: attachment` to any request, + * a list of whitelisted content types + + * `req_headers`, `resp_headers` additional headers. + + * `http`: options for [hackney](https://github.com/benoitc/hackney) or [gun](https://github.com/ninenines/gun). + + """ + @default_options [pool: :media] + + @inline_content_types [ + "image/gif", + "image/jpeg", + "image/jpg", + "image/png", + "image/svg+xml", + "audio/mpeg", + "audio/mp3", + "video/webm", + "video/mp4", + "video/quicktime" + ] + + require Logger + import Plug.Conn + + @type option() :: + {:max_read_duration, :timer.time() | :infinity} + | {:max_body_length, non_neg_integer() | :infinity} + | {:failed_request_ttl, :timer.time() | :infinity} + | {:http, []} + | {:req_headers, [{String.t(), String.t()}]} + | {:resp_headers, [{String.t(), String.t()}]} + | {:inline_content_types, boolean() | [String.t()]} + | {:redirect_on_failure, boolean()} + + @spec call(Plug.Conn.t(), url :: String.t(), [option()]) :: Plug.Conn.t() + def call(_conn, _url, _opts \\ []) + + def call(conn = %{method: method}, url, opts) when method in @methods do + client_opts = Keyword.merge(@default_options, Keyword.get(opts, :http, [])) + + req_headers = build_req_headers(conn.req_headers, opts) + + opts = + if filename = Pleroma.Web.MediaProxy.filename(url) do + Keyword.put_new(opts, :attachment_name, filename) + else + opts + end + + with {:ok, nil} <- @cachex.get(:failed_proxy_url_cache, url), + {:ok, code, headers, client} <- request(method, url, req_headers, client_opts), + :ok <- + header_length_constraint( + headers, + Keyword.get(opts, :max_body_length, @max_body_length) + ) do + response(conn, client, url, code, headers, opts) + else + {:ok, true} -> + conn + |> error_or_redirect(url, 500, "Request failed", opts) + |> halt() + + {:ok, code, headers} -> + head_response(conn, url, code, headers, opts) + |> halt() + + {:error, {:invalid_http_response, code}} -> + Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}") + track_failed_url(url, code, opts) + + conn + |> error_or_redirect( + url, + code, + "Request failed: " <> Plug.Conn.Status.reason_phrase(code), + opts + ) + |> halt() + + {:error, error} -> + Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}") + track_failed_url(url, error, opts) + + conn + |> error_or_redirect(url, 500, "Request failed", opts) + |> halt() + end + end + + def call(conn, _, _) do + conn + |> send_resp(400, Plug.Conn.Status.reason_phrase(400)) + |> halt() + end + + defp request(method, url, headers, opts) do + Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}") + method = method |> String.downcase() |> String.to_existing_atom() + + case client().request(method, url, headers, "", opts) do + {:ok, code, headers, client} when code in @valid_resp_codes -> + {:ok, code, downcase_headers(headers), client} + + {:ok, code, headers} when code in @valid_resp_codes -> + {:ok, code, downcase_headers(headers)} + + {:ok, code, _, _} -> + {:error, {:invalid_http_response, code}} + + {:ok, code, _} -> + {:error, {:invalid_http_response, code}} + + {:error, error} -> + {:error, error} + end + end + + defp response(conn, client, url, status, headers, opts) do + Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}") + + result = + conn + |> put_resp_headers(build_resp_headers(headers, opts)) + |> send_chunked(status) + |> chunk_reply(client, opts) + + case result do + {:ok, conn} -> + halt(conn) + + {:error, :closed, conn} -> + client().close(client) + halt(conn) + + {:error, error, conn} -> + Logger.warning( + "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}" + ) + + client().close(client) + halt(conn) + end + end + + defp chunk_reply(conn, client, opts) do + chunk_reply(conn, client, opts, 0, 0) + end + + defp chunk_reply(conn, client, opts, sent_so_far, duration) do + with {:ok, duration} <- + check_read_duration( + duration, + Keyword.get(opts, :max_read_duration, @max_read_duration) + ), + {:ok, data, client} <- client().stream_body(client), + {:ok, duration} <- increase_read_duration(duration), + sent_so_far = sent_so_far + byte_size(data), + :ok <- + body_size_constraint( + sent_so_far, + Keyword.get(opts, :max_body_length, @max_body_length) + ), + {:ok, conn} <- chunk(conn, data) do + chunk_reply(conn, client, opts, sent_so_far, duration) + else + :done -> {:ok, conn} + {:error, error} -> {:error, error, conn} + end + end + + defp head_response(conn, url, code, headers, opts) do + Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}") + + conn + |> put_resp_headers(build_resp_headers(headers, opts)) + |> send_resp(code, "") + end + + defp error_or_redirect(conn, url, code, body, opts) do + if Keyword.get(opts, :redirect_on_failure, false) do + conn + |> Phoenix.Controller.redirect(external: url) + |> halt() + else + conn + |> send_resp(code, body) + |> halt + end + end + + defp downcase_headers(headers) do + Enum.map(headers, fn {k, v} -> + {String.downcase(k), v} + end) + end + + defp get_content_type(headers) do + {_, content_type} = + List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"}) + + [content_type | _] = String.split(content_type, ";") + content_type + end + + defp put_resp_headers(conn, headers) do + Enum.reduce(headers, conn, fn {k, v}, conn -> + put_resp_header(conn, k, v) + end) + end + + defp build_req_headers(headers, opts) do + headers + |> downcase_headers() + |> Enum.filter(fn {k, _} -> k in @keep_req_headers end) + |> build_req_range_or_encoding_header(opts) + |> build_req_user_agent_header(opts) + |> Keyword.merge(Keyword.get(opts, :req_headers, [])) + end + + # Disable content-encoding if any @range_headers are requested (see #1823). + defp build_req_range_or_encoding_header(headers, _opts) do + range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end) + + if range? && List.keymember?(headers, "accept-encoding", 0) do + List.keydelete(headers, "accept-encoding", 0) + else + headers + end + end + + defp build_req_user_agent_header(headers, _opts) do + List.keystore( + headers, + "user-agent", + 0, + {"user-agent", Pleroma.Application.user_agent()} + ) + end + + defp build_resp_headers(headers, opts) do + headers + |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end) + |> build_resp_cache_headers(opts) + |> build_resp_content_disposition_header(opts) + |> Keyword.merge(Keyword.get(opts, :resp_headers, [])) + end + + defp build_resp_cache_headers(headers, _opts) do + has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end) + + cond do + has_cache? -> + # There's caching header present but no cache-control -- we need to set our own + # as Plug defaults to "max-age=0, private, must-revalidate" + List.keystore( + headers, + "cache-control", + 0, + {"cache-control", @default_cache_control_header} + ) + + true -> + List.keystore( + headers, + "cache-control", + 0, + {"cache-control", @default_cache_control_header} + ) + end + end + + defp build_resp_content_disposition_header(headers, opts) do + opt = Keyword.get(opts, :inline_content_types, @inline_content_types) + + content_type = get_content_type(headers) + + attachment? = + cond do + is_list(opt) && !Enum.member?(opt, content_type) -> true + opt == false -> true + true -> false + end + + if attachment? do + name = + try do + {{"content-disposition", content_disposition_string}, _} = + List.keytake(headers, "content-disposition", 0) + + [name | _] = + Regex.run( + ~r/filename="((?:[^"\\]|\\.)*)"/u, + content_disposition_string || "", + capture: :all_but_first + ) + + name + rescue + MatchError -> Keyword.get(opts, :attachment_name, "attachment") + end + + disposition = "attachment; filename=\"#{name}\"" + + List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition}) + else + headers + end + end + + defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do + with {_, size} <- List.keyfind(headers, "content-length", 0), + {size, _} <- Integer.parse(size), + true <- size <= limit do + :ok + else + false -> + {:error, :body_too_large} + + _ -> + :ok + end + end + + defp header_length_constraint(_, _), do: :ok + + defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do + {:error, :body_too_large} + end + + defp body_size_constraint(_, _), do: :ok + + defp check_read_duration(nil = _duration, max), do: check_read_duration(@max_read_duration, max) + + defp check_read_duration(duration, max) + when is_integer(duration) and is_integer(max) and max > 0 do + if duration > max do + {:error, :read_duration_exceeded} + else + {:ok, {duration, :erlang.system_time(:millisecond)}} + end + end + + defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit} + + defp increase_read_duration({previous_duration, started}) + when is_integer(previous_duration) and is_integer(started) do + duration = :erlang.system_time(:millisecond) - started + {:ok, previous_duration + duration} + end + + defp increase_read_duration(_) do + {:ok, :no_duration_limit, :no_duration_limit} + end + + defp client, do: Pleroma.ReverseProxy.Client.Wrapper + + defp track_failed_url(url, error, opts) do + ttl = + unless error in [:body_too_large, 400, 204] do + Keyword.get(opts, :failed_request_ttl, @failed_request_ttl) + else + nil + end + + @cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl) + end +end diff --git a/lib/pleroma/reverse_proxy/client.ex b/lib/pleroma/reverse_proxy/client.ex new file mode 100644 index 0000000..91f6e5a --- /dev/null +++ b/lib/pleroma/reverse_proxy/client.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReverseProxy.Client do + @type status :: pos_integer() + @type header_name :: String.t() + @type header_value :: String.t() + @type headers :: [{header_name(), header_value()}] + + @callback request(atom(), String.t(), headers(), String.t(), list()) :: + {:ok, status(), headers(), reference() | map()} + | {:ok, status(), headers()} + | {:ok, reference()} + | {:error, term()} + + @callback stream_body(map()) :: {:ok, binary(), map()} | :done | {:error, atom() | String.t()} + + @callback close(reference() | pid() | map()) :: :ok +end diff --git a/lib/pleroma/reverse_proxy/client/hackney.ex b/lib/pleroma/reverse_proxy/client/hackney.ex new file mode 100644 index 0000000..d3e9869 --- /dev/null +++ b/lib/pleroma/reverse_proxy/client/hackney.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReverseProxy.Client.Hackney do + @behaviour Pleroma.ReverseProxy.Client + + @impl true + def request(method, url, headers, body, opts \\ []) do + :hackney.request(method, url, headers, body, opts) + end + + @impl true + def stream_body(ref) do + case :hackney.stream_body(ref) do + :done -> :done + {:ok, data} -> {:ok, data, ref} + {:error, error} -> {:error, error} + end + end + + @impl true + def close(ref), do: :hackney.close(ref) +end diff --git a/lib/pleroma/reverse_proxy/client/tesla.ex b/lib/pleroma/reverse_proxy/client/tesla.ex new file mode 100644 index 0000000..4596d7a --- /dev/null +++ b/lib/pleroma/reverse_proxy/client/tesla.ex @@ -0,0 +1,86 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReverseProxy.Client.Tesla do + @behaviour Pleroma.ReverseProxy.Client + + alias Pleroma.Gun.ConnectionPool + + @type headers() :: [{String.t(), String.t()}] + @type status() :: pos_integer() + + @spec request(atom(), String.t(), headers(), String.t(), keyword()) :: + {:ok, status(), headers} + | {:ok, status(), headers, map()} + | {:error, atom() | String.t()} + | no_return() + + @impl true + def request(method, url, headers, body, opts \\ []) do + check_adapter() + + opts = Keyword.put(opts, :body_as, :chunks) + + with {:ok, response} <- + Pleroma.HTTP.request( + method, + url, + body, + headers, + opts + ) do + if is_map(response.body) and method != :head do + {:ok, response.status, response.headers, response.body} + else + conn_pid = response.opts[:adapter][:conn] + ConnectionPool.release_conn(conn_pid) + {:ok, response.status, response.headers} + end + else + {:error, error} -> {:error, error} + end + end + + @impl true + @spec stream_body(map()) :: + {:ok, binary(), map()} | {:error, atom() | String.t()} | :done | no_return() + def stream_body(%{pid: pid, fin: true}) do + ConnectionPool.release_conn(pid) + :done + end + + def stream_body(client) do + case read_chunk!(client) do + {:fin, body} -> + {:ok, body, Map.put(client, :fin, true)} + + {:nofin, part} -> + {:ok, part, client} + + {:error, error} -> + {:error, error} + end + end + + defp read_chunk!(%{pid: pid, stream: stream, opts: opts}) do + adapter = check_adapter() + adapter.read_chunk(pid, stream, opts) + end + + @impl true + @spec close(map) :: :ok | no_return() + def close(%{pid: pid}) do + ConnectionPool.release_conn(pid) + end + + defp check_adapter do + adapter = Application.get_env(:tesla, :adapter) + + unless adapter == Tesla.Adapter.Gun do + raise "#{adapter} doesn't support reading body in chunks" + end + + adapter + end +end diff --git a/lib/pleroma/reverse_proxy/client/wrapper.ex b/lib/pleroma/reverse_proxy/client/wrapper.ex new file mode 100644 index 0000000..1ce4769 --- /dev/null +++ b/lib/pleroma/reverse_proxy/client/wrapper.ex @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReverseProxy.Client.Wrapper do + @moduledoc "Meta-client that calls the appropriate client from the config." + @behaviour Pleroma.ReverseProxy.Client + + @impl true + def request(method, url, headers, body \\ "", opts \\ []) do + client().request(method, url, headers, body, opts) + end + + @impl true + def stream_body(ref), do: client().stream_body(ref) + + @impl true + def close(ref), do: client().close(ref) + + defp client do + :tesla + |> Application.get_env(:adapter) + |> client() + end + + defp client(Tesla.Adapter.Hackney), do: Pleroma.ReverseProxy.Client.Hackney + defp client(Tesla.Adapter.Gun), do: Pleroma.ReverseProxy.Client.Tesla + defp client({Tesla.Adapter.Finch, _}), do: Pleroma.ReverseProxy.Client.Hackney + defp client(_), do: Pleroma.Config.get!(Pleroma.ReverseProxy.Client) +end diff --git a/lib/pleroma/rule.ex b/lib/pleroma/rule.ex new file mode 100644 index 0000000..067d071 --- /dev/null +++ b/lib/pleroma/rule.ex @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Rule do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.Repo + alias Pleroma.Rule + + @primary_key {:id, Pleroma.EctoType.StringId, autogenerate: true} + + schema "rules" do + field(:priority, :integer, default: 0) + field(:text, :string) + + timestamps() + end + + def changeset(%Rule{} = rule, params \\ %{}) do + rule + |> cast(params, [:priority, :text]) + |> validate_required([:text]) + end + + def query do + Rule + |> order_by(asc: :priority) + |> order_by(asc: :id) + end + + def get(ids) when is_list(ids) do + from(r in __MODULE__, where: r.id in ^ids) + |> Repo.all() + end + + def get(id), do: Repo.get(__MODULE__, id) + + def create(params) do + {:ok, rule} = + %Rule{} + |> changeset(params) + |> Repo.insert() + + rule + end + + def update(params, id) do + {:ok, rule} = + get(id) + |> changeset(params) + |> Repo.update() + + rule + end + + def delete(id) do + get(id) + |> Repo.delete() + end +end diff --git a/lib/pleroma/scheduled_activity.ex b/lib/pleroma/scheduled_activity.ex new file mode 100644 index 0000000..0ed51ad --- /dev/null +++ b/lib/pleroma/scheduled_activity.ex @@ -0,0 +1,210 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ScheduledActivity do + use Ecto.Schema + + alias Ecto.Multi + alias Pleroma.Config + alias Pleroma.Repo + alias Pleroma.ScheduledActivity + alias Pleroma.User + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Workers.ScheduledActivityWorker + + import Ecto.Query + import Ecto.Changeset + + @type t :: %__MODULE__{} + + @min_offset :timer.minutes(5) + + schema "scheduled_activities" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + field(:scheduled_at, :naive_datetime) + field(:params, :map) + + timestamps() + end + + def changeset(%ScheduledActivity{} = scheduled_activity, attrs) do + scheduled_activity + |> cast(attrs, [:scheduled_at, :params]) + |> validate_required([:scheduled_at, :params]) + |> validate_scheduled_at() + |> with_media_attachments() + end + + defp with_media_attachments( + %{changes: %{params: %{"media_ids" => media_ids} = params}} = changeset + ) + when is_list(media_ids) do + media_attachments = + Utils.attachments_from_ids( + %{media_ids: media_ids}, + User.get_cached_by_id(changeset.data.user_id) + ) + + params = + params + |> Map.put("media_attachments", media_attachments) + |> Map.put("media_ids", media_ids) + + put_change(changeset, :params, params) + end + + defp with_media_attachments(changeset), do: changeset + + def update_changeset(%ScheduledActivity{} = scheduled_activity, attrs) do + scheduled_activity + |> cast(attrs, [:scheduled_at]) + |> validate_required([:scheduled_at]) + |> validate_scheduled_at() + end + + def validate_scheduled_at(changeset) do + validate_change(changeset, :scheduled_at, fn _, scheduled_at -> + cond do + not far_enough?(scheduled_at) -> + [scheduled_at: "must be at least 5 minutes from now"] + + exceeds_daily_user_limit?(changeset.data.user_id, scheduled_at) -> + [scheduled_at: "daily limit exceeded"] + + exceeds_total_user_limit?(changeset.data.user_id) -> + [scheduled_at: "total limit exceeded"] + + true -> + [] + end + end) + end + + def exceeds_daily_user_limit?(user_id, scheduled_at) do + ScheduledActivity + |> where(user_id: ^user_id) + |> where([sa], type(sa.scheduled_at, :date) == type(^scheduled_at, :date)) + |> select([sa], count(sa.id)) + |> Repo.one() + |> Kernel.>=(Config.get([ScheduledActivity, :daily_user_limit])) + end + + def exceeds_total_user_limit?(user_id) do + ScheduledActivity + |> where(user_id: ^user_id) + |> select([sa], count(sa.id)) + |> Repo.one() + |> Kernel.>=(Config.get([ScheduledActivity, :total_user_limit])) + end + + def far_enough?(scheduled_at) when is_binary(scheduled_at) do + with {:ok, scheduled_at} <- Ecto.Type.cast(:naive_datetime, scheduled_at) do + far_enough?(scheduled_at) + else + _ -> false + end + end + + def far_enough?(scheduled_at) do + now = NaiveDateTime.utc_now() + diff = NaiveDateTime.diff(scheduled_at, now, :millisecond) + diff > @min_offset + end + + def new(%User{} = user, attrs) do + changeset(%ScheduledActivity{user_id: user.id}, attrs) + end + + @doc """ + Creates ScheduledActivity and add to queue to perform at scheduled_at date + """ + @spec create(User.t(), map()) :: {:ok, ScheduledActivity.t()} | {:error, Ecto.Changeset.t()} + def create(%User{} = user, attrs) do + Multi.new() + |> Multi.insert(:scheduled_activity, new(user, attrs)) + |> maybe_add_jobs(Config.get([ScheduledActivity, :enabled])) + |> Repo.transaction() + |> transaction_response + end + + defp maybe_add_jobs(multi, true) do + multi + |> Multi.run(:scheduled_activity_job, fn _repo, %{scheduled_activity: activity} -> + %{activity_id: activity.id} + |> ScheduledActivityWorker.new(scheduled_at: activity.scheduled_at) + |> Oban.insert() + end) + end + + defp maybe_add_jobs(multi, _), do: multi + + def get(%User{} = user, scheduled_activity_id) do + ScheduledActivity + |> where(user_id: ^user.id) + |> where(id: ^scheduled_activity_id) + |> Repo.one() + end + + @spec update(ScheduledActivity.t(), map()) :: + {:ok, ScheduledActivity.t()} | {:error, Ecto.Changeset.t()} + def update(%ScheduledActivity{id: id} = scheduled_activity, attrs) do + with {:error, %Ecto.Changeset{valid?: true} = changeset} <- + {:error, update_changeset(scheduled_activity, attrs)} do + Multi.new() + |> Multi.update(:scheduled_activity, changeset) + |> Multi.update_all(:scheduled_job, job_query(id), + set: [scheduled_at: get_field(changeset, :scheduled_at)] + ) + |> Repo.transaction() + |> transaction_response + end + end + + @doc "Deletes a ScheduledActivity and linked jobs." + @spec delete(ScheduledActivity.t() | binary() | integer) :: + {:ok, ScheduledActivity.t()} | {:error, Ecto.Changeset.t()} + def delete(%ScheduledActivity{id: id} = scheduled_activity) do + Multi.new() + |> Multi.delete(:scheduled_activity, scheduled_activity, stale_error_field: :id) + |> Multi.delete_all(:jobs, job_query(id)) + |> Repo.transaction() + |> transaction_response + end + + def delete(id) when is_binary(id) or is_integer(id) do + delete(%__MODULE__{id: id}) + end + + defp transaction_response(result) do + case result do + {:ok, %{scheduled_activity: scheduled_activity}} -> + {:ok, scheduled_activity} + + {:error, _, changeset, _} -> + {:error, changeset} + end + end + + def for_user_query(%User{} = user) do + ScheduledActivity + |> where(user_id: ^user.id) + end + + def due_activities(offset \\ 0) do + naive_datetime = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(offset, :millisecond) + + ScheduledActivity + |> where([sa], sa.scheduled_at < ^naive_datetime) + |> Repo.all() + end + + def job_query(scheduled_activity_id) do + from(j in Oban.Job, + where: j.queue == "scheduled_activities", + where: fragment("args ->> 'activity_id' = ?::text", ^to_string(scheduled_activity_id)) + ) + end +end diff --git a/lib/pleroma/search.ex b/lib/pleroma/search.ex new file mode 100644 index 0000000..3b266e5 --- /dev/null +++ b/lib/pleroma/search.ex @@ -0,0 +1,17 @@ +defmodule Pleroma.Search do + alias Pleroma.Workers.SearchIndexingWorker + + def add_to_index(%Pleroma.Activity{id: activity_id}) do + SearchIndexingWorker.enqueue("add_to_index", %{"activity" => activity_id}) + end + + def remove_from_index(%Pleroma.Object{id: object_id}) do + SearchIndexingWorker.enqueue("remove_from_index", %{"object" => object_id}) + end + + def search(query, options) do + search_module = Pleroma.Config.get([Pleroma.Search, :module], Pleroma.Activity) + + search_module.search(options[:for_user], query, options) + end +end diff --git a/lib/pleroma/search/database_search.ex b/lib/pleroma/search/database_search.ex new file mode 100644 index 0000000..c6311e0 --- /dev/null +++ b/lib/pleroma/search/database_search.ex @@ -0,0 +1,172 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Search.DatabaseSearch do + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.Object.Fetcher + alias Pleroma.Pagination + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Visibility + + require Pleroma.Constants + + import Ecto.Query + + @behaviour Pleroma.Search.SearchBackend + + @impl true + def search(user, search_query, options \\ []) do + index_type = if Config.get([:database, :rum_enabled]), do: :rum, else: :gin + limit = Enum.min([Keyword.get(options, :limit), 40]) + offset = Keyword.get(options, :offset, 0) + author = Keyword.get(options, :author) + + search_function = + if :persistent_term.get({Pleroma.Repo, :postgres_version}) >= 11 do + :websearch + else + :plain + end + + try do + Activity + |> Activity.with_preloaded_object() + |> Activity.restrict_deactivated_users() + |> restrict_public(user) + |> query_with(index_type, search_query, search_function) + |> maybe_restrict_local(user) + |> maybe_restrict_author(author) + |> maybe_restrict_blocked(user) + |> Pagination.fetch_paginated( + %{"offset" => offset, "limit" => limit, "skip_order" => index_type == :rum}, + :offset + ) + |> maybe_fetch(user, search_query) + rescue + _ -> maybe_fetch([], user, search_query) + end + end + + @impl true + def add_to_index(_activity), do: :ok + + @impl true + def remove_from_index(_object), do: :ok + + def maybe_restrict_author(query, %User{} = author) do + Activity.Queries.by_author(query, author) + end + + def maybe_restrict_author(query, _), do: query + + def maybe_restrict_blocked(query, %User{} = user) do + Activity.Queries.exclude_authors(query, User.blocked_users_ap_ids(user)) + end + + def maybe_restrict_blocked(query, _), do: query + + defp restrict_public(q, user) when not is_nil(user) do + intended_recipients = [ + Pleroma.Constants.as_public(), + Pleroma.Web.ActivityPub.Utils.as_local_public() + ] + + from([a, o] in q, + where: fragment("?->>'type' = 'Create'", a.data), + where: fragment("? && ?", ^intended_recipients, a.recipients) + ) + end + + defp restrict_public(q, _user) do + from([a, o] in q, + where: fragment("?->>'type' = 'Create'", a.data), + where: ^Pleroma.Constants.as_public() in a.recipients + ) + end + + defp query_with(q, :gin, search_query, :plain) do + %{rows: [[tsc]]} = + Ecto.Adapters.SQL.query!( + Pleroma.Repo, + "select current_setting('default_text_search_config')::regconfig::oid;" + ) + + from([a, o] in q, + where: + fragment( + "to_tsvector(?::oid::regconfig, ?->>'content') @@ plainto_tsquery(?)", + ^tsc, + o.data, + ^search_query + ) + ) + end + + defp query_with(q, :gin, search_query, :websearch) do + %{rows: [[tsc]]} = + Ecto.Adapters.SQL.query!( + Pleroma.Repo, + "select current_setting('default_text_search_config')::regconfig::oid;" + ) + + from([a, o] in q, + where: + fragment( + "to_tsvector(?::oid::regconfig, ?->>'content') @@ websearch_to_tsquery(?)", + ^tsc, + o.data, + ^search_query + ) + ) + end + + defp query_with(q, :rum, search_query, :plain) do + from([a, o] in q, + where: + fragment( + "? @@ plainto_tsquery(?)", + o.fts_content, + ^search_query + ), + order_by: [fragment("? <=> now()::date", o.inserted_at)] + ) + end + + defp query_with(q, :rum, search_query, :websearch) do + from([a, o] in q, + where: + fragment( + "? @@ websearch_to_tsquery(?)", + o.fts_content, + ^search_query + ), + order_by: [fragment("? <=> now()::date", o.inserted_at)] + ) + end + + def maybe_restrict_local(q, user) do + limit = Config.get([:instance, :limit_to_local_content], :unauthenticated) + + case {limit, user} do + {:all, _} -> restrict_local(q) + {:unauthenticated, %User{}} -> q + {:unauthenticated, _} -> restrict_local(q) + {false, _} -> q + end + end + + defp restrict_local(q), do: where(q, local: true) + + def maybe_fetch(activities, user, search_query) do + with true <- Regex.match?(~r/https?:/, search_query), + {:ok, object} <- Fetcher.fetch_object_from_id(search_query), + %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), + true <- Visibility.visible_for_user?(activity, user) do + [activity | activities] + else + _ -> activities + end + end +end diff --git a/lib/pleroma/search/meilisearch.ex b/lib/pleroma/search/meilisearch.ex new file mode 100644 index 0000000..2bff663 --- /dev/null +++ b/lib/pleroma/search/meilisearch.ex @@ -0,0 +1,181 @@ +defmodule Pleroma.Search.Meilisearch do + require Logger + require Pleroma.Constants + + alias Pleroma.Activity + alias Pleroma.Config.Getting, as: Config + + import Pleroma.Search.DatabaseSearch + import Ecto.Query + + @behaviour Pleroma.Search.SearchBackend + + defp meili_headers do + private_key = Config.get([Pleroma.Search.Meilisearch, :private_key]) + + [{"Content-Type", "application/json"}] ++ + if is_nil(private_key), do: [], else: [{"Authorization", "Bearer #{private_key}"}] + end + + def meili_get(path) do + endpoint = Config.get([Pleroma.Search.Meilisearch, :url]) + + result = + Pleroma.HTTP.get( + Path.join(endpoint, path), + meili_headers() + ) + + with {:ok, res} <- result do + {:ok, Jason.decode!(res.body)} + end + end + + def meili_post(path, params) do + endpoint = Config.get([Pleroma.Search.Meilisearch, :url]) + + result = + Pleroma.HTTP.post( + Path.join(endpoint, path), + Jason.encode!(params), + meili_headers() + ) + + with {:ok, res} <- result do + {:ok, Jason.decode!(res.body)} + end + end + + def meili_put(path, params) do + endpoint = Config.get([Pleroma.Search.Meilisearch, :url]) + + result = + Pleroma.HTTP.request( + :put, + Path.join(endpoint, path), + Jason.encode!(params), + meili_headers(), + [] + ) + + with {:ok, res} <- result do + {:ok, Jason.decode!(res.body)} + end + end + + def meili_delete(path) do + endpoint = Config.get([Pleroma.Search.Meilisearch, :url]) + + with {:ok, _} <- + Pleroma.HTTP.request( + :delete, + Path.join(endpoint, path), + "", + meili_headers(), + [] + ) do + :ok + else + _ -> {:error, "Could not remove from index"} + end + end + + @impl true + def search(user, query, options \\ []) do + limit = Enum.min([Keyword.get(options, :limit), 40]) + offset = Keyword.get(options, :offset, 0) + author = Keyword.get(options, :author) + + res = + meili_post( + "/indexes/objects/search", + %{q: query, offset: offset, limit: limit} + ) + + with {:ok, result} <- res do + hits = result["hits"] |> Enum.map(& &1["ap"]) + + try do + hits + |> Activity.create_by_object_ap_id() + |> Activity.with_preloaded_object() + |> Activity.restrict_deactivated_users() + |> maybe_restrict_local(user) + |> maybe_restrict_author(author) + |> maybe_restrict_blocked(user) + |> maybe_fetch(user, query) + |> order_by([object: obj], desc: obj.data["published"]) + |> Pleroma.Repo.all() + rescue + _ -> maybe_fetch([], user, query) + end + end + end + + def object_to_search_data(object) do + # Only index public or unlisted Notes + if not is_nil(object) and object.data["type"] == "Note" and + not is_nil(object.data["content"]) and + (Pleroma.Constants.as_public() in object.data["to"] or + Pleroma.Constants.as_public() in object.data["cc"]) and + object.data["content"] not in ["", "."] do + data = object.data + + content_str = + case data["content"] do + [nil | rest] -> to_string(rest) + str -> str + end + + content = + with {:ok, scrubbed} <- + FastSanitize.Sanitizer.scrub(content_str, Pleroma.HTML.Scrubber.SearchIndexing), + trimmed <- String.trim(scrubbed) do + trimmed + end + + # Make sure we have a non-empty string + if content != "" do + {:ok, published, _} = DateTime.from_iso8601(data["published"]) + + %{ + id: object.id, + content: content, + ap: data["id"], + published: published |> DateTime.to_unix() + } + end + end + end + + @impl true + def add_to_index(activity) do + maybe_search_data = object_to_search_data(activity.object) + + if activity.data["type"] == "Create" and maybe_search_data do + result = + meili_put( + "/indexes/objects/documents", + [maybe_search_data] + ) + + with {:ok, %{"status" => "enqueued"}} <- result do + # Added successfully + :ok + else + _ -> + # There was an error, report it + Logger.error("Failed to add activity #{activity.id} to index: #{inspect(result)}") + {:error, result} + end + else + # The post isn't something we can search, that's ok + :ok + end + end + + @impl true + def remove_from_index(object) do + meili_delete("/indexes/objects/documents/#{object.id}") + end +end diff --git a/lib/pleroma/search/search_backend.ex b/lib/pleroma/search/search_backend.ex new file mode 100644 index 0000000..a42e2f5 --- /dev/null +++ b/lib/pleroma/search/search_backend.ex @@ -0,0 +1,24 @@ +defmodule Pleroma.Search.SearchBackend do + @doc """ + Search statuses with a query, restricting to only those the user should have access to. + """ + @callback search(user :: Pleroma.User.t(), query :: String.t(), options :: [any()]) :: [ + Pleroma.Activity.t() + ] + + @doc """ + Add the object associated with the activity to the search index. + + The whole activity is passed, to allow filtering on things such as scope. + """ + @callback add_to_index(activity :: Pleroma.Activity.t()) :: :ok | {:error, any()} + + @doc """ + Remove the object from the index. + + Just the object, as opposed to the whole activity, is passed, since the object + is what contains the actual content and there is no need for fitlering when removing + from index. + """ + @callback remove_from_index(object :: Pleroma.Object.t()) :: {:ok, any()} | {:error, any()} +end diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex new file mode 100644 index 0000000..5cfdae0 --- /dev/null +++ b/lib/pleroma/signature.ex @@ -0,0 +1,80 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Signature do + @behaviour HTTPSignatures.Adapter + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Keys + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + + @known_suffixes ["/publickey", "/main-key"] + + def key_id_to_actor_id(key_id) do + uri = + key_id + |> URI.parse() + |> Map.put(:fragment, nil) + |> remove_suffix(@known_suffixes) + + maybe_ap_id = URI.to_string(uri) + + case ObjectValidators.ObjectID.cast(maybe_ap_id) do + {:ok, ap_id} -> + {:ok, ap_id} + + _ -> + case Pleroma.Web.WebFinger.finger(maybe_ap_id) do + %{"ap_id" => ap_id} -> {:ok, ap_id} + _ -> {:error, maybe_ap_id} + end + end + end + + defp remove_suffix(uri, [test | rest]) do + if not is_nil(uri.path) and String.ends_with?(uri.path, test) do + Map.put(uri, :path, String.replace(uri.path, test, "")) + else + remove_suffix(uri, rest) + end + end + + defp remove_suffix(uri, []), do: uri + + def fetch_public_key(conn) do + with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), + {:ok, actor_id} <- key_id_to_actor_id(kid), + {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do + {:ok, public_key} + else + e -> + {:error, e} + end + end + + def refetch_public_key(conn) do + with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), + {:ok, actor_id} <- key_id_to_actor_id(kid), + {:ok, _user} <- ActivityPub.make_user_from_ap_id(actor_id), + {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do + {:ok, public_key} + else + e -> + {:error, e} + end + end + + def sign(%User{keys: keys} = user, headers) do + with {:ok, private_key, _} <- Keys.keys_from_pem(keys) do + HTTPSignatures.sign(private_key, user.ap_id <> "#main-key", headers) + end + end + + def signed_date, do: signed_date(NaiveDateTime.utc_now()) + + def signed_date(%NaiveDateTime{} = date) do + Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") + end +end diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex new file mode 100644 index 0000000..47b30b9 --- /dev/null +++ b/lib/pleroma/stats.ex @@ -0,0 +1,137 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Stats do + use GenServer + + import Ecto.Query + + alias Pleroma.CounterCache + alias Pleroma.Repo + alias Pleroma.User + + @interval :timer.seconds(60) + + def start_link(_) do + GenServer.start_link( + __MODULE__, + nil, + name: __MODULE__ + ) + end + + @impl true + def init(_args) do + if Pleroma.Config.get(:env) != :test do + {:ok, nil, {:continue, :calculate_stats}} + else + {:ok, calculate_stat_data()} + end + end + + @doc "Performs update stats" + def force_update do + GenServer.call(__MODULE__, :force_update) + end + + @doc "Returns stats data" + @spec get_stats() :: %{ + domain_count: non_neg_integer(), + status_count: non_neg_integer(), + user_count: non_neg_integer() + } + def get_stats do + %{stats: stats} = GenServer.call(__MODULE__, :get_state) + + stats + end + + @doc "Returns list peers" + @spec get_peers() :: list(String.t()) + def get_peers do + %{peers: peers} = GenServer.call(__MODULE__, :get_state) + + peers + end + + @spec calculate_stat_data() :: %{ + peers: list(), + stats: %{ + domain_count: non_neg_integer(), + status_count: non_neg_integer(), + user_count: non_neg_integer() + } + } + def calculate_stat_data do + peers = + from( + u in User, + select: fragment("distinct split_part(?, '@', 2)", u.nickname), + where: u.local != ^true + ) + |> Repo.all() + |> Enum.filter(& &1) + + domain_count = Enum.count(peers) + + status_count = Repo.aggregate(User.Query.build(%{local: true}), :sum, :note_count) + + users_query = + from(u in User, + where: u.is_active == true, + where: u.local == true, + where: not is_nil(u.nickname), + where: not u.invisible + ) + + user_count = Repo.aggregate(users_query, :count, :id) + + %{ + peers: peers, + stats: %{ + domain_count: domain_count, + status_count: status_count || 0, + user_count: user_count + } + } + end + + @spec get_status_visibility_count(String.t() | nil) :: map() + def get_status_visibility_count(instance \\ nil) do + if is_nil(instance) do + CounterCache.get_sum() + else + CounterCache.get_by_instance(instance) + end + end + + @impl true + def handle_continue(:calculate_stats, _) do + stats = calculate_stat_data() + + unless Pleroma.Config.get(:env) == :test do + Process.send_after(self(), :run_update, @interval) + end + + {:noreply, stats} + end + + @impl true + def handle_call(:force_update, _from, _state) do + new_stats = calculate_stat_data() + {:reply, new_stats, new_stats} + end + + @impl true + def handle_call(:get_state, _from, state) do + {:reply, state, state} + end + + @impl true + def handle_info(:run_update, _) do + new_stats = calculate_stat_data() + Process.send_after(self(), :run_update, @interval) + {:noreply, new_stats} + end +end diff --git a/lib/pleroma/telemetry/logger.ex b/lib/pleroma/telemetry/logger.ex new file mode 100644 index 0000000..92d3953 --- /dev/null +++ b/lib/pleroma/telemetry/logger.ex @@ -0,0 +1,90 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Telemetry.Logger do + @moduledoc "Transforms Pleroma telemetry events to logs" + + require Logger + + @events [ + [:pleroma, :connection_pool, :reclaim, :start], + [:pleroma, :connection_pool, :reclaim, :stop], + [:pleroma, :connection_pool, :provision_failure], + [:pleroma, :connection_pool, :client, :dead], + [:pleroma, :connection_pool, :client, :add] + ] + def attach do + :telemetry.attach_many("pleroma-logger", @events, &handle_event/4, []) + end + + # Passing anonymous functions instead of strings to logger is intentional, + # that way strings won't be concatenated if the message is going to be thrown + # out anyway due to higher log level configured + + def handle_event( + [:pleroma, :connection_pool, :reclaim, :start], + _, + %{max_connections: max_connections, reclaim_max: reclaim_max}, + _ + ) do + Logger.debug(fn -> + "Connection pool is exhausted (reached #{max_connections} connections). Starting idle connection cleanup to reclaim as much as #{reclaim_max} connections" + end) + end + + def handle_event( + [:pleroma, :connection_pool, :reclaim, :stop], + %{reclaimed_count: 0}, + _, + _ + ) do + Logger.error(fn -> + "Connection pool failed to reclaim any connections due to all of them being in use. It will have to drop requests for opening connections to new hosts" + end) + end + + def handle_event( + [:pleroma, :connection_pool, :reclaim, :stop], + %{reclaimed_count: reclaimed_count}, + _, + _ + ) do + Logger.debug(fn -> "Connection pool cleaned up #{reclaimed_count} idle connections" end) + end + + def handle_event( + [:pleroma, :connection_pool, :provision_failure], + %{opts: [key | _]}, + _, + _ + ) do + Logger.error(fn -> + "Connection pool had to refuse opening a connection to #{key} due to connection limit exhaustion" + end) + end + + def handle_event( + [:pleroma, :connection_pool, :client, :dead], + %{client_pid: client_pid, reason: reason}, + %{key: key}, + _ + ) do + Logger.warning(fn -> + "Pool worker for #{key}: Client #{inspect(client_pid)} died before releasing the connection with #{inspect(reason)}" + end) + end + + def handle_event( + [:pleroma, :connection_pool, :client, :add], + %{clients: [_, _ | _] = clients}, + %{key: key, protocol: :http}, + _ + ) do + Logger.info(fn -> + "Pool worker for #{key}: #{length(clients)} clients are using an HTTP1 connection at the same time, head-of-line blocking might occur." + end) + end + + def handle_event([:pleroma, :connection_pool, :client, :add], _, _, _), do: :ok +end diff --git a/lib/pleroma/tesla/middleware/connection_pool.ex b/lib/pleroma/tesla/middleware/connection_pool.ex new file mode 100644 index 0000000..de74270 --- /dev/null +++ b/lib/pleroma/tesla/middleware/connection_pool.ex @@ -0,0 +1,50 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Tesla.Middleware.ConnectionPool do + @moduledoc """ + Middleware to get/release connections from `Pleroma.Gun.ConnectionPool` + """ + + @behaviour Tesla.Middleware + + alias Pleroma.Gun.ConnectionPool + + @impl Tesla.Middleware + def call(%Tesla.Env{url: url, opts: opts} = env, next, _) do + uri = URI.parse(url) + + # Avoid leaking connections when the middleware is called twice + # with body_as: :chunks. We assume only the middleware can set + # opts[:adapter][:conn] + if opts[:adapter][:conn] do + ConnectionPool.release_conn(opts[:adapter][:conn]) + end + + case ConnectionPool.get_conn(uri, opts[:adapter]) do + {:ok, conn_pid} -> + adapter_opts = Keyword.merge(opts[:adapter], conn: conn_pid, close_conn: false) + opts = Keyword.put(opts, :adapter, adapter_opts) + env = %{env | opts: opts} + + case Tesla.run(env, next) do + {:ok, env} -> + unless opts[:adapter][:body_as] == :chunks do + ConnectionPool.release_conn(conn_pid) + {_, res} = pop_in(env.opts[:adapter][:conn]) + {:ok, res} + else + {:ok, env} + end + + err -> + ConnectionPool.release_conn(conn_pid) + err + end + + err -> + err + end + end +end diff --git a/lib/pleroma/tests/auth_test_controller.ex b/lib/pleroma/tests/auth_test_controller.ex new file mode 100644 index 0000000..d244bad --- /dev/null +++ b/lib/pleroma/tests/auth_test_controller.ex @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +# A test controller reachable only in :test env. +defmodule Pleroma.Tests.AuthTestController do + @moduledoc false + + use Pleroma.Web, :controller + + alias Pleroma.User + alias Pleroma.Web.Plugs.OAuthScopesPlug + + # Serves only with proper OAuth token (:api and :authenticated_api) + # Skipping EnsurePublicOrAuthenticatedPlug has no effect in this case + # + # Suggested use case: all :authenticated_api endpoints (makes no sense for :api endpoints) + plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :do_oauth_check) + + # Via :api, keeps :user if token has requested scopes (if :user is dropped, serves if public) + # Via :authenticated_api, serves if token is present and has requested scopes + # + # Suggested use case: vast majority of :api endpoints (no sense for :authenticated_api ones) + plug( + OAuthScopesPlug, + %{scopes: ["read"], fallback: :proceed_unauthenticated} + when action == :fallback_oauth_check + ) + + # Keeps :user if present, executes regardless of token / token scopes + # Fails with no :user for :authenticated_api / no user for :api on private instance + # Note: EnsurePublicOrAuthenticatedPlug is not skipped (private instance fails on no :user) + # Note: Basic Auth processing results in :skip_plug call for OAuthScopesPlug + # + # Suggested use: suppressing OAuth checks for other auth mechanisms (like Basic Auth) + # For controller-level use, see :skip_oauth_skip_publicity_check instead + plug( + :skip_plug, + OAuthScopesPlug when action == :skip_oauth_check + ) + + # (Shouldn't be executed since the plug is skipped) + plug(OAuthScopesPlug, %{scopes: ["admin"]} when action == :skip_oauth_check) + + # Via :api, keeps :user if token has requested scopes, and continues with nil :user otherwise + # Via :authenticated_api, serves if token is present and has requested scopes + # + # Suggested use: as :fallback_oauth_check but open with nil :user for :api on private instances + plug(:skip_public_check when action == :fallback_oauth_skip_publicity_check) + + plug( + OAuthScopesPlug, + %{scopes: ["read"], fallback: :proceed_unauthenticated} + when action == :fallback_oauth_skip_publicity_check + ) + + # Via :api, keeps :user if present, serves regardless of token presence / scopes / :user presence + # Via :authenticated_api, serves if :user is set (regardless of token presence and its scopes) + # + # Suggested use: making an :api endpoint always accessible (e.g. email confirmation endpoint) + plug(:skip_auth when action == :skip_oauth_skip_publicity_check) + + # Via :authenticated_api, always fails with 403 (endpoint is insecure) + # Via :api, drops :user if present and serves if public (private instance rejects on no user) + # + # Suggested use: none; please define OAuth rules for all :api / :authenticated_api endpoints + plug(:skip_plug, [] when action == :missing_oauth_check_definition) + + def do_oauth_check(conn, _params), do: conn_state(conn) + + def fallback_oauth_check(conn, _params), do: conn_state(conn) + + def skip_oauth_check(conn, _params), do: conn_state(conn) + + def fallback_oauth_skip_publicity_check(conn, _params), do: conn_state(conn) + + def skip_oauth_skip_publicity_check(conn, _params), do: conn_state(conn) + + def missing_oauth_check_definition(conn, _params), do: conn_state(conn) + + defp conn_state(%{assigns: %{user: %User{} = user}} = conn), + do: json(conn, %{user_id: user.id}) + + defp conn_state(conn), do: json(conn, %{user_id: nil}) +end diff --git a/lib/pleroma/thread_mute.ex b/lib/pleroma/thread_mute.ex new file mode 100644 index 0000000..8ea4181 --- /dev/null +++ b/lib/pleroma/thread_mute.ex @@ -0,0 +1,75 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ThreadMute do + use Ecto.Schema + + alias Pleroma.Repo + alias Pleroma.ThreadMute + alias Pleroma.User + + import Ecto.Changeset + import Ecto.Query + + schema "thread_mutes" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + field(:context, :string) + end + + def changeset(mute, params \\ %{}) do + mute + |> cast(params, [:user_id, :context]) + |> foreign_key_constraint(:user_id) + |> unique_constraint(:user_id, name: :unique_index) + end + + def query(user_id, context) do + user_binary_id = User.binary_id(user_id) + + ThreadMute + |> where(user_id: ^user_binary_id) + |> where(context: ^context) + end + + def muters_query(context) do + ThreadMute + |> join(:inner, [tm], u in assoc(tm, :user)) + |> where([tm], tm.context == ^context) + |> select([tm, u], u.ap_id) + end + + def muter_ap_ids(context, ap_ids \\ nil) + + # Note: applies to fake activities (ActivityPub.Utils.get_notified_from_object/1 etc.) + def muter_ap_ids(context, _ap_ids) when is_nil(context), do: [] + + def muter_ap_ids(context, ap_ids) do + context + |> muters_query() + |> maybe_filter_on_ap_id(ap_ids) + |> Repo.all() + end + + defp maybe_filter_on_ap_id(query, ap_ids) when is_list(ap_ids) do + where(query, [tm, u], u.ap_id in ^ap_ids) + end + + defp maybe_filter_on_ap_id(query, _ap_ids), do: query + + def add_mute(user_id, context) do + %ThreadMute{} + |> changeset(%{user_id: user_id, context: context}) + |> Repo.insert() + end + + def remove_mute(user_id, context) do + query(user_id, context) + |> Repo.delete_all() + end + + def exists?(user_id, context) do + query(user_id, context) + |> Repo.exists?() + end +end diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex new file mode 100644 index 0000000..d36e5de --- /dev/null +++ b/lib/pleroma/upload.ex @@ -0,0 +1,282 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload do + @moduledoc """ + Manage user uploads + + Options: + * `:type`: presets for activity type (defaults to Document) and size limits from app configuration + * `:description`: upload alternative text + * `:base_url`: override base url + * `:uploader`: override uploader + * `:filters`: override filters + * `:size_limit`: override size limit + * `:activity_type`: override activity type + + The `%Pleroma.Upload{}` struct: all documented fields are meant to be overwritten in filters: + + * `:id` - the upload id. + * `:name` - the upload file name. + * `:path` - the upload path: set at first to `id/name` but can be changed. Keep in mind that the path + is once created permanent and changing it (especially in uploaders) is probably a bad idea! + * `:tempfile` - path to the temporary file. Prefer in-place changes on the file rather than changing the + path as the temporary file is also tracked by `Plug.Upload{}` and automatically deleted once the request is over. + * `:width` - width of the media in pixels + * `:height` - height of the media in pixels + * `:blurhash` - string hash of the image encoded with the blurhash algorithm (https://blurha.sh/) + + Related behaviors: + + * `Pleroma.Uploaders.Uploader` + * `Pleroma.Upload.Filter` + + """ + alias Ecto.UUID + alias Pleroma.Config + alias Pleroma.Maps + alias Pleroma.Web.ActivityPub.Utils + require Logger + + @type source :: + Plug.Upload.t() + | (data_uri_string :: String.t()) + | {:from_local, name :: String.t(), id :: String.t(), path :: String.t()} + | map() + + @type option :: + {:type, :avatar | :banner | :background} + | {:description, String.t()} + | {:activity_type, String.t()} + | {:size_limit, nil | non_neg_integer()} + | {:uploader, module()} + | {:filters, [module()]} + + @type t :: %__MODULE__{ + id: String.t(), + name: String.t(), + tempfile: String.t(), + content_type: String.t(), + width: integer(), + height: integer(), + blurhash: String.t(), + description: String.t(), + path: String.t() + } + defstruct [ + :id, + :name, + :tempfile, + :content_type, + :width, + :height, + :blurhash, + :description, + :path + ] + + defp get_description(upload) do + case {upload.description, Pleroma.Config.get([Pleroma.Upload, :default_description])} do + {description, _} when is_binary(description) -> description + {_, :filename} -> upload.name + {_, str} when is_binary(str) -> str + _ -> "" + end + end + + @spec store(source, options :: [option()]) :: {:ok, Map.t()} | {:error, any()} + @doc "Store a file. If using a `Plug.Upload{}` as the source, be sure to use `Majic.Plug` to ensure its content_type and filename is correct." + def store(upload, opts \\ []) do + opts = get_opts(opts) + + with {:ok, upload} <- prepare_upload(upload, opts), + upload = %__MODULE__{upload | path: upload.path || "#{upload.id}/#{upload.name}"}, + {:ok, upload} <- Pleroma.Upload.Filter.filter(opts.filters, upload), + description = get_description(upload), + {_, true} <- + {:description_limit, + String.length(description) <= Pleroma.Config.get([:instance, :description_limit])}, + {:ok, url_spec} <- Pleroma.Uploaders.Uploader.put_file(opts.uploader, upload) do + {:ok, + %{ + "id" => Utils.generate_object_id(), + "type" => opts.activity_type, + "mediaType" => upload.content_type, + "url" => [ + %{ + "type" => "Link", + "mediaType" => upload.content_type, + "href" => url_from_spec(upload, opts.base_url, url_spec) + } + |> Maps.put_if_present("width", upload.width) + |> Maps.put_if_present("height", upload.height) + ], + "name" => description + } + |> Maps.put_if_present("blurhash", upload.blurhash)} + else + {:description_limit, _} -> + {:error, :description_too_long} + + {:error, error} -> + Logger.error( + "#{__MODULE__} store (using #{inspect(opts.uploader)}) failed: #{inspect(error)}" + ) + + {:error, error} + end + end + + def char_unescaped?(char) do + URI.char_unreserved?(char) or char == ?/ + end + + defp get_opts(opts) do + {size_limit, activity_type} = + case Keyword.get(opts, :type) do + :banner -> + {Pleroma.Config.get!([:instance, :banner_upload_limit]), "Image"} + + :avatar -> + {Pleroma.Config.get!([:instance, :avatar_upload_limit]), "Image"} + + :background -> + {Pleroma.Config.get!([:instance, :background_upload_limit]), "Image"} + + _ -> + {Pleroma.Config.get!([:instance, :upload_limit]), "Document"} + end + + %{ + activity_type: Keyword.get(opts, :activity_type, activity_type), + size_limit: Keyword.get(opts, :size_limit, size_limit), + uploader: Keyword.get(opts, :uploader, Pleroma.Config.get([__MODULE__, :uploader])), + filters: Keyword.get(opts, :filters, Pleroma.Config.get([__MODULE__, :filters])), + description: Keyword.get(opts, :description), + base_url: base_url() + } + end + + defp prepare_upload(%Plug.Upload{} = file, opts) do + with :ok <- check_file_size(file.path, opts.size_limit) do + {:ok, + %__MODULE__{ + id: UUID.generate(), + name: file.filename, + tempfile: file.path, + content_type: file.content_type, + description: opts.description + }} + end + end + + defp prepare_upload(%{img: "data:image/" <> image_data}, opts) do + parsed = Regex.named_captures(~r/(?jpeg|png|gif);base64,(?.*)/, image_data) + data = Base.decode64!(parsed["data"], ignore: :whitespace) + hash = Base.encode16(:crypto.hash(:sha256, data), case: :lower) + + with :ok <- check_binary_size(data, opts.size_limit), + tmp_path <- tempfile_for_image(data), + {:ok, %{mime_type: content_type}} <- + Majic.perform({:bytes, data}, pool: Pleroma.MajicPool), + [ext | _] <- MIME.extensions(content_type) do + {:ok, + %__MODULE__{ + id: UUID.generate(), + name: hash <> "." <> ext, + tempfile: tmp_path, + content_type: content_type, + description: opts.description + }} + end + end + + # For Mix.Tasks.MigrateLocalUploads + defp prepare_upload(%__MODULE__{tempfile: path} = upload, _opts) do + with {:ok, %{mime_type: content_type}} <- Majic.perform(path, pool: Pleroma.MajicPool) do + {:ok, %__MODULE__{upload | content_type: content_type}} + end + end + + defp check_binary_size(binary, size_limit) + when is_integer(size_limit) and size_limit > 0 and byte_size(binary) >= size_limit do + {:error, :file_too_large} + end + + defp check_binary_size(_, _), do: :ok + + defp check_file_size(path, size_limit) when is_integer(size_limit) and size_limit > 0 do + with {:ok, %{size: size}} <- File.stat(path), + true <- size <= size_limit do + :ok + else + false -> {:error, :file_too_large} + error -> error + end + end + + defp check_file_size(_, _), do: :ok + + # Creates a tempfile using the Plug.Upload Genserver which cleans them up + # automatically. + defp tempfile_for_image(data) do + {:ok, tmp_path} = Plug.Upload.random_file("profile_pics") + {:ok, tmp_file} = File.open(tmp_path, [:write, :raw, :binary]) + IO.binwrite(tmp_file, data) + + tmp_path + end + + defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do + path = + URI.encode(path, &char_unescaped?/1) <> + if Pleroma.Config.get([__MODULE__, :link_name], false) do + "?name=#{URI.encode(name, &char_unescaped?/1)}" + else + "" + end + + [base_url, path] + |> Path.join() + end + + defp url_from_spec(_upload, _base_url, {:url, url}), do: url + + def base_url do + uploader = Config.get([Pleroma.Upload, :uploader]) + upload_base_url = Config.get([Pleroma.Upload, :base_url]) + public_endpoint = Config.get([uploader, :public_endpoint]) + + case uploader do + Pleroma.Uploaders.Local -> + upload_base_url || Pleroma.Web.Endpoint.url() <> "/media/" + + Pleroma.Uploaders.S3 -> + bucket = Config.get([Pleroma.Uploaders.S3, :bucket]) + truncated_namespace = Config.get([Pleroma.Uploaders.S3, :truncated_namespace]) + namespace = Config.get([Pleroma.Uploaders.S3, :bucket_namespace]) + + bucket_with_namespace = + cond do + !is_nil(truncated_namespace) -> + truncated_namespace + + !is_nil(namespace) -> + namespace <> ":" <> bucket + + true -> + bucket + end + + if public_endpoint do + Path.join([public_endpoint, bucket_with_namespace]) + else + Path.join([upload_base_url, bucket_with_namespace]) + end + + _ -> + public_endpoint || upload_base_url || Pleroma.Web.Endpoint.url() <> "/media/" + end + end +end diff --git a/lib/pleroma/upload/filter.ex b/lib/pleroma/upload/filter.ex new file mode 100644 index 0000000..809bc6e --- /dev/null +++ b/lib/pleroma/upload/filter.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter do + @moduledoc """ + Upload Filter behaviour + + This behaviour allows to run filtering actions just before a file is uploaded. This allows to: + + * morph in place the temporary file + * change any field of a `Pleroma.Upload` struct + * cancel/stop the upload + """ + + require Logger + + @callback filter(upload :: struct()) :: + {:ok, :filtered} + | {:ok, :noop} + | {:ok, :filtered, upload :: struct()} + | {:error, any()} + + @spec filter([module()], upload :: struct()) :: {:ok, upload :: struct()} | {:error, any()} + + def filter([], upload) do + {:ok, upload} + end + + def filter([filter | rest], upload) do + case filter.filter(upload) do + {:ok, :filtered} -> + filter(rest, upload) + + {:ok, :filtered, upload} -> + filter(rest, upload) + + {:ok, :noop} -> + filter(rest, upload) + + {:error, e} -> + Logger.error("#{__MODULE__}: Filter #{filter} failed: #{inspect(e)}") + {:error, e} + end + end +end diff --git a/lib/pleroma/upload/filter/analyze_metadata.ex b/lib/pleroma/upload/filter/analyze_metadata.ex new file mode 100644 index 0000000..199d1d2 --- /dev/null +++ b/lib/pleroma/upload/filter/analyze_metadata.ex @@ -0,0 +1,83 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.AnalyzeMetadata do + @moduledoc """ + Extracts metadata about the upload, such as width/height + """ + require Logger + + @behaviour Pleroma.Upload.Filter + + @spec filter(Pleroma.Upload.t()) :: + {:ok, :filtered, Pleroma.Upload.t()} | {:ok, :noop} | {:error, String.t()} + def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _} = upload) do + try do + image = + file + |> Mogrify.open() + |> Mogrify.verbose() + + upload = + upload + |> Map.put(:width, image.width) + |> Map.put(:height, image.height) + |> Map.put(:blurhash, get_blurhash(file)) + + {:ok, :filtered, upload} + rescue + e in ErlangError -> + Logger.warning("#{__MODULE__}: #{inspect(e)}") + {:ok, :noop} + end + end + + def filter(%Pleroma.Upload{tempfile: file, content_type: "video" <> _} = upload) do + try do + result = media_dimensions(file) + + upload = + upload + |> Map.put(:width, result.width) + |> Map.put(:height, result.height) + + {:ok, :filtered, upload} + rescue + e in ErlangError -> + Logger.warning("#{__MODULE__}: #{inspect(e)}") + {:ok, :noop} + end + end + + def filter(_), do: {:ok, :noop} + + defp get_blurhash(file) do + with {:ok, blurhash} <- :eblurhash.magick(file) do + blurhash + else + _ -> nil + end + end + + defp media_dimensions(file) do + with executable when is_binary(executable) <- System.find_executable("ffprobe"), + args = [ + "-v", + "error", + "-show_entries", + "stream=width,height", + "-of", + "csv=p=0:s=x", + file + ], + {result, 0} <- System.cmd(executable, args), + [width, height] <- + String.split(String.trim(result), "x") |> Enum.map(&String.to_integer(&1)) do + %{width: width, height: height} + else + nil -> {:error, {:ffprobe, :command_not_found}} + {:error, _} = error -> error + end + end +end diff --git a/lib/pleroma/upload/filter/anonymize_filename.ex b/lib/pleroma/upload/filter/anonymize_filename.ex new file mode 100644 index 0000000..234ccb6 --- /dev/null +++ b/lib/pleroma/upload/filter/anonymize_filename.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.AnonymizeFilename do + @moduledoc """ + Replaces the original filename with a pre-defined text or randomly generated string. + + Should be used after `Pleroma.Upload.Filter.Dedupe`. + """ + @behaviour Pleroma.Upload.Filter + + alias Pleroma.Config + alias Pleroma.Upload + + def filter(%Upload{name: name} = upload) do + extension = List.last(String.split(name, ".")) + name = predefined_name(extension) || random(extension) + {:ok, :filtered, %Upload{upload | name: name}} + end + + def filter(_), do: {:ok, :noop} + + @spec predefined_name(String.t()) :: String.t() | nil + defp predefined_name(extension) do + with name when not is_nil(name) <- Config.get([__MODULE__, :text]), + do: String.replace(name, "{extension}", extension) + end + + defp random(extension) do + string = + 10 + |> :crypto.strong_rand_bytes() + |> Base.url_encode64(padding: false) + + string <> "." <> extension + end +end diff --git a/lib/pleroma/upload/filter/dedupe.ex b/lib/pleroma/upload/filter/dedupe.ex new file mode 100644 index 0000000..ef793d3 --- /dev/null +++ b/lib/pleroma/upload/filter/dedupe.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Dedupe do + @behaviour Pleroma.Upload.Filter + alias Pleroma.Upload + + def filter(%Upload{name: name, tempfile: tempfile} = upload) do + extension = + name + |> String.split(".") + |> List.last() + + shasum = + :crypto.hash(:sha256, File.read!(tempfile)) + |> Base.encode16(case: :lower) + + filename = shasum <> "." <> extension + {:ok, :filtered, %Upload{upload | id: shasum, path: filename}} + end + + def filter(_), do: {:ok, :noop} +end diff --git a/lib/pleroma/upload/filter/exiftool/read_description.ex b/lib/pleroma/upload/filter/exiftool/read_description.ex new file mode 100644 index 0000000..543b220 --- /dev/null +++ b/lib/pleroma/upload/filter/exiftool/read_description.ex @@ -0,0 +1,52 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Exiftool.ReadDescription do + @moduledoc """ + Gets a valid description from the related EXIF tags and provides them in the response if no description is provided yet. + It will first check ImageDescription, when that doesn't probide a valid description, it will check iptc:Caption-Abstract. + A valid description means the fields are filled in and not too long (see `:instance, :description_limit`). + """ + @behaviour Pleroma.Upload.Filter + + @spec filter(Pleroma.Upload.t()) :: {:ok, any()} | {:error, String.t()} + + def filter(%Pleroma.Upload{description: description}) + when is_binary(description), + do: {:ok, :noop} + + def filter(%Pleroma.Upload{tempfile: file} = upload), + do: {:ok, :filtered, upload |> Map.put(:description, read_description_from_exif_data(file))} + + def filter(_, _), do: {:ok, :noop} + + defp read_description_from_exif_data(file) do + nil + |> read_when_empty(file, "-ImageDescription") + |> read_when_empty(file, "-iptc:Caption-Abstract") + end + + defp read_when_empty(current_description, _, _) when is_binary(current_description), + do: current_description + + defp read_when_empty(_, file, tag) do + try do + {tag_content, 0} = + System.cmd("exiftool", ["-b", "-s3", tag, file], + stderr_to_stdout: false, + parallelism: true + ) + + tag_content = String.trim(tag_content) + + if tag_content != "" and + String.length(tag_content) <= + Pleroma.Config.get([:instance, :description_limit]), + do: tag_content, + else: nil + rescue + _ in ErlangError -> nil + end + end +end diff --git a/lib/pleroma/upload/filter/exiftool/strip_location.ex b/lib/pleroma/upload/filter/exiftool/strip_location.ex new file mode 100644 index 0000000..f2bcc46 --- /dev/null +++ b/lib/pleroma/upload/filter/exiftool/strip_location.ex @@ -0,0 +1,32 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Exiftool.StripLocation do + @moduledoc """ + Strips GPS related EXIF tags and overwrites the file in place. + Also strips or replaces filesystem metadata e.g., timestamps. + """ + @behaviour Pleroma.Upload.Filter + + @spec filter(Pleroma.Upload.t()) :: {:ok, any()} | {:error, String.t()} + + # Formats not compatible with exiftool at this time + def filter(%Pleroma.Upload{content_type: "image/heic"}), do: {:ok, :noop} + def filter(%Pleroma.Upload{content_type: "image/webp"}), do: {:ok, :noop} + def filter(%Pleroma.Upload{content_type: "image/svg" <> _}), do: {:ok, :noop} + + def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do + try do + case System.cmd("exiftool", ["-overwrite_original", "-gps:all=", file], parallelism: true) do + {_response, 0} -> {:ok, :filtered} + {error, 1} -> {:error, error} + end + rescue + e in ErlangError -> + {:error, "#{__MODULE__}: #{inspect(e)}"} + end + end + + def filter(_), do: {:ok, :noop} +end diff --git a/lib/pleroma/upload/filter/mogrifun.ex b/lib/pleroma/upload/filter/mogrifun.ex new file mode 100644 index 0000000..a0f247b --- /dev/null +++ b/lib/pleroma/upload/filter/mogrifun.ex @@ -0,0 +1,53 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Mogrifun do + @behaviour Pleroma.Upload.Filter + alias Pleroma.Upload.Filter + + @moduledoc """ + This module is just an example of an Upload filter. It's not supposed to be used in production. + """ + + @filters [ + {"implode", "1"}, + {"-raise", "20"}, + {"+raise", "20"}, + [{"-interpolate", "nearest"}, {"-virtual-pixel", "mirror"}, {"-spread", "5"}], + "+polaroid", + {"-statistic", "Mode 10"}, + {"-emboss", "0x1.1"}, + {"-emboss", "0x2"}, + {"-colorspace", "Gray"}, + "-negate", + [{"-channel", "green"}, "-negate"], + [{"-channel", "red"}, "-negate"], + [{"-channel", "blue"}, "-negate"], + {"+level-colors", "green,gold"}, + {"+level-colors", ",DodgerBlue"}, + {"+level-colors", ",Gold"}, + {"+level-colors", ",Lime"}, + {"+level-colors", ",Red"}, + {"+level-colors", ",DarkGreen"}, + {"+level-colors", "firebrick,yellow"}, + {"+level-colors", "'rgb(102,75,25)',lemonchiffon"}, + [{"fill", "red"}, {"tint", "40"}], + [{"fill", "green"}, {"tint", "40"}], + [{"fill", "blue"}, {"tint", "40"}], + [{"fill", "yellow"}, {"tint", "40"}] + ] + + @spec filter(Pleroma.Upload.t()) :: {:ok, atom()} | {:error, String.t()} + def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do + try do + Filter.Mogrify.do_filter(file, [Enum.random(@filters)]) + {:ok, :filtered} + rescue + e in ErlangError -> + {:error, "#{__MODULE__}: #{inspect(e)}"} + end + end + + def filter(_), do: {:ok, :noop} +end diff --git a/lib/pleroma/upload/filter/mogrify.ex b/lib/pleroma/upload/filter/mogrify.ex new file mode 100644 index 0000000..06efbf3 --- /dev/null +++ b/lib/pleroma/upload/filter/mogrify.ex @@ -0,0 +1,48 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Mogrify do + @behaviour Pleroma.Upload.Filter + + @type conversion :: action :: String.t() | {action :: String.t(), opts :: String.t()} + @type conversions :: conversion() | [conversion()] + + @spec filter(Pleroma.Upload.t()) :: {:ok, :atom} | {:error, String.t()} + def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do + try do + do_filter(file, Pleroma.Config.get!([__MODULE__, :args])) + {:ok, :filtered} + rescue + e in ErlangError -> + {:error, "#{__MODULE__}: #{inspect(e)}"} + end + end + + def filter(_), do: {:ok, :noop} + + def do_filter(file, filters) do + file + |> Mogrify.open() + |> mogrify_filter(filters) + |> Mogrify.save(in_place: true) + end + + defp mogrify_filter(mogrify, nil), do: mogrify + + defp mogrify_filter(mogrify, [filter | rest]) do + mogrify + |> mogrify_filter(filter) + |> mogrify_filter(rest) + end + + defp mogrify_filter(mogrify, []), do: mogrify + + defp mogrify_filter(mogrify, {action, options}) do + Mogrify.custom(mogrify, action, options) + end + + defp mogrify_filter(mogrify, action) when is_binary(action) do + Mogrify.custom(mogrify, action) + end +end diff --git a/lib/pleroma/upload/filter/only_media.ex b/lib/pleroma/upload/filter/only_media.ex new file mode 100644 index 0000000..a9caeba --- /dev/null +++ b/lib/pleroma/upload/filter/only_media.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.OnlyMedia do + @behaviour Pleroma.Upload.Filter + alias Pleroma.Upload + + def filter(%Upload{content_type: content_type}) do + [type, _subtype] = String.split(content_type, "/") + + if type in ["image", "video", "audio"] do + {:ok, :noop} + else + {:error, "Disallowed content-type: #{content_type}"} + end + end + + def filter(_), do: {:ok, :noop} +end diff --git a/lib/pleroma/upload/filter/set_meta.ex b/lib/pleroma/upload/filter/set_meta.ex new file mode 100644 index 0000000..178c0bd --- /dev/null +++ b/lib/pleroma/upload/filter/set_meta.ex @@ -0,0 +1,9 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.SetMeta do + # This filter was renamed to AnalyzeMetadata. + # Include this module for backwards compatibilty. + defdelegate filter(upload), to: Pleroma.Upload.Filter.AnalyzeMetadata +end diff --git a/lib/pleroma/uploaders/local.ex b/lib/pleroma/uploaders/local.ex new file mode 100644 index 0000000..e4a309c --- /dev/null +++ b/lib/pleroma/uploaders/local.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Uploaders.Local do + @behaviour Pleroma.Uploaders.Uploader + + @impl true + def get_file(_) do + {:ok, {:static_dir, upload_path()}} + end + + @impl true + def put_file(upload) do + {local_path, file} = + case Enum.reverse(Path.split(upload.path)) do + [file] -> + {upload_path(), file} + + [file | folders] -> + path = Path.join([upload_path()] ++ Enum.reverse(folders)) + File.mkdir_p!(path) + {path, file} + end + + result_file = Path.join(local_path, file) + + if not File.exists?(result_file) do + File.cp!(upload.tempfile, result_file) + end + + :ok + end + + def upload_path do + Pleroma.Config.get!([__MODULE__, :uploads]) + end + + @impl true + def delete_file(path) do + upload_path() + |> Path.join(path) + |> File.rm() + |> case do + :ok -> :ok + {:error, posix_error} -> {:error, to_string(posix_error)} + end + end +end diff --git a/lib/pleroma/uploaders/s3.ex b/lib/pleroma/uploaders/s3.ex new file mode 100644 index 0000000..19287c5 --- /dev/null +++ b/lib/pleroma/uploaders/s3.ex @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Uploaders.S3 do + @behaviour Pleroma.Uploaders.Uploader + require Logger + + alias Pleroma.Config + + # The file name is re-encoded with S3's constraints here to comply with previous + # links with less strict filenames + @impl true + def get_file(file) do + {:ok, + {:url, + Path.join([ + Pleroma.Upload.base_url(), + strict_encode(URI.decode(file)) + ])}} + end + + @impl true + def put_file(%Pleroma.Upload{} = upload) do + config = Config.get([__MODULE__]) + bucket = Keyword.get(config, :bucket) + streaming = Keyword.get(config, :streaming_enabled) + + s3_name = strict_encode(upload.path) + + op = + if streaming do + op = + upload.tempfile + |> ExAws.S3.Upload.stream_file() + |> ExAws.S3.upload(bucket, s3_name, [ + {:acl, :public_read}, + {:content_type, upload.content_type} + ]) + + if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Gun do + # set s3 upload timeout to respect :upload pool timeout + # timeout should be slightly larger, so s3 can retry upload on fail + timeout = Pleroma.HTTP.AdapterHelper.Gun.pool_timeout(:upload) + 1_000 + opts = Keyword.put(op.opts, :timeout, timeout) + Map.put(op, :opts, opts) + else + op + end + else + {:ok, file_data} = File.read(upload.tempfile) + + ExAws.S3.put_object(bucket, s3_name, file_data, [ + {:acl, :public_read}, + {:content_type, upload.content_type} + ]) + end + + case ExAws.request(op) do + {:ok, _} -> + {:ok, {:file, s3_name}} + + error -> + Logger.error("#{__MODULE__}: #{inspect(error)}") + {:error, "S3 Upload failed"} + end + end + + @impl true + def delete_file(file) do + [__MODULE__, :bucket] + |> Config.get() + |> ExAws.S3.delete_object(file) + |> ExAws.request() + |> case do + {:ok, %{status_code: 204}} -> :ok + error -> {:error, inspect(error)} + end + end + + @regex Regex.compile!("[^0-9a-zA-Z!.*/'()_-]") + def strict_encode(name) do + String.replace(name, @regex, "-") + end +end diff --git a/lib/pleroma/uploaders/uploader.ex b/lib/pleroma/uploaders/uploader.ex new file mode 100644 index 0000000..77f6f02 --- /dev/null +++ b/lib/pleroma/uploaders/uploader.ex @@ -0,0 +1,84 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Uploaders.Uploader do + import Pleroma.Web.Gettext + + @mix_env Mix.env() + + @moduledoc """ + Defines the contract to put and get an uploaded file to any backend. + """ + + @doc """ + Instructs how to get the file from the backend. + + Used by `Pleroma.Web.Plugs.UploadedMedia`. + """ + @type get_method :: {:static_dir, directory :: String.t()} | {:url, url :: String.t()} + @callback get_file(file :: String.t()) :: {:ok, get_method()} + + @doc """ + Put a file to the backend. + + Returns: + + * `:ok` which assumes `{:ok, upload.path}` + * `{:ok, spec}` where spec is: + * `{:file, filename :: String.t}` to handle reads with `get_file/1` (recommended) + + This allows to correctly proxy or redirect requests to the backend, while allowing to migrate backends without breaking any URL. + * `{url, url :: String.t}` to bypass `get_file/2` and use the `url` directly in the activity. + * `{:error, String.t}` error information if the file failed to be saved to the backend. + * `:wait_callback` will wait for an http post request at `/api/pleroma/upload_callback/:upload_path` and call the uploader's `http_callback/3` method. + + """ + @type file_spec :: {:file | :url, String.t()} + @callback put_file(upload :: struct()) :: + :ok | {:ok, file_spec()} | {:error, String.t()} | :wait_callback + + @callback delete_file(file :: String.t()) :: :ok | {:error, String.t()} + + @callback http_callback(Plug.Conn.t(), Map.t()) :: + {:ok, Plug.Conn.t()} + | {:ok, Plug.Conn.t(), file_spec()} + | {:error, Plug.Conn.t(), String.t()} + @optional_callbacks http_callback: 2 + + @spec put_file(module(), upload :: struct()) :: {:ok, file_spec()} | {:error, String.t()} + def put_file(uploader, upload) do + case uploader.put_file(upload) do + :ok -> {:ok, {:file, upload.path}} + :wait_callback -> handle_callback(uploader, upload) + {:ok, _} = ok -> ok + {:error, _} = error -> error + end + end + + defp handle_callback(uploader, upload) do + :global.register_name({__MODULE__, upload.path}, self()) + + receive do + {__MODULE__, pid, conn, params} -> + case uploader.http_callback(conn, params) do + {:ok, conn, ok} -> + send(pid, {__MODULE__, conn}) + {:ok, ok} + + {:error, conn, error} -> + send(pid, {__MODULE__, conn}) + {:error, error} + end + after + callback_timeout() -> {:error, dgettext("errors", "Uploader callback timeout")} + end + end + + defp callback_timeout do + case @mix_env do + :test -> 1_000 + _ -> 30_000 + end + end +end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex new file mode 100644 index 0000000..f0c22ee --- /dev/null +++ b/lib/pleroma/user.ex @@ -0,0 +1,2833 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + import Ecto, only: [assoc: 2] + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] + + alias Ecto.Multi + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.Conversation.Participation + alias Pleroma.Delivery + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Emoji + alias Pleroma.FollowingRelationship + alias Pleroma.Formatter + alias Pleroma.HTML + alias Pleroma.Keys + alias Pleroma.MFA + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Registration + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.UserRelationship + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils + alias Pleroma.Web.Endpoint + alias Pleroma.Web.OAuth + alias Pleroma.Web.RelMe + alias Pleroma.Webhook.Notify + alias Pleroma.Workers.BackgroundWorker + + require Logger + + @type t :: %__MODULE__{} + @type account_status :: + :active + | :deactivated + | :password_reset_pending + | :confirmation_pending + | :approval_pending + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + + # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength + @email_regex ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ + + @strict_local_nickname_regex ~r/^[a-zA-Z\d]+$/ + @extended_local_nickname_regex ~r/^[a-zA-Z\d_-]+$/ + + # AP ID user relationships (blocks, mutes etc.) + # Format: [rel_type: [outgoing_rel: :outgoing_rel_target, incoming_rel: :incoming_rel_source]] + @user_relationships_config [ + block: [ + blocker_blocks: :blocked_users, + blockee_blocks: :blocker_users + ], + mute: [ + muter_mutes: :muted_users, + mutee_mutes: :muter_users + ], + reblog_mute: [ + reblog_muter_mutes: :reblog_muted_users, + reblog_mutee_mutes: :reblog_muter_users + ], + notification_mute: [ + notification_muter_mutes: :notification_muted_users, + notification_mutee_mutes: :notification_muter_users + ], + # Note: `inverse_subscription` relationship is inverse: subscriber acts as relationship target + inverse_subscription: [ + subscribee_subscriptions: :subscriber_users, + subscriber_subscriptions: :subscribee_users + ], + endorsement: [ + endorser_endorsements: :endorsed_users, + endorsee_endorsements: :endorser_users + ] + ] + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + + schema "users" do + field(:bio, :string, default: "") + field(:raw_bio, :string) + field(:email, :string) + field(:name, :string) + field(:nickname, :string) + field(:password_hash, :string) + field(:password, :string, virtual: true) + field(:password_confirmation, :string, virtual: true) + field(:keys, :string) + field(:public_key, :string) + field(:ap_id, :string) + field(:avatar, :map, default: %{}) + field(:local, :boolean, default: true) + field(:follower_address, :string) + field(:following_address, :string) + field(:featured_address, :string) + field(:search_rank, :float, virtual: true) + field(:search_type, :integer, virtual: true) + field(:tags, {:array, :string}, default: []) + field(:last_refreshed_at, :naive_datetime_usec) + field(:last_digest_emailed_at, :naive_datetime) + field(:banner, :map, default: %{}) + field(:background, :map, default: %{}) + field(:note_count, :integer, default: 0) + field(:follower_count, :integer, default: 0) + field(:following_count, :integer, default: 0) + field(:is_locked, :boolean, default: false) + field(:is_confirmed, :boolean, default: true) + field(:password_reset_pending, :boolean, default: false) + field(:is_approved, :boolean, default: true) + field(:registration_reason, :string, default: nil) + field(:confirmation_token, :string, default: nil) + field(:default_scope, :string, default: "public") + field(:domain_blocks, {:array, :string}, default: []) + field(:is_active, :boolean, default: true) + field(:no_rich_text, :boolean, default: false) + field(:is_moderator, :boolean, default: false) + field(:is_admin, :boolean, default: false) + field(:show_role, :boolean, default: true) + field(:uri, ObjectValidators.Uri, default: nil) + field(:hide_followers_count, :boolean, default: false) + field(:hide_follows_count, :boolean, default: false) + field(:hide_followers, :boolean, default: false) + field(:hide_follows, :boolean, default: false) + field(:hide_favorites, :boolean, default: true) + field(:email_notifications, :map, default: %{"digest" => false}) + field(:mascot, :map, default: nil) + field(:emoji, :map, default: %{}) + field(:pleroma_settings_store, :map, default: %{}) + field(:fields, {:array, :map}, default: []) + field(:raw_fields, {:array, :map}, default: []) + field(:is_discoverable, :boolean, default: false) + field(:invisible, :boolean, default: false) + field(:allow_following_move, :boolean, default: true) + field(:skip_thread_containment, :boolean, default: false) + field(:actor_type, :string, default: "Person") + field(:also_known_as, {:array, ObjectValidators.ObjectID}, default: []) + field(:inbox, :string) + field(:shared_inbox, :string) + field(:accepts_chat_messages, :boolean, default: nil) + field(:last_active_at, :naive_datetime) + field(:disclose_client, :boolean, default: true) + field(:accepts_email_list, :boolean, default: false) + field(:pinned_objects, :map, default: %{}) + field(:is_suggested, :boolean, default: false) + field(:last_status_at, :naive_datetime) + field(:birthday, :date) + field(:show_birthday, :boolean, default: false) + field(:location, :string) + field(:language, :string) + field(:last_move_at, :naive_datetime) + + embeds_one( + :notification_settings, + Pleroma.User.NotificationSetting, + on_replace: :update + ) + + has_many(:notifications, Notification) + has_many(:registrations, Registration) + has_many(:deliveries, Delivery) + + has_many(:outgoing_relationships, UserRelationship, foreign_key: :source_id) + has_many(:incoming_relationships, UserRelationship, foreign_key: :target_id) + + for {relationship_type, + [ + {outgoing_relation, outgoing_relation_target}, + {incoming_relation, incoming_relation_source} + ]} <- @user_relationships_config do + # Definitions of `has_many` relations: :blocker_blocks, :muter_mutes, :reblog_muter_mutes, + # :notification_muter_mutes, :subscribee_subscriptions, :endorser_endorsements + has_many(outgoing_relation, UserRelationship, + foreign_key: :source_id, + where: [relationship_type: relationship_type] + ) + + # Definitions of `has_many` relations: :blockee_blocks, :mutee_mutes, :reblog_mutee_mutes, + # :notification_mutee_mutes, :subscriber_subscriptions, :endorsee_endorsements + has_many(incoming_relation, UserRelationship, + foreign_key: :target_id, + where: [relationship_type: relationship_type] + ) + + # Definitions of `has_many` relations: :blocked_users, :muted_users, :reblog_muted_users, + # :notification_muted_users, :subscriber_users, :endorsed_users + has_many(outgoing_relation_target, through: [outgoing_relation, :target]) + + # Definitions of `has_many` relations: :blocker_users, :muter_users, :reblog_muter_users, + # :notification_muter_users, :subscribee_users, :endorser_users + has_many(incoming_relation_source, through: [incoming_relation, :source]) + end + + # `:blocks` is deprecated (replaced with `blocked_users` relation) + field(:blocks, {:array, :string}, default: []) + # `:mutes` is deprecated (replaced with `muted_users` relation) + field(:mutes, {:array, :string}, default: []) + # `:muted_reblogs` is deprecated (replaced with `reblog_muted_users` relation) + field(:muted_reblogs, {:array, :string}, default: []) + # `:muted_notifications` is deprecated (replaced with `notification_muted_users` relation) + field(:muted_notifications, {:array, :string}, default: []) + # `:subscribers` is deprecated (replaced with `subscriber_users` relation) + field(:subscribers, {:array, :string}, default: []) + + embeds_one( + :multi_factor_authentication_settings, + MFA.Settings, + on_replace: :delete + ) + + timestamps() + end + + for {_relationship_type, [{_outgoing_relation, outgoing_relation_target}, _]} <- + @user_relationships_config do + # `def blocked_users_relation/2`, `def muted_users_relation/2`, + # `def reblog_muted_users_relation/2`, `def notification_muted_users/2`, + # `def subscriber_users/2`, `def endorsed_users_relation/2` + def unquote(:"#{outgoing_relation_target}_relation")(user, restrict_deactivated? \\ false) do + target_users_query = assoc(user, unquote(outgoing_relation_target)) + + if restrict_deactivated? do + target_users_query + |> User.Query.build(%{deactivated: false}) + else + target_users_query + end + end + + # `def blocked_users/2`, `def muted_users/2`, `def reblog_muted_users/2`, + # `def notification_muted_users/2`, `def subscriber_users/2`, `def endorsed_users/2` + def unquote(outgoing_relation_target)(user, restrict_deactivated? \\ false) do + __MODULE__ + |> apply(unquote(:"#{outgoing_relation_target}_relation"), [ + user, + restrict_deactivated? + ]) + |> Repo.all() + end + + # `def blocked_users_ap_ids/2`, `def muted_users_ap_ids/2`, `def reblog_muted_users_ap_ids/2`, + # `def notification_muted_users_ap_ids/2`, `def subscriber_users_ap_ids/2`, + # `def endorsed_users_ap_ids/2` + def unquote(:"#{outgoing_relation_target}_ap_ids")(user, restrict_deactivated? \\ false) do + __MODULE__ + |> apply(unquote(:"#{outgoing_relation_target}_relation"), [ + user, + restrict_deactivated? + ]) + |> select([u], u.ap_id) + |> Repo.all() + end + end + + def cached_blocked_users_ap_ids(user) do + @cachex.fetch!(:user_cache, "blocked_users_ap_ids:#{user.ap_id}", fn _ -> + blocked_users_ap_ids(user) + end) + end + + def cached_muted_users_ap_ids(user) do + @cachex.fetch!(:user_cache, "muted_users_ap_ids:#{user.ap_id}", fn _ -> + muted_users_ap_ids(user) + end) + end + + defdelegate following_count(user), to: FollowingRelationship + defdelegate following(user), to: FollowingRelationship + defdelegate following?(follower, followed), to: FollowingRelationship + defdelegate following_ap_ids(user), to: FollowingRelationship + defdelegate get_follow_requests(user), to: FollowingRelationship + defdelegate search(query, opts \\ []), to: User.Search + + @doc """ + Dumps Flake Id to SQL-compatible format (16-byte UUID). + E.g. "9pQtDGXuq4p3VlcJEm" -> <<0, 0, 1, 110, 179, 218, 42, 92, 213, 41, 44, 227, 95, 213, 0, 0>> + """ + def binary_id(source_id) when is_binary(source_id) do + with {:ok, dumped_id} <- FlakeId.Ecto.CompatType.dump(source_id) do + dumped_id + else + _ -> source_id + end + end + + def binary_id(source_ids) when is_list(source_ids) do + Enum.map(source_ids, &binary_id/1) + end + + def binary_id(%User{} = user), do: binary_id(user.id) + + @doc "Returns status account" + @spec account_status(User.t()) :: account_status() + def account_status(%User{is_active: false}), do: :deactivated + def account_status(%User{password_reset_pending: true}), do: :password_reset_pending + def account_status(%User{local: true, is_approved: false}), do: :approval_pending + def account_status(%User{local: true, is_confirmed: false}), do: :confirmation_pending + def account_status(%User{}), do: :active + + @spec visible_for(User.t(), User.t() | nil) :: + :visible + | :invisible + | :restricted_unauthenticated + | :deactivated + | :confirmation_pending + def visible_for(user, for_user \\ nil) + + def visible_for(%User{invisible: true}, _), do: :invisible + + def visible_for(%User{id: user_id}, %User{id: user_id}), do: :visible + + def visible_for(%User{} = user, nil) do + if restrict_unauthenticated?(user) do + :restrict_unauthenticated + else + visible_account_status(user) + end + end + + def visible_for(%User{} = user, for_user) do + if privileged?(for_user, :users_manage_activation_state) do + :visible + else + visible_account_status(user) + end + end + + def visible_for(_, _), do: :invisible + + defp restrict_unauthenticated?(%User{local: true}) do + Config.restrict_unauthenticated_access?(:profiles, :local) + end + + defp restrict_unauthenticated?(%User{local: _}) do + Config.restrict_unauthenticated_access?(:profiles, :remote) + end + + defp visible_account_status(user) do + status = account_status(user) + + if status in [:active, :password_reset_pending] do + :visible + else + status + end + end + + @spec privileged?(User.t(), atom()) :: boolean() + def privileged?(%User{is_admin: false, is_moderator: false}, _), do: false + + def privileged?( + %User{local: true, is_admin: is_admin, is_moderator: is_moderator}, + privilege_tag + ), + do: + privileged_for?(privilege_tag, is_admin, :admin_privileges) or + privileged_for?(privilege_tag, is_moderator, :moderator_privileges) + + def privileged?(_, _), do: false + + defp privileged_for?(privilege_tag, true, config_role_key), + do: privilege_tag in Config.get([:instance, config_role_key]) + + defp privileged_for?(_, _, _), do: false + + @spec privileges(User.t()) :: [atom()] + def privileges(%User{local: false}) do + [] + end + + def privileges(%User{is_moderator: false, is_admin: false}) do + [] + end + + def privileges(%User{local: true, is_moderator: true, is_admin: true}) do + (Config.get([:instance, :moderator_privileges]) ++ Config.get([:instance, :admin_privileges])) + |> Enum.uniq() + end + + def privileges(%User{local: true, is_moderator: true, is_admin: false}) do + Config.get([:instance, :moderator_privileges]) + end + + def privileges(%User{local: true, is_moderator: false, is_admin: true}) do + Config.get([:instance, :admin_privileges]) + end + + @spec invisible?(User.t()) :: boolean() + def invisible?(%User{invisible: true}), do: true + def invisible?(_), do: false + + def avatar_url(user, options \\ []) do + case user.avatar do + %{"url" => [%{"href" => href} | _]} -> + href + + _ -> + unless options[:no_default] do + Config.get([:assets, :default_user_avatar], "#{Endpoint.url()}/images/avi.png") + end + end + end + + def banner_url(user, options \\ []) do + case user.banner do + %{"url" => [%{"href" => href} | _]} -> href + _ -> !options[:no_default] && "#{Endpoint.url()}/images/banner.png" + end + end + + # Should probably be renamed or removed + @spec ap_id(User.t()) :: String.t() + def ap_id(%User{nickname: nickname}), do: "#{Endpoint.url()}/users/#{nickname}" + + @spec ap_followers(User.t()) :: String.t() + def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa + def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers" + + @spec ap_following(User.t()) :: String.t() + def ap_following(%User{following_address: fa}) when is_binary(fa), do: fa + def ap_following(%User{} = user), do: "#{ap_id(user)}/following" + + @spec ap_featured_collection(User.t()) :: String.t() + def ap_featured_collection(%User{featured_address: fa}) when is_binary(fa), do: fa + + def ap_featured_collection(%User{} = user), do: "#{ap_id(user)}/collections/featured" + + defp truncate_fields_param(params) do + if Map.has_key?(params, :fields) do + Map.put(params, :fields, Enum.map(params[:fields], &truncate_field/1)) + else + params + end + end + + defp truncate_if_exists(params, key, max_length) do + if Map.has_key?(params, key) and is_binary(params[key]) do + {value, _chopped} = String.split_at(params[key], max_length) + Map.put(params, key, value) + else + params + end + end + + defp fix_follower_address(%{follower_address: _, following_address: _} = params), do: params + + defp fix_follower_address(%{nickname: nickname} = params), + do: Map.put(params, :follower_address, ap_followers(%User{nickname: nickname})) + + defp fix_follower_address(params), do: params + + def remote_user_changeset(struct \\ %User{local: false}, params) do + bio_limit = Config.get([:instance, :user_bio_length], 5000) + name_limit = Config.get([:instance, :user_name_length], 100) + location_limit = Config.get([:instance, :user_location_length], 50) + + name = + case params[:name] do + name when is_binary(name) and byte_size(name) > 0 -> name + _ -> params[:nickname] + end + + params = + params + |> Map.put(:name, name) + |> Map.put_new(:last_refreshed_at, NaiveDateTime.utc_now()) + |> truncate_if_exists(:name, name_limit) + |> truncate_if_exists(:bio, bio_limit) + |> truncate_fields_param() + |> fix_follower_address() + + struct + |> cast( + params, + [ + :bio, + :emoji, + :ap_id, + :inbox, + :shared_inbox, + :nickname, + :public_key, + :avatar, + :banner, + :is_locked, + :last_refreshed_at, + :uri, + :follower_address, + :following_address, + :featured_address, + :hide_followers, + :hide_follows, + :hide_followers_count, + :hide_follows_count, + :follower_count, + :fields, + :following_count, + :is_discoverable, + :invisible, + :actor_type, + :also_known_as, + :accepts_chat_messages, + :pinned_objects, + :birthday, + :show_birthday, + :location + ] + ) + |> cast(params, [:name], empty_values: []) + |> validate_required([:ap_id]) + |> validate_required([:name], trim: false) + |> unique_constraint(:nickname) + |> validate_format(:nickname, @email_regex) + |> validate_length(:bio, max: bio_limit) + |> validate_length(:name, max: name_limit) + |> validate_length(:location, max: location_limit) + |> validate_fields(true) + |> validate_non_local() + end + + defp validate_non_local(cng) do + local? = get_field(cng, :local) + + if local? do + cng + |> add_error(:local, "User is local, can't update with this changeset.") + else + cng + end + end + + def update_changeset(struct, params \\ %{}) do + bio_limit = Config.get([:instance, :user_bio_length], 5000) + name_limit = Config.get([:instance, :user_name_length], 100) + location_limit = Config.get([:instance, :user_location_length], 50) + + struct + |> cast( + params, + [ + :bio, + :raw_bio, + :name, + :emoji, + :avatar, + :public_key, + :inbox, + :shared_inbox, + :is_locked, + :no_rich_text, + :default_scope, + :banner, + :hide_follows, + :hide_followers, + :hide_followers_count, + :hide_follows_count, + :hide_favorites, + :allow_following_move, + :also_known_as, + :background, + :show_role, + :skip_thread_containment, + :fields, + :raw_fields, + :pleroma_settings_store, + :is_discoverable, + :actor_type, + :accepts_chat_messages, + :disclose_client, + :accepts_email_list, + :birthday, + :show_birthday, + :location + ] + ) + |> validate_min_age() + |> unique_constraint(:nickname) + |> validate_format(:nickname, local_nickname_regex()) + |> validate_length(:bio, max: bio_limit) + |> validate_length(:location, max: location_limit) + |> validate_length(:name, min: 1, max: name_limit) + |> validate_inclusion(:actor_type, ["Person", "Service"]) + |> put_fields() + |> put_emoji() + |> put_change_if_present(:bio, &{:ok, parse_bio(&1)}) + |> put_change_if_present(:avatar, &put_upload(&1, :avatar)) + |> put_change_if_present(:banner, &put_upload(&1, :banner)) + |> put_change_if_present(:background, &put_upload(&1, :background)) + |> put_change_if_present( + :pleroma_settings_store, + &{:ok, Map.merge(struct.pleroma_settings_store, &1)} + ) + |> validate_fields(false) + end + + defp put_fields(changeset) do + if raw_fields = get_change(changeset, :raw_fields) do + old_fields = changeset.data.raw_fields + + raw_fields = + raw_fields + |> Enum.filter(fn %{"name" => n} -> n != "" end) + |> Enum.map(fn field -> + previous = + old_fields + |> Enum.find(fn %{"value" => value} -> field["value"] == value end) + + if previous && Map.has_key?(previous, "verified_at") do + field + |> Map.put("verified_at", previous["verified_at"]) + else + field + end + end) + + fields = + raw_fields + |> Enum.map(fn f -> Map.update!(f, "value", &parse_fields(&1)) end) + + changeset + |> put_change(:raw_fields, raw_fields) + |> put_change(:fields, fields) + else + changeset + end + end + + defp parse_fields(value) do + value + |> Formatter.linkify(mentions_format: :full) + |> elem(0) + end + + defp put_emoji(changeset) do + emojified_fields = [:bio, :name, :raw_fields] + + if Enum.any?(changeset.changes, fn {k, _} -> k in emojified_fields end) do + bio = Emoji.Formatter.get_emoji_map(get_field(changeset, :bio)) + name = Emoji.Formatter.get_emoji_map(get_field(changeset, :name)) + + emoji = Map.merge(bio, name) + + emoji = + changeset + |> get_field(:raw_fields) + |> Enum.reduce(emoji, fn x, acc -> + Map.merge(acc, Emoji.Formatter.get_emoji_map(x["name"] <> x["value"])) + end) + + put_change(changeset, :emoji, emoji) + else + changeset + end + end + + defp put_change_if_present(changeset, map_field, value_function) do + with {:ok, value} <- fetch_change(changeset, map_field), + {:ok, new_value} <- value_function.(value) do + put_change(changeset, map_field, new_value) + else + {:error, :file_too_large} -> + Ecto.Changeset.validate_change(changeset, map_field, fn map_field, _value -> + [{map_field, "file is too large"}] + end) + + _ -> + changeset + end + end + + defp put_upload(value, type) do + with %Plug.Upload{} <- value, + {:ok, object} <- ActivityPub.upload(value, type: type) do + {:ok, object.data} + end + end + + def update_as_admin_changeset(struct, params) do + struct + |> update_changeset(params) + |> cast(params, [:email]) + |> delete_change(:also_known_as) + |> unique_constraint(:email) + |> validate_format(:email, @email_regex) + |> validate_inclusion(:actor_type, ["Person", "Service"]) + end + + @spec update_as_admin(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t()} + def update_as_admin(user, params) do + params = Map.put(params, "password_confirmation", params["password"]) + changeset = update_as_admin_changeset(user, params) + + if params["password"] do + reset_password(user, changeset, params) + else + User.update_and_set_cache(changeset) + end + end + + def password_update_changeset(struct, params) do + struct + |> cast(params, [:password, :password_confirmation]) + |> validate_required([:password, :password_confirmation]) + |> validate_confirmation(:password) + |> put_password_hash() + |> put_change(:password_reset_pending, false) + end + + @spec reset_password(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t()} + def reset_password(%User{} = user, params) do + reset_password(user, user, params) + end + + def reset_password(%User{id: user_id} = user, struct, params) do + multi = + Multi.new() + |> Multi.update(:user, password_update_changeset(struct, params)) + |> Multi.delete_all(:tokens, OAuth.Token.Query.get_by_user(user_id)) + |> Multi.delete_all(:auth, OAuth.Authorization.delete_by_user_query(user)) + + case Repo.transaction(multi) do + {:ok, %{user: user} = _} -> set_cache(user) + {:error, _, changeset, _} -> {:error, changeset} + end + end + + def update_password_reset_pending(user, value) do + user + |> change() + |> put_change(:password_reset_pending, value) + |> update_and_set_cache() + end + + def force_password_reset_async(user) do + BackgroundWorker.enqueue("force_password_reset", %{"user_id" => user.id}) + end + + @spec force_password_reset(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def force_password_reset(user), do: update_password_reset_pending(user, true) + + # Used to auto-register LDAP accounts which won't have a password hash stored locally + def register_changeset_ldap(struct, params = %{password: password}) + when is_nil(password) do + params = Map.put_new(params, :accepts_chat_messages, true) + + params = + if Map.has_key?(params, :email) do + Map.put_new(params, :email, params[:email]) + else + params + end + + struct + |> cast(params, [ + :name, + :nickname, + :email, + :accepts_chat_messages, + :accepts_email_list + ]) + |> validate_required([:name, :nickname]) + |> unique_constraint(:nickname) + |> validate_not_restricted_nickname(:nickname) + |> validate_format(:nickname, local_nickname_regex()) + |> put_ap_id() + |> unique_constraint(:ap_id) + |> put_following_and_follower_and_featured_address() + |> put_private_key() + end + + def register_changeset(struct, params \\ %{}, opts \\ []) do + bio_limit = Config.get([:instance, :user_bio_length], 5000) + name_limit = Config.get([:instance, :user_name_length], 100) + reason_limit = Config.get([:instance, :registration_reason_length], 500) + params = Map.put_new(params, :accepts_chat_messages, true) + + confirmed? = + if is_nil(opts[:confirmed]) do + !Config.get([:instance, :account_activation_required]) + else + opts[:confirmed] + end + + approved? = + if is_nil(opts[:approved]) do + !Config.get([:instance, :account_approval_required]) + else + opts[:approved] + end + + struct + |> confirmation_changeset(set_confirmation: confirmed?) + |> approval_changeset(set_approval: approved?) + |> cast(params, [ + :bio, + :raw_bio, + :email, + :name, + :nickname, + :password, + :password_confirmation, + :emoji, + :accepts_chat_messages, + :registration_reason, + :accepts_email_list, + :birthday, + :language + ]) + |> validate_required([:name, :nickname, :password, :password_confirmation]) + |> validate_confirmation(:password) + |> unique_constraint(:email) + |> validate_format(:email, @email_regex) + |> validate_email_not_in_blacklisted_domain(:email) + |> unique_constraint(:nickname) + |> validate_not_restricted_nickname(:nickname) + |> validate_format(:nickname, local_nickname_regex()) + |> validate_length(:bio, max: bio_limit) + |> validate_length(:name, min: 1, max: name_limit) + |> validate_length(:registration_reason, max: reason_limit) + |> maybe_validate_required_email(opts[:external]) + |> maybe_validate_required_birthday + |> validate_min_age() + |> put_password_hash + |> put_ap_id() + |> unique_constraint(:ap_id) + |> put_following_and_follower_and_featured_address() + |> put_private_key() + end + + def validate_not_restricted_nickname(changeset, field) do + validate_change(changeset, field, fn _, value -> + valid? = + Config.get([User, :restricted_nicknames]) + |> Enum.all?(fn restricted_nickname -> + String.downcase(value) != String.downcase(restricted_nickname) + end) + + if valid?, do: [], else: [nickname: "Invalid nickname"] + end) + end + + def validate_email_not_in_blacklisted_domain(changeset, field) do + validate_change(changeset, field, fn _, value -> + valid? = + Config.get([User, :email_blacklist]) + |> Enum.all?(fn blacklisted_domain -> + blacklisted_domain_downcase = String.downcase(blacklisted_domain) + + !String.ends_with?(String.downcase(value), [ + "@" <> blacklisted_domain_downcase, + "." <> blacklisted_domain_downcase + ]) + end) + + if valid?, do: [], else: [email: "Invalid email"] + end) + end + + def maybe_validate_required_email(changeset, true), do: changeset + + def maybe_validate_required_email(changeset, _) do + if Config.get([:instance, :account_activation_required]) do + validate_required(changeset, [:email]) + else + changeset + end + end + + defp maybe_validate_required_birthday(changeset) do + if Config.get([:instance, :birthday_required]) do + validate_required(changeset, [:birthday]) + else + changeset + end + end + + defp validate_min_age(changeset) do + changeset + |> validate_change(:birthday, fn :birthday, birthday -> + valid? = + Date.utc_today() + |> Date.diff(birthday) >= + Config.get([:instance, :birthday_min_age]) + + if valid?, do: [], else: [birthday: "Invalid age"] + end) + end + + defp put_ap_id(changeset) do + ap_id = ap_id(%User{nickname: get_field(changeset, :nickname)}) + put_change(changeset, :ap_id, ap_id) + end + + defp put_following_and_follower_and_featured_address(changeset) do + user = %User{nickname: get_field(changeset, :nickname)} + followers = ap_followers(user) + following = ap_following(user) + featured = ap_featured_collection(user) + + changeset + |> put_change(:follower_address, followers) + |> put_change(:following_address, following) + |> put_change(:featured_address, featured) + end + + defp put_private_key(changeset) do + {:ok, pem} = Keys.generate_rsa_pem() + put_change(changeset, :keys, pem) + end + + defp autofollow_users(user) do + candidates = Config.get([:instance, :autofollowed_nicknames]) + + autofollowed_users = + User.Query.build(%{nickname: candidates, local: true, is_active: true}) + |> Repo.all() + + follow_all(user, autofollowed_users) + end + + defp autofollowing_users(user) do + candidates = Config.get([:instance, :autofollowing_nicknames]) + + User.Query.build(%{nickname: candidates, local: true, deactivated: false}) + |> Repo.all() + |> Enum.each(&follow(&1, user, :follow_accept)) + + {:ok, :success} + end + + @doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)" + def register(%Ecto.Changeset{} = changeset) do + with {:ok, user} <- Repo.insert(changeset) do + Notify.trigger_webhooks(user, :"account.created") + post_register_action(user) + end + end + + def post_register_action(%User{is_confirmed: false} = user) do + with {:ok, _} <- maybe_send_confirmation_email(user) do + {:ok, user} + end + end + + def post_register_action(%User{is_approved: false} = user) do + with {:ok, _} <- send_user_approval_email(user), + {:ok, _} <- send_admin_approval_emails(user) do + {:ok, user} + end + end + + def post_register_action(%User{is_approved: true, is_confirmed: true} = user) do + with {:ok, user} <- autofollow_users(user), + {:ok, _} <- autofollowing_users(user), + {:ok, user} <- set_cache(user), + {:ok, _} <- maybe_send_registration_email(user), + {:ok, _} <- maybe_send_welcome_email(user), + {:ok, _} <- maybe_send_welcome_message(user), + {:ok, _} <- maybe_send_welcome_chat_message(user) do + {:ok, user} + end + end + + defp send_user_approval_email(%User{email: email} = user) when is_binary(email) do + user + |> Pleroma.Emails.UserEmail.approval_pending_email() + |> Pleroma.Emails.Mailer.deliver_async() + + {:ok, :enqueued} + end + + defp send_user_approval_email(_user) do + {:ok, :skipped} + end + + defp send_admin_approval_emails(user) do + all_superusers() + |> Enum.filter(fn user -> not is_nil(user.email) end) + |> Enum.each(fn superuser -> + superuser + |> Pleroma.Emails.AdminEmail.new_unapproved_registration(user) + |> Pleroma.Emails.Mailer.deliver_async() + end) + + {:ok, :enqueued} + end + + defp maybe_send_welcome_message(user) do + if User.WelcomeMessage.enabled?() do + User.WelcomeMessage.post_message(user) + {:ok, :enqueued} + else + {:ok, :noop} + end + end + + defp maybe_send_welcome_chat_message(user) do + if User.WelcomeChatMessage.enabled?() do + User.WelcomeChatMessage.post_message(user) + {:ok, :enqueued} + else + {:ok, :noop} + end + end + + defp maybe_send_welcome_email(%User{email: email} = user) when is_binary(email) do + if User.WelcomeEmail.enabled?() do + User.WelcomeEmail.send_email(user) + {:ok, :enqueued} + else + {:ok, :noop} + end + end + + defp maybe_send_welcome_email(_), do: {:ok, :noop} + + @spec maybe_send_confirmation_email(User.t()) :: {:ok, :enqueued | :noop} + def maybe_send_confirmation_email(%User{is_confirmed: false, email: email} = user) + when is_binary(email) do + if Config.get([:instance, :account_activation_required]) do + send_confirmation_email(user) + {:ok, :enqueued} + else + {:ok, :noop} + end + end + + def maybe_send_confirmation_email(_), do: {:ok, :noop} + + @spec send_confirmation_email(Uset.t()) :: User.t() + def send_confirmation_email(%User{} = user) do + user + |> Pleroma.Emails.UserEmail.account_confirmation_email() + |> Pleroma.Emails.Mailer.deliver_async() + + user + end + + @spec maybe_send_registration_email(User.t()) :: {:ok, :enqueued | :noop} + defp maybe_send_registration_email(%User{email: email} = user) when is_binary(email) do + with false <- User.WelcomeEmail.enabled?(), + false <- Config.get([:instance, :account_activation_required], false), + false <- Config.get([:instance, :account_approval_required], false) do + user + |> Pleroma.Emails.UserEmail.successful_registration_email() + |> Pleroma.Emails.Mailer.deliver_async() + + {:ok, :enqueued} + else + _ -> + {:ok, :noop} + end + end + + defp maybe_send_registration_email(_), do: {:ok, :noop} + + def needs_update?(%User{local: true}), do: false + + def needs_update?(%User{local: false, last_refreshed_at: nil}), do: true + + def needs_update?(%User{local: false} = user) do + NaiveDateTime.diff(NaiveDateTime.utc_now(), user.last_refreshed_at) >= 86_400 + end + + def needs_update?(_), do: true + + @spec maybe_direct_follow(User.t(), User.t()) :: {:ok, User.t()} | {:error, String.t()} + + # "Locked" (self-locked) users demand explicit authorization of follow requests + def maybe_direct_follow(%User{} = follower, %User{local: true, is_locked: true} = followed) do + follow(follower, followed, :follow_pending) + end + + def maybe_direct_follow(%User{} = follower, %User{local: true} = followed) do + follow(follower, followed) + end + + def maybe_direct_follow(%User{} = follower, %User{} = followed) do + {:ok, follower, followed} + end + + @doc "A mass follow for local users. Respects blocks in both directions but does not create activities." + @spec follow_all(User.t(), list(User.t())) :: {atom(), User.t()} + def follow_all(follower, followeds) do + followeds + |> Enum.reject(fn followed -> blocks?(follower, followed) || blocks?(followed, follower) end) + |> Enum.each(&follow(follower, &1, :follow_accept)) + + set_cache(follower) + end + + def follow(%User{} = follower, %User{} = followed, state \\ :follow_accept) do + deny_follow_blocked = Config.get([:user, :deny_follow_blocked]) + + cond do + not followed.is_active -> + {:error, "Could not follow user: #{followed.nickname} is deactivated."} + + deny_follow_blocked and blocks?(followed, follower) -> + {:error, "Could not follow user: #{followed.nickname} blocked you."} + + true -> + FollowingRelationship.follow(follower, followed, state) + end + end + + def unfollow(%User{ap_id: ap_id}, %User{ap_id: ap_id}) do + {:error, "Not subscribed!"} + end + + @spec unfollow(User.t(), User.t()) :: {:ok, User.t(), Activity.t()} | {:error, String.t()} + def unfollow(%User{} = follower, %User{} = followed) do + case do_unfollow(follower, followed) do + {:ok, follower, followed} -> + {:ok, follower, Utils.fetch_latest_follow(follower, followed)} + + error -> + error + end + end + + @spec do_unfollow(User.t(), User.t()) :: {:ok, User.t(), User.t()} | {:error, String.t()} + defp do_unfollow(%User{} = follower, %User{} = followed) do + case get_follow_state(follower, followed) do + state when state in [:follow_pending, :follow_accept] -> + FollowingRelationship.unfollow(follower, followed) + + nil -> + {:error, "Not subscribed!"} + end + end + + @doc "Returns follow state as Pleroma.FollowingRelationship.State value" + def get_follow_state(%User{} = follower, %User{} = following) do + following_relationship = FollowingRelationship.get(follower, following) + get_follow_state(follower, following, following_relationship) + end + + def get_follow_state( + %User{} = follower, + %User{} = following, + following_relationship + ) do + case {following_relationship, following.local} do + {nil, false} -> + case Utils.fetch_latest_follow(follower, following) do + %Activity{data: %{"state" => state}} when state in ["pending", "accept"] -> + FollowingRelationship.state_to_enum(state) + + _ -> + nil + end + + {%{state: state}, _} -> + state + + {nil, _} -> + nil + end + end + + def locked?(%User{} = user) do + user.is_locked || false + end + + def get_by_id(id) do + Repo.get_by(User, id: id) + end + + def get_by_ap_id(ap_id) do + Repo.get_by(User, ap_id: ap_id) + end + + def get_by_uri(uri) do + Repo.get_by(User, uri: uri) + end + + def get_all_by_ap_id(ap_ids) do + from(u in __MODULE__, + where: u.ap_id in ^ap_ids + ) + |> Repo.all() + end + + def get_all_by_ids(ids) do + from(u in __MODULE__, where: u.id in ^ids) + |> Repo.all() + end + + # This is mostly an SPC migration fix. This guesses the user nickname by taking the last part + # of the ap_id and the domain and tries to get that user + def get_by_guessed_nickname(ap_id) do + domain = URI.parse(ap_id).host + name = List.last(String.split(ap_id, "/")) + nickname = "#{name}@#{domain}" + + get_cached_by_nickname(nickname) + end + + def set_cache({:ok, user}), do: set_cache(user) + def set_cache({:error, err}), do: {:error, err} + + def set_cache(%User{} = user) do + @cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) + @cachex.put(:user_cache, "nickname:#{user.nickname}", user) + @cachex.put(:user_cache, "friends_ap_ids:#{user.nickname}", get_user_friends_ap_ids(user)) + {:ok, user} + end + + def update_and_set_cache(struct, params) do + struct + |> update_changeset(params) + |> update_and_set_cache() + end + + def update_and_set_cache(changeset) do + with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do + if get_change(changeset, :raw_fields) do + BackgroundWorker.enqueue("verify_fields_links", %{"user_id" => user.id}) + end + + set_cache(user) + end + end + + def get_user_friends_ap_ids(user) do + from(u in User.get_friends_query(user), select: u.ap_id) + |> Repo.all() + end + + @spec get_cached_user_friends_ap_ids(User.t()) :: [String.t()] + def get_cached_user_friends_ap_ids(user) do + @cachex.fetch!(:user_cache, "friends_ap_ids:#{user.ap_id}", fn _ -> + get_user_friends_ap_ids(user) + end) + end + + def invalidate_cache(user) do + @cachex.del(:user_cache, "ap_id:#{user.ap_id}") + @cachex.del(:user_cache, "nickname:#{user.nickname}") + @cachex.del(:user_cache, "friends_ap_ids:#{user.ap_id}") + @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") + @cachex.del(:user_cache, "muted_users_ap_ids:#{user.ap_id}") + end + + @spec get_cached_by_ap_id(String.t()) :: User.t() | nil + def get_cached_by_ap_id(ap_id) do + key = "ap_id:#{ap_id}" + + with {:ok, nil} <- @cachex.get(:user_cache, key), + user when not is_nil(user) <- get_by_ap_id(ap_id), + {:ok, true} <- @cachex.put(:user_cache, key, user) do + user + else + {:ok, user} -> user + nil -> nil + end + end + + def get_cached_by_id(id) do + key = "id:#{id}" + + ap_id = + @cachex.fetch!(:user_cache, key, fn _ -> + user = get_by_id(id) + + if user do + @cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) + {:commit, user.ap_id} + else + {:ignore, ""} + end + end) + + get_cached_by_ap_id(ap_id) + end + + def get_cached_by_nickname(nickname) do + key = "nickname:#{nickname}" + + @cachex.fetch!(:user_cache, key, fn _ -> + case get_or_fetch_by_nickname(nickname) do + {:ok, user} -> {:commit, user} + {:error, _error} -> {:ignore, nil} + end + end) + end + + def get_cached_by_nickname_or_id(nickname_or_id, opts \\ []) do + restrict_to_local = Config.get([:instance, :limit_to_local_content]) + + cond do + is_integer(nickname_or_id) or FlakeId.flake_id?(nickname_or_id) -> + get_cached_by_id(nickname_or_id) || get_cached_by_nickname(nickname_or_id) + + restrict_to_local == false or not String.contains?(nickname_or_id, "@") -> + get_cached_by_nickname(nickname_or_id) + + restrict_to_local == :unauthenticated and match?(%User{}, opts[:for]) -> + get_cached_by_nickname(nickname_or_id) + + true -> + nil + end + end + + @spec get_by_nickname(String.t()) :: User.t() | nil + def get_by_nickname(nickname) do + Repo.get_by(User, nickname: nickname) || + if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do + Repo.get_by(User, nickname: local_nickname(nickname)) + end + end + + def get_by_email(email), do: Repo.get_by(User, email: email) + + def get_by_nickname_or_email(nickname_or_email) do + get_by_nickname(nickname_or_email) || get_by_email(nickname_or_email) + end + + def fetch_by_nickname(nickname), do: ActivityPub.make_user_from_nickname(nickname) + + def get_or_fetch_by_nickname(nickname) do + with %User{} = user <- get_by_nickname(nickname) do + {:ok, user} + else + _e -> + with [_nick, _domain] <- String.split(nickname, "@"), + {:ok, user} <- fetch_by_nickname(nickname) do + {:ok, user} + else + _e -> {:error, "not found " <> nickname} + end + end + end + + @spec get_followers_query(User.t(), pos_integer() | nil) :: Ecto.Query.t() + def get_followers_query(%User{} = user, nil) do + User.Query.build(%{followers: user, is_active: true}) + end + + def get_followers_query(%User{} = user, page) do + user + |> get_followers_query(nil) + |> User.Query.paginate(page, 20) + end + + @spec get_followers_query(User.t()) :: Ecto.Query.t() + def get_followers_query(%User{} = user), do: get_followers_query(user, nil) + + @spec get_followers(User.t(), pos_integer() | nil) :: {:ok, list(User.t())} + def get_followers(%User{} = user, page \\ nil) do + user + |> get_followers_query(page) + |> Repo.all() + end + + @spec get_external_followers(User.t(), pos_integer() | nil) :: {:ok, list(User.t())} + def get_external_followers(%User{} = user, page \\ nil) do + user + |> get_followers_query(page) + |> User.Query.build(%{external: true}) + |> Repo.all() + end + + def get_followers_ids(%User{} = user, page \\ nil) do + user + |> get_followers_query(page) + |> select([u], u.id) + |> Repo.all() + end + + @spec get_friends_query(User.t(), pos_integer() | nil) :: Ecto.Query.t() + def get_friends_query(%User{} = user, nil) do + User.Query.build(%{friends: user, deactivated: false}) + end + + def get_friends_query(%User{} = user, page) do + user + |> get_friends_query(nil) + |> User.Query.paginate(page, 20) + end + + @spec get_friends_query(User.t()) :: Ecto.Query.t() + def get_friends_query(%User{} = user), do: get_friends_query(user, nil) + + def get_friends(%User{} = user, page \\ nil) do + user + |> get_friends_query(page) + |> Repo.all() + end + + def get_friends_ap_ids(%User{} = user) do + user + |> get_friends_query(nil) + |> select([u], u.ap_id) + |> Repo.all() + end + + def get_friends_ids(%User{} = user, page \\ nil) do + user + |> get_friends_query(page) + |> select([u], u.id) + |> Repo.all() + end + + @spec get_familiar_followers_query(User.t(), User.t(), pos_integer() | nil) :: Ecto.Query.t() + def get_familiar_followers_query(%User{} = user, %User{} = current_user, nil) do + friends = + get_friends_query(current_user) + |> where([u], not u.hide_follows) + |> select([u], u.id) + + User.Query.build(%{is_active: true}) + |> where([u], u.id not in ^[user.id, current_user.id]) + |> join(:inner, [u], r in FollowingRelationship, + as: :followers_relationships, + on: r.following_id == ^user.id and r.follower_id == u.id + ) + |> where([followers_relationships: r], r.state == ^:follow_accept) + |> where([followers_relationships: r], r.follower_id in subquery(friends)) + end + + def get_familiar_followers_query(%User{} = user, %User{} = current_user, page) do + user + |> get_familiar_followers_query(current_user, nil) + |> User.Query.paginate(page, 20) + end + + @spec get_familiar_followers_query(User.t(), User.t()) :: Ecto.Query.t() + def get_familiar_followers_query(%User{} = user, %User{} = current_user), + do: get_familiar_followers_query(user, current_user, nil) + + @spec get_familiar_followers(User.t(), User.t(), pos_integer() | nil) :: {:ok, list(User.t())} + def get_familiar_followers(%User{} = user, %User{} = current_user, page \\ nil) do + user + |> get_familiar_followers_query(current_user, page) + |> Repo.all() + end + + def increase_note_count(%User{} = user) do + User + |> where(id: ^user.id) + |> update([u], inc: [note_count: 1]) + |> select([u], u) + |> Repo.update_all([]) + |> case do + {1, [user]} -> set_cache(user) + _ -> {:error, user} + end + end + + def decrease_note_count(%User{} = user) do + User + |> where(id: ^user.id) + |> update([u], + set: [ + note_count: fragment("greatest(0, note_count - 1)") + ] + ) + |> select([u], u) + |> Repo.update_all([]) + |> case do + {1, [user]} -> set_cache(user) + _ -> {:error, user} + end + end + + def update_note_count(%User{} = user, note_count \\ nil) do + note_count = + note_count || + from( + a in Object, + where: fragment("?->>'actor' = ? and ?->>'type' = 'Note'", a.data, ^user.ap_id, a.data), + select: count(a.id) + ) + |> Repo.one() + + user + |> cast(%{note_count: note_count}, [:note_count]) + |> update_and_set_cache() + end + + @spec maybe_fetch_follow_information(User.t()) :: User.t() + def maybe_fetch_follow_information(user) do + with {:ok, user} <- fetch_follow_information(user) do + user + else + e -> + Logger.error("Follower/Following counter update for #{user.ap_id} failed.\n#{inspect(e)}") + + user + end + end + + def fetch_follow_information(user) do + with {:ok, info} <- ActivityPub.fetch_follow_information_for_user(user) do + user + |> follow_information_changeset(info) + |> update_and_set_cache() + end + end + + defp follow_information_changeset(user, params) do + user + |> cast(params, [ + :hide_followers, + :hide_follows, + :follower_count, + :following_count, + :hide_followers_count, + :hide_follows_count + ]) + end + + @spec update_follower_count(User.t()) :: {:ok, User.t()} + def update_follower_count(%User{} = user) do + if user.local or !Config.get([:instance, :external_user_synchronization]) do + follower_count = FollowingRelationship.follower_count(user) + + user + |> follow_information_changeset(%{follower_count: follower_count}) + |> update_and_set_cache + else + {:ok, maybe_fetch_follow_information(user)} + end + end + + @spec update_following_count(User.t()) :: {:ok, User.t()} + def update_following_count(%User{local: false} = user) do + if Config.get([:instance, :external_user_synchronization]) do + {:ok, maybe_fetch_follow_information(user)} + else + {:ok, user} + end + end + + def update_following_count(%User{local: true} = user) do + following_count = FollowingRelationship.following_count(user) + + user + |> follow_information_changeset(%{following_count: following_count}) + |> update_and_set_cache() + end + + @spec get_users_from_set([String.t()], keyword()) :: [User.t()] + def get_users_from_set(ap_ids, opts \\ []) do + local_only = Keyword.get(opts, :local_only, true) + criteria = %{ap_id: ap_ids, is_active: true} + criteria = if local_only, do: Map.put(criteria, :local, true), else: criteria + + User.Query.build(criteria) + |> Repo.all() + end + + @spec get_recipients_from_activity(Activity.t()) :: [User.t()] + def get_recipients_from_activity(%Activity{recipients: to, actor: actor}) do + to = [actor | to] + + query = User.Query.build(%{recipients_from_activity: to, local: true, is_active: true}) + + query + |> Repo.all() + end + + @spec mute(User.t(), User.t(), map()) :: + {:ok, list(UserRelationship.t())} | {:error, String.t()} + def mute(%User{} = muter, %User{} = mutee, params \\ %{}) do + notifications? = Map.get(params, :notifications, true) + duration = Map.get(params, :duration, 0) + + expires_at = + if duration > 0 do + DateTime.utc_now() + |> DateTime.add(duration) + else + nil + end + + with {:ok, user_mute} <- UserRelationship.create_mute(muter, mutee, expires_at), + {:ok, user_notification_mute} <- + (notifications? && + UserRelationship.create_notification_mute( + muter, + mutee, + expires_at + )) || + {:ok, nil} do + if duration > 0 do + Pleroma.Workers.MuteExpireWorker.enqueue( + "unmute_user", + %{"muter_id" => muter.id, "mutee_id" => mutee.id}, + scheduled_at: expires_at + ) + end + + @cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}") + + {:ok, Enum.filter([user_mute, user_notification_mute], & &1)} + end + end + + def unmute(%User{} = muter, %User{} = mutee) do + with {:ok, user_mute} <- UserRelationship.delete_mute(muter, mutee), + {:ok, user_notification_mute} <- + UserRelationship.delete_notification_mute(muter, mutee) do + @cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}") + {:ok, [user_mute, user_notification_mute]} + end + end + + def unmute(muter_id, mutee_id) do + with {:muter, %User{} = muter} <- {:muter, User.get_by_id(muter_id)}, + {:mutee, %User{} = mutee} <- {:mutee, User.get_by_id(mutee_id)} do + unmute(muter, mutee) + else + {who, result} = error -> + Logger.warning( + "User.unmute/2 failed. #{who}: #{result}, muter_id: #{muter_id}, mutee_id: #{mutee_id}" + ) + + {:error, error} + end + end + + def subscribe(%User{} = subscriber, %User{} = target) do + deny_follow_blocked = Config.get([:user, :deny_follow_blocked]) + + if blocks?(target, subscriber) and deny_follow_blocked do + {:error, "Could not subscribe: #{target.nickname} is blocking you"} + else + # Note: the relationship is inverse: subscriber acts as relationship target + UserRelationship.create_inverse_subscription(target, subscriber) + end + end + + def subscribe(%User{} = subscriber, %{ap_id: ap_id}) do + with %User{} = subscribee <- get_cached_by_ap_id(ap_id) do + subscribe(subscriber, subscribee) + end + end + + def unsubscribe(%User{} = unsubscriber, %User{} = target) do + # Note: the relationship is inverse: subscriber acts as relationship target + UserRelationship.delete_inverse_subscription(target, unsubscriber) + end + + def unsubscribe(%User{} = unsubscriber, %{ap_id: ap_id}) do + with %User{} = user <- get_cached_by_ap_id(ap_id) do + unsubscribe(unsubscriber, user) + end + end + + def block(%User{} = blocker, %User{} = blocked) do + # sever any follow relationships to prevent leaks per activitypub (Pleroma issue #213) + blocker = + if following?(blocker, blocked) do + {:ok, blocker, _} = unfollow(blocker, blocked) + blocker + else + blocker + end + + # clear any requested follows from both sides as well + blocked = + case CommonAPI.reject_follow_request(blocked, blocker) do + {:ok, %User{} = updated_blocked} -> updated_blocked + nil -> blocked + end + + blocker = + case CommonAPI.reject_follow_request(blocker, blocked) do + {:ok, %User{} = updated_blocker} -> updated_blocker + nil -> blocker + end + + unsubscribe(blocked, blocker) + + unfollowing_blocked = Config.get([:activitypub, :unfollow_blocked], true) + if unfollowing_blocked && following?(blocked, blocker), do: unfollow(blocked, blocker) + + {:ok, blocker} = update_follower_count(blocker) + {:ok, blocker, _} = Participation.mark_all_as_read(blocker, blocked) + add_to_block(blocker, blocked) + end + + # helper to handle the block given only an actor's AP id + def block(%User{} = blocker, %{ap_id: ap_id}) do + block(blocker, get_cached_by_ap_id(ap_id)) + end + + def unblock(%User{} = blocker, %User{} = blocked) do + remove_from_block(blocker, blocked) + end + + # helper to handle the block given only an actor's AP id + def unblock(%User{} = blocker, %{ap_id: ap_id}) do + unblock(blocker, get_cached_by_ap_id(ap_id)) + end + + def endorse(%User{} = endorser, %User{} = target) do + with max_endorsed_users <- Pleroma.Config.get([:instance, :max_endorsed_users], 0), + endorsed_users <- + User.endorsed_users_relation(endorser) + |> Repo.aggregate(:count, :id) do + cond do + endorsed_users >= max_endorsed_users -> + {:error, "You have already pinned the maximum number of users"} + + not following?(endorser, target) -> + {:error, "Could not endorse: You are not following #{target.nickname}"} + + true -> + UserRelationship.create_endorsement(endorser, target) + end + end + end + + def endorse(%User{} = endorser, %{ap_id: ap_id}) do + with %User{} = endorsed <- get_cached_by_ap_id(ap_id) do + endorse(endorser, endorsed) + end + end + + def unendorse(%User{} = unendorser, %User{} = target) do + UserRelationship.delete_endorsement(unendorser, target) + end + + def unendorse(%User{} = unendorser, %{ap_id: ap_id}) do + with %User{} = user <- get_cached_by_ap_id(ap_id) do + unendorse(unendorser, user) + end + end + + def mutes?(nil, _), do: false + def mutes?(%User{} = user, %User{} = target), do: mutes_user?(user, target) + + def mutes_user?(%User{} = user, %User{} = target) do + UserRelationship.mute_exists?(user, target) + end + + @spec muted_notifications?(User.t() | nil, User.t() | map()) :: boolean() + def muted_notifications?(nil, _), do: false + + def muted_notifications?(%User{} = user, %User{} = target), + do: UserRelationship.notification_mute_exists?(user, target) + + def blocks?(nil, _), do: false + + def blocks?(%User{} = user, %User{} = target) do + blocks_user?(user, target) || + (blocks_domain?(user, target) and not User.following?(user, target)) + end + + def blocks_user?(%User{} = user, %User{} = target) do + UserRelationship.block_exists?(user, target) + end + + def blocks_user?(_, _), do: false + + def blocks_domain?(%User{} = user, %User{} = target) do + domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks) + %{host: host} = URI.parse(target.ap_id) + Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, host) + end + + def blocks_domain?(_, _), do: false + + def subscribed_to?(%User{} = user, %User{} = target) do + # Note: the relationship is inverse: subscriber acts as relationship target + UserRelationship.inverse_subscription_exists?(target, user) + end + + def subscribed_to?(%User{} = user, %{ap_id: ap_id}) do + with %User{} = target <- get_cached_by_ap_id(ap_id) do + subscribed_to?(user, target) + end + end + + def endorses?(%User{} = user, %User{} = target) do + UserRelationship.endorsement_exists?(user, target) + end + + @doc """ + Returns map of outgoing (blocked, muted etc.) relationships' user AP IDs by relation type. + E.g. `outgoing_relationships_ap_ids(user, [:block])` -> `%{block: ["https://some.site/users/userapid"]}` + """ + @spec outgoing_relationships_ap_ids(User.t(), list(atom())) :: %{atom() => list(String.t())} + def outgoing_relationships_ap_ids(_user, []), do: %{} + + def outgoing_relationships_ap_ids(nil, _relationship_types), do: %{} + + def outgoing_relationships_ap_ids(%User{} = user, relationship_types) + when is_list(relationship_types) do + db_result = + user + |> assoc(:outgoing_relationships) + |> join(:inner, [user_rel], u in assoc(user_rel, :target)) + |> where([user_rel, u], user_rel.relationship_type in ^relationship_types) + |> select([user_rel, u], [user_rel.relationship_type, fragment("array_agg(?)", u.ap_id)]) + |> group_by([user_rel, u], user_rel.relationship_type) + |> Repo.all() + |> Enum.into(%{}, fn [k, v] -> {k, v} end) + + Enum.into( + relationship_types, + %{}, + fn rel_type -> {rel_type, db_result[rel_type] || []} end + ) + end + + def incoming_relationships_ungrouped_ap_ids(user, relationship_types, ap_ids \\ nil) + + def incoming_relationships_ungrouped_ap_ids(_user, [], _ap_ids), do: [] + + def incoming_relationships_ungrouped_ap_ids(nil, _relationship_types, _ap_ids), do: [] + + def incoming_relationships_ungrouped_ap_ids(%User{} = user, relationship_types, ap_ids) + when is_list(relationship_types) do + user + |> assoc(:incoming_relationships) + |> join(:inner, [user_rel], u in assoc(user_rel, :source)) + |> where([user_rel, u], user_rel.relationship_type in ^relationship_types) + |> maybe_filter_on_ap_id(ap_ids) + |> select([user_rel, u], u.ap_id) + |> distinct(true) + |> Repo.all() + end + + defp maybe_filter_on_ap_id(query, ap_ids) when is_list(ap_ids) do + where(query, [user_rel, u], u.ap_id in ^ap_ids) + end + + defp maybe_filter_on_ap_id(query, _ap_ids), do: query + + def set_activation_async(user, status \\ true) do + BackgroundWorker.enqueue("user_activation", %{"user_id" => user.id, "status" => status}) + end + + @spec set_activation([User.t()], boolean()) :: {:ok, User.t()} | {:error, Changeset.t()} + def set_activation(users, status) when is_list(users) do + Repo.transaction(fn -> + for user <- users, do: set_activation(user, status) + end) + end + + @spec set_activation(User.t(), boolean()) :: {:ok, User.t()} | {:error, Changeset.t()} + def set_activation(%User{} = user, status) do + with {:ok, user} <- set_activation_status(user, status) do + user + |> get_followers() + |> Enum.filter(& &1.local) + |> Enum.each(&set_cache(update_following_count(&1))) + + # Only update local user counts, remote will be update during the next pull. + user + |> get_friends() + |> Enum.filter(& &1.local) + |> Enum.each(&do_unfollow(user, &1)) + + {:ok, user} + end + end + + def approve(users) when is_list(users) do + Repo.transaction(fn -> + Enum.map(users, fn user -> + with {:ok, user} <- approve(user), do: user + end) + end) + end + + def approve(%User{is_approved: false} = user) do + with chg <- change(user, is_approved: true), + {:ok, user} <- update_and_set_cache(chg) do + post_register_action(user) + {:ok, user} + end + end + + def approve(%User{} = user), do: {:ok, user} + + def reject(%User{is_approved: false} = user) do + delete(user) + end + + def reject(%User{} = _user), do: {:error, "User is approved"} + + def confirm(users) when is_list(users) do + Repo.transaction(fn -> + Enum.map(users, fn user -> + with {:ok, user} <- confirm(user), do: user + end) + end) + end + + def confirm(%User{is_confirmed: false} = user) do + with chg <- confirmation_changeset(user, set_confirmation: true), + {:ok, user} <- update_and_set_cache(chg) do + post_register_action(user) + {:ok, user} + end + end + + def confirm(%User{} = user), do: {:ok, user} + + def set_suggestion(users, is_suggested) when is_list(users) do + Repo.transaction(fn -> + Enum.map(users, fn user -> + with {:ok, user} <- set_suggestion(user, is_suggested), do: user + end) + end) + end + + def set_suggestion(%User{is_suggested: is_suggested} = user, is_suggested), do: {:ok, user} + + def set_suggestion(%User{} = user, is_suggested) when is_boolean(is_suggested) do + user + |> change(is_suggested: is_suggested) + |> update_and_set_cache() + end + + def update_notification_settings(%User{} = user, settings) do + user + |> cast(%{notification_settings: settings}, []) + |> cast_embed(:notification_settings) + |> validate_required([:notification_settings]) + |> update_and_set_cache() + end + + @spec purge_user_changeset(User.t()) :: Changeset.t() + def purge_user_changeset(user) do + # "Right to be forgotten" + # https://gdpr.eu/right-to-be-forgotten/ + change(user, %{ + bio: "", + raw_bio: nil, + email: nil, + name: nil, + password_hash: nil, + avatar: %{}, + tags: [], + last_refreshed_at: nil, + last_digest_emailed_at: nil, + banner: %{}, + background: %{}, + note_count: 0, + follower_count: 0, + following_count: 0, + is_locked: false, + password_reset_pending: false, + registration_reason: nil, + confirmation_token: nil, + domain_blocks: [], + is_active: false, + is_moderator: false, + is_admin: false, + mascot: nil, + emoji: %{}, + pleroma_settings_store: %{}, + fields: [], + raw_fields: [], + is_discoverable: false, + also_known_as: [], + accepts_email_list: false + # id: preserved + # ap_id: preserved + # nickname: preserved + }) + end + + # Purge doesn't delete the user from the database. + # It just nulls all its fields and deactivates it. + # See `User.purge_user_changeset/1` above. + defp purge(%User{} = user) do + user + |> purge_user_changeset() + |> update_and_set_cache() + end + + def delete(users) when is_list(users) do + for user <- users, do: delete(user) + end + + def delete(%User{} = user) do + # Purge the user immediately + purge(user) + BackgroundWorker.enqueue("delete_user", %{"user_id" => user.id}) + end + + # *Actually* delete the user from the DB + defp delete_from_db(%User{} = user) do + invalidate_cache(user) + Repo.delete(user) + end + + # If the user never finalized their account, it's safe to delete them. + defp maybe_delete_from_db(%User{local: true, is_confirmed: false} = user), + do: delete_from_db(user) + + defp maybe_delete_from_db(%User{local: true, is_approved: false} = user), + do: delete_from_db(user) + + defp maybe_delete_from_db(user), do: {:ok, user} + + def perform(:force_password_reset, user), do: force_password_reset(user) + + @spec perform(atom(), User.t()) :: {:ok, User.t()} + def perform(:delete, %User{} = user) do + # Purge the user again, in case perform/2 is called directly + purge(user) + + # Remove all relationships + user + |> get_followers() + |> Enum.each(fn follower -> + ActivityPub.unfollow(follower, user) + unfollow(follower, user) + end) + + user + |> get_friends() + |> Enum.each(fn followed -> + ActivityPub.unfollow(user, followed) + unfollow(user, followed) + end) + + delete_user_activities(user) + delete_notifications_from_user_activities(user) + delete_outgoing_pending_follow_requests(user) + + maybe_delete_from_db(user) + end + + def perform(:verify_fields_links, user) do + profile_urls = [user.ap_id, "#{Endpoint.url()}/@#{user.nickname}"] + + fields = + user.raw_fields + |> Enum.map(&verify_field_link(&1, profile_urls)) + + changeset = + user + |> update_changeset(%{raw_fields: fields}) + + with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do + set_cache(user) + end + end + + def perform(:set_activation_async, user, status), do: set_activation(user, status) + + defp verify_field_link(field, profile_urls) do + verified_at = + with %{"value" => value} <- field, + {:verified_at, nil} <- {:verified_at, Map.get(field, "verified_at")}, + %{scheme: scheme, userinfo: nil, host: host} + when not_empty_string(host) and scheme in ["http", "https"] <- + URI.parse(value), + {:not_idn, true} <- {:not_idn, to_string(:idna.encode(host)) == host}, + attr <- Pleroma.Web.RelMe.maybe_put_rel_me(value, profile_urls) do + if attr == "me" do + CommonUtils.to_masto_date(NaiveDateTime.utc_now()) + end + else + {:verified_at, value} when not_empty_string(value) -> + value + + _ -> + nil + end + + Map.put(field, "verified_at", verified_at) + end + + @spec external_users_query() :: Ecto.Query.t() + def external_users_query do + User.Query.build(%{ + external: true, + active: true, + order_by: :id + }) + end + + @spec external_users(keyword()) :: [User.t()] + def external_users(opts \\ []) do + query = + external_users_query() + |> select([u], struct(u, [:id, :ap_id])) + + query = + if opts[:max_id], + do: where(query, [u], u.id > ^opts[:max_id]), + else: query + + query = + if opts[:limit], + do: limit(query, ^opts[:limit]), + else: query + + Repo.all(query) + end + + def delete_notifications_from_user_activities(%User{ap_id: ap_id}) do + Notification + |> join(:inner, [n], activity in assoc(n, :activity)) + |> where([n, a], fragment("? = ?", a.actor, ^ap_id)) + |> Repo.delete_all() + end + + def delete_user_activities(%User{ap_id: ap_id} = user) do + ap_id + |> Activity.Queries.by_actor() + |> Repo.chunk_stream(50, :batches) + |> Stream.each(fn activities -> + Enum.each(activities, fn activity -> delete_activity(activity, user) end) + end) + |> Stream.run() + end + + defp delete_activity(%{data: %{"type" => "Create", "object" => object}} = activity, user) do + with {_, %Object{}} <- {:find_object, Object.get_by_ap_id(object)}, + {:ok, delete_data, _} <- Builder.delete(user, object) do + Pipeline.common_pipeline(delete_data, local: user.local) + else + {:find_object, nil} -> + # We have the create activity, but not the object, it was probably pruned. + # Insert a tombstone and try again + with {:ok, tombstone_data, _} <- Builder.tombstone(user.ap_id, object), + {:ok, _tombstone} <- Object.create(tombstone_data) do + delete_activity(activity, user) + end + + e -> + Logger.error("Could not delete #{object} created by #{activity.data["ap_id"]}") + Logger.error("Error: #{inspect(e)}") + end + end + + defp delete_activity(%{data: %{"type" => type}} = activity, user) + when type in ["Like", "Announce"] do + {:ok, undo, _} = Builder.undo(user, activity) + Pipeline.common_pipeline(undo, local: user.local) + end + + defp delete_activity(_activity, _user), do: "Doing nothing" + + defp delete_outgoing_pending_follow_requests(user) do + user + |> FollowingRelationship.outgoing_pending_follow_requests_query() + |> Repo.delete_all() + end + + def html_filter_policy(%User{no_rich_text: true}) do + Pleroma.HTML.Scrubber.TwitterText + end + + def html_filter_policy(_), do: Config.get([:markup, :scrub_policy]) + + def fetch_by_ap_id(ap_id), do: ActivityPub.make_user_from_ap_id(ap_id) + + def get_or_fetch_by_ap_id(ap_id) do + cached_user = get_cached_by_ap_id(ap_id) + + maybe_fetched_user = needs_update?(cached_user) && fetch_by_ap_id(ap_id) + + case {cached_user, maybe_fetched_user} do + {_, {:ok, %User{} = user}} -> + {:ok, user} + + {%User{} = user, _} -> + {:ok, user} + + _ -> + {:error, :not_found} + end + end + + @doc """ + Creates an internal service actor by URI if missing. + Optionally takes nickname for addressing. + """ + @spec get_or_create_service_actor_by_ap_id(String.t(), String.t()) :: User.t() | nil + def get_or_create_service_actor_by_ap_id(uri, nickname) do + {_, user} = + case get_cached_by_ap_id(uri) do + nil -> + with {:error, %{errors: errors}} <- create_service_actor(uri, nickname) do + Logger.error("Cannot create service actor: #{uri}/.\n#{inspect(errors)}") + {:error, nil} + end + + %User{invisible: false} = user -> + set_invisible(user) + + user -> + {:ok, user} + end + + user + end + + @spec set_invisible(User.t()) :: {:ok, User.t()} + defp set_invisible(user) do + user + |> change(%{invisible: true}) + |> update_and_set_cache() + end + + @spec create_service_actor(String.t(), String.t()) :: + {:ok, User.t()} | {:error, Ecto.Changeset.t()} + defp create_service_actor(uri, nickname) do + %User{ + invisible: true, + local: true, + ap_id: uri, + nickname: nickname, + follower_address: uri <> "/followers" + } + |> change + |> put_private_key() + |> unique_constraint(:nickname) + |> Repo.insert() + |> set_cache() + end + + def public_key(%{public_key: public_key_pem}) when is_binary(public_key_pem) do + key = + public_key_pem + |> :public_key.pem_decode() + |> hd() + |> :public_key.pem_entry_decode() + + {:ok, key} + end + + def public_key(_), do: {:error, "key not found"} + + def get_public_key_for_ap_id(ap_id) do + with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id), + {:ok, public_key} <- public_key(user) do + {:ok, public_key} + else + _ -> :error + end + end + + @doc "Gets or fetch a user by uri or nickname." + @spec get_or_fetch(String.t()) :: {:ok, User.t()} | {:error, String.t()} + def get_or_fetch("http://" <> _host = uri), do: get_or_fetch_by_ap_id(uri) + def get_or_fetch("https://" <> _host = uri), do: get_or_fetch_by_ap_id(uri) + def get_or_fetch(nickname), do: get_or_fetch_by_nickname(nickname) + + # wait a period of time and return newest version of the User structs + # this is because we have synchronous follow APIs and need to simulate them + # with an async handshake + def wait_and_refresh(_, %User{local: true} = a, %User{local: true} = b) do + with %User{} = a <- get_cached_by_id(a.id), + %User{} = b <- get_cached_by_id(b.id) do + {:ok, a, b} + else + nil -> :error + end + end + + def wait_and_refresh(timeout, %User{} = a, %User{} = b) do + with :ok <- :timer.sleep(timeout), + %User{} = a <- get_cached_by_id(a.id), + %User{} = b <- get_cached_by_id(b.id) do + {:ok, a, b} + else + nil -> :error + end + end + + def parse_bio(bio) when is_binary(bio) and bio != "" do + bio + |> CommonUtils.format_input("text/plain", mentions_format: :full) + |> elem(0) + end + + def parse_bio(_), do: "" + + def parse_bio(bio, user) when is_binary(bio) and bio != "" do + # TODO: get profile URLs other than user.ap_id + profile_urls = [user.ap_id] + + bio + |> CommonUtils.format_input("text/plain", + mentions_format: :full, + rel: &RelMe.maybe_put_rel_me(&1, profile_urls) + ) + |> elem(0) + end + + def parse_bio(_, _), do: "" + + def tag(user_identifiers, tags) when is_list(user_identifiers) do + Repo.transaction(fn -> + for user_identifier <- user_identifiers, do: tag(user_identifier, tags) + end) + end + + def tag(nickname, tags) when is_binary(nickname), + do: tag(get_by_nickname(nickname), tags) + + def tag(%User{} = user, tags), + do: update_tags(user, Enum.uniq((user.tags || []) ++ normalize_tags(tags))) + + def untag(user_identifiers, tags) when is_list(user_identifiers) do + Repo.transaction(fn -> + for user_identifier <- user_identifiers, do: untag(user_identifier, tags) + end) + end + + def untag(nickname, tags) when is_binary(nickname), + do: untag(get_by_nickname(nickname), tags) + + def untag(%User{} = user, tags), + do: update_tags(user, (user.tags || []) -- normalize_tags(tags)) + + defp update_tags(%User{} = user, new_tags) do + {:ok, updated_user} = + user + |> change(%{tags: new_tags}) + |> update_and_set_cache() + + updated_user + end + + defp normalize_tags(tags) do + [tags] + |> List.flatten() + |> Enum.map(&String.downcase/1) + end + + defp local_nickname_regex do + if Config.get([:instance, :extended_nickname_format]) do + @extended_local_nickname_regex + else + @strict_local_nickname_regex + end + end + + def local_nickname(nickname_or_mention) do + nickname_or_mention + |> full_nickname() + |> String.split("@") + |> hd() + end + + def full_nickname(%User{} = user) do + if String.contains?(user.nickname, "@") do + user.nickname + else + host = Pleroma.Web.WebFinger.domain() + user.nickname <> "@" <> host + end + end + + def full_nickname(nickname_or_mention), + do: String.trim_leading(nickname_or_mention, "@") + + def error_user(ap_id) do + %User{ + name: ap_id, + ap_id: ap_id, + nickname: "erroruser@example.com", + inserted_at: NaiveDateTime.utc_now() + } + end + + @spec all_superusers() :: [User.t()] + def all_superusers do + User.Query.build(%{super_users: true, local: true, is_active: true}) + |> Repo.all() + end + + @spec all_users_with_privilege(atom()) :: [User.t()] + def all_users_with_privilege(privilege) do + User.Query.build(%{is_privileged: privilege}) |> Repo.all() + end + + def muting_reblogs?(%User{} = user, %User{} = target) do + UserRelationship.reblog_mute_exists?(user, target) + end + + def showing_reblogs?(%User{} = user, %User{} = target) do + not muting_reblogs?(user, target) + end + + @doc """ + The function returns a query to get users with no activity for given interval of days. + Inactive users are those who didn't read any notification, or had any activity where + the user is the activity's actor, during `inactivity_threshold` days. + Deactivated users will not appear in this list. + + ## Examples + + iex> Pleroma.User.list_inactive_users() + %Ecto.Query{} + """ + @spec list_inactive_users_query(integer()) :: Ecto.Query.t() + def list_inactive_users_query(inactivity_threshold \\ 7) do + negative_inactivity_threshold = -inactivity_threshold + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + # Subqueries are not supported in `where` clauses, join gets too complicated. + has_read_notifications = + from(n in Pleroma.Notification, + where: n.seen == true, + group_by: n.id, + having: max(n.updated_at) > datetime_add(^now, ^negative_inactivity_threshold, "day"), + select: n.user_id + ) + |> Pleroma.Repo.all() + + from(u in Pleroma.User, + left_join: a in Pleroma.Activity, + on: u.ap_id == a.actor, + where: not is_nil(u.nickname), + where: u.is_active == ^true, + where: u.id not in ^has_read_notifications, + group_by: u.id, + having: + max(a.inserted_at) < datetime_add(^now, ^negative_inactivity_threshold, "day") or + is_nil(max(a.inserted_at)) + ) + end + + @doc """ + Enable or disable email notifications for user + + ## Examples + + iex> Pleroma.User.switch_email_notifications(Pleroma.User{email_notifications: %{"digest" => false}}, "digest", true) + Pleroma.User{email_notifications: %{"digest" => true}} + + iex> Pleroma.User.switch_email_notifications(Pleroma.User{email_notifications: %{"digest" => true}}, "digest", false) + Pleroma.User{email_notifications: %{"digest" => false}} + """ + @spec switch_email_notifications(t(), String.t(), boolean()) :: + {:ok, t()} | {:error, Ecto.Changeset.t()} + def switch_email_notifications(user, type, status) do + User.update_email_notifications(user, %{type => status}) + end + + @doc """ + Set `last_digest_emailed_at` value for the user to current time + """ + @spec touch_last_digest_emailed_at(t()) :: t() + def touch_last_digest_emailed_at(user) do + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + + {:ok, updated_user} = + user + |> change(%{last_digest_emailed_at: now}) + |> update_and_set_cache() + + updated_user + end + + @spec set_confirmation(User.t(), boolean()) :: {:ok, User.t()} | {:error, Changeset.t()} + def set_confirmation(%User{} = user, bool) do + user + |> confirmation_changeset(set_confirmation: bool) + |> update_and_set_cache() + end + + def get_mascot(%{mascot: %{} = mascot}) when not is_nil(mascot) do + mascot + end + + def get_mascot(%{mascot: mascot}) when is_nil(mascot) do + # use instance-default + config = Config.get([:assets, :mascots]) + default_mascot = Config.get([:assets, :default_mascot]) + mascot = Keyword.get(config, default_mascot) + + %{ + "id" => "default-mascot", + "url" => mascot[:url], + "preview_url" => mascot[:url], + "pleroma" => %{ + "mime_type" => mascot[:mime_type] + } + } + end + + def get_ap_ids_by_nicknames(nicknames) do + from(u in User, + where: u.nickname in ^nicknames, + order_by: fragment("array_position(?, ?)", ^nicknames, u.nickname), + select: u.ap_id + ) + |> Repo.all() + end + + defp put_password_hash( + %Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset + ) do + change(changeset, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt(password)) + end + + defp put_password_hash(changeset), do: changeset + + def is_internal_user?(%User{nickname: nil}), do: true + def is_internal_user?(%User{local: true, nickname: "internal." <> _}), do: true + def is_internal_user?(_), do: false + + # A hack because user delete activities have a fake id for whatever reason + # TODO: Get rid of this + def get_delivered_users_by_object_id("pleroma:fake_object_id"), do: [] + + def get_delivered_users_by_object_id(object_id) do + from(u in User, + inner_join: delivery in assoc(u, :deliveries), + where: delivery.object_id == ^object_id + ) + |> Repo.all() + end + + def change_email(user, email) do + user + |> cast(%{email: email}, [:email]) + |> maybe_validate_required_email(false) + |> unique_constraint(:email) + |> validate_format(:email, @email_regex) + |> update_and_set_cache() + end + + def alias_users(user) do + user.also_known_as + |> Enum.map(&User.get_cached_by_ap_id/1) + |> Enum.filter(fn user -> user != nil end) + end + + def add_alias(user, new_alias_user) do + current_aliases = user.also_known_as || [] + new_alias_ap_id = new_alias_user.ap_id + + if new_alias_ap_id in current_aliases do + {:ok, user} + else + user + |> cast(%{also_known_as: current_aliases ++ [new_alias_ap_id]}, [:also_known_as]) + |> update_and_set_cache() + end + end + + def delete_alias(user, alias_user) do + current_aliases = user.also_known_as || [] + alias_ap_id = alias_user.ap_id + + if alias_ap_id in current_aliases do + user + |> cast(%{also_known_as: current_aliases -- [alias_ap_id]}, [:also_known_as]) + |> update_and_set_cache() + else + {:error, :no_such_alias} + end + end + + # Internal function; public one is `set_activation/2` + defp set_activation_status(user, status) do + user + |> cast(%{is_active: status}, [:is_active]) + |> update_and_set_cache() + end + + def update_banner(user, banner) do + user + |> cast(%{banner: banner}, [:banner]) + |> update_and_set_cache() + end + + def update_background(user, background) do + user + |> cast(%{background: background}, [:background]) + |> update_and_set_cache() + end + + def validate_fields(changeset, remote? \\ false) do + limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields + limit = Config.get([:instance, limit_name], 0) + + changeset + |> validate_length(:fields, max: limit) + |> validate_change(:fields, fn :fields, fields -> + if Enum.all?(fields, &valid_field?/1) do + [] + else + [fields: "invalid"] + end + end) + end + + defp valid_field?(%{"name" => name, "value" => value}) do + name_limit = Config.get([:instance, :account_field_name_length], 255) + value_limit = Config.get([:instance, :account_field_value_length], 255) + + is_binary(name) && is_binary(value) && String.length(name) <= name_limit && + String.length(value) <= value_limit + end + + defp valid_field?(_), do: false + + defp truncate_field(%{"name" => name, "value" => value}) do + {name, _chopped} = + String.split_at(name, Config.get([:instance, :account_field_name_length], 255)) + + {value, _chopped} = + String.split_at(value, Config.get([:instance, :account_field_value_length], 255)) + + %{"name" => name, "value" => value} + end + + def admin_api_update(user, params) do + user + |> cast(params, [ + :is_moderator, + :is_admin, + :show_role + ]) + |> update_and_set_cache() + end + + @doc "Signs user out of all applications" + def global_sign_out(user) do + OAuth.Authorization.delete_user_authorizations(user) + OAuth.Token.delete_user_tokens(user) + end + + def mascot_update(user, url) do + user + |> cast(%{mascot: url}, [:mascot]) + |> validate_required([:mascot]) + |> update_and_set_cache() + end + + @spec confirmation_changeset(User.t(), keyword()) :: Changeset.t() + def confirmation_changeset(user, set_confirmation: confirmed?) do + params = + if confirmed? do + %{ + is_confirmed: true, + confirmation_token: nil + } + else + %{ + is_confirmed: false, + confirmation_token: :crypto.strong_rand_bytes(32) |> Base.url_encode64() + } + end + + cast(user, params, [:is_confirmed, :confirmation_token]) + end + + @spec approval_changeset(User.t(), keyword()) :: Changeset.t() + def approval_changeset(user, set_approval: approved?) do + cast(user, %{is_approved: approved?}, [:is_approved]) + end + + @spec add_pinned_object_id(User.t(), String.t()) :: {:ok, User.t()} | {:error, term()} + def add_pinned_object_id(%User{} = user, object_id) do + if !user.pinned_objects[object_id] do + params = %{pinned_objects: Map.put(user.pinned_objects, object_id, NaiveDateTime.utc_now())} + + user + |> cast(params, [:pinned_objects]) + |> validate_change(:pinned_objects, fn :pinned_objects, pinned_objects -> + max_pinned_statuses = Config.get([:instance, :max_pinned_statuses], 0) + + if Enum.count(pinned_objects) <= max_pinned_statuses do + [] + else + [pinned_objects: "You have already pinned the maximum number of statuses"] + end + end) + else + change(user) + end + |> update_and_set_cache() + end + + @spec remove_pinned_object_id(User.t(), String.t()) :: {:ok, t()} | {:error, term()} + def remove_pinned_object_id(%User{} = user, object_id) do + user + |> cast( + %{pinned_objects: Map.delete(user.pinned_objects, object_id)}, + [:pinned_objects] + ) + |> update_and_set_cache() + end + + def update_email_notifications(user, settings) do + email_notifications = + user.email_notifications + |> Map.merge(settings) + |> Map.take(["digest"]) + + params = %{email_notifications: email_notifications} + fields = [:email_notifications] + + user + |> cast(params, fields) + |> validate_required(fields) + |> update_and_set_cache() + end + + defp set_domain_blocks(user, domain_blocks) do + params = %{domain_blocks: domain_blocks} + + user + |> cast(params, [:domain_blocks]) + |> validate_required([:domain_blocks]) + |> update_and_set_cache() + end + + def block_domain(user, domain_blocked) do + set_domain_blocks(user, Enum.uniq([domain_blocked | user.domain_blocks])) + end + + def unblock_domain(user, domain_blocked) do + set_domain_blocks(user, List.delete(user.domain_blocks, domain_blocked)) + end + + @spec add_to_block(User.t(), User.t()) :: + {:ok, UserRelationship.t()} | {:error, Ecto.Changeset.t()} + defp add_to_block(%User{} = user, %User{} = blocked) do + with {:ok, relationship} <- UserRelationship.create_block(user, blocked) do + @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") + {:ok, relationship} + end + end + + @spec add_to_block(User.t(), User.t()) :: + {:ok, UserRelationship.t()} | {:ok, nil} | {:error, Ecto.Changeset.t()} + defp remove_from_block(%User{} = user, %User{} = blocked) do + with {:ok, relationship} <- UserRelationship.delete_block(user, blocked) do + @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") + {:ok, relationship} + end + end + + def set_invisible(user, invisible) do + params = %{invisible: invisible} + + user + |> cast(params, [:invisible]) + |> validate_required([:invisible]) + |> update_and_set_cache() + end + + def sanitize_html(%User{} = user) do + sanitize_html(user, nil) + end + + # User data that mastodon isn't filtering (treated as plaintext): + # - field name + # - display name + def sanitize_html(%User{} = user, filter) do + fields = + Enum.map(user.fields, fn %{"name" => name, "value" => value} = field -> + %{ + "name" => name, + "value" => HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly), + "verified_at" => Map.get(field, "verified_at") + } + end) + + user + |> Map.put(:bio, HTML.filter_tags(user.bio, filter)) + |> Map.put(:fields, fields) + end + + def get_host(%User{ap_id: ap_id} = _user) do + URI.parse(ap_id).host + end + + def update_last_active_at(%__MODULE__{local: true} = user) do + user + |> cast(%{last_active_at: NaiveDateTime.utc_now()}, [:last_active_at]) + |> update_and_set_cache() + end + + def active_user_count(days \\ 30) do + active_after = Timex.shift(NaiveDateTime.utc_now(), days: -days) + + __MODULE__ + |> where([u], u.last_active_at >= ^active_after) + |> where([u], u.local == true) + |> Repo.aggregate(:count) + end + + def update_last_status_at(user) do + User + |> where(id: ^user.id) + |> update([u], set: [last_status_at: fragment("NOW()")]) + |> select([u], u) + |> Repo.update_all([]) + |> case do + {1, [user]} -> set_cache(user) + _ -> {:error, user} + end + end + + def get_friends_birthdays_query(%User{} = user, day, month) do + User.Query.build(%{ + friends: user, + deactivated: false, + birthday_day: day, + birthday_month: month + }) + end + + def update_last_move_at(%__MODULE__{local: true} = user) do + user + |> cast(%{last_move_at: NaiveDateTime.utc_now()}, [:last_move_at]) + |> update_and_set_cache() + end +end diff --git a/lib/pleroma/user/backup.ex b/lib/pleroma/user/backup.ex new file mode 100644 index 0000000..d77c0ed --- /dev/null +++ b/lib/pleroma/user/backup.ex @@ -0,0 +1,367 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.Backup do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + import Pleroma.Web.Gettext + + require Logger + require Pleroma.Constants + + alias Pleroma.Activity + alias Pleroma.Bookmark + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.User.Backup.State + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.UserView + alias Pleroma.Workers.BackupWorker + + schema "backups" do + field(:content_type, :string) + field(:file_name, :string) + field(:file_size, :integer, default: 0) + field(:processed, :boolean, default: false) + field(:state, State, default: :invalid) + field(:processed_number, :integer, default: 0) + + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + + timestamps() + end + + def create(user, admin_id \\ nil) do + with :ok <- validate_limit(user, admin_id), + {:ok, backup} <- user |> new() |> Repo.insert() do + BackupWorker.process(backup, admin_id) + end + end + + def new(user) do + rand_str = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + datetime = Calendar.NaiveDateTime.Format.iso8601_basic(NaiveDateTime.utc_now()) + name = "archive-#{user.nickname}-#{datetime}-#{rand_str}.zip" + + %__MODULE__{ + user_id: user.id, + content_type: "application/zip", + file_name: name, + state: :pending + } + end + + def delete(backup) do + uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) + + with :ok <- uploader.delete_file(Path.join("backups", backup.file_name)) do + Repo.delete(backup) + end + end + + defp validate_limit(_user, admin_id) when is_binary(admin_id), do: :ok + + defp validate_limit(user, nil) do + case get_last(user.id) do + %__MODULE__{inserted_at: inserted_at} -> + days = Pleroma.Config.get([__MODULE__, :limit_days]) + diff = Timex.diff(NaiveDateTime.utc_now(), inserted_at, :days) + + if diff > days do + :ok + else + {:error, + dngettext( + "errors", + "Last export was less than a day ago", + "Last export was less than %{days} days ago", + days, + days: days + )} + end + + nil -> + :ok + end + end + + def get_last(user_id) do + __MODULE__ + |> where(user_id: ^user_id) + |> order_by(desc: :id) + |> limit(1) + |> Repo.one() + end + + def list(%User{id: user_id}) do + __MODULE__ + |> where(user_id: ^user_id) + |> order_by(desc: :id) + |> Repo.all() + end + + def remove_outdated(%__MODULE__{id: latest_id, user_id: user_id}) do + __MODULE__ + |> where(user_id: ^user_id) + |> where([b], b.id != ^latest_id) + |> Repo.all() + |> Enum.each(&BackupWorker.delete/1) + end + + def get(id), do: Repo.get(__MODULE__, id) + + defp set_state(backup, state, processed_number \\ nil) do + struct = + %{state: state} + |> Pleroma.Maps.put_if_present(:processed_number, processed_number) + + backup + |> cast(struct, [:state, :processed_number]) + |> Repo.update() + end + + def process(%__MODULE__{} = backup) do + set_state(backup, :running, 0) + + current_pid = self() + + task = + Task.Supervisor.async_nolink( + Pleroma.TaskSupervisor, + __MODULE__, + :do_process, + [backup, current_pid] + ) + + wait_backup(backup, backup.processed_number, task) + end + + def do_process(backup, current_pid) do + with {:ok, zip_file} <- export(backup, current_pid), + {:ok, %{size: size}} <- File.stat(zip_file), + {:ok, _upload} <- upload(backup, zip_file) do + backup + |> cast( + %{ + file_size: size, + processed: true, + state: :complete + }, + [:file_size, :processed, :state] + ) + |> Repo.update() + end + end + + defp wait_backup(backup, current_processed, task) do + wait_time = Pleroma.Config.get([__MODULE__, :process_wait_time]) + + receive do + {:progress, new_processed} -> + total_processed = current_processed + new_processed + + set_state(backup, :running, total_processed) + wait_backup(backup, total_processed, task) + + {:DOWN, _ref, _proc, _pid, reason} -> + backup = get(backup.id) + + if reason != :normal do + Logger.error("Backup #{backup.id} process ended abnormally: #{inspect(reason)}") + + {:ok, backup} = set_state(backup, :failed) + + cleanup(backup) + + {:error, + %{ + backup: backup, + reason: :exit, + details: reason + }} + else + {:ok, backup} + end + after + wait_time -> + Logger.error( + "Backup #{backup.id} timed out after no response for #{wait_time}ms, terminating" + ) + + Task.Supervisor.terminate_child(Pleroma.TaskSupervisor, task.pid) + + {:ok, backup} = set_state(backup, :failed) + + cleanup(backup) + + {:error, + %{ + backup: backup, + reason: :timeout + }} + end + end + + @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json'] + def export(%__MODULE__{} = backup, caller_pid) do + backup = Repo.preload(backup, :user) + dir = backup_tempdir(backup) + + with :ok <- File.mkdir(dir), + :ok <- actor(dir, backup.user, caller_pid), + :ok <- statuses(dir, backup.user, caller_pid), + :ok <- likes(dir, backup.user, caller_pid), + :ok <- bookmarks(dir, backup.user, caller_pid), + {:ok, zip_path} <- :zip.create(String.to_charlist(dir <> ".zip"), @files, cwd: dir), + {:ok, _} <- File.rm_rf(dir) do + {:ok, to_string(zip_path)} + end + end + + def dir(name) do + dir = Pleroma.Config.get([__MODULE__, :dir]) || System.tmp_dir!() + Path.join(dir, name) + end + + def upload(%__MODULE__{} = backup, zip_path) do + uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) + + upload = %Pleroma.Upload{ + name: backup.file_name, + tempfile: zip_path, + content_type: backup.content_type, + path: Path.join("backups", backup.file_name) + } + + with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload), + :ok <- File.rm(zip_path) do + {:ok, upload} + end + end + + defp actor(dir, user, caller_pid) do + with {:ok, json} <- + UserView.render("user.json", %{user: user}) + |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"}) + |> Jason.encode() do + send(caller_pid, {:progress, 1}) + File.write(Path.join(dir, "actor.json"), json) + end + end + + defp write_header(file, name) do + IO.write( + file, + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "#{name}.json", + "type": "OrderedCollection", + "orderedItems": [ + + """ + ) + end + + defp should_report?(num, chunk_size), do: rem(num, chunk_size) == 0 + + defp backup_tempdir(backup) do + name = String.trim_trailing(backup.file_name, ".zip") + dir(name) + end + + defp cleanup(backup) do + dir = backup_tempdir(backup) + File.rm_rf(dir) + end + + defp write(query, dir, name, fun, caller_pid) do + path = Path.join(dir, "#{name}.json") + + chunk_size = Pleroma.Config.get([__MODULE__, :process_chunk_size]) + + with {:ok, file} <- File.open(path, [:write, :utf8]), + :ok <- write_header(file, name) do + total = + query + |> Pleroma.Repo.chunk_stream(chunk_size, _returns_as = :one, timeout: :infinity) + |> Enum.reduce(0, fn i, acc -> + with {:ok, data} <- + (try do + fun.(i) + rescue + e -> {:error, e} + end), + {:ok, str} <- Jason.encode(data), + :ok <- IO.write(file, str <> ",\n") do + if should_report?(acc + 1, chunk_size) do + send(caller_pid, {:progress, chunk_size}) + end + + acc + 1 + else + {:error, e} -> + Logger.warning( + "Error processing backup item: #{inspect(e)}\n The item is: #{inspect(i)}" + ) + + acc + + _ -> + acc + end + end) + + send(caller_pid, {:progress, rem(total, chunk_size)}) + + with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do + File.close(file) + end + end + end + + defp bookmarks(dir, %{id: user_id} = _user, caller_pid) do + Bookmark + |> where(user_id: ^user_id) + |> join(:inner, [b], activity in assoc(b, :activity)) + |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)}) + |> write(dir, "bookmarks", fn a -> {:ok, a.object} end, caller_pid) + end + + defp likes(dir, user, caller_pid) do + user.ap_id + |> Activity.Queries.by_actor() + |> Activity.Queries.by_type("Like") + |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)}) + |> write(dir, "likes", fn a -> {:ok, a.object} end, caller_pid) + end + + defp statuses(dir, user, caller_pid) do + opts = + %{} + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:actor_id, user.ap_id) + + [ + [Pleroma.Constants.as_public(), user.ap_id], + User.following(user), + Pleroma.List.memberships(user) + ] + |> Enum.concat() + |> ActivityPub.fetch_activities_query(opts) + |> write( + dir, + "outbox", + fn a -> + with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do + {:ok, Map.delete(activity, "@context")} + end + end, + caller_pid + ) + end +end diff --git a/lib/pleroma/user/email_list.ex b/lib/pleroma/user/email_list.ex new file mode 100644 index 0000000..a483d91 --- /dev/null +++ b/lib/pleroma/user/email_list.ex @@ -0,0 +1,70 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.EmailList do + @moduledoc """ + Functions for generating email lists from local users. + """ + import Ecto.Query + + alias Pleroma.Repo + alias Pleroma.User + + @header_row ["Email Address", "Nickname", "Subscribe?"] + + defp query(:subscribers) do + User.Query.build(%{ + local: true, + active: true, + accepts_email_list: true + }) + |> where([u], not is_nil(u.email)) + end + + defp query(:unsubscribers) do + User.Query.build(%{ + local: true, + accepts_email_list: false + }) + |> where([u], not is_nil(u.email)) + end + + defp query(:combined) do + User.Query.build(%{ + local: true + }) + |> where([u], not is_nil(u.email)) + end + + def generate_csv(audience) when is_atom(audience) do + audience + |> query() + |> generate_csv() + end + + def generate_csv(%Ecto.Query{} = query) do + query + |> Repo.all() + |> Enum.map(&build_row/1) + |> build_csv() + end + + defp subscribe?(%User{} = user) do + user.accepts_email_list && user.is_active && user.is_approved && user.is_confirmed + end + + defp build_row(%User{} = user) do + [ + user.email, + user.nickname, + subscribe?(user) + ] + end + + defp build_csv(lines) do + [@header_row | lines] + |> CSV.encode() + |> Enum.join() + end +end diff --git a/lib/pleroma/user/import.ex b/lib/pleroma/user/import.ex new file mode 100644 index 0000000..4baa7e3 --- /dev/null +++ b/lib/pleroma/user/import.ex @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.Import do + use Ecto.Schema + + alias Pleroma.User + alias Pleroma.Web.CommonAPI + alias Pleroma.Workers.BackgroundWorker + + require Logger + + @spec perform(atom(), User.t(), list()) :: :ok | list() | {:error, any()} + def perform(:mutes_import, %User{} = user, [_ | _] = identifiers) do + Enum.map( + identifiers, + fn identifier -> + with {:ok, %User{} = muted_user} <- User.get_or_fetch(identifier), + {:ok, _} <- User.mute(user, muted_user) do + muted_user + else + error -> handle_error(:mutes_import, identifier, error) + end + end + ) + end + + def perform(:blocks_import, %User{} = blocker, [_ | _] = identifiers) do + Enum.map( + identifiers, + fn identifier -> + with {:ok, %User{} = blocked} <- User.get_or_fetch(identifier), + {:ok, _block} <- CommonAPI.block(blocker, blocked) do + blocked + else + error -> handle_error(:blocks_import, identifier, error) + end + end + ) + end + + def perform(:follow_import, %User{} = follower, [_ | _] = identifiers) do + Enum.map( + identifiers, + fn identifier -> + with {:ok, %User{} = followed} <- User.get_or_fetch(identifier), + {:ok, follower, followed} <- User.maybe_direct_follow(follower, followed), + {:ok, _, _, _} <- CommonAPI.follow(follower, followed) do + followed + else + error -> handle_error(:follow_import, identifier, error) + end + end + ) + end + + def perform(_, _, _), do: :ok + + defp handle_error(op, user_id, error) do + Logger.debug("#{op} failed for #{user_id} with: #{inspect(error)}") + error + end + + def blocks_import(%User{} = blocker, [_ | _] = identifiers) do + BackgroundWorker.enqueue( + "blocks_import", + %{"user_id" => blocker.id, "identifiers" => identifiers} + ) + end + + def follow_import(%User{} = follower, [_ | _] = identifiers) do + BackgroundWorker.enqueue( + "follow_import", + %{"user_id" => follower.id, "identifiers" => identifiers} + ) + end + + def mutes_import(%User{} = user, [_ | _] = identifiers) do + BackgroundWorker.enqueue( + "mutes_import", + %{"user_id" => user.id, "identifiers" => identifiers} + ) + end +end diff --git a/lib/pleroma/user/notification_setting.ex b/lib/pleroma/user/notification_setting.ex new file mode 100644 index 0000000..3fb845d --- /dev/null +++ b/lib/pleroma/user/notification_setting.ex @@ -0,0 +1,34 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.NotificationSetting do + use Ecto.Schema + import Ecto.Changeset + + @derive Jason.Encoder + @primary_key false + + embedded_schema do + field(:block_from_strangers, :boolean, default: false) + field(:hide_notification_contents, :boolean, default: false) + end + + def changeset(schema, params) do + schema + |> cast(prepare_attrs(params), [ + :block_from_strangers, + :hide_notification_contents + ]) + end + + defp prepare_attrs(params) do + Enum.reduce(params, %{}, fn + {k, v}, acc when is_binary(v) -> + Map.put(acc, k, String.downcase(v)) + + {k, v}, acc -> + Map.put(acc, k, v) + end) + end +end diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex new file mode 100644 index 0000000..3d9dab4 --- /dev/null +++ b/lib/pleroma/user/query.ex @@ -0,0 +1,314 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.Query do + @moduledoc """ + User query builder module. Builds query from new query or another user query. + + ## Example: + query = Pleroma.User.Query.build(%{nickname: "nickname"}) + another_query = Pleroma.User.Query.build(query, %{email: "email@example.com"}) + Pleroma.Repo.all(query) + Pleroma.Repo.all(another_query) + + Adding new rules: + - *ilike criteria* + - add field to @ilike_criteria list + - pass non empty string + - e.g. Pleroma.User.Query.build(%{nickname: "nickname"}) + - *equal criteria* + - add field to @equal_criteria list + - pass non empty string + - e.g. Pleroma.User.Query.build(%{email: "email@example.com"}) + - *contains criteria* + - add field to @containns_criteria list + - pass values list + - e.g. Pleroma.User.Query.build(%{ap_id: ["http://ap_id1", "http://ap_id2"]}) + """ + import Ecto.Query + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] + + alias Pleroma.Config + alias Pleroma.FollowingRelationship + alias Pleroma.User + + @type criteria :: + %{ + query: String.t(), + tags: [String.t()], + name: String.t(), + email: String.t(), + local: boolean(), + external: boolean(), + active: boolean(), + deactivated: boolean(), + need_approval: boolean(), + unconfirmed: boolean(), + is_admin: boolean(), + is_moderator: boolean(), + is_suggested: boolean(), + is_discoverable: boolean(), + super_users: boolean(), + is_privileged: atom(), + invisible: boolean(), + internal: boolean(), + followers: User.t(), + friends: User.t(), + recipients_from_activity: [String.t()], + nickname: [String.t()] | String.t(), + ap_id: [String.t()], + order_by: term(), + select: term(), + limit: pos_integer(), + actor_types: [String.t()], + birthday_day: pos_integer(), + birthday_month: pos_integer(), + staff: boolean() + } + | map() + + @ilike_criteria [:nickname, :name, :query] + @equal_criteria [:email] + @contains_criteria [:ap_id, :nickname] + + @spec build(Query.t(), criteria()) :: Query.t() + def build(query \\ base_query(), criteria) do + prepare_query(query, criteria) + end + + @spec paginate(Ecto.Query.t(), pos_integer(), pos_integer()) :: Ecto.Query.t() + def paginate(query, page, page_size) do + from(u in query, + limit: ^page_size, + offset: ^((page - 1) * page_size) + ) + end + + defp base_query do + from(u in User) + end + + defp prepare_query(query, criteria) do + criteria + |> Map.put_new(:internal, false) + |> Enum.reduce(query, &compose_query/2) + end + + defp compose_query({key, value}, query) + when key in @ilike_criteria and not_empty_string(value) do + # hack for :query key + key = if key == :query, do: :nickname, else: key + where(query, [u], ilike(field(u, ^key), ^"%#{value}%")) + end + + defp compose_query({:invisible, bool}, query) when is_boolean(bool) do + where(query, [u], u.invisible == ^bool) + end + + defp compose_query({key, value}, query) + when key in @equal_criteria and not_empty_string(value) do + where(query, [u], ^[{key, value}]) + end + + defp compose_query({key, values}, query) when key in @contains_criteria and is_list(values) do + where(query, [u], field(u, ^key) in ^values) + end + + defp compose_query({:tags, tags}, query) when is_list(tags) and length(tags) > 0 do + where(query, [u], fragment("? && ?", u.tags, ^tags)) + end + + defp compose_query({:is_admin, bool}, query) do + where(query, [u], u.is_admin == ^bool) + end + + defp compose_query({:actor_types, actor_types}, query) when is_list(actor_types) do + where(query, [u], u.actor_type in ^actor_types) + end + + defp compose_query({:is_moderator, bool}, query) do + where(query, [u], u.is_moderator == ^bool) + end + + defp compose_query({:accepts_email_list, bool}, query) do + where(query, [u], u.accepts_email_list == ^bool) + end + + defp compose_query({:super_users, _}, query) do + where( + query, + [u], + u.is_admin or u.is_moderator + ) + end + + defp compose_query({:is_privileged, privilege}, query) do + moderator_privileged = privilege in Config.get([:instance, :moderator_privileges]) + admin_privileged = privilege in Config.get([:instance, :admin_privileges]) + + query = compose_query({:active, true}, query) + query = compose_query({:local, true}, query) + + case {admin_privileged, moderator_privileged} do + {false, false} -> + where( + query, + false + ) + + {true, true} -> + where( + query, + [u], + u.is_admin or u.is_moderator + ) + + {true, false} -> + where( + query, + [u], + u.is_admin + ) + + {false, true} -> + where( + query, + [u], + u.is_moderator + ) + end + end + + defp compose_query({:local, _}, query), do: location_query(query, true) + + defp compose_query({:external, _}, query), do: location_query(query, false) + + defp compose_query({:active, false}, query) do + where(query, [u], u.is_active == false or u.is_approved == false or u.is_confirmed == false) + end + + defp compose_query({:active, _}, query) do + where(query, [u], u.is_active == true) + |> where([u], u.is_approved == true) + |> where([u], u.is_confirmed == true) + end + + defp compose_query({:legacy_active, _}, query) do + query + |> where([u], fragment("not (?->'deactivated' @> 'true')", u.info)) + end + + defp compose_query({:deactivated, false}, query) do + where(query, [u], u.is_active == true) + end + + defp compose_query({:deactivated, true}, query) do + where(query, [u], u.is_active == false) + end + + defp compose_query({:confirmation_pending, bool}, query) do + where(query, [u], u.is_confirmed != ^bool) + end + + defp compose_query({:need_approval, false}, query) do + where(query, [u], u.is_approved == true) + end + + defp compose_query({:need_approval, _}, query) do + where(query, [u], u.is_approved == false) + end + + defp compose_query({:unconfirmed, _}, query) do + where(query, [u], u.is_confirmed == false) + end + + defp compose_query({:is_suggested, bool}, query) do + where(query, [u], u.is_suggested == ^bool) + end + + defp compose_query({:is_discoverable, bool}, query) do + where(query, [u], u.is_discoverable == ^bool) + end + + defp compose_query({:followers, %User{id: id}}, query) do + query + |> where([u], u.id != ^id) + |> join(:inner, [u], r in FollowingRelationship, + as: :relationships, + on: r.following_id == ^id and r.follower_id == u.id + ) + |> where([relationships: r], r.state == ^:follow_accept) + end + + defp compose_query({:friends, %User{id: id}}, query) do + query + |> where([u], u.id != ^id) + |> join(:inner, [u], r in FollowingRelationship, + as: :relationships, + on: r.following_id == u.id and r.follower_id == ^id + ) + |> where([relationships: r], r.state == ^:follow_accept) + end + + defp compose_query({:recipients_from_activity, to}, query) do + following_query = + from(u in User, + join: f in FollowingRelationship, + on: u.id == f.following_id, + where: f.state == ^:follow_accept, + where: u.follower_address in ^to, + select: f.follower_id + ) + + from(u in query, + where: u.ap_id in ^to or u.id in subquery(following_query) + ) + end + + defp compose_query({:order_by, key}, query) do + order_by(query, [u], field(u, ^key)) + end + + defp compose_query({:select, keys}, query) do + select(query, [u], ^keys) + end + + defp compose_query({:limit, limit}, query) do + limit(query, ^limit) + end + + defp compose_query({:internal, false}, query) do + query + |> where([u], not is_nil(u.nickname)) + |> where([u], not like(u.nickname, "internal.%")) + end + + defp compose_query({:birthday_day, day}, query) do + query + |> where([u], u.show_birthday == true) + |> where([u], not is_nil(u.birthday)) + |> where([u], fragment("date_part('day', ?)", u.birthday) == ^day) + end + + defp compose_query({:birthday_month, month}, query) do + query + |> where([u], u.show_birthday == true) + |> where([u], not is_nil(u.birthday)) + |> where([u], fragment("date_part('month', ?)", u.birthday) == ^month) + end + + defp compose_query({:staff, false}, query) do + where(query, [u], u.is_admin == false and u.is_moderator == false) + end + + defp compose_query({:staff, _}, query) do + where(query, [u], u.is_admin == true or u.is_moderator == true) + end + + defp compose_query(_unsupported_param, query), do: query + + defp location_query(query, local) do + where(query, [u], u.local == ^local) + end +end diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex new file mode 100644 index 0000000..a7fb8fb --- /dev/null +++ b/lib/pleroma/user/search.ex @@ -0,0 +1,266 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.Search do + alias Pleroma.EctoType.ActivityPub.ObjectValidators.Uri, as: UriType + alias Pleroma.Pagination + alias Pleroma.User + + import Ecto.Query + + @limit 20 + + def search(query_string, opts \\ []) do + resolve = Keyword.get(opts, :resolve, false) + following = Keyword.get(opts, :following, false) + result_limit = Keyword.get(opts, :limit, @limit) + offset = Keyword.get(opts, :offset, 0) + + for_user = Keyword.get(opts, :for_user) + + query_string = format_query(query_string) + + # If this returns anything, it should bounce to the top + maybe_resolved = maybe_resolve(resolve, for_user, query_string) + + top_user_ids = + [] + |> maybe_add_resolved(maybe_resolved) + |> maybe_add_ap_id_match(query_string) + |> maybe_add_uri_match(query_string) + + results = + query_string + |> search_query(for_user, following, top_user_ids) + |> Pagination.fetch_paginated(%{"offset" => offset, "limit" => result_limit}, :offset) + + results + end + + defp maybe_add_resolved(list, {:ok, %User{} = user}) do + [user.id | list] + end + + defp maybe_add_resolved(list, _), do: list + + defp maybe_add_ap_id_match(list, query) do + if user = User.get_cached_by_ap_id(query) do + [user.id | list] + else + list + end + end + + defp maybe_add_uri_match(list, query) do + with {:ok, query} <- UriType.cast(query), + q = from(u in User, where: u.uri == ^query, select: u.id), + users = Pleroma.Repo.all(q) do + users ++ list + else + _ -> list + end + 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]+/, "") + |> String.to_charlist() + |> :idna.encode() + |> to_string() + + name <> "@" <> encoded_domain + else + _ -> query_string + end + end + + defp search_query(query_string, for_user, following, top_user_ids) do + for_user + |> base_query(following) + |> filter_blocked_user(for_user) + |> filter_invisible_users() + |> filter_internal_users() + |> filter_blocked_domains(for_user) + |> fts_search(query_string) + |> select_top_users(top_user_ids) + |> trigram_rank(query_string) + |> boost_search_rank(for_user, top_user_ids) + |> subquery() + |> order_by(desc: :search_rank) + |> maybe_restrict_local(for_user) + |> filter_deactivated_users() + end + + defp select_top_users(query, top_user_ids) do + from(u in query, + or_where: u.id in ^top_user_ids + ) + end + + defp fts_search(query, query_string) do + query_string = to_tsquery(query_string) + + from( + u in query, + where: + fragment( + # The fragment must _exactly_ match `users_fts_index`, otherwise the index won't work + """ + ( + setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') || + setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B') + ) @@ to_tsquery('simple', ?) + """, + u.nickname, + u.name, + ^query_string + ) + ) + end + + defp to_tsquery(query_string) do + String.trim_trailing(query_string, "@" <> local_domain()) + |> String.replace(~r/[!-\/|@|[-`|{-~|:-?]+/, " ") + |> String.trim() + |> String.split() + |> Enum.map(&(&1 <> ":*")) + |> Enum.join(" | ") + end + + # Considers nickname match, localized nickname match, name match; preferences nickname match + defp trigram_rank(query, query_string) do + from( + u in query, + select_merge: %{ + search_rank: + fragment( + """ + similarity(?, ?) + + similarity(?, regexp_replace(?, '@.+', '')) + + similarity(?, trim(coalesce(?, ''))) + """, + ^query_string, + u.nickname, + ^query_string, + u.nickname, + ^query_string, + u.name + ) + } + ) + end + + defp base_query(%User{} = user, true), do: User.get_friends_query(user) + defp base_query(_user, _following), do: User + + defp filter_invisible_users(query) do + from(q in query, where: q.invisible == false) + end + + defp filter_internal_users(query) do + from(q in query, where: q.actor_type != "Application") + end + + defp filter_deactivated_users(query) do + from(q in query, where: q.is_active == true) + end + + defp filter_blocked_user(query, %User{} = blocker) do + query + |> join(:left, [u], b in Pleroma.UserRelationship, + as: :blocks, + on: b.relationship_type == ^:block and b.source_id == ^blocker.id and u.id == b.target_id + ) + |> where([blocks: b], is_nil(b.target_id)) + end + + defp filter_blocked_user(query, _), do: query + + defp filter_blocked_domains(query, %User{domain_blocks: domain_blocks}) + when length(domain_blocks) > 0 do + domains = Enum.join(domain_blocks, ",") + + from( + q in query, + where: fragment("substring(ap_id from '.*://([^/]*)') NOT IN (?)", ^domains) + ) + end + + defp filter_blocked_domains(query, _), do: query + + defp maybe_resolve(true, user, query) do + case {limit(), user} do + {:all, _} -> :noop + {:unauthenticated, %User{}} -> User.get_or_fetch(query) + {:unauthenticated, _} -> :noop + {false, _} -> User.get_or_fetch(query) + end + end + + defp maybe_resolve(_, _, _), do: :noop + + defp maybe_restrict_local(q, user) do + case {limit(), user} do + {:all, _} -> restrict_local(q) + {:unauthenticated, %User{}} -> q + {:unauthenticated, _} -> restrict_local(q) + {false, _} -> q + end + end + + defp limit, do: Pleroma.Config.get([:instance, :limit_to_local_content], :unauthenticated) + + defp restrict_local(q), do: where(q, [u], u.local == true) + + defp local_domain, do: Pleroma.Config.get([Pleroma.Web.Endpoint, :url, :host]) + + defp boost_search_rank(query, %User{} = for_user, top_user_ids) do + friends_ids = User.get_friends_ids(for_user) + followers_ids = User.get_followers_ids(for_user) + + from(u in subquery(query), + select_merge: %{ + search_rank: + fragment( + """ + CASE WHEN (?) THEN (?) * 1.5 + WHEN (?) THEN (?) * 1.3 + WHEN (?) THEN (?) * 1.1 + WHEN (?) THEN 9001 + ELSE (?) END + """, + u.id in ^friends_ids and u.id in ^followers_ids, + u.search_rank, + u.id in ^friends_ids, + u.search_rank, + u.id in ^followers_ids, + u.search_rank, + u.id in ^top_user_ids, + u.search_rank + ) + } + ) + end + + defp boost_search_rank(query, _for_user, top_user_ids) do + from(u in subquery(query), + select_merge: %{ + search_rank: + fragment( + """ + CASE WHEN (?) THEN 9001 + ELSE (?) END + """, + u.id in ^top_user_ids, + u.search_rank + ) + } + ) + end +end diff --git a/lib/pleroma/user/welcome_chat_message.ex b/lib/pleroma/user/welcome_chat_message.ex new file mode 100644 index 0000000..31e0bfa --- /dev/null +++ b/lib/pleroma/user/welcome_chat_message.ex @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.WelcomeChatMessage do + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + @spec enabled?() :: boolean() + def enabled?, do: Config.get([:welcome, :chat_message, :enabled], false) + + @spec post_message(User.t()) :: {:ok, Pleroma.Activity.t() | nil} + def post_message(user) do + [:welcome, :chat_message, :sender_nickname] + |> Config.get(nil) + |> fetch_sender() + |> do_post(user, welcome_message()) + end + + defp do_post(%User{} = sender, recipient, message) + when is_binary(message) do + CommonAPI.post_chat_message( + sender, + recipient, + message + ) + end + + defp do_post(_sender, _recipient, _message), do: {:ok, nil} + + defp fetch_sender(nickname) when is_binary(nickname) do + with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + user + else + _ -> nil + end + end + + defp fetch_sender(_), do: nil + + defp welcome_message do + Config.get([:welcome, :chat_message, :message], nil) + end +end diff --git a/lib/pleroma/user/welcome_email.ex b/lib/pleroma/user/welcome_email.ex new file mode 100644 index 0000000..970975a --- /dev/null +++ b/lib/pleroma/user/welcome_email.ex @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.WelcomeEmail do + @moduledoc """ + The module represents the functions to send welcome email. + """ + + alias Pleroma.Config + alias Pleroma.Emails + alias Pleroma.User + + import Pleroma.Config.Helpers, only: [instance_name: 0] + + @spec enabled?() :: boolean() + def enabled?, do: Config.get([:welcome, :email, :enabled], false) + + @spec send_email(User.t()) :: {:ok, Oban.Job.t()} + def send_email(%User{} = user) do + user + |> Emails.UserEmail.welcome(email_options(user)) + |> Emails.Mailer.deliver_async() + end + + defp email_options(user) do + bindings = [user: user, instance_name: instance_name()] + + %{} + |> add_sender(Config.get([:welcome, :email, :sender], nil)) + |> add_option(:subject, bindings) + |> add_option(:html, bindings) + |> add_option(:text, bindings) + end + + defp add_option(opts, option, bindings) do + [:welcome, :email, option] + |> Config.get(nil) + |> eval_string(bindings) + |> merge_options(opts, option) + end + + defp add_sender(opts, {_name, _email} = sender) do + merge_options(sender, opts, :sender) + end + + defp add_sender(opts, sender) when is_binary(sender) do + add_sender(opts, {instance_name(), sender}) + end + + defp add_sender(opts, _), do: opts + + defp merge_options(nil, options, _option), do: options + + defp merge_options(value, options, option) do + Map.merge(options, %{option => value}) + end + + defp eval_string(nil, _), do: nil + defp eval_string("", _), do: nil + defp eval_string(str, bindings), do: EEx.eval_string(str, bindings) +end diff --git a/lib/pleroma/user/welcome_message.ex b/lib/pleroma/user/welcome_message.ex new file mode 100644 index 0000000..6010e80 --- /dev/null +++ b/lib/pleroma/user/welcome_message.ex @@ -0,0 +1,47 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.WelcomeMessage do + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + @spec enabled?() :: boolean() + def enabled?, do: Config.get([:welcome, :direct_message, :enabled], false) + + @spec post_message(User.t()) :: {:ok, Pleroma.Activity.t() | nil} + def post_message(user) do + [:welcome, :direct_message, :sender_nickname] + |> Config.get(nil) + |> fetch_sender() + |> do_post(user, welcome_message()) + end + + defp do_post(%User{} = sender, %User{nickname: nickname}, message) + when is_binary(message) do + CommonAPI.post( + sender, + %{ + visibility: "direct", + status: "@#{nickname}\n#{message}" + } + ) + end + + defp do_post(_sender, _recipient, _message), do: {:ok, nil} + + defp fetch_sender(nickname) when is_binary(nickname) do + with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + user + else + _ -> nil + end + end + + defp fetch_sender(_), do: nil + + defp welcome_message do + Config.get([:welcome, :direct_message, :message], nil) + end +end diff --git a/lib/pleroma/user_invite_token.ex b/lib/pleroma/user_invite_token.ex new file mode 100644 index 0000000..b242a88 --- /dev/null +++ b/lib/pleroma/user_invite_token.ex @@ -0,0 +1,124 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.UserInviteToken do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + alias Pleroma.Repo + alias Pleroma.UserInviteToken + + @type t :: %__MODULE__{} + @type token :: String.t() + + schema "user_invite_tokens" do + field(:token, :string) + field(:used, :boolean, default: false) + field(:max_use, :integer) + field(:expires_at, :date) + field(:uses, :integer, default: 0) + field(:invite_type, :string) + + timestamps() + end + + @spec create_invite(map()) :: {:ok, UserInviteToken.t()} + def create_invite(params \\ %{}) do + %UserInviteToken{} + |> cast(params, [:max_use, :expires_at]) + |> add_token() + |> assign_type() + |> Repo.insert() + end + + defp add_token(changeset) do + token = :crypto.strong_rand_bytes(32) |> Base.url_encode64() + put_change(changeset, :token, token) + end + + defp assign_type(%{changes: %{max_use: _max_use, expires_at: _expires_at}} = changeset) do + put_change(changeset, :invite_type, "reusable_date_limited") + end + + defp assign_type(%{changes: %{expires_at: _expires_at}} = changeset) do + put_change(changeset, :invite_type, "date_limited") + end + + defp assign_type(%{changes: %{max_use: _max_use}} = changeset) do + put_change(changeset, :invite_type, "reusable") + end + + defp assign_type(changeset), do: put_change(changeset, :invite_type, "one_time") + + @spec list_invites() :: [UserInviteToken.t()] + def list_invites do + query = from(u in UserInviteToken, order_by: u.id) + Repo.all(query) + end + + @spec update_invite!(UserInviteToken.t(), map()) :: UserInviteToken.t() | no_return() + def update_invite!(invite, changes) do + change(invite, changes) |> Repo.update!() + end + + @spec update_invite(UserInviteToken.t(), map()) :: + {:ok, UserInviteToken.t()} | {:error, Changeset.t()} + def update_invite(invite, changes) do + change(invite, changes) |> Repo.update() + end + + @spec find_by_token!(token()) :: UserInviteToken.t() | no_return() + def find_by_token!(token), do: Repo.get_by!(UserInviteToken, token: token) + + @spec find_by_token(token()) :: {:ok, UserInviteToken.t()} | nil + def find_by_token(token) do + with %UserInviteToken{} = invite <- Repo.get_by(UserInviteToken, token: token) do + {:ok, invite} + end + end + + @spec valid_invite?(UserInviteToken.t()) :: boolean() + def valid_invite?(%{invite_type: "one_time"} = invite) do + not invite.used + end + + def valid_invite?(%{invite_type: "date_limited"} = invite) do + not_overdue_date?(invite) and not invite.used + end + + def valid_invite?(%{invite_type: "reusable"} = invite) do + invite.uses < invite.max_use and not invite.used + end + + def valid_invite?(%{invite_type: "reusable_date_limited"} = invite) do + not_overdue_date?(invite) and invite.uses < invite.max_use and not invite.used + end + + defp not_overdue_date?(%{expires_at: expires_at}) do + Date.compare(Date.utc_today(), expires_at) in [:lt, :eq] + end + + @spec update_usage!(UserInviteToken.t()) :: nil | UserInviteToken.t() | no_return() + def update_usage!(%{invite_type: "date_limited"}), do: nil + + def update_usage!(%{invite_type: "one_time"} = invite), + do: update_invite!(invite, %{used: true}) + + def update_usage!(%{invite_type: invite_type} = invite) + when invite_type == "reusable" or invite_type == "reusable_date_limited" do + changes = %{ + uses: invite.uses + 1 + } + + changes = + if changes.uses >= invite.max_use do + Map.put(changes, :used, true) + else + changes + end + + update_invite!(invite, changes) + end +end diff --git a/lib/pleroma/user_note.ex b/lib/pleroma/user_note.ex new file mode 100644 index 0000000..d4b8256 --- /dev/null +++ b/lib/pleroma/user_note.ex @@ -0,0 +1,52 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.UserNote do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.UserNote + + schema "user_notes" do + belongs_to(:source, User, type: FlakeId.Ecto.CompatType) + belongs_to(:target, User, type: FlakeId.Ecto.CompatType) + field(:comment, :string) + + timestamps() + end + + def changeset(%UserNote{} = user_note, params \\ %{}) do + user_note + |> cast(params, [:source_id, :target_id, :comment]) + |> validate_required([:source_id, :target_id]) + end + + def show(%User{} = source, %User{} = target) do + with %UserNote{} = note <- + UserNote + |> where(source_id: ^source.id, target_id: ^target.id) + |> Repo.one() do + note.comment + else + _ -> "" + end + end + + def create(%User{} = source, %User{} = target, comment) do + %UserNote{} + |> changeset(%{ + source_id: source.id, + target_id: target.id, + comment: comment + }) + |> Repo.insert( + on_conflict: {:replace, [:comment]}, + conflict_target: [:source_id, :target_id] + ) + end +end diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex new file mode 100644 index 0000000..fbecf31 --- /dev/null +++ b/lib/pleroma/user_relationship.ex @@ -0,0 +1,242 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.UserRelationship do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Ecto.Changeset + alias Pleroma.FollowingRelationship + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.UserRelationship + + schema "user_relationships" do + belongs_to(:source, User, type: FlakeId.Ecto.CompatType) + belongs_to(:target, User, type: FlakeId.Ecto.CompatType) + field(:relationship_type, Pleroma.UserRelationship.Type) + field(:expires_at, :utc_datetime) + + timestamps(updated_at: false) + end + + for relationship_type <- Keyword.keys(Pleroma.UserRelationship.Type.__enum_map__()) do + # `def create_block/3`, `def create_mute/3`, `def create_reblog_mute/3`, + # `def create_notification_mute/3`, `def create_inverse_subscription/3`, + # `def endorsement/3` + def unquote(:"create_#{relationship_type}")(source, target, expires_at \\ nil), + do: create(unquote(relationship_type), source, target, expires_at) + + # `def delete_block/2`, `def delete_mute/2`, `def delete_reblog_mute/2`, + # `def delete_notification_mute/2`, `def delete_inverse_subscription/2`, + # `def delete_endorsement/2` + def unquote(:"delete_#{relationship_type}")(source, target), + do: delete(unquote(relationship_type), source, target) + + # `def block_exists?/2`, `def mute_exists?/2`, `def reblog_mute_exists?/2`, + # `def notification_mute_exists?/2`, `def inverse_subscription_exists?/2`, + # `def inverse_endorsement_exists?/2` + def unquote(:"#{relationship_type}_exists?")(source, target), + do: exists?(unquote(relationship_type), source, target) + + # `def get_block_expire_date/2`, `def get_mute_expire_date/2`, + # `def get_reblog_mute_expire_date/2`, `def get_notification_mute_exists?/2`, + # `def get_inverse_subscription_expire_date/2`, `def get_inverse_endorsement_expire_date/2` + def unquote(:"get_#{relationship_type}_expire_date")(source, target), + do: get_expire_date(unquote(relationship_type), source, target) + end + + def user_relationship_types, do: Keyword.keys(user_relationship_mappings()) + + def user_relationship_mappings, do: Pleroma.UserRelationship.Type.__enum_map__() + + def changeset(%UserRelationship{} = user_relationship, params \\ %{}) do + user_relationship + |> cast(params, [:relationship_type, :source_id, :target_id, :expires_at]) + |> validate_required([:relationship_type, :source_id, :target_id]) + |> unique_constraint(:relationship_type, + name: :user_relationships_source_id_relationship_type_target_id_index + ) + |> validate_not_self_relationship() + end + + def exists?(relationship_type, %User{} = source, %User{} = target) do + UserRelationship + |> where(relationship_type: ^relationship_type, source_id: ^source.id, target_id: ^target.id) + |> Repo.exists?() + end + + def get_expire_date(relationship_type, %User{} = source, %User{} = target) do + %UserRelationship{expires_at: expires_at} = + UserRelationship + |> where( + relationship_type: ^relationship_type, + source_id: ^source.id, + target_id: ^target.id + ) + |> Repo.one!() + + expires_at + end + + def create(relationship_type, %User{} = source, %User{} = target, expires_at \\ nil) do + %UserRelationship{} + |> changeset(%{ + relationship_type: relationship_type, + source_id: source.id, + target_id: target.id, + expires_at: expires_at + }) + |> Repo.insert( + on_conflict: {:replace_all_except, [:id, :inserted_at]}, + conflict_target: [:source_id, :relationship_type, :target_id], + returning: true + ) + end + + def delete(relationship_type, %User{} = source, %User{} = target) do + attrs = %{relationship_type: relationship_type, source_id: source.id, target_id: target.id} + + case Repo.get_by(UserRelationship, attrs) do + %UserRelationship{} = existing_record -> Repo.delete(existing_record) + nil -> {:ok, nil} + end + end + + def dictionary( + source_users, + target_users, + source_to_target_rel_types \\ nil, + target_to_source_rel_types \\ nil + ) + + def dictionary( + _source_users, + _target_users, + [] = _source_to_target_rel_types, + [] = _target_to_source_rel_types + ) do + [] + end + + def dictionary( + source_users, + target_users, + source_to_target_rel_types, + target_to_source_rel_types + ) + when is_list(source_users) and is_list(target_users) do + source_user_ids = User.binary_id(source_users) + target_user_ids = User.binary_id(target_users) + + get_rel_type_codes = fn rel_type -> user_relationship_mappings()[rel_type] end + + source_to_target_rel_types = + Enum.map(source_to_target_rel_types || user_relationship_types(), &get_rel_type_codes.(&1)) + + target_to_source_rel_types = + Enum.map(target_to_source_rel_types || user_relationship_types(), &get_rel_type_codes.(&1)) + + __MODULE__ + |> where( + fragment( + "(source_id = ANY(?) AND target_id = ANY(?) AND relationship_type = ANY(?)) OR \ + (source_id = ANY(?) AND target_id = ANY(?) AND relationship_type = ANY(?))", + ^source_user_ids, + ^target_user_ids, + ^source_to_target_rel_types, + ^target_user_ids, + ^source_user_ids, + ^target_to_source_rel_types + ) + ) + |> select([ur], [ur.relationship_type, ur.source_id, ur.target_id]) + |> Repo.all() + end + + def exists?(dictionary, rel_type, source, target, func) do + cond do + is_nil(source) or is_nil(target) -> + false + + dictionary -> + [rel_type, source.id, target.id] in dictionary + + true -> + func.(source, target) + end + end + + @doc ":relationships option for StatusView / AccountView / NotificationView" + def view_relationships_option(reading_user, actors, opts \\ []) + + def view_relationships_option(nil = _reading_user, _actors, _opts) do + %{user_relationships: [], following_relationships: []} + end + + def view_relationships_option(%User{} = reading_user, actors, opts) do + {source_to_target_rel_types, target_to_source_rel_types} = + case opts[:subset] do + :source_mutes -> + # Used for statuses rendering (FE needs `muted` flag for each status when statuses load) + {[:mute], []} + + nil -> + {[:block, :mute, :notification_mute, :reblog_mute], [:block, :inverse_subscription]} + + unknown -> + raise "Unsupported :subset option value: #{inspect(unknown)}" + end + + user_relationships = + UserRelationship.dictionary( + [reading_user], + actors, + source_to_target_rel_types, + target_to_source_rel_types + ) + + following_relationships = + case opts[:subset] do + :source_mutes -> + [] + + nil -> + FollowingRelationship.all_between_user_sets([reading_user], actors) + + unknown -> + raise "Unsupported :subset option value: #{inspect(unknown)}" + end + + %{user_relationships: user_relationships, following_relationships: following_relationships} + end + + defp validate_not_self_relationship(%Changeset{} = changeset) do + changeset + |> validate_source_id_target_id_inequality() + |> validate_target_id_source_id_inequality() + end + + defp validate_source_id_target_id_inequality(%Changeset{} = changeset) do + validate_change(changeset, :source_id, fn _, source_id -> + if source_id == get_field(changeset, :target_id) do + [source_id: "can't be equal to target_id"] + else + [] + end + end) + end + + defp validate_target_id_source_id_inequality(%Changeset{} = changeset) do + validate_change(changeset, :target_id, fn _, target_id -> + if target_id == get_field(changeset, :source_id) do + [target_id: "can't be equal to source_id"] + else + [] + end + end) + end +end diff --git a/lib/pleroma/utils.ex b/lib/pleroma/utils.ex new file mode 100644 index 0000000..73001c9 --- /dev/null +++ b/lib/pleroma/utils.ex @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Utils do + @posix_error_codes ~w( + eacces eagain ebadf ebadmsg ebusy edeadlk edeadlock edquot eexist efault + efbig eftype eintr einval eio eisdir eloop emfile emlink emultihop + enametoolong enfile enobufs enodev enolck enolink enoent enomem enospc + enosr enostr enosys enotblk enotdir enotsup enxio eopnotsupp eoverflow + eperm epipe erange erofs espipe esrch estale etxtbsy exdev + )a + + @repo_timeout Pleroma.Config.get([Pleroma.Repo, :timeout], 15_000) + + def compile_dir(dir) when is_binary(dir) do + dir + |> File.ls!() + |> Enum.map(&Path.join(dir, &1)) + |> Kernel.ParallelCompiler.compile() + end + + @doc """ + POSIX-compliant check if command is available in the system + + ## Examples + iex> command_available?("git") + true + iex> command_available?("wrongcmd") + false + + """ + @spec command_available?(String.t()) :: boolean() + def command_available?(command) do + case :os.find_executable(String.to_charlist(command)) do + false -> false + _ -> true + end + end + + @doc "creates the uniq temporary directory" + @spec tmp_dir(String.t()) :: {:ok, String.t()} | {:error, :file.posix()} + def tmp_dir(prefix \\ "") do + sub_dir = + [ + prefix, + Timex.to_unix(Timex.now()), + :os.getpid(), + String.downcase(Integer.to_string(:rand.uniform(0x100000000), 36)) + ] + |> Enum.join("-") + + tmp_dir = Path.join(System.tmp_dir!(), sub_dir) + + case File.mkdir(tmp_dir) do + :ok -> {:ok, tmp_dir} + error -> error + end + end + + @spec posix_error_message(atom()) :: binary() + def posix_error_message(code) when code in @posix_error_codes do + error_message = Gettext.dgettext(Pleroma.Web.Gettext, "posix_errors", "#{code}") + "(POSIX error: #{error_message})" + end + + def posix_error_message(_), do: "" + + @doc """ + Returns [timeout: integer] suitable for passing as an option to Repo functions. + + This function detects if the execution was triggered from IEx shell, Mix task, or + ./bin/pleroma_ctl and sets the timeout to :infinity, else returns the default timeout value. + """ + @spec query_timeout() :: [timeout: integer] + def query_timeout do + {parent, _, _, _} = Process.info(self(), :current_stacktrace) |> elem(1) |> Enum.fetch!(2) + + cond do + parent |> to_string |> String.starts_with?("Elixir.Mix.Task") -> [timeout: :infinity] + parent == :erl_eval -> [timeout: :infinity] + true -> [timeout: @repo_timeout] + end + end +end diff --git a/lib/pleroma/web.ex b/lib/pleroma/web.ex new file mode 100644 index 0000000..7a8b176 --- /dev/null +++ b/lib/pleroma/web.ex @@ -0,0 +1,242 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web do + @moduledoc """ + A module that keeps using definitions for controllers, + views and so on. + + This can be used in your application as: + + use Pleroma.Web, :controller + use Pleroma.Web, :view + + The definitions below will be executed for every view, + controller, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. + """ + + alias Pleroma.Helpers.AuthHelper + alias Pleroma.Web.Plugs.EnsureAuthenticatedPlug + alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug + alias Pleroma.Web.Plugs.ExpectAuthenticatedCheckPlug + alias Pleroma.Web.Plugs.ExpectPublicOrAuthenticatedCheckPlug + alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Web.Plugs.PlugHelper + + def controller do + quote do + use Phoenix.Controller, namespace: Pleroma.Web + + import Plug.Conn + + import Pleroma.Web.Gettext + import Pleroma.Web.TranslationHelpers + + alias Pleroma.Web.Router.Helpers, as: Routes + + plug(:set_put_layout) + + defp set_put_layout(conn, _) do + put_layout(conn, Pleroma.Config.get(:app_layout, "app.html")) + end + + # Marks plugs intentionally skipped and blocks their execution if present in plugs chain + defp skip_plug(conn, plug_modules) do + plug_modules + |> List.wrap() + |> Enum.reduce( + conn, + fn plug_module, conn -> + try do + plug_module.skip_plug(conn) + rescue + UndefinedFunctionError -> + raise "`#{plug_module}` is not skippable. Append `use Pleroma.Web, :plug` to its code." + end + end + ) + end + + defp skip_auth(conn, _) do + skip_plug(conn, [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug]) + end + + defp skip_public_check(conn, _) do + skip_plug(conn, EnsurePublicOrAuthenticatedPlug) + end + + # Executed just before actual controller action, invokes before-action hooks (callbacks) + defp action(conn, params) do + with %{halted: false} = conn <- + maybe_drop_authentication_if_oauth_check_ignored(conn), + %{halted: false} = conn <- maybe_perform_public_or_authenticated_check(conn), + %{halted: false} = conn <- maybe_perform_authenticated_check(conn), + %{halted: false} = conn <- maybe_halt_on_missing_oauth_scopes_check(conn) do + super(conn, params) + end + end + + # For non-authenticated API actions, drops auth info if OAuth scopes check was ignored + # (neither performed nor explicitly skipped) + defp maybe_drop_authentication_if_oauth_check_ignored(conn) do + if PlugHelper.plug_called?(conn, ExpectPublicOrAuthenticatedCheckPlug) and + not PlugHelper.plug_called_or_skipped?(conn, OAuthScopesPlug) do + AuthHelper.drop_auth_info(conn) + else + conn + end + end + + # Ensures instance is public -or- user is authenticated if such check was scheduled + defp maybe_perform_public_or_authenticated_check(conn) do + if PlugHelper.plug_called?(conn, ExpectPublicOrAuthenticatedCheckPlug) do + EnsurePublicOrAuthenticatedPlug.call(conn, %{}) + else + conn + end + end + + # Ensures user is authenticated if such check was scheduled + # Note: runs prior to action even if it was already executed earlier in plug chain + # (since OAuthScopesPlug has option of proceeding unauthenticated) + defp maybe_perform_authenticated_check(conn) do + if PlugHelper.plug_called?(conn, ExpectAuthenticatedCheckPlug) do + EnsureAuthenticatedPlug.call(conn, %{}) + else + conn + end + end + + # Halts if authenticated API action neither performs nor explicitly skips OAuth scopes check + defp maybe_halt_on_missing_oauth_scopes_check(conn) do + if PlugHelper.plug_called?(conn, ExpectAuthenticatedCheckPlug) and + not PlugHelper.plug_called_or_skipped?(conn, OAuthScopesPlug) do + conn + |> render_error( + :forbidden, + "Security violation: OAuth scopes check was neither handled nor explicitly skipped." + ) + |> halt() + else + conn + end + end + end + end + + def view do + quote do + use Phoenix.View, + root: "lib/pleroma/web/templates", + namespace: Pleroma.Web + + # Import convenience functions from controllers + import Phoenix.Controller, only: [get_csrf_token: 0, view_module: 1] + + import Pleroma.Web.ErrorHelpers + import Pleroma.Web.Gettext + + alias Pleroma.Web.Router.Helpers, as: Routes + + require Logger + + @doc "Same as `render/3` but wrapped in a rescue block" + def safe_render(view, template, assigns \\ %{}) do + Phoenix.View.render(view, template, assigns) + rescue + error -> + Logger.error( + "#{__MODULE__} failed to render #{inspect({view, template})}\n" <> + Exception.format(:error, error, __STACKTRACE__) + ) + + nil + end + + @doc """ + Same as `render_many/4` but wrapped in rescue block. + """ + def safe_render_many(collection, view, template, assigns \\ %{}) do + Enum.map(collection, fn resource -> + as = Map.get(assigns, :as) || view.__resource__ + assigns = Map.put(assigns, as, resource) + safe_render(view, template, assigns) + end) + |> Enum.filter(& &1) + end + end + end + + def router do + quote do + use Phoenix.Router + # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse + import Plug.Conn + import Phoenix.Controller + end + end + + def channel do + quote do + # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse + import Phoenix.Channel + import Pleroma.Web.Gettext + end + end + + def plug do + quote do + @behaviour Pleroma.Web.Plug + @behaviour Plug + + @doc """ + Marks a plug intentionally skipped and blocks its execution if it's present in plugs chain. + """ + def skip_plug(conn) do + PlugHelper.append_to_private_list( + conn, + PlugHelper.skipped_plugs_list_id(), + __MODULE__ + ) + end + + @impl Plug + @doc """ + Before-plug hook that + * ensures the plug is not skipped + * processes `:if_func` / `:unless_func` functional pre-run conditions + * adds plug to the list of called plugs and calls `perform/2` if checks are passed + + Note: multiple invocations of the same plug (with different or same options) are allowed. + """ + def call(%Plug.Conn{} = conn, options) do + if PlugHelper.plug_skipped?(conn, __MODULE__) || + (options[:if_func] && !options[:if_func].(conn)) || + (options[:unless_func] && options[:unless_func].(conn)) do + conn + else + conn = + PlugHelper.append_to_private_list( + conn, + PlugHelper.called_plugs_list_id(), + __MODULE__ + ) + + apply(__MODULE__, :perform, [conn, options]) + end + end + end + end + + @doc """ + When used, dispatch to the appropriate controller/view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex new file mode 100644 index 0000000..409e23b --- /dev/null +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -0,0 +1,1934 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ActivityPub do + alias Pleroma.Activity + alias Pleroma.Activity.Ir.Topics + alias Pleroma.Config + alias Pleroma.Constants + alias Pleroma.Conversation + alias Pleroma.Conversation.Participation + alias Pleroma.Filter + alias Pleroma.Hashtag + alias Pleroma.Maps + alias Pleroma.Object + alias Pleroma.Object.Containment + alias Pleroma.Object.Fetcher + alias Pleroma.Pagination + alias Pleroma.Repo + alias Pleroma.Upload + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.MRF + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.Streamer + alias Pleroma.Web.WebFinger + alias Pleroma.Workers.BackgroundWorker + alias Pleroma.Workers.EventReminderWorker + alias Pleroma.Workers.NotificationWorker + alias Pleroma.Workers.PollWorker + + import Ecto.Query + import Pleroma.Web.ActivityPub.Utils + import Pleroma.Web.ActivityPub.Visibility + import Pleroma.Webhook.Notify, only: [trigger_webhooks: 2] + + require Logger + require Pleroma.Constants + + @behaviour Pleroma.Web.ActivityPub.ActivityPub.Persisting + @behaviour Pleroma.Web.ActivityPub.ActivityPub.Streaming + + defp get_recipients(%{"type" => "Create"} = data) do + to = Map.get(data, "to", []) + cc = Map.get(data, "cc", []) + bcc = Map.get(data, "bcc", []) + actor = Map.get(data, "actor", []) + recipients = [to, cc, bcc, [actor]] |> Enum.concat() |> Enum.uniq() + {recipients, to, cc} + end + + defp get_recipients(data) do + to = Map.get(data, "to", []) + cc = Map.get(data, "cc", []) + bcc = Map.get(data, "bcc", []) + recipients = Enum.concat([to, cc, bcc]) + {recipients, to, cc} + end + + defp check_actor_can_insert(%{"type" => "Delete"}), do: true + defp check_actor_can_insert(%{"type" => "Undo"}), do: true + + defp check_actor_can_insert(%{"actor" => actor}) when is_binary(actor) do + case User.get_cached_by_ap_id(actor) do + %User{is_active: true} -> true + _ -> false + end + end + + defp check_actor_can_insert(_), do: true + + defp check_remote_limit(%{"object" => %{"content" => content}}) when not is_nil(content) do + limit = Config.get([:instance, :remote_limit]) + String.length(content) <= limit + end + + defp check_remote_limit(_), do: true + + def increase_note_count_if_public(actor, object) do + if is_public?(object), do: User.increase_note_count(actor), else: {:ok, actor} + end + + def decrease_note_count_if_public(actor, object) do + if is_public?(object), do: User.decrease_note_count(actor), else: {:ok, actor} + end + + def update_last_status_at_if_public(actor, object) do + if is_public?(object), do: User.update_last_status_at(actor), else: {:ok, actor} + end + + defp increase_replies_count_if_reply(%{ + "object" => %{"inReplyTo" => reply_ap_id} = object, + "type" => "Create" + }) do + if is_public?(object) do + Object.increase_replies_count(reply_ap_id) + end + end + + defp increase_replies_count_if_reply(_create_data), do: :noop + + defp increase_quotes_count_if_quote(%{ + "object" => %{"quoteUrl" => quote_ap_id} = object, + "type" => "Create" + }) do + if is_public?(object) do + Object.increase_quotes_count(quote_ap_id) + end + end + + defp increase_quotes_count_if_quote(_create_data), do: :noop + + @object_types ~w[ChatMessage Question Answer Audio Video Image Event Article Note Page] + @impl true + def persist(%{"type" => type} = object, meta) when type in @object_types do + with {:ok, object} <- Object.create(object) do + {:ok, object, meta} + end + end + + @impl true + def persist(object, meta) do + with local <- Keyword.fetch!(meta, :local), + {recipients, _, _} <- get_recipients(object), + {:ok, activity} <- + Repo.insert(%Activity{ + data: object, + local: local, + recipients: recipients, + actor: object["actor"] + }), + # TODO: add tests for expired activities, when Note type will be supported in new pipeline + {:ok, _} <- maybe_create_activity_expiration(activity) do + {:ok, activity, meta} + end + end + + @spec insert(map(), boolean(), boolean(), boolean()) :: {:ok, Activity.t()} | {:error, any()} + def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when is_map(map) do + with nil <- Activity.normalize(map), + map <- lazy_put_activity_defaults(map, fake), + {_, true} <- {:actor_check, bypass_actor_check || check_actor_can_insert(map)}, + {_, true} <- {:remote_limit_pass, check_remote_limit(map)}, + {:ok, map} <- MRF.filter(map), + {recipients, _, _} = get_recipients(map), + {:fake, false, map, recipients} <- {:fake, fake, map, recipients}, + {:containment, :ok} <- {:containment, Containment.contain_child(map)}, + {:ok, map, object} <- insert_full_object(map), + {:ok, activity} <- insert_activity_with_expiration(map, local, recipients) do + # Splice in the child object if we have one. + activity = Maps.put_if_present(activity, :object, object) + + ConcurrentLimiter.limit(Pleroma.Web.RichMedia.Helpers, fn -> + Task.start(fn -> Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) end) + end) + + # Add local posts to search index + if local, do: Pleroma.Search.add_to_index(activity) + + {:ok, activity} + else + %Activity{} = activity -> + {:ok, activity} + + {:actor_check, _} -> + {:error, false} + + {:containment, _} = error -> + error + + {:error, _} = error -> + error + + {:fake, true, map, recipients} -> + activity = %Activity{ + data: map, + local: local, + actor: map["actor"], + recipients: recipients, + id: "pleroma:fakeid" + } + + Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) + {:ok, activity} + + {:remote_limit_pass, _} -> + {:error, :remote_limit} + + {:reject, _} = e -> + {:error, e} + end + end + + defp insert_activity_with_expiration(data, local, recipients) do + struct = %Activity{ + data: data, + local: local, + actor: data["actor"], + recipients: recipients + } + + with {:ok, activity} <- Repo.insert(struct) do + maybe_create_activity_expiration(activity) + end + end + + def notify_and_stream(activity) do + NotificationWorker.enqueue("create", %{"activity_id" => activity.id}) + + original_activity = + case activity do + %{data: %{"type" => "Update"}, object: %{data: %{"id" => id}}} -> + Activity.get_create_by_object_ap_id_with_object(id) + + _ -> + activity + end + + conversation = create_or_bump_conversation(original_activity, original_activity.actor) + participations = get_participations(conversation) + stream_out(activity) + stream_out_participations(participations) + end + + defp maybe_create_activity_expiration( + %{data: %{"expires_at" => %DateTime{} = expires_at}} = activity + ) do + with {:ok, _job} <- + Pleroma.Workers.PurgeExpiredActivity.enqueue(%{ + activity_id: activity.id, + expires_at: expires_at + }) do + {:ok, activity} + end + end + + defp maybe_create_activity_expiration(activity), do: {:ok, activity} + + defp create_or_bump_conversation(activity, actor) do + with {:ok, conversation} <- Conversation.create_or_bump_for(activity), + %User{} = user <- User.get_cached_by_ap_id(actor) do + Participation.mark_as_read(user, conversation) + {:ok, conversation} + end + end + + defp get_participations({:ok, conversation}) do + conversation + |> Repo.preload(:participations, force: true) + |> Map.get(:participations) + end + + defp get_participations(_), do: [] + + def stream_out_participations(participations) do + participations = + participations + |> Repo.preload(:user) + + Streamer.stream("participation", participations) + end + + @impl true + def stream_out_participations(%Object{data: %{"context" => context}}, user) do + with %Conversation{} = conversation <- Conversation.get_for_ap_id(context) do + conversation = Repo.preload(conversation, :participations) + + last_activity_id = + fetch_latest_direct_activity_id_for_context(conversation.ap_id, %{ + user: user, + blocking_user: user + }) + + if last_activity_id do + stream_out_participations(conversation.participations) + end + end + end + + @impl true + def stream_out_participations(_, _), do: :noop + + @impl true + def stream_out(%Activity{data: %{"type" => data_type}} = activity) + when data_type in ["Create", "Announce", "Delete", "Update"] do + activity + |> Topics.get_activity_topics() + |> Streamer.stream(activity) + end + + @impl true + def stream_out(_activity) do + :noop + end + + @spec create(map(), boolean()) :: {:ok, Activity.t()} | {:error, any()} + def create(params, fake \\ false) do + with {:ok, result} <- Repo.transaction(fn -> do_create(params, fake) end) do + result + end + end + + defp do_create(%{to: to, actor: actor, context: context, object: object} = params, fake) do + additional = params[:additional] || %{} + # only accept false as false value + local = !(params[:local] == false) + published = params[:published] + quick_insert? = Config.get([:env]) == :benchmark + + create_data = + make_create_data( + %{to: to, actor: actor, published: published, context: context, object: object}, + additional + ) + + with {:ok, activity} <- insert(create_data, local, fake), + {:fake, false, activity} <- {:fake, fake, activity}, + _ <- increase_replies_count_if_reply(create_data), + _ <- increase_quotes_count_if_quote(create_data), + {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity}, + {:ok, _actor} <- increase_note_count_if_public(actor, activity), + {:ok, _actor} <- update_last_status_at_if_public(actor, activity), + _ <- notify_and_stream(activity), + :ok <- maybe_schedule_poll_notifications(activity), + :ok <- maybe_schedule_event_notifications(activity), + :ok <- maybe_join_own_event(actor, activity), + :ok <- maybe_federate(activity) do + {:ok, activity} + else + {:quick_insert, true, activity} -> + {:ok, activity} + + {:fake, true, activity} -> + {:ok, activity} + + {:error, message} -> + Repo.rollback(message) + end + end + + defp maybe_schedule_poll_notifications(activity) do + PollWorker.schedule_poll_end(activity) + :ok + end + + defp maybe_schedule_event_notifications(activity) do + EventReminderWorker.schedule_event_reminder(activity) + :ok + end + + defp maybe_join_own_event(actor, %{object: %{data: %{"type" => "Event"}} = object}) do + {:ok, join_object, meta} = Builder.join(actor, object) + + {:ok, _, _} = Pipeline.common_pipeline(join_object, Keyword.put(meta, :local, true)) + + :ok + end + + defp maybe_join_own_event(_, _), do: :ok + + @spec listen(map()) :: {:ok, Activity.t()} | {:error, any()} + def listen(%{to: to, actor: actor, context: context, object: object} = params) do + additional = params[:additional] || %{} + # only accept false as false value + local = !(params[:local] == false) + published = params[:published] + + listen_data = + make_listen_data( + %{to: to, actor: actor, published: published, context: context, object: object}, + additional + ) + + with {:ok, activity} <- insert(listen_data, local), + _ <- notify_and_stream(activity), + :ok <- maybe_federate(activity) do + {:ok, activity} + end + end + + @spec unfollow(User.t(), User.t(), String.t() | nil, boolean()) :: + {:ok, Activity.t()} | nil | {:error, any()} + def unfollow(follower, followed, activity_id \\ nil, local \\ true) do + with {:ok, result} <- + Repo.transaction(fn -> do_unfollow(follower, followed, activity_id, local) end) do + result + end + end + + defp do_unfollow(follower, followed, activity_id, local) do + with %Activity{} = follow_activity <- fetch_latest_follow(follower, followed), + {:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"), + unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id), + {:ok, activity} <- insert(unfollow_data, local), + _ <- notify_and_stream(activity), + :ok <- maybe_federate(activity) do + {:ok, activity} + else + nil -> nil + {:error, error} -> Repo.rollback(error) + end + end + + @spec flag(map()) :: {:ok, Activity.t()} | {:error, any()} + def flag(params) do + with {:ok, result} <- Repo.transaction(fn -> do_flag(params) end) do + result + end + end + + defp do_flag( + %{ + actor: actor, + context: _context, + account: account, + statuses: statuses, + content: content + } = params + ) do + # only accept false as false value + local = !(params[:local] == false) + forward = !(params[:forward] == false) + + additional = params[:additional] || %{} + + additional = + if forward do + Map.merge(additional, %{"to" => [], "cc" => [account.ap_id]}) + else + Map.merge(additional, %{"to" => [], "cc" => []}) + end + + with flag_data <- make_flag_data(params, additional), + {:ok, activity} <- insert(flag_data, local), + {:ok, stripped_activity} <- strip_report_status_data(activity), + _ <- notify_and_stream(activity), + _ <- trigger_webhooks(activity, :"report.created"), + :ok <- + maybe_federate(stripped_activity) do + User.all_users_with_privilege(:reports_manage_reports) + |> Enum.filter(fn user -> user.ap_id != actor end) + |> Enum.filter(fn user -> not is_nil(user.email) end) + |> Enum.each(fn privileged_user -> + privileged_user + |> Pleroma.Emails.AdminEmail.report(actor, account, statuses, content) + |> Pleroma.Emails.Mailer.deliver_async() + end) + + {:ok, activity} + else + {:error, error} -> Repo.rollback(error) + end + end + + @spec move(User.t(), User.t(), boolean()) :: {:ok, Activity.t()} | {:error, any()} + def move(%User{} = origin, %User{} = target, local \\ true) do + params = %{ + "type" => "Move", + "actor" => origin.ap_id, + "object" => origin.ap_id, + "target" => target.ap_id, + "to" => [origin.follower_address] + } + + with true <- origin.ap_id in target.also_known_as, + {:ok, activity} <- insert(params, local), + _ <- notify_and_stream(activity) do + maybe_federate(activity) + + BackgroundWorker.enqueue("move_following", %{ + "origin_id" => origin.id, + "target_id" => target.id + }) + + User.update_last_move_at(origin) + + {:ok, activity} + else + false -> {:error, "Target account must have the origin in `alsoKnownAs`"} + err -> err + end + end + + def fetch_activities_for_context_query(context, opts) do + public = [Constants.as_public()] + + recipients = + if opts[:user], + do: [opts[:user].ap_id | User.following(opts[:user])] ++ public, + else: public + + from(activity in Activity) + |> maybe_preload_objects(opts) + |> maybe_preload_bookmarks(opts) + |> maybe_set_thread_muted_field(opts) + |> restrict_unauthenticated(opts[:user]) + |> restrict_blocked(opts) + |> restrict_blockers_visibility(opts) + |> restrict_recipients(recipients, opts[:user]) + |> restrict_filtered(opts) + |> where( + [activity], + fragment( + "?->>'type' = 'Create' and ?->>'context' = ?", + activity.data, + activity.data, + ^context + ) + ) + |> exclude_poll_votes(opts) + |> exclude_id(opts) + |> order_by([activity], desc: activity.id) + end + + @spec fetch_activities_for_context(String.t(), keyword() | map()) :: [Activity.t()] + def fetch_activities_for_context(context, opts \\ %{}) do + context + |> fetch_activities_for_context_query(opts) + |> Repo.all() + end + + @spec fetch_latest_direct_activity_id_for_context(String.t(), keyword() | map()) :: + FlakeId.Ecto.CompatType.t() | nil + def fetch_latest_direct_activity_id_for_context(context, opts \\ %{}) do + context + |> fetch_activities_for_context_query(Map.merge(%{skip_preload: true}, opts)) + |> restrict_visibility(%{visibility: "direct"}) + |> limit(1) + |> select([a], a.id) + |> Repo.one() + end + + defp fetch_paginated_optimized(query, opts, pagination) do + # Note: tag-filtering funcs may apply "ORDER BY objects.id DESC", + # and extra sorting on "activities.id DESC NULLS LAST" would worse the query plan + opts = Map.put(opts, :skip_extra_order, true) + + Pagination.fetch_paginated(query, opts, pagination) + end + + def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do + list_memberships = Pleroma.List.memberships(opts[:user]) + + fetch_activities_query(recipients ++ list_memberships, opts) + |> fetch_paginated_optimized(opts, pagination) + |> Enum.reverse() + |> maybe_update_cc(list_memberships, opts[:user]) + end + + @spec fetch_public_or_unlisted_activities(map(), Pagination.type()) :: [Activity.t()] + def fetch_public_or_unlisted_activities(opts \\ %{}, pagination \\ :keyset) do + includes_local_public = Map.get(opts, :includes_local_public, false) + + opts = Map.delete(opts, :user) + + intended_recipients = + if includes_local_public do + [Constants.as_public(), as_local_public()] + else + [Constants.as_public()] + end + + intended_recipients + |> fetch_activities_query(opts) + |> restrict_unlisted(opts) + |> fetch_paginated_optimized(opts, pagination) + end + + @spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()] + def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do + opts + |> Map.put(:restrict_unlisted, true) + |> fetch_public_or_unlisted_activities(pagination) + end + + @valid_visibilities ~w[direct unlisted public private] + + defp restrict_visibility(query, %{visibility: visibility}) + when is_list(visibility) do + if Enum.all?(visibility, &(&1 in @valid_visibilities)) do + from( + a in query, + where: + fragment( + "activity_visibility(?, ?, ?) = ANY (?)", + a.actor, + a.recipients, + a.data, + ^visibility + ) + ) + else + Logger.error("Could not restrict visibility to #{visibility}") + end + end + + defp restrict_visibility(query, %{visibility: visibility}) + when visibility in @valid_visibilities do + from( + a in query, + where: + fragment("activity_visibility(?, ?, ?) = ?", a.actor, a.recipients, a.data, ^visibility) + ) + end + + defp restrict_visibility(_query, %{visibility: visibility}) + when visibility not in @valid_visibilities do + Logger.error("Could not restrict visibility to #{visibility}") + end + + defp restrict_visibility(query, _visibility), do: query + + defp exclude_visibility(query, %{exclude_visibilities: visibility}) + when is_list(visibility) do + if Enum.all?(visibility, &(&1 in @valid_visibilities)) do + from( + a in query, + where: + not fragment( + "activity_visibility(?, ?, ?) = ANY (?)", + a.actor, + a.recipients, + a.data, + ^visibility + ) + ) + else + Logger.error("Could not exclude visibility to #{visibility}") + query + end + end + + defp exclude_visibility(query, %{exclude_visibilities: visibility}) + when visibility in @valid_visibilities do + from( + a in query, + where: + not fragment( + "activity_visibility(?, ?, ?) = ?", + a.actor, + a.recipients, + a.data, + ^visibility + ) + ) + end + + defp exclude_visibility(query, %{exclude_visibilities: visibility}) + when visibility not in [nil | @valid_visibilities] do + Logger.error("Could not exclude visibility to #{visibility}") + query + end + + defp exclude_visibility(query, _visibility), do: query + + defp restrict_thread_visibility(query, _, %{skip_thread_containment: true} = _), + do: query + + defp restrict_thread_visibility(query, %{user: %User{skip_thread_containment: true}}, _), + do: query + + defp restrict_thread_visibility(query, %{user: %User{ap_id: ap_id}}, _) do + local_public = as_local_public() + + from( + a in query, + where: fragment("thread_visibility(?, (?)->>'id', ?) = true", ^ap_id, a.data, ^local_public) + ) + end + + defp restrict_thread_visibility(query, _, _), do: query + + def fetch_user_abstract_activities(user, reading_user, params \\ %{}) do + params = + params + |> Map.put(:user, reading_user) + |> Map.put(:actor_id, user.ap_id) + + %{ + godmode: params[:godmode], + reading_user: reading_user + } + |> user_activities_recipients() + |> fetch_activities(params) + |> Enum.reverse() + end + + def fetch_user_activities(user, reading_user, params \\ %{}) + + def fetch_user_activities(user, reading_user, %{total: true} = params) do + result = fetch_activities_for_user(user, reading_user, params) + + Keyword.put(result, :items, Enum.reverse(result[:items])) + end + + def fetch_user_activities(user, reading_user, params) do + user + |> fetch_activities_for_user(reading_user, params) + |> Enum.reverse() + end + + defp fetch_activities_for_user(user, reading_user, params) do + pinned_object_ids = if user.pinned_objects, do: Map.keys(user.pinned_objects), else: [] + + params = + params + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:user, reading_user) + |> Map.put(:actor_id, user.ap_id) + |> Map.put(:pinned_object_ids, pinned_object_ids) + + params = + if User.blocks?(reading_user, user) do + params + else + params + |> Map.put(:blocking_user, reading_user) + |> Map.put(:muting_user, reading_user) + end + + pagination_type = Map.get(params, :pagination_type) || :keyset + + %{ + godmode: params[:godmode], + reading_user: reading_user + } + |> user_activities_recipients() + |> fetch_activities(params, pagination_type) + end + + def fetch_statuses(reading_user, %{total: true} = params) do + result = fetch_activities_for_reading_user(reading_user, params) + Keyword.put(result, :items, Enum.reverse(result[:items])) + end + + def fetch_statuses(reading_user, params) do + reading_user + |> fetch_activities_for_reading_user(params) + |> Enum.reverse() + end + + defp fetch_activities_for_reading_user(reading_user, params) do + params = Map.put(params, :type, ["Create", "Announce"]) + + %{ + godmode: params[:godmode], + reading_user: reading_user + } + |> user_activities_recipients() + |> fetch_activities(params, :offset) + end + + defp user_activities_recipients(%{godmode: true}), do: [] + + defp user_activities_recipients(%{reading_user: reading_user}) do + if not is_nil(reading_user) and reading_user.local do + [ + Constants.as_public(), + as_local_public(), + reading_user.ap_id | User.following(reading_user) + ] + else + [Constants.as_public()] + end + end + + defp restrict_announce_object_actor(_query, %{announce_filtering_user: _, skip_preload: true}) do + raise "Can't use the child object without preloading!" + end + + defp restrict_announce_object_actor(query, %{announce_filtering_user: %{ap_id: actor}}) do + from( + [activity, object] in query, + where: + fragment( + "?->>'type' != ? or ?->>'actor' != ?", + activity.data, + "Announce", + object.data, + ^actor + ) + ) + end + + defp restrict_announce_object_actor(query, _), do: query + + defp restrict_since(query, %{since_id: ""}), do: query + + defp restrict_since(query, %{since_id: since_id}) do + from(activity in query, where: activity.id > ^since_id) + end + + defp restrict_since(query, _), do: query + + defp restrict_embedded_tag_all(_query, %{tag_all: _tag_all, skip_preload: true}) do + raise_on_missing_preload() + end + + defp restrict_embedded_tag_all(query, %{tag_all: [_ | _] = tag_all}) do + from( + [_activity, object] in query, + where: fragment("(?)->'tag' \\?& (?)", object.data, ^tag_all) + ) + end + + defp restrict_embedded_tag_all(query, %{tag_all: tag}) when is_binary(tag) do + restrict_embedded_tag_any(query, %{tag: tag}) + end + + defp restrict_embedded_tag_all(query, _), do: query + + defp restrict_embedded_tag_any(_query, %{tag: _tag, skip_preload: true}) do + raise_on_missing_preload() + end + + defp restrict_embedded_tag_any(query, %{tag: [_ | _] = tag_any}) do + from( + [_activity, object] in query, + where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag_any) + ) + end + + defp restrict_embedded_tag_any(query, %{tag: tag}) when is_binary(tag) do + restrict_embedded_tag_any(query, %{tag: [tag]}) + end + + defp restrict_embedded_tag_any(query, _), do: query + + defp restrict_embedded_tag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do + raise_on_missing_preload() + end + + defp restrict_embedded_tag_reject_any(query, %{tag_reject: [_ | _] = tag_reject}) do + from( + [_activity, object] in query, + where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject) + ) + end + + defp restrict_embedded_tag_reject_any(query, %{tag_reject: tag_reject}) + when is_binary(tag_reject) do + restrict_embedded_tag_reject_any(query, %{tag_reject: [tag_reject]}) + end + + defp restrict_embedded_tag_reject_any(query, _), do: query + + defp object_ids_query_for_tags(tags) do + from(hto in "hashtags_objects") + |> join(:inner, [hto], ht in Pleroma.Hashtag, on: hto.hashtag_id == ht.id) + |> where([hto, ht], ht.name in ^tags) + |> select([hto], hto.object_id) + |> distinct([hto], true) + end + + defp restrict_hashtag_all(_query, %{tag_all: _tag, skip_preload: true}) do + raise_on_missing_preload() + end + + defp restrict_hashtag_all(query, %{tag_all: [single_tag]}) do + restrict_hashtag_any(query, %{tag: single_tag}) + end + + defp restrict_hashtag_all(query, %{tag_all: [_ | _] = tags}) do + from( + [_activity, object] in query, + where: + fragment( + """ + (SELECT array_agg(hashtags.name) FROM hashtags JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) + AND hashtags_objects.object_id = ?) @> ? + """, + ^tags, + object.id, + ^tags + ) + ) + end + + defp restrict_hashtag_all(query, %{tag_all: tag}) when is_binary(tag) do + restrict_hashtag_all(query, %{tag_all: [tag]}) + end + + defp restrict_hashtag_all(query, _), do: query + + defp restrict_hashtag_any(_query, %{tag: _tag, skip_preload: true}) do + raise_on_missing_preload() + end + + defp restrict_hashtag_any(query, %{tag: [_ | _] = tags}) do + hashtag_ids = + from(ht in Hashtag, where: ht.name in ^tags, select: ht.id) + |> Repo.all() + + # Note: NO extra ordering should be done on "activities.id desc nulls last" for optimal plan + from( + [_activity, object] in query, + join: hto in "hashtags_objects", + on: hto.object_id == object.id, + where: hto.hashtag_id in ^hashtag_ids, + distinct: [desc: object.id], + order_by: [desc: object.id] + ) + end + + defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do + restrict_hashtag_any(query, %{tag: [tag]}) + end + + defp restrict_hashtag_any(query, _), do: query + + defp restrict_hashtag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do + raise_on_missing_preload() + end + + defp restrict_hashtag_reject_any(query, %{tag_reject: [_ | _] = tags_reject}) do + from( + [_activity, object] in query, + where: object.id not in subquery(object_ids_query_for_tags(tags_reject)) + ) + end + + defp restrict_hashtag_reject_any(query, %{tag_reject: tag_reject}) when is_binary(tag_reject) do + restrict_hashtag_reject_any(query, %{tag_reject: [tag_reject]}) + end + + defp restrict_hashtag_reject_any(query, _), do: query + + defp raise_on_missing_preload do + raise "Can't use the child object without preloading!" + end + + defp restrict_recipients(query, [], _user), do: query + + defp restrict_recipients(query, recipients, nil) do + from(activity in query, where: fragment("? && ?", ^recipients, activity.recipients)) + end + + defp restrict_recipients(query, recipients, user) do + from( + activity in query, + where: fragment("? && ?", ^recipients, activity.recipients), + or_where: activity.actor == ^user.ap_id + ) + end + + defp restrict_local(query, %{local_only: true}) do + from(activity in query, where: activity.local == true) + end + + defp restrict_local(query, _), do: query + + defp restrict_remote(query, %{remote: true}) do + from(activity in query, where: activity.local == false) + end + + defp restrict_remote(query, _), do: query + + defp restrict_actor(query, %{actor_id: actor_id}) do + from(activity in query, where: activity.actor == ^actor_id) + end + + defp restrict_actor(query, _), do: query + + defp restrict_type(query, %{type: type}) when is_binary(type) do + from(activity in query, where: fragment("?->>'type' = ?", activity.data, ^type)) + end + + defp restrict_type(query, %{type: type}) do + from(activity in query, where: fragment("?->>'type' = ANY(?)", activity.data, ^type)) + end + + defp restrict_type(query, _), do: query + + defp restrict_state(query, %{state: state}) do + from(activity in query, where: fragment("?->>'state' = ?", activity.data, ^state)) + end + + defp restrict_state(query, _), do: query + + defp restrict_assigned_account(query, %{assigned_account: assigned_account}) do + from(activity in query, + where: fragment("?->>'assigned_account' = ?", activity.data, ^assigned_account) + ) + end + + defp restrict_assigned_account(query, _), do: query + + defp restrict_favorited_by(query, %{favorited_by: ap_id}) do + from( + [_activity, object] in query, + where: fragment("(?)->'likes' \\? (?)", object.data, ^ap_id) + ) + end + + defp restrict_favorited_by(query, _), do: query + + defp restrict_media(_query, %{only_media: _val, skip_preload: true}) do + raise "Can't use the child object without preloading!" + end + + defp restrict_media(query, %{only_media: true}) do + from( + [activity, object] in query, + where: fragment("(?)->>'type' = 'Create'", activity.data), + where: fragment("not (?)->'attachment' = (?)", object.data, ^[]) + ) + end + + defp restrict_media(query, _), do: query + + defp restrict_events(_query, %{only_events: _val, skip_preload: true}) do + raise "Can't use the child object without preloading!" + end + + defp restrict_events(query, %{only_events: true}) do + from( + [activity, object] in query, + where: fragment("(?)->>'type' = 'Create'", activity.data), + where: fragment("(?)->>'type' = 'Event'", object.data) + ) + end + + defp restrict_events(query, _), do: query + + defp restrict_replies(query, %{exclude_replies: true}) do + from( + [activity, object] in query, + where: + fragment("?->>'inReplyTo' is null or ?->>'type' = 'Announce'", object.data, activity.data) + ) + end + + defp restrict_replies(query, %{ + reply_filtering_user: %User{} = user, + reply_visibility: "self" + }) do + from( + [activity, object] in query, + where: + fragment( + "?->>'inReplyTo' is null OR ? = ANY(?)", + object.data, + ^user.ap_id, + activity.recipients + ) + ) + end + + defp restrict_replies(query, %{ + reply_filtering_user: %User{} = user, + reply_visibility: "following" + }) do + from( + [activity, object] in query, + where: + fragment( + """ + ?->>'type' != 'Create' -- This isn't a Create + OR ?->>'inReplyTo' is null -- this isn't a reply + OR ? && array_remove(?, ?) -- The recipient is us or one of our friends, + -- unless they are the author (because authors + -- are also part of the recipients). This leads + -- to a bug that self-replies by friends won't + -- show up. + OR ? = ? -- The actor is us + """, + activity.data, + object.data, + ^[user.ap_id | User.get_cached_user_friends_ap_ids(user)], + activity.recipients, + activity.actor, + activity.actor, + ^user.ap_id + ) + ) + end + + defp restrict_replies(query, _), do: query + + defp restrict_reblogs(query, %{exclude_reblogs: true}) do + from(activity in query, where: fragment("?->>'type' != 'Announce'", activity.data)) + end + + defp restrict_reblogs(query, _), do: query + + defp restrict_muted(query, %{with_muted: true}), do: query + + defp restrict_muted(query, %{muting_user: %User{} = user} = opts) do + mutes = opts[:muted_users_ap_ids] || User.muted_users_ap_ids(user) + + query = + from([activity] in query, + where: fragment("not (? = ANY(?))", activity.actor, ^mutes), + where: + fragment( + "not (?->'to' \\?| ?) or ? = ?", + activity.data, + ^mutes, + activity.actor, + ^user.ap_id + ) + ) + + unless opts[:skip_preload] do + from([thread_mute: tm] in query, where: is_nil(tm.user_id)) + else + query + end + end + + defp restrict_muted(query, _), do: query + + defp restrict_blocked(query, %{blocking_user: %User{} = user} = opts) do + blocked_ap_ids = opts[:blocked_users_ap_ids] || User.blocked_users_ap_ids(user) + domain_blocks = user.domain_blocks || [] + + following_ap_ids = User.get_friends_ap_ids(user) + + query = + if has_named_binding?(query, :object), do: query, else: Activity.with_joined_object(query) + + from( + [activity, object: o] in query, + # You don't block the author + where: fragment("not (? = ANY(?))", activity.actor, ^blocked_ap_ids), + + # You don't block any recipients, and didn't author the post + where: + fragment( + "((not (? && ?)) or ? = ?)", + activity.recipients, + ^blocked_ap_ids, + activity.actor, + ^user.ap_id + ), + + # You don't block the domain of any recipients, and didn't author the post + where: + fragment( + "(recipients_contain_blocked_domains(?, ?) = false) or ? = ?", + activity.recipients, + ^domain_blocks, + activity.actor, + ^user.ap_id + ), + + # It's not a boost of a user you block + where: + fragment( + "not (?->>'type' = 'Announce' and ?->'to' \\?| ?)", + activity.data, + activity.data, + ^blocked_ap_ids + ), + + # You don't block the author's domain, and also don't follow the author + where: + fragment( + "(not (split_part(?, '/', 3) = ANY(?))) or ? = ANY(?)", + activity.actor, + ^domain_blocks, + activity.actor, + ^following_ap_ids + ), + + # Same as above, but checks the Object + where: + fragment( + "(not (split_part(?->>'actor', '/', 3) = ANY(?))) or (?->>'actor') = ANY(?)", + o.data, + ^domain_blocks, + o.data, + ^following_ap_ids + ) + ) + end + + defp restrict_blocked(query, _), do: query + + defp restrict_blockers_visibility(query, %{blocking_user: %User{} = user}) do + if Config.get([:activitypub, :blockers_visible]) == true do + query + else + blocker_ap_ids = User.incoming_relationships_ungrouped_ap_ids(user, [:block]) + + from( + activity in query, + # The author doesn't block you + where: fragment("not (? = ANY(?))", activity.actor, ^blocker_ap_ids), + + # It's not a boost of a user that blocks you + where: + fragment( + "not (?->>'type' = 'Announce' and ?->'to' \\?| ?)", + activity.data, + activity.data, + ^blocker_ap_ids + ) + ) + end + end + + defp restrict_blockers_visibility(query, _), do: query + + defp restrict_unlisted(query, %{restrict_unlisted: true}) do + from( + activity in query, + where: + fragment( + "not (coalesce(?->'cc', '{}'::jsonb) \\?| ?)", + activity.data, + ^[Constants.as_public()] + ) + ) + end + + defp restrict_unlisted(query, _), do: query + + defp restrict_pinned(query, %{pinned: true, pinned_object_ids: ids}) do + from( + [activity, object: o] in query, + where: + fragment( + "(?)->>'type' = 'Create' and associated_object_id((?)) = any (?)", + activity.data, + activity.data, + ^ids + ) + ) + end + + defp restrict_pinned(query, _), do: query + + defp restrict_muted_reblogs(query, %{muting_user: %User{} = user} = opts) do + muted_reblogs = opts[:reblog_muted_users_ap_ids] || User.reblog_muted_users_ap_ids(user) + + from( + activity in query, + where: + fragment( + "not ( ?->>'type' = 'Announce' and ? = ANY(?))", + activity.data, + activity.actor, + ^muted_reblogs + ) + ) + end + + defp restrict_muted_reblogs(query, _), do: query + + defp restrict_instance(query, %{instance: instance}) when is_binary(instance) do + from( + activity in query, + where: fragment("split_part(actor::text, '/'::text, 3) = ?", ^instance) + ) + end + + defp restrict_instance(query, _), do: query + + defp restrict_filtered(query, %{user: %User{} = user}) do + case Filter.compose_regex(user) do + nil -> + query + + regex -> + from([activity, object] in query, + where: + fragment("not(?->>'content' ~* ?)", object.data, ^regex) or + activity.actor == ^user.ap_id + ) + end + end + + defp restrict_filtered(query, %{blocking_user: %User{} = user}) do + restrict_filtered(query, %{user: user}) + end + + defp restrict_filtered(query, _), do: query + + defp restrict_object(query, %{object: object}) do + from(activity in query, where: fragment("?->>'object' = ?", activity.data, ^object)) + end + + defp restrict_object(query, _), do: query + + defp restrict_quote_url(query, %{quote_url: quote_url}) do + from([_activity, object] in query, + where: fragment("(?)->'quoteUrl' = ?", object.data, ^quote_url) + ) + end + + defp restrict_quote_url(query, _), do: query + + defp restrict_join_state(query, %{state: state}) when is_binary(state) do + from( + [activity] in query, + where: fragment("(?)->>'state' = ?", activity.data, ^state) + ) + end + + defp restrict_join_state(query, _), do: query + + defp restrict_unauthenticated(query, nil) do + local = Config.restrict_unauthenticated_access?(:activities, :local) + remote = Config.restrict_unauthenticated_access?(:activities, :remote) + + cond do + local and remote -> + from(activity in query, where: false) + + local -> + from(activity in query, where: activity.local == false) + + remote -> + from(activity in query, where: activity.local == true) + + true -> + query + end + end + + defp restrict_unauthenticated(query, _), do: query + + defp exclude_poll_votes(query, %{include_poll_votes: true}), do: query + + defp exclude_poll_votes(query, _) do + if has_named_binding?(query, :object) do + from([activity, object: o] in query, + where: fragment("not(?->>'type' = 'Answer')", o.data) + ) + else + query + end + end + + defp exclude_chat_messages(query, %{include_chat_messages: true}), do: query + + defp exclude_chat_messages(query, _) do + if has_named_binding?(query, :object) do + from([activity, object: o] in query, + where: fragment("not(?->>'type' = 'ChatMessage')", o.data) + ) + else + query + end + end + + defp exclude_invisible_actors(query, %{type: "Flag"}), do: query + defp exclude_invisible_actors(query, %{invisible_actors: true}), do: query + + defp exclude_invisible_actors(query, _opts) do + query + |> join(:inner, [activity], u in User, + as: :u, + on: activity.actor == u.ap_id and u.invisible == false + ) + end + + defp exclude_id(query, %{exclude_id: id}) when is_binary(id) do + from(activity in query, where: activity.id != ^id) + end + + defp exclude_id(query, _), do: query + + defp maybe_preload_objects(query, %{skip_preload: true}), do: query + + defp maybe_preload_objects(query, _) do + query + |> Activity.with_preloaded_object() + end + + defp maybe_preload_bookmarks(query, %{skip_preload: true}), do: query + + defp maybe_preload_bookmarks(query, opts) do + query + |> Activity.with_preloaded_bookmark(opts[:user]) + end + + defp maybe_preload_report_notes(query, %{preload_report_notes: true}) do + query + |> Activity.with_preloaded_report_notes() + end + + defp maybe_preload_report_notes(query, _), do: query + + defp maybe_set_thread_muted_field(query, %{skip_preload: true}), do: query + + defp maybe_set_thread_muted_field(query, opts) do + query + |> Activity.with_set_thread_muted_field(opts[:muting_user] || opts[:user]) + end + + defp maybe_order(query, %{order: :desc}) do + query + |> order_by(desc: :id) + end + + defp maybe_order(query, %{order: :asc}) do + query + |> order_by(asc: :id) + end + + defp maybe_order(query, _), do: query + + defp normalize_fetch_activities_query_opts(opts) do + Enum.reduce([:tag, :tag_all, :tag_reject], opts, fn key, opts -> + case opts[key] do + value when is_bitstring(value) -> + Map.put(opts, key, Hashtag.normalize_name(value)) + + value when is_list(value) -> + normalized_value = + value + |> Enum.map(&Hashtag.normalize_name/1) + |> Enum.uniq() + + Map.put(opts, key, normalized_value) + + _ -> + opts + end + end) + end + + defp fetch_activities_query_ap_ids_ops(opts) do + source_user = opts[:muting_user] + ap_id_relationships = if source_user, do: [:mute, :reblog_mute], else: [] + + ap_id_relationships = + if opts[:blocking_user] && opts[:blocking_user] == source_user do + [:block | ap_id_relationships] + else + ap_id_relationships + end + + preloaded_ap_ids = User.outgoing_relationships_ap_ids(source_user, ap_id_relationships) + + restrict_blocked_opts = Map.merge(%{blocked_users_ap_ids: preloaded_ap_ids[:block]}, opts) + restrict_muted_opts = Map.merge(%{muted_users_ap_ids: preloaded_ap_ids[:mute]}, opts) + + restrict_muted_reblogs_opts = + Map.merge(%{reblog_muted_users_ap_ids: preloaded_ap_ids[:reblog_mute]}, opts) + + {restrict_blocked_opts, restrict_muted_opts, restrict_muted_reblogs_opts} + end + + def fetch_activities_query(recipients, opts \\ %{}) do + opts = normalize_fetch_activities_query_opts(opts) + + {restrict_blocked_opts, restrict_muted_opts, restrict_muted_reblogs_opts} = + fetch_activities_query_ap_ids_ops(opts) + + config = %{ + skip_thread_containment: Config.get([:instance, :skip_thread_containment]) + } + + query = + Activity + |> maybe_preload_objects(opts) + |> maybe_preload_bookmarks(opts) + |> maybe_preload_report_notes(opts) + |> maybe_set_thread_muted_field(opts) + |> maybe_order(opts) + |> restrict_recipients(recipients, opts[:user]) + |> restrict_replies(opts) + |> restrict_since(opts) + |> restrict_local(opts) + |> restrict_remote(opts) + |> restrict_actor(opts) + |> restrict_type(opts) + |> restrict_state(opts) + |> restrict_assigned_account(opts) + |> restrict_favorited_by(opts) + |> restrict_blocked(restrict_blocked_opts) + |> restrict_blockers_visibility(opts) + |> restrict_muted(restrict_muted_opts) + |> restrict_filtered(opts) + |> restrict_media(opts) + |> restrict_events(opts) + |> restrict_visibility(opts) + |> restrict_thread_visibility(opts, config) + |> restrict_reblogs(opts) + |> restrict_pinned(opts) + |> restrict_muted_reblogs(restrict_muted_reblogs_opts) + |> restrict_instance(opts) + |> restrict_announce_object_actor(opts) + |> restrict_object(opts) + |> restrict_filtered(opts) + |> restrict_quote_url(opts) + |> maybe_restrict_deactivated_users(opts) + |> exclude_poll_votes(opts) + |> exclude_chat_messages(opts) + |> exclude_invisible_actors(opts) + |> exclude_visibility(opts) + + if Config.feature_enabled?(:improved_hashtag_timeline) do + query + |> restrict_hashtag_any(opts) + |> restrict_hashtag_all(opts) + |> restrict_hashtag_reject_any(opts) + else + query + |> restrict_embedded_tag_any(opts) + |> restrict_embedded_tag_all(opts) + |> restrict_embedded_tag_reject_any(opts) + end + end + + @doc """ + Fetch favorites activities of user with order by sort adds to favorites + """ + @spec fetch_favourites(User.t(), map(), Pagination.type()) :: list(Activity.t()) + def fetch_favourites(user, params \\ %{}, pagination \\ :keyset) do + user.ap_id + |> Activity.Queries.by_actor() + |> Activity.Queries.by_type("Like") + |> Activity.with_joined_object() + |> Object.with_joined_activity() + |> select([like, object, activity], %{activity | object: object, pagination_id: like.id}) + |> order_by([like, _, _], desc_nulls_last: like.id) + |> Pagination.fetch_paginated( + Map.merge(params, %{skip_order: true}), + pagination + ) + end + + defp maybe_update_cc(activities, [_ | _] = list_memberships, %User{ap_id: user_ap_id}) do + Enum.map(activities, fn + %{data: %{"bcc" => [_ | _] = bcc}} = activity -> + if Enum.any?(bcc, &(&1 in list_memberships)) do + update_in(activity.data["cc"], &[user_ap_id | &1]) + else + activity + end + + activity -> + activity + end) + end + + defp maybe_update_cc(activities, _, _), do: activities + + defp fetch_activities_bounded_query(query, recipients, recipients_with_public) do + from(activity in query, + where: + fragment("? && ?", activity.recipients, ^recipients) or + (fragment("? && ?", activity.recipients, ^recipients_with_public) and + ^Constants.as_public() in activity.recipients) + ) + end + + def fetch_activities_bounded( + recipients, + recipients_with_public, + opts \\ %{}, + pagination \\ :keyset + ) do + fetch_activities_query([], opts) + |> fetch_activities_bounded_query(recipients, recipients_with_public) + |> Pagination.fetch_paginated(opts, pagination) + |> Enum.reverse() + end + + @spec upload(Upload.source(), keyword()) :: {:ok, Object.t()} | {:error, any()} + def upload(file, opts \\ []) do + with {:ok, data} <- Upload.store(sanitize_upload_file(file), opts) do + obj_data = Maps.put_if_present(data, "actor", opts[:actor]) + + Repo.insert(%Object{data: obj_data}) + end + end + + defp sanitize_upload_file(%Plug.Upload{filename: filename} = upload) when is_binary(filename) do + %Plug.Upload{ + upload + | filename: Path.basename(filename) + } + end + + defp sanitize_upload_file(upload), do: upload + + @spec get_actor_url(any()) :: binary() | nil + defp get_actor_url(url) when is_binary(url), do: url + defp get_actor_url(%{"href" => href}) when is_binary(href), do: href + + defp get_actor_url(url) when is_list(url) do + url + |> List.first() + |> get_actor_url() + end + + defp get_actor_url(_url), do: nil + + defp normalize_image(%{"url" => url}) do + %{ + "type" => "Image", + "url" => [%{"href" => url}] + } + end + + defp normalize_image(urls) when is_list(urls), do: urls |> List.first() |> normalize_image() + defp normalize_image(_), do: nil + + defp object_to_user_data(data, additional) do + fields = + data + |> Map.get("attachment", []) + |> Enum.filter(fn %{"type" => t} -> t == "PropertyValue" end) + |> Enum.map(fn fields -> Map.take(fields, ["name", "value"]) end) + + emojis = + data + |> Map.get("tag", []) + |> Enum.filter(fn + %{"type" => "Emoji"} -> true + _ -> false + end) + |> Map.new(fn %{"icon" => %{"url" => url}, "name" => name} -> + {String.trim(name, ":"), url} + end) + + is_locked = data["manuallyApprovesFollowers"] || false + capabilities = data["capabilities"] || %{} + accepts_chat_messages = capabilities["acceptsChatMessages"] + data = Transmogrifier.maybe_fix_user_object(data) + is_discoverable = data["discoverable"] || false + invisible = data["invisible"] || false + actor_type = data["type"] || "Person" + + featured_address = data["featured"] + {:ok, pinned_objects} = fetch_and_prepare_featured_from_ap_id(featured_address) + + public_key = + if is_map(data["publicKey"]) && is_binary(data["publicKey"]["publicKeyPem"]) do + data["publicKey"]["publicKeyPem"] + end + + shared_inbox = + if is_map(data["endpoints"]) && is_binary(data["endpoints"]["sharedInbox"]) do + data["endpoints"]["sharedInbox"] + end + + birthday = + if is_binary(data["vcard:bday"]) do + case Date.from_iso8601(data["vcard:bday"]) do + {:ok, date} -> date + {:error, _} -> nil + end + end + + show_birthday = !!birthday + + # if WebFinger request was already done, we probably have acct, otherwise + # we request WebFinger here + nickname = additional[:nickname_from_acct] || generate_nickname(data) + + %{ + ap_id: data["id"], + uri: get_actor_url(data["url"]), + banner: normalize_image(data["image"]), + fields: fields, + emoji: emojis, + is_locked: is_locked, + is_discoverable: is_discoverable, + invisible: invisible, + avatar: normalize_image(data["icon"]), + name: data["name"], + follower_address: data["followers"], + following_address: data["following"], + featured_address: featured_address, + bio: data["summary"] || "", + actor_type: actor_type, + also_known_as: Map.get(data, "alsoKnownAs", []), + public_key: public_key, + inbox: data["inbox"], + shared_inbox: shared_inbox, + accepts_chat_messages: accepts_chat_messages, + birthday: birthday, + show_birthday: show_birthday, + nickname: nickname, + location: data["vcard:Address"] || "", + pinned_objects: pinned_objects + } + end + + defp generate_nickname(%{"preferredUsername" => username} = data) when is_binary(username) do + generated = "#{username}@#{URI.parse(data["id"]).host}" + + if Config.get([WebFinger, :update_nickname_on_user_fetch]) do + case WebFinger.finger(generated) do + {:ok, %{"subject" => "acct:" <> acct}} -> acct + _ -> generated + end + else + generated + end + end + + # nickname can be nil because of virtual actors + defp generate_nickname(_), do: nil + + def fetch_follow_information_for_user(user) do + with {:ok, following_data} <- + Fetcher.fetch_and_contain_remote_object_from_id(user.following_address), + {:ok, hide_follows} <- collection_private(following_data), + {:ok, followers_data} <- + Fetcher.fetch_and_contain_remote_object_from_id(user.follower_address), + {:ok, hide_followers} <- collection_private(followers_data) do + {:ok, + %{ + hide_follows: hide_follows, + follower_count: normalize_counter(followers_data["totalItems"]), + following_count: normalize_counter(following_data["totalItems"]), + hide_followers: hide_followers + }} + else + {:error, _} = e -> e + e -> {:error, e} + end + end + + defp normalize_counter(counter) when is_integer(counter), do: counter + defp normalize_counter(_), do: 0 + + def maybe_update_follow_information(user_data) do + with {:enabled, true} <- {:enabled, Config.get([:instance, :external_user_synchronization])}, + {_, true} <- {:user_type_check, user_data[:type] in ["Person", "Service"]}, + {_, true} <- + {:collections_available, + !!(user_data[:following_address] && user_data[:follower_address])}, + {:ok, info} <- + fetch_follow_information_for_user(user_data) do + info = Map.merge(user_data[:info] || %{}, info) + + user_data + |> Map.put(:info, info) + else + {:user_type_check, false} -> + user_data + + {:collections_available, false} -> + user_data + + {:enabled, false} -> + user_data + + e -> + Logger.error( + "Follower/Following counter update for #{user_data.ap_id} failed.\n" <> inspect(e) + ) + + user_data + end + end + + defp collection_private(%{"first" => %{"type" => type}}) + when type in ["CollectionPage", "OrderedCollectionPage"], + do: {:ok, false} + + defp collection_private(%{"first" => first}) do + with {:ok, %{"type" => type}} when type in ["CollectionPage", "OrderedCollectionPage"] <- + Fetcher.fetch_and_contain_remote_object_from_id(first) do + {:ok, false} + else + {:error, {:ok, %{status: code}}} when code in [401, 403] -> {:ok, true} + {:error, _} = e -> e + e -> {:error, e} + end + end + + defp collection_private(_data), do: {:ok, true} + + def user_data_from_user_object(data, additional \\ []) do + with {:ok, data} <- MRF.filter(data) do + {:ok, object_to_user_data(data, additional)} + else + e -> {:error, e} + end + end + + defp fetch_and_prepare_user_from_ap_id(ap_id, additional) do + with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id), + {:ok, data} <- user_data_from_user_object(data, additional) do + {:ok, maybe_update_follow_information(data)} + else + # If this has been deleted, only log a debug and not an error + {:error, "Object has been deleted" = e} -> + Logger.debug("Could not decode user at fetch #{ap_id}, #{inspect(e)}") + {:error, e} + + {:error, {:reject, reason} = e} -> + Logger.info("Rejected user #{ap_id}: #{inspect(reason)}") + {:error, e} + + {:error, e} -> + Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}") + {:error, e} + end + end + + def maybe_handle_clashing_nickname(data) do + with nickname when is_binary(nickname) <- data[:nickname], + %User{} = old_user <- User.get_by_nickname(nickname), + {_, false} <- {:ap_id_comparison, data[:ap_id] == old_user.ap_id} do + Logger.info( + "Found an old user for #{nickname}, the old ap id is #{old_user.ap_id}, new one is #{data[:ap_id]}, renaming." + ) + + old_user + |> User.remote_user_changeset(%{nickname: "#{old_user.id}.#{old_user.nickname}"}) + |> User.update_and_set_cache() + else + {:ap_id_comparison, true} -> + Logger.info( + "Found an old user for #{data[:nickname]}, but the ap id #{data[:ap_id]} is the same as the new user. Race condition? Not changing anything." + ) + + _ -> + nil + end + end + + def pin_data_from_featured_collection(%{ + "type" => type, + "orderedItems" => objects + }) + when type in ["OrderedCollection", "Collection"] do + Map.new(objects, fn + %{"id" => object_ap_id} -> {object_ap_id, NaiveDateTime.utc_now()} + object_ap_id when is_binary(object_ap_id) -> {object_ap_id, NaiveDateTime.utc_now()} + end) + end + + def pin_data_from_featured_collection(obj) do + Logger.error("Could not parse featured collection #{inspect(obj)}") + %{} + end + + def fetch_and_prepare_featured_from_ap_id(nil) do + {:ok, %{}} + end + + def fetch_and_prepare_featured_from_ap_id(ap_id) do + with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id) do + {:ok, pin_data_from_featured_collection(data)} + else + e -> + Logger.error("Could not decode featured collection at fetch #{ap_id}, #{inspect(e)}") + {:ok, %{}} + end + end + + def pinned_fetch_task(nil), do: nil + + def pinned_fetch_task(%{pinned_objects: pins}) do + if Enum.all?(pins, fn {ap_id, _} -> + Object.get_cached_by_ap_id(ap_id) || + match?({:ok, _object}, Fetcher.fetch_object_from_id(ap_id)) + end) do + :ok + else + :error + end + end + + def make_user_from_ap_id(ap_id, additional \\ []) do + user = User.get_cached_by_ap_id(ap_id) + + with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id, additional) do + {:ok, _pid} = Task.start(fn -> pinned_fetch_task(data) end) + + if user do + user + |> User.remote_user_changeset(data) + |> User.update_and_set_cache() + else + maybe_handle_clashing_nickname(data) + + data + |> User.remote_user_changeset() + |> Repo.insert() + |> User.set_cache() + end + end + end + + def make_user_from_nickname(nickname) do + with {:ok, %{"ap_id" => ap_id, "subject" => "acct:" <> acct}} when not is_nil(ap_id) <- + WebFinger.finger(nickname) do + make_user_from_ap_id(ap_id, nickname_from_acct: acct) + else + _e -> {:error, "No AP id in WebFinger"} + end + end + + # filter out broken threads + defp contain_broken_threads(%Activity{} = activity, %User{} = user) do + entire_thread_visible_for_user?(activity, user) + end + + # do post-processing on a specific activity + def contain_activity(%Activity{} = activity, %User{} = user) do + contain_broken_threads(activity, user) + end + + def fetch_direct_messages_query do + Activity + |> restrict_type(%{type: "Create"}) + |> restrict_visibility(%{visibility: "direct"}) + |> order_by([activity], asc: activity.id) + end + + defp maybe_restrict_deactivated_users(activity, %{type: "Flag"}), do: activity + + defp maybe_restrict_deactivated_users(activity, _opts), + do: Activity.restrict_deactivated_users(activity) + + def fetch_joined_events(user, params \\ %{}, pagination \\ :keyset) do + user.ap_id + |> Activity.Queries.by_actor() + |> Activity.Queries.by_type("Join") + |> Activity.with_joined_object() + |> Object.with_joined_activity() + |> select([join, object, activity], %{activity | object: object, pagination_id: join.id}) + |> order_by([join, _, _], desc_nulls_last: join.id) + |> restrict_join_state(params) + |> Pagination.fetch_paginated( + Map.merge(params, %{skip_order: true}), + pagination + ) + end +end diff --git a/lib/pleroma/web/activity_pub/activity_pub/persisting.ex b/lib/pleroma/web/activity_pub/activity_pub/persisting.ex new file mode 100644 index 0000000..3dbfdee --- /dev/null +++ b/lib/pleroma/web/activity_pub/activity_pub/persisting.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ActivityPub.Persisting do + @callback persist(map(), keyword()) :: {:ok, struct()} +end diff --git a/lib/pleroma/web/activity_pub/activity_pub/streaming.ex b/lib/pleroma/web/activity_pub/activity_pub/streaming.ex new file mode 100644 index 0000000..d735817 --- /dev/null +++ b/lib/pleroma/web/activity_pub/activity_pub/streaming.ex @@ -0,0 +1,8 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ActivityPub.Streaming do + @callback stream_out(struct()) :: any() + @callback stream_out_participations(struct(), struct()) :: any() +end diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex new file mode 100644 index 0000000..9f1a723 --- /dev/null +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -0,0 +1,550 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ActivityPubController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.Delivery + alias Pleroma.Object + alias Pleroma.Object.Fetcher + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.InternalFetchActor + alias Pleroma.Web.ActivityPub.ObjectView + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.ActivityPub.Relay + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.UserView + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.ControllerHelper + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Federator + alias Pleroma.Web.Plugs.EnsureAuthenticatedPlug + alias Pleroma.Web.Plugs.FederatingPlug + + require Logger + + action_fallback(:errors) + + @federating_only_actions [:internal_fetch, :relay, :relay_following, :relay_followers] + + plug(FederatingPlug when action in @federating_only_actions) + + plug( + EnsureAuthenticatedPlug, + [unless_func: &FederatingPlug.federating?/1] when action not in @federating_only_actions + ) + + # Note: :following and :followers must be served even without authentication (as via :api) + plug( + EnsureAuthenticatedPlug + when action in [:read_inbox, :update_outbox, :whoami, :upload_media] + ) + + plug(Majic.Plug, [pool: Pleroma.MajicPool] when action in [:upload_media]) + + plug( + Pleroma.Web.Plugs.Cache, + [query_params: false, tracking_fun: &__MODULE__.track_object_fetch/2] + when action in [:activity, :object] + ) + + plug(:set_requester_reachable when action in [:inbox]) + plug(:relay_active? when action in [:relay]) + + defp relay_active?(conn, _) do + if Pleroma.Config.get([:instance, :allow_relay]) do + conn + else + conn + |> render_error(:not_found, "not found") + |> halt() + end + end + + def user(conn, %{"nickname" => nickname}) do + with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("user.json", %{user: user}) + else + nil -> {:error, :not_found} + %{local: false} -> {:error, :not_found} + end + end + + def object(%{assigns: assigns} = conn, _) do + with ap_id <- Endpoint.url() <> conn.request_path, + %Object{} = object <- Object.get_cached_by_ap_id(ap_id), + user <- Map.get(assigns, :user, nil), + {_, true} <- {:visible?, Visibility.visible_for_user?(object, user)} do + conn + |> maybe_skip_cache(user) + |> assign(:tracking_fun_data, object.id) + |> set_cache_ttl_for(object) + |> put_resp_content_type("application/activity+json") + |> put_view(ObjectView) + |> render("object.json", object: object) + else + {:visible?, false} -> {:error, :not_found} + nil -> {:error, :not_found} + end + end + + def track_object_fetch(conn, nil), do: conn + + def track_object_fetch(conn, object_id) do + with %{assigns: %{user: %User{id: user_id}}} <- conn do + Delivery.create(object_id, user_id) + end + + conn + end + + def activity(%{assigns: assigns} = conn, _) do + with ap_id <- Endpoint.url() <> conn.request_path, + %Activity{} = activity <- Activity.normalize(ap_id), + {_, true} <- {:local?, activity.local}, + user <- Map.get(assigns, :user, nil), + {_, true} <- {:visible?, Visibility.visible_for_user?(activity, user)} do + conn + |> maybe_skip_cache(user) + |> maybe_set_tracking_data(activity) + |> set_cache_ttl_for(activity) + |> put_resp_content_type("application/activity+json") + |> put_view(ObjectView) + |> render("object.json", object: activity) + else + {:visible?, false} -> {:error, :not_found} + {:local?, false} -> {:error, :not_found} + nil -> {:error, :not_found} + end + end + + defp maybe_set_tracking_data(conn, %Activity{data: %{"type" => "Create"}} = activity) do + object_id = Object.normalize(activity, fetch: false).id + assign(conn, :tracking_fun_data, object_id) + end + + defp maybe_set_tracking_data(conn, _activity), do: conn + + defp set_cache_ttl_for(conn, %Activity{object: object}) do + set_cache_ttl_for(conn, object) + end + + defp set_cache_ttl_for(conn, entity) do + ttl = + case entity do + %Object{data: %{"type" => "Question"}} -> + Pleroma.Config.get([:web_cache_ttl, :activity_pub_question]) + + %Object{} -> + Pleroma.Config.get([:web_cache_ttl, :activity_pub]) + + _ -> + nil + end + + assign(conn, :cache_ttl, ttl) + end + + def maybe_skip_cache(conn, user) do + if user do + conn + |> assign(:skip_cache, true) + else + conn + end + end + + # GET /relay/following + def relay_following(conn, _params) do + with %{halted: false} = conn <- FederatingPlug.call(conn, []) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("following.json", %{user: Relay.get_actor()}) + end + end + + def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do + with %User{} = user <- User.get_cached_by_nickname(nickname), + {:show_follows, true} <- + {:show_follows, (for_user && for_user == user) || !user.hide_follows} do + {page, _} = Integer.parse(page) + + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("following.json", %{user: user, page: page, for: for_user}) + else + {:show_follows, _} -> + conn + |> put_resp_content_type("application/activity+json") + |> send_resp(403, "") + end + end + + def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_cached_by_nickname(nickname) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("following.json", %{user: user, for: for_user}) + end + end + + # GET /relay/followers + def relay_followers(conn, _params) do + with %{halted: false} = conn <- FederatingPlug.call(conn, []) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("followers.json", %{user: Relay.get_actor()}) + end + end + + def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do + with %User{} = user <- User.get_cached_by_nickname(nickname), + {:show_followers, true} <- + {:show_followers, (for_user && for_user == user) || !user.hide_followers} do + {page, _} = Integer.parse(page) + + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("followers.json", %{user: user, page: page, for: for_user}) + else + {:show_followers, _} -> + conn + |> put_resp_content_type("application/activity+json") + |> send_resp(403, "") + end + end + + def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_cached_by_nickname(nickname) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("followers.json", %{user: user, for: for_user}) + end + end + + def outbox( + %{assigns: %{user: for_user}} = conn, + %{"nickname" => nickname, "page" => page?} = params + ) + when page? in [true, "true"] do + with %User{} = user <- User.get_cached_by_nickname(nickname) do + # "include_poll_votes" is a hack because postgres generates inefficient + # queries when filtering by 'Answer', poll votes will be hidden by the + # visibility filter in this case anyway + params = + params + |> Map.drop(["nickname", "page"]) + |> Map.put("include_poll_votes", true) + |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) + + activities = ActivityPub.fetch_user_activities(user, for_user, params) + + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("activity_collection_page.json", %{ + activities: activities, + pagination: ControllerHelper.get_pagination_fields(conn, activities), + iri: "#{user.ap_id}/outbox" + }) + end + end + + def outbox(conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_cached_by_nickname(nickname) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("activity_collection.json", %{iri: "#{user.ap_id}/outbox"}) + end + end + + def inbox(%{assigns: %{valid_signature: true}} = conn, %{"nickname" => nickname} = params) do + with %User{} = recipient <- User.get_cached_by_nickname(nickname), + {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(params["actor"]), + true <- Utils.recipient_in_message(recipient, actor, params), + params <- Utils.maybe_splice_recipient(recipient.ap_id, params) do + Federator.incoming_ap_doc(params) + json(conn, "ok") + end + end + + def inbox(%{assigns: %{valid_signature: true}} = conn, params) do + Federator.incoming_ap_doc(params) + json(conn, "ok") + end + + def inbox(%{assigns: %{valid_signature: false}} = conn, _params) do + conn + |> put_status(:bad_request) + |> json("Invalid HTTP Signature") + end + + # POST /relay/inbox -or- POST /internal/fetch/inbox + def inbox(conn, %{"type" => "Create"} = params) do + if FederatingPlug.federating?() do + post_inbox_relayed_create(conn, params) + else + conn + |> put_status(:bad_request) + |> json("Not federating") + end + end + + def inbox(conn, _params) do + conn + |> put_status(:bad_request) + |> json("error, missing HTTP Signature") + end + + defp post_inbox_relayed_create(conn, params) do + Logger.debug( + "Signature missing or not from author, relayed Create message, fetching object from source" + ) + + Fetcher.fetch_object_from_id(params["object"]["id"]) + + json(conn, "ok") + end + + defp represent_service_actor(%User{} = user, conn) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("user.json", %{user: user}) + end + + defp represent_service_actor(nil, _), do: {:error, :not_found} + + def relay(conn, _params) do + Relay.get_actor() + |> represent_service_actor(conn) + end + + def internal_fetch(%Plug.Conn{host: host} = conn, _params) do + with fetch_actor_origin when is_binary(fetch_actor_origin) <- + Pleroma.Config.get([:activitypub, :fetch_actor_origin]), + %URI{host: fetch_actor_host} <- URI.parse(fetch_actor_origin), + true <- host == fetch_actor_host do + fetch_actor_origin + else + _ -> Pleroma.Web.Endpoint.url() + end + |> InternalFetchActor.get_actor() + |> represent_service_actor(conn) + end + + @doc "Returns the authenticated user's ActivityPub User object or a 404 Not Found if non-authenticated" + def whoami(%{assigns: %{user: %User{} = user}} = conn, _params) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("user.json", %{user: user}) + end + + def read_inbox( + %{assigns: %{user: %User{nickname: nickname} = user}} = conn, + %{"nickname" => nickname, "page" => page?} = params + ) + when page? in [true, "true"] do + params = + params + |> Map.drop(["nickname", "page"]) + |> Map.put("blocking_user", user) + |> Map.put("user", user) + |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) + + activities = + [user.ap_id | User.following(user)] + |> ActivityPub.fetch_activities(params) + |> Enum.reverse() + + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("activity_collection_page.json", %{ + activities: activities, + pagination: ControllerHelper.get_pagination_fields(conn, activities), + iri: "#{user.ap_id}/inbox" + }) + end + + def read_inbox(%{assigns: %{user: %User{nickname: nickname} = user}} = conn, %{ + "nickname" => nickname + }) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("activity_collection.json", %{iri: "#{user.ap_id}/inbox"}) + end + + def read_inbox(%{assigns: %{user: %User{nickname: as_nickname}}} = conn, %{ + "nickname" => nickname + }) do + err = + dgettext("errors", "can't read inbox of %{nickname} as %{as_nickname}", + nickname: nickname, + as_nickname: as_nickname + ) + + conn + |> put_status(:forbidden) + |> json(err) + end + + defp fix_user_message(%User{ap_id: actor}, %{"type" => "Create", "object" => object} = activity) + when is_map(object) do + length = + [object["content"], object["summary"], object["name"]] + |> Enum.filter(&is_binary(&1)) + |> Enum.join("") + |> String.length() + + limit = Pleroma.Config.get([:instance, :limit]) + + if length < limit do + object = + object + |> Transmogrifier.strip_internal_fields() + |> Map.put("attributedTo", actor) + |> Map.put("actor", actor) + |> Map.put("id", Utils.generate_object_id()) + + {:ok, Map.put(activity, "object", object)} + else + {:error, + dgettext( + "errors", + "Character limit (%{limit} characters) exceeded, contains %{length} characters", + limit: limit, + length: length + )} + end + end + + defp fix_user_message( + %User{ap_id: actor} = user, + %{"type" => "Delete", "object" => object} = activity + ) do + with {_, %Object{data: object_data}} <- {:normalize, Object.normalize(object, fetch: false)}, + {_, true} <- {:permission, user.is_moderator || actor == object_data["actor"]} do + {:ok, activity} + else + {:normalize, _} -> + {:error, "No such object found"} + + {:permission, _} -> + {:forbidden, "You can't delete this object"} + end + end + + defp fix_user_message(%User{}, activity) do + {:ok, activity} + end + + def update_outbox( + %{assigns: %{user: %User{nickname: nickname, ap_id: actor} = user}} = conn, + %{"nickname" => nickname} = params + ) do + params = + params + |> Map.drop(["nickname"]) + |> Map.put("id", Utils.generate_activity_id()) + |> Map.put("actor", actor) + + with {:ok, params} <- fix_user_message(user, params), + {:ok, activity, _} <- Pipeline.common_pipeline(params, local: true), + %Activity{data: activity_data} <- Activity.normalize(activity) do + conn + |> put_status(:created) + |> put_resp_header("location", activity_data["id"]) + |> json(activity_data) + else + {:forbidden, message} -> + conn + |> put_status(:forbidden) + |> json(message) + + {:error, message} -> + conn + |> put_status(:bad_request) + |> json(message) + + e -> + Logger.warning(fn -> "AP C2S: #{inspect(e)}" end) + + conn + |> put_status(:bad_request) + |> json("Bad Request") + end + end + + def update_outbox(%{assigns: %{user: %User{} = user}} = conn, %{"nickname" => nickname}) do + err = + dgettext("errors", "can't update outbox of %{nickname} as %{as_nickname}", + nickname: nickname, + as_nickname: user.nickname + ) + + conn + |> put_status(:forbidden) + |> json(err) + end + + defp errors(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> json(dgettext("errors", "Not found")) + end + + defp errors(conn, _e) do + conn + |> put_status(:internal_server_error) + |> json(dgettext("errors", "error")) + end + + defp set_requester_reachable(%Plug.Conn{} = conn, _) do + with actor <- conn.params["actor"], + true <- is_binary(actor) do + Pleroma.Instances.set_reachable(actor) + end + + conn + end + + def upload_media(%{assigns: %{user: %User{} = user}} = conn, %{"file" => file} = data) do + with {:ok, object} <- + ActivityPub.upload( + file, + actor: User.ap_id(user), + description: Map.get(data, "description") + ) do + Logger.debug(inspect(object)) + + conn + |> put_status(:created) + |> json(object.data) + end + end + + def pinned(conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_cached_by_nickname(nickname) do + conn + |> put_resp_header("content-type", "application/activity+json") + |> json(UserView.render("featured.json", %{user: user})) + end + end +end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex new file mode 100644 index 0000000..054bd04 --- /dev/null +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -0,0 +1,480 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Builder do + @moduledoc """ + This module builds the objects. Meant to be used for creating local objects. + + This module encodes our addressing policies and general shape of our objects. + """ + + alias Pleroma.Emoji + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Relay + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI.ActivityDraft + alias Pleroma.Web.Endpoint + + require Pleroma.Constants + + def accept_or_reject(%User{ap_id: ap_id}, activity, type) do + data = %{ + "id" => Utils.generate_activity_id(), + "actor" => ap_id, + "type" => type, + "object" => activity.data["id"], + "to" => [activity.actor] + } + + {:ok, data, []} + end + + def accept_or_reject(%Object{data: %{"actor" => actor}}, activity, type) do + data = %{ + "id" => Utils.generate_activity_id(), + "actor" => actor, + "type" => type, + "object" => activity.data["id"], + "to" => [activity.actor] + } + + {:ok, data, []} + end + + @spec reject(User.t() | Object.t(), Activity.t()) :: {:ok, map(), keyword()} + def reject(object, rejected_activity) do + accept_or_reject(object, rejected_activity, "Reject") + end + + @spec accept(User.t() | Object.t(), Activity.t()) :: {:ok, map(), keyword()} + def accept(object, accepted_activity) do + accept_or_reject(object, accepted_activity, "Accept") + end + + @spec follow(User.t(), User.t()) :: {:ok, map(), keyword()} + def follow(follower, followed) do + data = %{ + "id" => Utils.generate_activity_id(), + "actor" => follower.ap_id, + "type" => "Follow", + "object" => followed.ap_id, + "to" => [followed.ap_id] + } + + {:ok, data, []} + end + + defp unicode_emoji_react(_object, data, emoji) do + data + |> Map.put("content", emoji) + |> Map.put("type", "EmojiReact") + end + + defp add_emoji_content(data, emoji, url) do + tag = [ + %{ + "id" => url, + "type" => "Emoji", + "name" => Emoji.maybe_quote(emoji), + "icon" => %{ + "type" => "Image", + "url" => url + } + } + ] + + data + |> Map.put("content", Emoji.maybe_quote(emoji)) + |> Map.put("type", "EmojiReact") + |> Map.put("tag", tag) + end + + defp remote_custom_emoji_react( + %{data: %{"reactions" => existing_reactions}}, + data, + emoji + ) do + [emoji_code, instance] = String.split(Emoji.maybe_strip_name(emoji), "@") + + matching_reaction = + Enum.find( + existing_reactions, + fn [name, _, url] -> + if url != nil do + url = URI.parse(url) + url.host == instance && name == emoji_code + end + end + ) + + if matching_reaction do + [name, _, url] = matching_reaction + add_emoji_content(data, name, url) + else + {:error, "Could not react"} + end + end + + defp remote_custom_emoji_react(_object, _data, _emoji) do + {:error, "Could not react"} + end + + defp local_custom_emoji_react(data, emoji) do + with %{file: path} = emojo <- Emoji.get(emoji) do + url = "#{Endpoint.url()}#{path}" + add_emoji_content(data, emojo.code, url) + else + _ -> {:error, "Emoji does not exist"} + end + end + + defp custom_emoji_react(object, data, emoji) do + if String.contains?(emoji, "@") do + remote_custom_emoji_react(object, data, emoji) + else + local_custom_emoji_react(data, emoji) + end + end + + @spec emoji_react(User.t(), Object.t(), String.t()) :: {:ok, map(), keyword()} + def emoji_react(actor, object, emoji) do + with {:ok, data, meta} <- object_action(actor, object) do + data = + if Emoji.is_unicode_emoji?(emoji) do + unicode_emoji_react(object, data, emoji) + else + custom_emoji_react(object, data, emoji) + end + + {:ok, data, meta} + end + end + + @spec undo(User.t(), Activity.t()) :: {:ok, map(), keyword()} + def undo(actor, object) do + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "type" => "Undo", + "object" => object.data["id"], + "to" => object.data["to"] || [], + "cc" => object.data["cc"] || [] + }, []} + end + + @spec delete(User.t(), String.t()) :: {:ok, map(), keyword()} + def delete(actor, object_id) do + object = Object.normalize(object_id, fetch: false) + + user = !object && User.get_cached_by_ap_id(object_id) + + to = + case {object, user} do + {%Object{}, _} -> + # We are deleting an object, address everyone who was originally mentioned + (object.data["to"] || []) ++ (object.data["cc"] || []) + + {_, %User{follower_address: follower_address}} -> + # We are deleting a user, address the followers of that user + [follower_address] + end + + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "object" => object_id, + "to" => to, + "type" => "Delete" + }, []} + end + + def create(actor, object, recipients) do + context = + if is_map(object) do + object["context"] + else + nil + end + + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "to" => recipients, + "object" => object, + "type" => "Create", + "published" => DateTime.utc_now() |> DateTime.to_iso8601() + } + |> Pleroma.Maps.put_if_present("context", context), []} + end + + @spec note(ActivityDraft.t()) :: {:ok, map(), keyword()} + def note(%ActivityDraft{} = draft) do + data = + %{ + "type" => "Note", + "to" => draft.to, + "cc" => draft.cc, + "content" => draft.content_html, + "summary" => draft.summary, + "sensitive" => draft.sensitive, + "context" => draft.context, + "attachment" => draft.attachments, + "actor" => draft.user.ap_id, + "tag" => Keyword.values(draft.tags) |> Enum.uniq() + } + |> add_in_reply_to(draft.in_reply_to) + |> add_quote(draft.quote_post) + |> Map.merge(draft.extra) + + {:ok, data, []} + end + + defp add_in_reply_to(object, nil), do: object + + defp add_in_reply_to(object, in_reply_to) do + with %Object{} = in_reply_to_object <- Object.normalize(in_reply_to, fetch: false) do + Map.put(object, "inReplyTo", in_reply_to_object.data["id"]) + else + _ -> object + end + end + + defp add_quote(object, nil), do: object + + defp add_quote(object, quote_post) do + with %Object{} = quote_object <- Object.normalize(quote_post, fetch: false) do + Map.put(object, "quoteUrl", quote_object.data["id"]) + else + _ -> object + end + end + + def chat_message(actor, recipient, content, opts \\ []) do + basic = %{ + "id" => Utils.generate_object_id(), + "actor" => actor.ap_id, + "type" => "ChatMessage", + "to" => [recipient], + "content" => content, + "published" => DateTime.utc_now() |> DateTime.to_iso8601(), + "emoji" => Emoji.Formatter.get_emoji_map(content) + } + + case opts[:attachment] do + %Object{data: attachment_data} -> + { + :ok, + Map.put(basic, "attachment", attachment_data), + [] + } + + _ -> + {:ok, basic, []} + end + end + + def answer(user, object, name) do + {:ok, + %{ + "type" => "Answer", + "actor" => user.ap_id, + "attributedTo" => user.ap_id, + "cc" => [object.data["actor"]], + "to" => [], + "name" => name, + "inReplyTo" => object.data["id"], + "context" => object.data["context"], + "published" => DateTime.utc_now() |> DateTime.to_iso8601(), + "id" => Utils.generate_object_id() + }, []} + end + + @spec tombstone(String.t(), String.t()) :: {:ok, map(), keyword()} + def tombstone(actor, id) do + {:ok, + %{ + "id" => id, + "actor" => actor, + "type" => "Tombstone" + }, []} + end + + @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} + def like(actor, object) do + with {:ok, data, meta} <- object_action(actor, object) do + data = + data + |> Map.put("type", "Like") + + {:ok, data, meta} + end + end + + @spec update(User.t(), Object.t()) :: {:ok, map(), keyword()} + def update(actor, object) do + {to, cc, bcc} = + if object["type"] in Pleroma.Constants.actor_types() do + # User updates, always public + {[Pleroma.Constants.as_public(), actor.follower_address], [], []} + else + # Status updates, follow the recipients in the object + {object["to"] || [], object["cc"] || [], object["participations"] || []} + end + + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "type" => "Update", + "actor" => actor.ap_id, + "object" => object, + "to" => to, + "cc" => cc, + "bcc" => bcc + }, []} + end + + @spec block(User.t(), User.t()) :: {:ok, map(), keyword()} + def block(blocker, blocked) do + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "type" => "Block", + "actor" => blocker.ap_id, + "object" => blocked.ap_id, + "to" => [blocked.ap_id] + }, []} + end + + @spec announce(User.t(), Object.t(), keyword()) :: {:ok, map(), keyword()} + def announce(actor, object, options \\ []) do + public? = Keyword.get(options, :public, false) + + to = + cond do + actor.ap_id == Relay.ap_id() -> + [actor.follower_address] + + public? and Visibility.is_local_public?(object) -> + [actor.follower_address, object.data["actor"], Utils.as_local_public()] + + public? -> + [actor.follower_address, object.data["actor"], Pleroma.Constants.as_public()] + + true -> + [actor.follower_address, object.data["actor"]] + end + + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "object" => object.data["id"], + "to" => to, + "context" => object.data["context"], + "type" => "Announce", + "published" => Utils.make_date() + }, []} + end + + @spec object_action(User.t(), Object.t()) :: {:ok, map(), keyword()} + defp object_action(actor, object) do + object_actor = User.get_cached_by_ap_id(object.data["actor"]) + + # Address the actor of the object, and our actor's follower collection if the post is public. + to = + if Visibility.is_public?(object) do + [actor.follower_address, object.data["actor"]] + else + [object.data["actor"]] + end + + # CC everyone who's been addressed in the object, except ourself and the object actor's + # follower collection + cc = + (object.data["to"] ++ (object.data["cc"] || [])) + |> List.delete(actor.ap_id) + |> List.delete(object_actor.follower_address) + + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "object" => object.data["id"], + "to" => to, + "cc" => cc, + "context" => object.data["context"] + }, []} + end + + @spec pin(User.t(), Object.t()) :: {:ok, map(), keyword()} + def pin(%User{} = user, object) do + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "target" => pinned_url(user.nickname), + "object" => object.data["id"], + "actor" => user.ap_id, + "type" => "Add", + "to" => [Pleroma.Constants.as_public()], + "cc" => [user.follower_address] + }, []} + end + + @spec unpin(User.t(), Object.t()) :: {:ok, map, keyword()} + def unpin(%User{} = user, object) do + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "target" => pinned_url(user.nickname), + "object" => object.data["id"], + "actor" => user.ap_id, + "type" => "Remove", + "to" => [Pleroma.Constants.as_public()], + "cc" => [user.follower_address] + }, []} + end + + defp pinned_url(nickname) when is_binary(nickname) do + Pleroma.Web.Router.Helpers.activity_pub_url(Pleroma.Web.Endpoint, :pinned, nickname) + end + + def join(actor, object, participation_message \\ nil) do + with {:ok, data, meta} <- object_action(actor, object) do + data = + data + |> Map.put("type", "Join") + |> Map.put("participationMessage", participation_message) + + {:ok, data, meta} + end + end + + @spec event(ActivityDraft.t()) :: {:ok, map(), keyword()} + def event(%ActivityDraft{} = draft) do + data = %{ + "type" => "Event", + "to" => draft.to, + "cc" => draft.cc, + "name" => draft.params[:name], + "content" => draft.content_html, + "context" => draft.context, + "attachment" => draft.attachments, + "actor" => draft.user.ap_id, + "tag" => Keyword.values(draft.tags) |> Enum.uniq(), + "joinMode" => draft.params[:join_mode] || "free", + "location" => draft.location, + "location_id" => draft.location_id, + "location_provider" => draft.location_provider, + "startTime" => draft.start_time, + "endTime" => draft.end_time + } + + {:ok, data, []} + end +end diff --git a/lib/pleroma/web/activity_pub/internal_fetch_actor.ex b/lib/pleroma/web/activity_pub/internal_fetch_actor.ex new file mode 100644 index 0000000..1c12859 --- /dev/null +++ b/lib/pleroma/web/activity_pub/internal_fetch_actor.ex @@ -0,0 +1,34 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.InternalFetchActor do + alias Pleroma.User + + require Logger + + def init do + # Wait for everything to settle. + Process.sleep(1000 * 5) + get_actor() + get_actor(Pleroma.Web.Endpoint.url()) + end + + def get_actor(origin) do + %URI{host: host} = URI.parse(origin) + + nickname = + cond do + host == Pleroma.Web.Endpoint.host() -> "internal.fetch" + true -> "internal.fetch@#{host}" + end + + "#{origin}/internal/fetch" + |> User.get_or_create_service_actor_by_ap_id(nickname) + end + + def get_actor() do + (Pleroma.Config.get([:activitypub, :fetch_actor_origin]) || Pleroma.Web.Endpoint.url()) + |> get_actor() + end +end diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex new file mode 100644 index 0000000..b729c78 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -0,0 +1,231 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF do + require Logger + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] + + @behaviour Pleroma.Web.ActivityPub.MRF.PipelineFiltering + + @mrf_config_descriptions [ + %{ + group: :pleroma, + key: :mrf, + tab: :mrf, + label: "MRF", + type: :group, + description: "General MRF settings", + children: [ + %{ + key: :policies, + type: [:module, {:list, :module}], + description: + "A list of MRF policies enabled. Module names are shortened (removed leading `Pleroma.Web.ActivityPub.MRF.` part), but on adding custom module you need to use full name.", + suggestions: {:list_behaviour_implementations, Pleroma.Web.ActivityPub.MRF.Policy} + }, + %{ + key: :transparency, + label: "MRF transparency", + type: :boolean, + description: + "Make the content of your Message Rewrite Facility settings public (via nodeinfo)" + }, + %{ + key: :transparency_exclusions, + label: "MRF transparency exclusions", + type: {:list, :tuple}, + key_placeholder: "instance", + value_placeholder: "reason", + description: + "Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value. You can also provide a reason for excluding these instance names. The instances and reasons won't be publicly disclosed.", + suggestions: [ + "exclusion.com" + ] + } + ] + } + ] + + @default_description %{ + label: "", + description: "" + } + + @required_description_keys [:key, :related_policy] + + def filter_one(policy, message) do + should_plug_history? = + if function_exported?(policy, :history_awareness, 0) do + policy.history_awareness() + else + :manual + end + |> Kernel.==(:auto) + + if not should_plug_history? do + policy.filter(message) + else + main_result = policy.filter(message) + + with {_, {:ok, main_message}} <- {:main, main_result}, + {_, + %{ + "formerRepresentations" => %{ + "orderedItems" => [_ | _] + } + }} = {_, object} <- {:object, message["object"]}, + {_, {:ok, new_history}} <- + {:history, + Pleroma.Object.Updater.for_each_history_item( + object["formerRepresentations"], + object, + fn item -> + with {:ok, filtered} <- policy.filter(Map.put(message, "object", item)) do + {:ok, filtered["object"]} + else + e -> e + end + end + )} do + {:ok, put_in(main_message, ["object", "formerRepresentations"], new_history)} + else + {:main, _} -> main_result + {:object, _} -> main_result + {:history, e} -> e + end + end + end + + def filter(policies, %{} = message) do + policies + |> Enum.reduce({:ok, message}, fn + policy, {:ok, message} -> filter_one(policy, message) + _, error -> error + end) + end + + def filter(%{} = object), do: get_policies() |> filter(object) + + @impl true + def pipeline_filter(%{} = message, meta) do + object = meta[:object_data] + ap_id = message["object"] + + if object && ap_id do + with {:ok, message} <- filter(Map.put(message, "object", object)) do + meta = Keyword.put(meta, :object_data, message["object"]) + {:ok, Map.put(message, "object", ap_id), meta} + else + {err, message} -> {err, message, meta} + end + else + {err, message} = filter(message) + + {err, message, meta} + end + end + + def get_policies do + Pleroma.Config.get([:mrf, :policies], []) + |> get_policies() + |> Enum.concat([Pleroma.Web.ActivityPub.MRF.HashtagPolicy]) + end + + defp get_policies(policy) when is_atom(policy), do: [policy] + defp get_policies(policies) when is_list(policies), do: policies + defp get_policies(_), do: [] + + @spec subdomains_regex([String.t()]) :: [Regex.t()] + def subdomains_regex(domains) when is_list(domains) do + for domain <- domains, do: ~r(^#{String.replace(domain, "*.", "(.*\\.)*")}$)i + end + + @spec subdomain_match?([Regex.t()], String.t()) :: boolean() + def subdomain_match?(domains, host) do + Enum.any?(domains, fn domain -> Regex.match?(domain, host) end) + end + + @spec instance_list_from_tuples([{String.t(), String.t()}]) :: [String.t()] + def instance_list_from_tuples(list) do + Enum.map(list, fn + {instance, _} -> instance + instance when is_binary(instance) -> instance + end) + end + + @spec normalize_instance_list(list()) :: [{String.t(), String.t()}] + def normalize_instance_list(list) do + Enum.map(list, fn + {host, reason} when not_empty_string(host) and not_empty_string(reason) -> {host, reason} + {host, _reason} when not_empty_string(host) -> {host, ""} + host when not_empty_string(host) -> {host, ""} + value -> raise "Invalid MRF config: #{inspect(value)}" + end) + end + + def describe(policies) do + {:ok, policy_configs} = + policies + |> Enum.reduce({:ok, %{}}, fn + policy, {:ok, data} -> + {:ok, policy_data} = policy.describe() + {:ok, Map.merge(data, policy_data)} + + _, error -> + error + end) + + mrf_policies = + get_policies() + |> Enum.map(fn policy -> to_string(policy) |> String.split(".") |> List.last() end) + + exclusions = Pleroma.Config.get([:mrf, :transparency_exclusions]) + + base = + %{ + mrf_policies: mrf_policies, + exclusions: length(exclusions) > 0 + } + |> Map.merge(policy_configs) + + {:ok, base} + end + + def describe, do: get_policies() |> describe() + + def config_descriptions do + Pleroma.Web.ActivityPub.MRF.Policy + |> Pleroma.Docs.Generator.list_behaviour_implementations() + |> config_descriptions() + end + + def config_descriptions(policies) do + Enum.reduce(policies, @mrf_config_descriptions, fn policy, acc -> + if function_exported?(policy, :config_description, 0) do + description = + @default_description + |> Map.merge(policy.config_description) + |> Map.put(:group, :pleroma) + |> Map.put(:tab, :mrf) + |> Map.put(:type, :group) + + if Enum.all?(@required_description_keys, &Map.has_key?(description, &1)) do + [description | acc] + else + Logger.warning( + "#{policy} config description doesn't have one or all required keys #{inspect(@required_description_keys)}" + ) + + acc + end + else + Logger.debug( + "#{policy} is excluded from config descriptions, because does not implement `config_description/0` method." + ) + + acc + end + end) + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex new file mode 100644 index 0000000..88f6ca0 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -0,0 +1,61 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do + @moduledoc "Adds expiration to all local Create activities" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + @impl true + def filter(activity) do + activity = + if note?(activity) and local?(activity) do + maybe_add_expiration(activity) + else + activity + end + + {:ok, activity} + end + + @impl true + def describe, do: {:ok, %{}} + + defp local?(%{"actor" => actor}) do + String.starts_with?(actor, Pleroma.Web.Endpoint.url()) + end + + defp note?(activity) do + match?(%{"type" => "Create", "object" => %{"type" => "Note"}}, activity) + end + + defp maybe_add_expiration(activity) do + days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365) + expires_at = DateTime.utc_now() |> Timex.shift(days: days) + + with %{"expires_at" => existing_expires_at} <- activity, + :lt <- DateTime.compare(existing_expires_at, expires_at) do + activity + else + _ -> Map.put(activity, "expires_at", expires_at) + end + end + + @impl true + def config_description do + %{ + key: :mrf_activity_expiration, + related_policy: "Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy", + label: "MRF Activity Expiration Policy", + description: "Adds automatic expiration to all local activities", + children: [ + %{ + key: :days, + type: :integer, + description: "Default global expiration time for all local activities (in days)", + suggestions: [90, 365] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/anti_duplication_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_duplication_policy.ex new file mode 100644 index 0000000..ddec4b8 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/anti_duplication_policy.ex @@ -0,0 +1,38 @@ +defmodule Pleroma.Web.ActivityPub.MRF.AntiDuplicationPolicy do + @moduledoc "Prevents messages with the exact same content from being posted repeatedly, regardless of its source." + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + @cache :anti_duplication_mrf_cache + + @impl true + def filter(%{"type" => "Create", "object" => %{"content" => content}} = activity) + when is_binary(content) do + ttl = Pleroma.Config.get([:mrf_anti_duplication, :ttl], :timer.minutes(1)) + min_length = Pleroma.Config.get([:mrf_anti_duplication, :min_length], 50) + + if String.length(content) >= min_length do + # We use SHA1 because it's faster and we don't need cryptographic security here. + key = :crypto.hash(:sha, content) |> Base.encode64(case: :lower) + + case @cachex.exists?(@cache, key) do + {:ok, true} -> + @cachex.expire(@cache, key, ttl) + {:reject, "[AntiDuplicationPolicy] Message is a duplicate"} + + {:ok, false} -> + @cachex.put(@cache, key, true, ttl: ttl) + {:ok, activity} + end + else + {:ok, activity} + end + end + + def filter(object) do + {:ok, object} + end + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex new file mode 100644 index 0000000..97d75ec --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy do + alias Pleroma.User + + @moduledoc "Prevent followbots from following with a bit of heuristic" + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + # XXX: this should become User.normalize_by_ap_id() or similar, really. + defp normalize_by_ap_id(%{"id" => id}), do: User.get_cached_by_ap_id(id) + defp normalize_by_ap_id(uri) when is_binary(uri), do: User.get_cached_by_ap_id(uri) + defp normalize_by_ap_id(_), do: nil + + defp score_nickname("followbot@" <> _), do: 1.0 + defp score_nickname("federationbot@" <> _), do: 1.0 + defp score_nickname("federation_bot@" <> _), do: 1.0 + defp score_nickname(_), do: 0.0 + + defp score_displayname("federation bot"), do: 1.0 + defp score_displayname("federationbot"), do: 1.0 + defp score_displayname("fedibot"), do: 1.0 + defp score_displayname(_), do: 0.0 + + defp determine_if_followbot(%User{nickname: nickname, name: displayname, actor_type: actor_type}) do + # nickname will be a binary string except when following a relay + nick_score = + if is_binary(nickname) do + nickname + |> String.downcase() + |> score_nickname() + else + 0.0 + end + + # displayname will either be a binary string or nil, if a displayname isn't set. + name_score = + if is_binary(displayname) do + displayname + |> String.downcase() + |> score_displayname() + else + 0.0 + end + + # actor_type "Service" is a Bot account + actor_type_score = + if actor_type == "Service" do + 1.0 + else + 0.0 + end + + nick_score + name_score + actor_type_score + end + + defp determine_if_followbot(_), do: 0.0 + + defp bot_allowed?(%{"object" => target}, bot_actor) do + %User{} = user = normalize_by_ap_id(target) + + User.following?(user, bot_actor) + end + + @impl true + def filter(%{"type" => "Follow", "actor" => actor_id} = message) do + %User{} = actor = normalize_by_ap_id(actor_id) + + score = determine_if_followbot(actor) + + if score < 0.8 || bot_allowed?(message, actor) do + {:ok, message} + else + {:reject, "[AntiFollowbotPolicy] Scored #{actor_id} as #{score}"} + end + end + + @impl true + def filter(message), do: {:ok, message} + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex new file mode 100644 index 0000000..3ec9c52 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex @@ -0,0 +1,60 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy do + alias Pleroma.User + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + require Logger + + @impl true + def history_awareness, do: :auto + + # has the user successfully posted before? + defp old_user?(%User{} = u) do + u.note_count > 0 || u.follower_count > 0 + end + + # does the post contain links? + defp contains_links?(%{"content" => content} = _object) do + content + |> Floki.parse_fragment!() + |> Floki.filter_out("a.mention,a.hashtag,a[rel~=\"tag\"],a.zrl") + |> Floki.attribute("a", "href") + |> length() > 0 + end + + defp contains_links?(_), do: false + + @impl true + def filter(%{"type" => "Create", "actor" => actor, "object" => object} = message) do + with {:ok, %User{local: false} = u} <- User.get_or_fetch_by_ap_id(actor), + {:contains_links, true} <- {:contains_links, contains_links?(object)}, + {:old_user, true} <- {:old_user, old_user?(u)} do + {:ok, message} + else + {:ok, %User{local: true}} -> + {:ok, message} + + {:contains_links, false} -> + {:ok, message} + + {:old_user, false} -> + {:reject, "[AntiLinkSpamPolicy] User has no posts nor followers"} + + {:error, _} -> + {:reject, "[AntiLinkSpamPolicy] Failed to get or fetch user by ap_id"} + + e -> + {:reject, "[AntiLinkSpamPolicy] Unhandled error #{inspect(e)}"} + end + end + + # in all other cases, pass through + def filter(message), do: {:ok, message} + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/lib/pleroma/web/activity_pub/mrf/anti_mention_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_mention_spam_policy.ex new file mode 100644 index 0000000..9cdb207 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/anti_mention_spam_policy.ex @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.AntiMentionSpamPolicy do + alias Pleroma.User + require Pleroma.Constants + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + defp user_has_posted?(%User{} = u), do: u.note_count > 0 + + defp user_has_age?(%User{} = u) do + diff = NaiveDateTime.utc_now() |> NaiveDateTime.diff(u.inserted_at, :second) + diff >= :timer.seconds(30) + end + + defp good_reputation?(%User{} = u) do + user_has_age?(u) and user_has_posted?(u) + end + + # copied from HellthreadPolicy + defp get_recipient_count(message) do + recipients = (message["to"] || []) ++ (message["cc"] || []) + + follower_collection = + User.get_cached_by_ap_id(message["actor"] || message["attributedTo"]).follower_address + + if Enum.member?(recipients, Pleroma.Constants.as_public()) do + recipients = + recipients + |> List.delete(Pleroma.Constants.as_public()) + |> List.delete(follower_collection) + + {:public, length(recipients)} + else + recipients = + recipients + |> List.delete(follower_collection) + + {:not_public, length(recipients)} + end + end + + defp object_has_recipients?(%{"object" => object} = activity) do + {_, object_count} = get_recipient_count(object) + {_, activity_count} = get_recipient_count(activity) + object_count + activity_count > 0 + end + + defp object_has_recipients?(object) do + {_, count} = get_recipient_count(object) + count > 0 + end + + @impl true + def filter(%{"type" => "Create", "actor" => actor} = activity) do + with {:ok, %User{local: false} = u} <- User.get_or_fetch_by_ap_id(actor), + {:has_mentions, true} <- {:has_mentions, object_has_recipients?(activity)}, + {:good_reputation, true} <- {:good_reputation, good_reputation?(u)} do + {:ok, activity} + else + {:ok, %User{local: true}} -> + {:ok, activity} + + {:has_mentions, false} -> + {:ok, activity} + + {:good_reputation, false} -> + {:reject, "[AntiMentionSpamPolicy] User rejected"} + + {:error, _} -> + {:reject, "[AntiMentionSpamPolicy] Failed to get or fetch user by ap_id"} + + e -> + {:reject, "[AntiMentionSpamPolicy] Unhandled error #{inspect(e)}"} + end + end + + # in all other cases, pass through + def filter(message), do: {:ok, message} + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/lib/pleroma/web/activity_pub/mrf/block_notification_policy.ex b/lib/pleroma/web/activity_pub/mrf/block_notification_policy.ex new file mode 100644 index 0000000..6d3ecff --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/block_notification_policy.ex @@ -0,0 +1,95 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.BlockNotificationPolicy do + @moduledoc "Notify local users upon remote block and unblock." + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + defp is_block_or_unblock(%{"type" => "Block", "object" => object}), + do: {true, "blocked", object} + + defp is_block_or_unblock(%{ + "type" => "Undo", + "object" => %{"type" => "Block", "object" => object} + }), + do: {true, "unblocked", object} + + defp is_block_or_unblock(_), do: {false, nil, nil} + + @impl true + def filter(message) do + with {true, action, object} <- is_block_or_unblock(message), + %User{} = actor <- User.get_cached_by_ap_id(message["actor"]), + true <- actor.is_active, + %User{} = recipient <- User.get_cached_by_ap_id(object) do + bot_user = Pleroma.Config.get([:mrf_block_notification_policy, :user]) + + replacements = %{ + "actor" => actor.nickname, + "target" => recipient.nickname, + "action" => action + } + + msg = + Regex.replace( + ~r/{([a-z]+)?}/, + Pleroma.Config.get([:mrf_block_notification_policy, :message]), + fn _, match -> + replacements[match] + end + ) + + _reply = + CommonAPI.post(User.get_by_nickname(bot_user), %{ + status: msg, + visibility: Pleroma.Config.get([:mrf_block_notification_policy, :visibility]) + }) + end + + {:ok, message} + end + + @impl true + def describe do + mrf_block_notification_policy = Config.get(:mrf_block_notification_policy) + + {:ok, %{mrf_block_notification_policy: mrf_block_notification_policy}} + end + + @impl true + def config_description do + %{ + key: :mrf_block_notification_policy, + related_policy: "Pleroma.Web.ActivityPub.MRF.BlockNotificationPolicy", + description: "Notify local users upon remote block.", + children: [ + %{ + key: :message, + type: :string, + label: "Message", + description: + "The message to send when someone is blocked or unblocked; use {actor}, {target}, and {action} variables", + suggestions: ["@{actor} {action} @{target}"] + }, + %{ + key: :user, + type: :string, + label: "Block User", + description: "The user account that announces a block" + }, + %{ + key: :visibility, + type: :string, + label: "Visibility", + description: "The visibility of block messages", + suggestions: ["public", "unlisted", "private", "direct"] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/drop_policy.ex b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex new file mode 100644 index 0000000..ad09368 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex @@ -0,0 +1,18 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.DropPolicy do + require Logger + @moduledoc "Drop and log everything received" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + @impl true + def filter(object) do + Logger.debug("REJECTING #{inspect(object)}") + {:reject, object} + end + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex b/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex new file mode 100644 index 0000000..f884962 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex @@ -0,0 +1,281 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.EmojiPolicy do + require Pleroma.Constants + + alias Pleroma.Object.Updater + alias Pleroma.Web.ActivityPub.MRF.Utils + + @moduledoc "Reject or force-unlisted emojis with certain URLs or names" + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + defp config_remove_url do + Pleroma.Config.get([:mrf_emoji, :remove_url], []) + end + + defp config_remove_shortcode do + Pleroma.Config.get([:mrf_emoji, :remove_shortcode], []) + end + + defp config_unlist_url do + Pleroma.Config.get([:mrf_emoji, :federated_timeline_removal_url], []) + end + + defp config_unlist_shortcode do + Pleroma.Config.get([:mrf_emoji, :federated_timeline_removal_shortcode], []) + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def history_awareness, do: :manual + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def filter(%{"type" => type, "object" => %{"type" => objtype} = object} = message) + when type in ["Create", "Update"] and objtype in Pleroma.Constants.status_object_types() do + with {:ok, object} <- + Updater.do_with_history(object, fn object -> + {:ok, process_remove(object, :url, config_remove_url())} + end), + {:ok, object} <- + Updater.do_with_history(object, fn object -> + {:ok, process_remove(object, :shortcode, config_remove_shortcode())} + end), + activity <- Map.put(message, "object", object), + activity <- maybe_delist(activity) do + {:ok, activity} + end + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def filter(%{"type" => type} = object) when type in Pleroma.Constants.actor_types() do + with object <- process_remove(object, :url, config_remove_url()), + object <- process_remove(object, :shortcode, config_remove_shortcode()) do + {:ok, object} + end + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def filter(%{"type" => "EmojiReact"} = object) do + with {:ok, _} <- + matched_emoji_checker(config_remove_url(), config_remove_shortcode()).(object) do + {:ok, object} + else + _ -> + {:reject, "[EmojiPolicy] Rejected for having disallowed emoji"} + end + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def filter(message) do + {:ok, message} + end + + defp match_string?(string, pattern) when is_binary(pattern) do + string == pattern + end + + defp match_string?(string, %Regex{} = pattern) do + String.match?(string, pattern) + end + + defp match_any?(string, patterns) do + Enum.any?(patterns, &match_string?(string, &1)) + end + + defp url_from_tag(%{"icon" => %{"url" => url}}), do: url + defp url_from_tag(_), do: nil + + defp url_from_emoji({_name, url}), do: url + + defp shortcode_from_tag(%{"name" => name}) when is_binary(name), do: String.trim(name, ":") + defp shortcode_from_tag(_), do: nil + + defp shortcode_from_emoji({name, _url}), do: name + + defp process_remove(object, :url, patterns) do + process_remove_impl(object, &url_from_tag/1, &url_from_emoji/1, patterns) + end + + defp process_remove(object, :shortcode, patterns) do + process_remove_impl(object, &shortcode_from_tag/1, &shortcode_from_emoji/1, patterns) + end + + defp process_remove_impl(object, extract_from_tag, extract_from_emoji, patterns) do + object = + if object["tag"] do + Map.put( + object, + "tag", + Enum.filter( + object["tag"], + fn + %{"type" => "Emoji"} = tag -> + str = extract_from_tag.(tag) + + if is_binary(str) do + not match_any?(str, patterns) + else + true + end + + _ -> + true + end + ) + ) + else + object + end + + object = + if object["emoji"] do + Map.put( + object, + "emoji", + object["emoji"] + |> Enum.reduce(%{}, fn {name, url} = emoji, acc -> + if not match_any?(extract_from_emoji.(emoji), patterns) do + Map.put(acc, name, url) + else + acc + end + end) + ) + else + object + end + + object + end + + defp matched_emoji_checker(urls, shortcodes) do + fn object -> + if any_emoji_match?(object, &url_from_tag/1, &url_from_emoji/1, urls) or + any_emoji_match?( + object, + &shortcode_from_tag/1, + &shortcode_from_emoji/1, + shortcodes + ) do + {:matched, nil} + else + {:ok, %{}} + end + end + end + + defp maybe_delist(%{"object" => object, "to" => to, "type" => "Create"} = activity) do + check = matched_emoji_checker(config_unlist_url(), config_unlist_shortcode()) + + should_delist? = fn object -> + with {:ok, _} <- Pleroma.Object.Updater.do_with_history(object, check) do + false + else + _ -> true + end + end + + if Pleroma.Constants.as_public() in to and should_delist?.(object) do + to = List.delete(to, Pleroma.Constants.as_public()) + cc = [Pleroma.Constants.as_public() | activity["cc"] || []] + + activity + |> Map.put("to", to) + |> Map.put("cc", cc) + else + activity + end + end + + defp maybe_delist(activity), do: activity + + defp any_emoji_match?(object, extract_from_tag, extract_from_emoji, patterns) do + Kernel.||( + Enum.any?( + object["tag"] || [], + fn + %{"type" => "Emoji"} = tag -> + str = extract_from_tag.(tag) + + if is_binary(str) do + match_any?(str, patterns) + else + false + end + + _ -> + false + end + ), + (object["emoji"] || []) + |> Enum.any?(fn emoji -> match_any?(extract_from_emoji.(emoji), patterns) end) + ) + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def describe do + mrf_emoji = + Pleroma.Config.get(:mrf_emoji, []) + |> Enum.map(fn {key, value} -> + {key, Enum.map(value, &Utils.describe_regex_or_string/1)} + end) + |> Enum.into(%{}) + + {:ok, %{mrf_emoji: mrf_emoji}} + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def config_description do + %{ + key: :mrf_emoji, + related_policy: "Pleroma.Web.ActivityPub.MRF.EmojiPolicy", + label: "MRF Emoji", + description: + "Reject or force-unlisted emojis whose URLs or names match a keyword or [Regex](https://hexdocs.pm/elixir/Regex.html).", + children: [ + %{ + key: :remove_url, + type: {:list, :string}, + description: """ + A list of patterns which result in emoji whose URL matches being removed from the message. This will apply to statuses, emoji reactions, and user profiles. + + Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + """, + suggestions: ["https://example.org/foo.png", ~r/example.org\/foo/iu] + }, + %{ + key: :remove_shortcode, + type: {:list, :string}, + description: """ + A list of patterns which result in emoji whose shortcode matches being removed from the message. This will apply to statuses, emoji reactions, and user profiles. + + Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + """, + suggestions: ["foo", ~r/foo/iu] + }, + %{ + key: :federated_timeline_removal_url, + type: {:list, :string}, + description: """ + A list of patterns which result in message with emojis whose URLs match being removed from federated timelines (a.k.a unlisted). This will apply only to statuses. + + Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + """, + suggestions: ["https://example.org/foo.png", ~r/example.org\/foo/iu] + }, + %{ + key: :federated_timeline_removal_shortcode, + type: {:list, :string}, + description: """ + A list of patterns which result in message with emojis whose shortcodes match being removed from federated timelines (a.k.a unlisted). This will apply only to statuses. + + Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + """, + suggestions: ["foo", ~r/foo/iu] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex new file mode 100644 index 0000000..a148cc1 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex @@ -0,0 +1,47 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrepended do + alias Pleroma.Object + + @moduledoc "Ensure a re: is prepended on replies to a post with a Subject" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + @reply_prefix Regex.compile!("^re:[[:space:]]*", [:caseless]) + + def history_awareness, do: :auto + + def filter_by_summary( + %{data: %{"summary" => parent_summary}} = _in_reply_to, + %{"summary" => child_summary} = child + ) + when not is_nil(child_summary) and byte_size(child_summary) > 0 and + not is_nil(parent_summary) and byte_size(parent_summary) > 0 do + if (child_summary == parent_summary and not Regex.match?(@reply_prefix, child_summary)) or + (Regex.match?(@reply_prefix, parent_summary) && + Regex.replace(@reply_prefix, parent_summary, "") == child_summary) do + Map.put(child, "summary", "re: " <> child_summary) + else + child + end + end + + def filter_by_summary(_in_reply_to, child), do: child + + def filter(%{"type" => type, "object" => child_object} = object) + when type in ["Create", "Update"] and is_map(child_object) do + child = + child_object["inReplyTo"] + |> Object.normalize(fetch: false) + |> filter_by_summary(child_object) + + object = Map.put(object, "object", child) + + {:ok, object} + end + + def filter(object), do: {:ok, object} + + def describe, do: {:ok, %{}} +end diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex new file mode 100644 index 0000000..5a4a976 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex @@ -0,0 +1,63 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.FollowBotPolicy do + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + require Logger + + @impl true + def filter(message) do + with follower_nickname <- Config.get([:mrf_follow_bot, :follower_nickname]), + %User{actor_type: "Service"} = follower <- + User.get_cached_by_nickname(follower_nickname), + %{"type" => "Create", "object" => %{"type" => "Note"}} <- message do + try_follow(follower, message) + else + nil -> + Logger.warning( + "#{__MODULE__} skipped because of missing `:mrf_follow_bot, :follower_nickname` configuration, the :follower_nickname + account does not exist, or the account is not correctly configured as a bot." + ) + + {:ok, message} + + _ -> + {:ok, message} + end + end + + defp try_follow(follower, message) do + to = Map.get(message, "to", []) + cc = Map.get(message, "cc", []) + actor = [message["actor"]] + + Enum.concat([to, cc, actor]) + |> List.flatten() + |> Enum.uniq() + |> User.get_all_by_ap_id() + |> Enum.each(fn user -> + with false <- user.local, + false <- User.following?(follower, user), + false <- User.locked?(user), + false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot") do + Logger.debug( + "#{__MODULE__}: Follow request from #{follower.nickname} to #{user.nickname}" + ) + + CommonAPI.follow(follower, user) + end + end) + + {:ok, message} + end + + @impl true + def describe do + {:ok, %{}} + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex b/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex new file mode 100644 index 0000000..8cec8ea --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex @@ -0,0 +1,56 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicy do + alias Pleroma.User + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + @moduledoc "Remove bot posts from federated timeline" + + require Pleroma.Constants + + defp check_by_actor_type(user), do: user.actor_type in ["Application", "Service"] + defp check_by_nickname(user), do: Regex.match?(~r/.bot@|ebooks@/i, user.nickname) + + defp check_if_bot(user), do: check_by_actor_type(user) or check_by_nickname(user) + + @impl true + def filter( + %{ + "type" => "Create", + "to" => to, + "cc" => cc, + "actor" => actor, + "object" => object + } = message + ) do + user = User.get_cached_by_ap_id(actor) + isbot = check_if_bot(user) + + if isbot and Enum.member?(to, Pleroma.Constants.as_public()) do + to = List.delete(to, Pleroma.Constants.as_public()) ++ [user.follower_address] + cc = List.delete(cc, user.follower_address) ++ [Pleroma.Constants.as_public()] + + object = + object + |> Map.put("to", to) + |> Map.put("cc", cc) + + message = + message + |> Map.put("to", to) + |> Map.put("cc", cc) + |> Map.put("object", object) + + {:ok, message} + else + {:ok, message} + end + end + + @impl true + def filter(message), do: {:ok, message} + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex b/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex new file mode 100644 index 0000000..5532093 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex @@ -0,0 +1,137 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.ForceMentionsInContent do + require Pleroma.Constants + + alias Pleroma.Formatter + alias Pleroma.Object + alias Pleroma.User + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + @impl true + def history_awareness, do: :auto + + defp do_extract({:a, attrs, _}, acc) do + if Enum.find(attrs, fn {name, value} -> + name == "class" && value in ["mention", "u-url mention", "mention u-url"] + end) do + href = Enum.find(attrs, fn {name, _} -> name == "href" end) |> elem(1) + acc ++ [href] + else + acc + end + end + + defp do_extract({_, _, children}, acc) do + do_extract(children, acc) + end + + defp do_extract(nodes, acc) when is_list(nodes) do + Enum.reduce(nodes, acc, fn node, acc -> do_extract(node, acc) end) + end + + defp do_extract(_, acc), do: acc + + defp extract_mention_uris_from_content(content) do + {:ok, tree} = :fast_html.decode(content, format: [:html_atoms]) + do_extract(tree, []) + end + + defp get_replied_to_user(%{"inReplyTo" => in_reply_to}) do + case Object.normalize(in_reply_to, fetch: false) do + %Object{data: %{"actor" => actor}} -> User.get_cached_by_ap_id(actor) + _ -> nil + end + end + + defp get_replied_to_user(_object), do: nil + + # Ensure the replied-to user is sorted to the left + defp sort_replied_user([%User{id: user_id} | _] = users, %User{id: user_id}), do: users + + defp sort_replied_user(users, %User{id: user_id} = user) do + if Enum.find(users, fn u -> u.id == user_id end) do + users = Enum.reject(users, fn u -> u.id == user_id end) + [user | users] + else + users + end + end + + defp sort_replied_user(users, _), do: users + + # Drop constants and the actor's own AP ID + defp clean_recipients(recipients, object) do + Enum.reject(recipients, fn ap_id -> + ap_id in [ + object["object"]["actor"], + Pleroma.Constants.as_public(), + Pleroma.Web.ActivityPub.Utils.as_local_public() + ] + end) + end + + @impl true + def filter( + %{ + "type" => type, + "object" => %{"type" => "Note", "to" => to, "inReplyTo" => in_reply_to} + } = object + ) + when type in ["Create", "Update"] and is_list(to) and is_binary(in_reply_to) do + # image-only posts from pleroma apparently reach this MRF without the content field + content = object["object"]["content"] || "" + + # Get the replied-to user for sorting + replied_to_user = get_replied_to_user(object["object"]) + + mention_users = + to + |> clean_recipients(object) + |> Enum.map(&User.get_cached_by_ap_id/1) + |> Enum.reject(&is_nil/1) + |> sort_replied_user(replied_to_user) + + explicitly_mentioned_uris = + extract_mention_uris_from_content(content) + |> MapSet.new() + + added_mentions = + Enum.reduce(mention_users, "", fn %User{ap_id: ap_id, uri: uri} = user, acc -> + if MapSet.disjoint?(MapSet.new([ap_id, uri]), explicitly_mentioned_uris) do + acc <> Formatter.mention_from_user(user, %{mentions_format: :compact}) <> " " + else + acc + end + end) + + recipients_inline = + if added_mentions != "", + do: "#{added_mentions}", + else: "" + + content = + cond do + # For Markdown posts, insert the mentions inside the first

    tag + recipients_inline != "" && String.starts_with?(content, "

    ") -> + "

    " <> recipients_inline <> String.trim_leading(content, "

    ") + + recipients_inline != "" -> + recipients_inline <> content + + true -> + content + end + + {:ok, put_in(object["object"]["content"], content)} + end + + @impl true + def filter(object), do: {:ok, object} + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex new file mode 100644 index 0000000..b73fd97 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex @@ -0,0 +1,143 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do + require Pleroma.Constants + + alias Pleroma.Config + alias Pleroma.Object + + @moduledoc """ + Reject, TWKN-remove or Set-Sensitive messsages with specific hashtags (without the leading #) + + Note: This MRF Policy is always enabled, if you want to disable it you have to set empty lists. + """ + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + @impl true + def history_awareness, do: :manual + + defp check_reject(message, hashtags) do + if Enum.any?(Config.get([:mrf_hashtag, :reject]), fn match -> match in hashtags end) do + {:reject, "[HashtagPolicy] Matches with rejected keyword"} + else + {:ok, message} + end + end + + defp check_ftl_removal(%{"to" => to} = message, hashtags) do + if Pleroma.Constants.as_public() in to and + Enum.any?(Config.get([:mrf_hashtag, :federated_timeline_removal]), fn match -> + match in hashtags + end) do + to = List.delete(to, Pleroma.Constants.as_public()) + cc = [Pleroma.Constants.as_public() | message["cc"] || []] + + message = + message + |> Map.put("to", to) + |> Map.put("cc", cc) + |> Kernel.put_in(["object", "to"], to) + |> Kernel.put_in(["object", "cc"], cc) + + {:ok, message} + else + {:ok, message} + end + end + + defp check_ftl_removal(message, _hashtags), do: {:ok, message} + + defp check_sensitive(message) do + {:ok, new_object} = + Object.Updater.do_with_history(message["object"], fn object -> + hashtags = Object.hashtags(%Object{data: object}) + + if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do + {:ok, Map.put(object, "sensitive", true)} + else + {:ok, object} + end + end) + + {:ok, Map.put(message, "object", new_object)} + end + + @impl true + def filter(%{"type" => type, "object" => object} = message) when type in ["Create", "Update"] do + history_items = + with %{"formerRepresentations" => %{"orderedItems" => items}} <- object do + items + else + _ -> [] + end + + historical_hashtags = + Enum.reduce(history_items, [], fn item, acc -> + acc ++ Object.hashtags(%Object{data: item}) + end) + + hashtags = Object.hashtags(%Object{data: object}) ++ historical_hashtags + + if hashtags != [] do + with {:ok, message} <- check_reject(message, hashtags), + {:ok, message} <- + (if "type" == "Create" do + check_ftl_removal(message, hashtags) + else + {:ok, message} + end), + {:ok, message} <- check_sensitive(message) do + {:ok, message} + end + else + {:ok, message} + end + end + + @impl true + def filter(message), do: {:ok, message} + + @impl true + def describe do + mrf_hashtag = + Config.get(:mrf_hashtag) + |> Enum.into(%{}) + + {:ok, %{mrf_hashtag: mrf_hashtag}} + end + + @impl true + def config_description do + %{ + key: :mrf_hashtag, + related_policy: "Pleroma.Web.ActivityPub.MRF.HashtagPolicy", + label: "MRF Hashtag", + description: @moduledoc, + children: [ + %{ + key: :reject, + type: {:list, :string}, + description: "A list of hashtags which result in message being rejected.", + suggestions: ["foo"] + }, + %{ + key: :federated_timeline_removal, + type: {:list, :string}, + description: + "A list of hashtags which result in message being removed from federated timelines (a.k.a unlisted).", + suggestions: ["foo"] + }, + %{ + key: :sensitive, + type: {:list, :string}, + description: + "A list of hashtags which result in message being set as sensitive (a.k.a NSFW/R-18)", + suggestions: ["nsfw", "r18"] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex new file mode 100644 index 0000000..80e235d --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex @@ -0,0 +1,127 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicy do + alias Pleroma.User + + require Pleroma.Constants + + @moduledoc "Block messages with too much mentions (configurable)" + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + defp delist_message(message, threshold) when threshold > 0 do + follower_collection = User.get_cached_by_ap_id(message["actor"]).follower_address + to = message["to"] || [] + cc = message["cc"] || [] + + follower_collection? = Enum.member?(to ++ cc, follower_collection) + + message = + case get_recipient_count(message) do + {:public, recipients} + when follower_collection? and recipients > threshold -> + message + |> Map.put("to", [follower_collection]) + |> Map.put("cc", [Pleroma.Constants.as_public()]) + + {:public, recipients} when recipients > threshold -> + message + |> Map.put("to", []) + |> Map.put("cc", [Pleroma.Constants.as_public()]) + + _ -> + message + end + + {:ok, message} + end + + defp delist_message(message, _threshold), do: {:ok, message} + + defp reject_message(message, threshold) when threshold > 0 do + with {_, recipients} <- get_recipient_count(message) do + if recipients > threshold do + {:reject, "[HellthreadPolicy] #{recipients} recipients is over the limit of #{threshold}"} + else + {:ok, message} + end + end + end + + defp reject_message(message, _threshold), do: {:ok, message} + + defp get_recipient_count(message) do + recipients = (message["to"] || []) ++ (message["cc"] || []) + follower_collection = User.get_cached_by_ap_id(message["actor"]).follower_address + + if Enum.member?(recipients, Pleroma.Constants.as_public()) do + recipients = + recipients + |> List.delete(Pleroma.Constants.as_public()) + |> List.delete(follower_collection) + + {:public, length(recipients)} + else + recipients = + recipients + |> List.delete(follower_collection) + + {:not_public, length(recipients)} + end + end + + @impl true + def filter(%{"type" => "Create", "object" => %{"type" => object_type}} = message) + when object_type in ~w{Note Article} do + reject_threshold = + Pleroma.Config.get( + [:mrf_hellthread, :reject_threshold], + Pleroma.Config.get([:mrf_hellthread, :threshold]) + ) + + delist_threshold = Pleroma.Config.get([:mrf_hellthread, :delist_threshold]) + + with {:ok, message} <- reject_message(message, reject_threshold), + {:ok, message} <- delist_message(message, delist_threshold) do + {:ok, message} + else + e -> e + end + end + + @impl true + def filter(message), do: {:ok, message} + + @impl true + def describe, + do: {:ok, %{mrf_hellthread: Pleroma.Config.get(:mrf_hellthread) |> Enum.into(%{})}} + + @impl true + def config_description do + %{ + key: :mrf_hellthread, + related_policy: "Pleroma.Web.ActivityPub.MRF.HellthreadPolicy", + label: "MRF Hellthread", + description: "Block messages with excessive user mentions", + children: [ + %{ + key: :delist_threshold, + type: :integer, + description: + "Number of mentioned users after which the message gets removed from timelines and" <> + "disables notifications. Set to 0 to disable.", + suggestions: [10] + }, + %{ + key: :reject_threshold, + type: :integer, + description: + "Number of mentioned users after which the messaged gets rejected. Set to 0 to disable.", + suggestions: [20] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex new file mode 100644 index 0000000..5c2352d --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex @@ -0,0 +1,78 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy do + @moduledoc "Force a quote line into the message content." + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + defp build_inline_quote(template, url) do + quote_line = String.replace(template, "{url}", "#{url}") + + "

    #{quote_line}
    " + end + + defp has_inline_quote?(content, quote_url) do + cond do + # Does the quote URL exist in the content? + content =~ quote_url -> true + # Does the content already have a .quote-inline span? + content =~ "" -> true + # No inline quote found + true -> false + end + end + + defp filter_object(%{"quoteUrl" => quote_url} = object) do + content = object["content"] || "" + + if has_inline_quote?(content, quote_url) do + object + else + template = Pleroma.Config.get([:mrf_inline_quote, :template]) + + content = + if String.ends_with?(content, "

    "), + do: + String.trim_trailing(content, "

    ") <> + build_inline_quote(template, quote_url) <> "

    ", + else: content <> build_inline_quote(template, quote_url) + + Map.put(object, "content", content) + end + end + + @impl true + def filter(%{"object" => %{"quoteUrl" => _} = object} = activity) do + {:ok, Map.put(activity, "object", filter_object(object))} + end + + @impl true + def filter(object), do: {:ok, object} + + @impl true + def describe, do: {:ok, %{}} + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def history_awareness, do: :auto + + @impl true + def config_description do + %{ + key: :mrf_inline_quote, + related_policy: "Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy", + label: "MRF Inline Quote Policy", + type: :group, + description: "Force quote url to appear in post content.", + children: [ + %{ + key: :template, + type: :string, + description: + "The template to append to the post. `{url}` will be replaced with the actual link to the quoted post.", + suggestions: ["RT: {url}", "QT: {url}"] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex new file mode 100644 index 0000000..874fe9a --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex @@ -0,0 +1,196 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do + require Pleroma.Constants + + alias Pleroma.Web.ActivityPub.MRF.Utils + + @moduledoc "Reject or Word-Replace messages with a keyword or regex" + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + defp string_matches?(string, _) when not is_binary(string) do + false + end + + defp string_matches?(string, pattern) when is_binary(pattern) do + String.contains?(string, pattern) + end + + defp string_matches?(string, pattern) do + String.match?(string, pattern) + end + + defp object_payload(%{} = object) do + [object["content"], object["summary"], object["name"]] + |> Enum.filter(& &1) + |> Enum.join("\n") + end + + defp check_reject(%{"object" => %{} = object} = message) do + with {:ok, _new_object} <- + Pleroma.Object.Updater.do_with_history(object, fn object -> + payload = object_payload(object) + + if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern -> + string_matches?(payload, pattern) + end) do + {:reject, "[KeywordPolicy] Matches with rejected keyword"} + else + {:ok, message} + end + end) do + {:ok, message} + else + e -> e + end + end + + defp check_ftl_removal(%{"type" => "Create", "to" => to, "object" => %{} = object} = message) do + check_keyword = fn object -> + payload = object_payload(object) + + if Enum.any?(Pleroma.Config.get([:mrf_keyword, :federated_timeline_removal]), fn pattern -> + string_matches?(payload, pattern) + end) do + {:should_delist, nil} + else + {:ok, %{}} + end + end + + should_delist? = fn object -> + with {:ok, _} <- Pleroma.Object.Updater.do_with_history(object, check_keyword) do + false + else + _ -> true + end + end + + if Pleroma.Constants.as_public() in to and should_delist?.(object) do + to = List.delete(to, Pleroma.Constants.as_public()) + cc = [Pleroma.Constants.as_public() | message["cc"] || []] + + message = + message + |> Map.put("to", to) + |> Map.put("cc", cc) + + {:ok, message} + else + {:ok, message} + end + end + + defp check_ftl_removal(message) do + {:ok, message} + end + + defp check_replace(%{"object" => %{} = object} = message) do + replace_kw = fn object -> + ["content", "name", "summary"] + |> Enum.filter(fn field -> Map.has_key?(object, field) && object[field] end) + |> Enum.reduce(object, fn field, object -> + data = + Enum.reduce( + Pleroma.Config.get([:mrf_keyword, :replace]), + object[field], + fn {pat, repl}, acc -> String.replace(acc, pat, repl) end + ) + + Map.put(object, field, data) + end) + |> (fn object -> {:ok, object} end).() + end + + {:ok, object} = Pleroma.Object.Updater.do_with_history(object, replace_kw) + + message = Map.put(message, "object", object) + + {:ok, message} + end + + @impl true + def filter(%{"type" => type, "object" => %{"content" => _content}} = message) + when type in ["Create", "Update"] do + with {:ok, message} <- check_reject(message), + {:ok, message} <- check_ftl_removal(message), + {:ok, message} <- check_replace(message) do + {:ok, message} + else + {:reject, nil} -> {:reject, "[KeywordPolicy] "} + {:reject, _} = e -> e + _e -> {:reject, "[KeywordPolicy] "} + end + end + + @impl true + def filter(message), do: {:ok, message} + + @impl true + def describe do + mrf_keyword = + Pleroma.Config.get(:mrf_keyword, []) + |> Enum.map(fn {key, value} -> + {key, + Enum.map(value, fn + {pattern, replacement} -> + %{ + "pattern" => Utils.describe_regex_or_string(pattern), + "replacement" => replacement + } + + pattern -> + Utils.describe_regex_or_string(pattern) + end)} + end) + |> Enum.into(%{}) + + {:ok, %{mrf_keyword: mrf_keyword}} + end + + @impl true + def config_description do + %{ + key: :mrf_keyword, + related_policy: "Pleroma.Web.ActivityPub.MRF.KeywordPolicy", + label: "MRF Keyword", + description: + "Reject or Word-Replace messages matching a keyword or [Regex](https://hexdocs.pm/elixir/Regex.html).", + children: [ + %{ + key: :reject, + type: {:list, :string}, + description: """ + A list of patterns which result in message being rejected. + + Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + """, + suggestions: ["foo", ~r/foo/iu] + }, + %{ + key: :federated_timeline_removal, + type: {:list, :string}, + description: """ + A list of patterns which result in message being removed from federated timelines (a.k.a unlisted). + + Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + """, + suggestions: ["foo", ~r/foo/iu] + }, + %{ + key: :replace, + type: {:list, :tuple}, + key_placeholder: "instance", + value_placeholder: "reason", + description: """ + **Pattern**: a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + + **Replacement**: a string. Leaving the field empty is permitted. + """ + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex new file mode 100644 index 0000000..c95d35b --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex @@ -0,0 +1,72 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do + @moduledoc "Preloads any attachments in the MediaProxy cache by prefetching them" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.HTTP + alias Pleroma.Web.MediaProxy + + require Logger + + @adapter_options [ + pool: :media, + recv_timeout: 10_000 + ] + + @impl true + def history_awareness, do: :auto + + defp prefetch(url) do + # Fetching only proxiable resources + if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do + # If preview proxy is enabled, it'll also hit media proxy (so we're caching both requests) + prefetch_url = MediaProxy.preview_url(url) + + Logger.debug("Prefetching #{inspect(url)} as #{inspect(prefetch_url)}") + + if Pleroma.Config.get(:env) == :test do + fetch(prefetch_url) + else + ConcurrentLimiter.limit(__MODULE__, fn -> + Task.start(fn -> fetch(prefetch_url) end) + end) + end + end + end + + defp fetch(url), do: HTTP.get(url, [], @adapter_options) + + defp preload(%{"object" => %{"attachment" => attachments}} = _message) do + Enum.each(attachments, fn + %{"url" => url} when is_list(url) -> + url + |> Enum.each(fn + %{"href" => href} -> + prefetch(href) + + x -> + Logger.debug("Unhandled attachment URL object #{inspect(x)}") + end) + + x -> + Logger.debug("Unhandled attachment #{inspect(x)}") + end) + end + + @impl true + def filter(%{"type" => type, "object" => %{"attachment" => attachments} = _object} = message) + when type in ["Create", "Update"] and is_list(attachments) and length(attachments) > 0 do + preload(message) + + {:ok, message} + end + + @impl true + def filter(message), do: {:ok, message} + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex new file mode 100644 index 0000000..8aa4f34 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.MentionPolicy do + @moduledoc "Block messages which mention a user" + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + @impl true + def filter(%{"type" => "Create"} = message) do + reject_actors = Pleroma.Config.get([:mrf_mention, :actors], []) + recipients = (message["to"] || []) ++ (message["cc"] || []) + + if rejected_mention = + Enum.find(recipients, fn recipient -> Enum.member?(reject_actors, recipient) end) do + {:reject, "[MentionPolicy] Rejected for mention of #{rejected_mention}"} + else + {:ok, message} + end + end + + @impl true + def filter(message), do: {:ok, message} + + @impl true + def describe, do: {:ok, %{}} + + @impl true + def config_description do + %{ + key: :mrf_mention, + related_policy: "Pleroma.Web.ActivityPub.MRF.MentionPolicy", + label: "MRF Mention", + description: "Block messages which mention a specific user", + children: [ + %{ + key: :actors, + type: {:list, :string}, + description: "A list of actors for which any post mentioning them will be dropped", + suggestions: ["actor1", "actor2"] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex new file mode 100644 index 0000000..855cda3 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex @@ -0,0 +1,70 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do + @moduledoc "Filter local activities which have no content" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.Web.Endpoint + + @impl true + def filter(%{"actor" => actor} = object) do + with true <- is_local?(actor), + true <- is_eligible_type?(object), + true <- is_note?(object), + false <- has_attachment?(object), + true <- only_mentions?(object) do + {:reject, "[NoEmptyPolicy]"} + else + _ -> + {:ok, object} + end + end + + def filter(object), do: {:ok, object} + + defp is_local?(actor) do + if actor |> String.starts_with?("#{Endpoint.url()}") do + true + else + false + end + end + + defp has_attachment?(%{ + "object" => %{"type" => "Note", "attachment" => attachments} + }) + when length(attachments) > 0, + do: true + + defp has_attachment?(_), do: false + + defp only_mentions?(%{"object" => %{"type" => "Note", "source" => source}}) do + source = + case source do + %{"content" => text} -> text + _ -> source + end + + non_mentions = + source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length + + if non_mentions > 0 do + false + else + true + end + end + + defp only_mentions?(_), do: false + + defp is_note?(%{"object" => %{"type" => "Note"}}), do: true + defp is_note?(_), do: false + + defp is_eligible_type?(%{"type" => type}) when type in ["Create", "Update"], do: true + defp is_eligible_type?(_), do: false + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex new file mode 100644 index 0000000..8840c4f --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex @@ -0,0 +1,16 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.NoOpPolicy do + @moduledoc "Does nothing (lets the messages go through unmodified)" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + @impl true + def filter(object) do + {:ok, object} + end + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex new file mode 100644 index 0000000..f81e9e5 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy do + @moduledoc "Ensure no content placeholder is present (such as the dot from mastodon)" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + @impl true + def history_awareness, do: :auto + + @impl true + def filter( + %{ + "type" => type, + "object" => %{"content" => content, "attachment" => _} = _child_object + } = object + ) + when type in ["Create", "Update"] and content in [".", "

    .

    "] do + {:ok, put_in(object, ["object", "content"], "")} + end + + @impl true + def filter(object), do: {:ok, object} + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex new file mode 100644 index 0000000..2dfc9a9 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkup do + @moduledoc "Scrub configured hypertext markup" + alias Pleroma.HTML + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + @impl true + def history_awareness, do: :auto + + @impl true + def filter(%{"type" => type, "object" => child_object} = object) + when type in ["Create", "Update"] do + scrub_policy = Pleroma.Config.get([:mrf_normalize_markup, :scrub_policy]) + + content = + child_object["content"] + |> HTML.filter_tags(scrub_policy) + + object = put_in(object, ["object", "content"], content) + + {:ok, object} + end + + def filter(object), do: {:ok, object} + + @impl true + def describe, do: {:ok, %{}} + + @impl true + def config_description do + %{ + key: :mrf_normalize_markup, + related_policy: "Pleroma.Web.ActivityPub.MRF.NormalizeMarkup", + label: "MRF Normalize Markup", + description: "MRF NormalizeMarkup settings. Scrub configured hypertext markup.", + children: [ + %{ + key: :scrub_policy, + type: :module, + suggestions: [Pleroma.HTML.Scrubber.Default] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/nsfw_api_policy.ex b/lib/pleroma/web/activity_pub/mrf/nsfw_api_policy.ex new file mode 100644 index 0000000..3d1c273 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/nsfw_api_policy.ex @@ -0,0 +1,265 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.NsfwApiPolicy do + @moduledoc """ + Hide, delete, or mark sensitive NSFW content with artificial intelligence. + + Requires a NSFW API server, configured like so: + + config :pleroma, Pleroma.Web.ActivityPub.MRF.NsfwMRF, + url: "http://127.0.0.1:5000/", + threshold: 0.7, + mark_sensitive: true, + unlist: false, + reject: false + + The NSFW API server must implement an HTTP endpoint like this: + + curl http://localhost:5000/?url=https://fedi.com/images/001.jpg + + Returning a response like this: + + {"score", 0.314} + + Where a score is 0-1, with `1` being definitely NSFW. + + A good API server is here: https://github.com/EugenCepoi/nsfw_api + You can run it with Docker with a one-liner: + + docker run -it -p 127.0.0.1:5000:5000/tcp --env PORT=5000 eugencepoi/nsfw_api:latest + + Options: + + - `url`: Base URL of the API server. Default: "http://127.0.0.1:5000/" + - `threshold`: Lowest score to take action on. Default: `0.7` + - `mark_sensitive`: Mark sensitive all detected NSFW content? Default: `true` + - `unlist`: Unlist all detected NSFW content? Default: `false` + - `reject`: Reject all detected NSFW content (takes precedence)? Default: `false` + """ + alias Pleroma.Config + alias Pleroma.Constants + alias Pleroma.HTTP + alias Pleroma.User + + require Logger + require Pleroma.Constants + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + @policy :mrf_nsfw_api + + def build_request_url(url) do + Config.get([@policy, :url]) + |> URI.parse() + |> fix_path() + |> Map.put(:query, "url=#{url}") + |> URI.to_string() + end + + def parse_url(url) do + request = build_request_url(url) + + with {:ok, %Tesla.Env{body: body}} <- HTTP.get(request) do + Jason.decode(body) + else + error -> + Logger.warning(""" + [NsfwApiPolicy]: The API server failed. Skipping. + #{inspect(error)} + """) + + error + end + end + + def check_url_nsfw(url) when is_binary(url) do + threshold = Config.get([@policy, :threshold]) + + case parse_url(url) do + {:ok, %{"score" => score}} when score >= threshold -> + {:nsfw, %{url: url, score: score, threshold: threshold}} + + {:ok, %{"score" => score}} -> + {:sfw, %{url: url, score: score, threshold: threshold}} + + _ -> + {:sfw, %{url: url, score: nil, threshold: threshold}} + end + end + + def check_url_nsfw(%{"href" => url}) when is_binary(url) do + check_url_nsfw(url) + end + + def check_url_nsfw(url) do + threshold = Config.get([@policy, :threshold]) + {:sfw, %{url: url, score: nil, threshold: threshold}} + end + + def check_attachment_nsfw(%{"url" => urls} = attachment) when is_list(urls) do + if Enum.all?(urls, &match?({:sfw, _}, check_url_nsfw(&1))) do + {:sfw, attachment} + else + {:nsfw, attachment} + end + end + + def check_attachment_nsfw(%{"url" => url} = attachment) when is_binary(url) do + case check_url_nsfw(url) do + {:sfw, _} -> {:sfw, attachment} + {:nsfw, _} -> {:nsfw, attachment} + end + end + + def check_attachment_nsfw(attachment), do: {:sfw, attachment} + + def check_object_nsfw(%{"attachment" => attachments} = object) when is_list(attachments) do + if Enum.all?(attachments, &match?({:sfw, _}, check_attachment_nsfw(&1))) do + {:sfw, object} + else + {:nsfw, object} + end + end + + def check_object_nsfw(%{"object" => %{} = child_object} = object) do + case check_object_nsfw(child_object) do + {:sfw, _} -> {:sfw, object} + {:nsfw, _} -> {:nsfw, object} + end + end + + def check_object_nsfw(object), do: {:sfw, object} + + @impl true + def filter(object) do + with {:sfw, object} <- check_object_nsfw(object) do + {:ok, object} + else + {:nsfw, _data} -> handle_nsfw(object) + _ -> {:reject, "NSFW: Attachment rejected"} + end + end + + defp handle_nsfw(object) do + if Config.get([@policy, :reject]) do + {:reject, object} + else + {:ok, + object + |> maybe_unlist() + |> maybe_mark_sensitive()} + end + end + + defp maybe_unlist(object) do + if Config.get([@policy, :unlist]) do + unlist(object) + else + object + end + end + + defp maybe_mark_sensitive(object) do + if Config.get([@policy, :mark_sensitive]) do + mark_sensitive(object) + else + object + end + end + + def unlist(%{"to" => to, "cc" => cc, "actor" => actor} = object) do + with %User{} = user <- User.get_cached_by_ap_id(actor) do + to = + [user.follower_address | to] + |> List.delete(Constants.as_public()) + |> Enum.uniq() + + cc = + [Constants.as_public() | cc] + |> List.delete(user.follower_address) + |> Enum.uniq() + + object + |> Map.put("to", to) + |> Map.put("cc", cc) + else + _ -> raise "[NsfwApiPolicy]: Could not find user #{actor}" + end + end + + def mark_sensitive(%{"object" => child_object} = object) when is_map(child_object) do + Map.put(object, "object", mark_sensitive(child_object)) + end + + def mark_sensitive(object) when is_map(object) do + tags = (object["tag"] || []) ++ ["nsfw"] + + object + |> Map.put("tag", tags) + |> Map.put("sensitive", true) + end + + # Hackney needs a trailing slash + defp fix_path(%URI{path: path} = uri) when is_binary(path) do + path = String.trim_trailing(path, "/") <> "/" + Map.put(uri, :path, path) + end + + defp fix_path(%URI{path: nil} = uri), do: Map.put(uri, :path, "/") + + @impl true + def describe do + options = %{ + threshold: Config.get([@policy, :threshold]), + mark_sensitive: Config.get([@policy, :mark_sensitive]), + unlist: Config.get([@policy, :unlist]), + reject: Config.get([@policy, :reject]) + } + + {:ok, %{@policy => options}} + end + + @impl true + def config_description do + %{ + key: @policy, + related_policy: to_string(__MODULE__), + label: "NSFW API Policy", + description: + "Hide, delete, or mark sensitive NSFW content with artificial intelligence. Requires running an external API server.", + children: [ + %{ + key: :url, + type: :string, + description: "Base URL of the API server.", + suggestions: ["http://127.0.0.1:5000/"] + }, + %{ + key: :threshold, + type: :float, + description: "Lowest score to take action on. Between 0 and 1.", + suggestions: [0.7] + }, + %{ + key: :mark_sensitive, + type: :boolean, + description: "Mark sensitive all detected NSFW content?", + suggestions: [true] + }, + %{ + key: :unlist, + type: :boolean, + description: "Unlist sensitive all detected NSFW content?", + suggestions: [false] + }, + %{ + key: :reject, + type: :boolean, + description: "Reject sensitive all detected NSFW content (takes precedence)?", + suggestions: [false] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex new file mode 100644 index 0000000..df1a6dc --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex @@ -0,0 +1,141 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy do + alias Pleroma.Config + alias Pleroma.User + + require Pleroma.Constants + + @moduledoc "Filter activities depending on their age" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + defp check_date(%{"object" => %{"published" => published}} = message) do + with %DateTime{} = now <- DateTime.utc_now(), + {:ok, %DateTime{} = then, _} <- DateTime.from_iso8601(published), + max_ttl <- Config.get([:mrf_object_age, :threshold]), + {:ttl, false} <- {:ttl, DateTime.diff(now, then) > max_ttl} do + {:ok, message} + else + {:ttl, true} -> + {:reject, nil} + + e -> + {:error, e} + end + end + + defp check_reject(message, actions) do + if :reject in actions do + {:reject, "[ObjectAgePolicy]"} + else + {:ok, message} + end + end + + defp check_delist(message, actions) do + if :delist in actions do + with %User{} = user <- User.get_cached_by_ap_id(message["actor"]) do + to = + List.delete(message["to"] || [], Pleroma.Constants.as_public()) ++ + [user.follower_address] + + cc = + List.delete(message["cc"] || [], user.follower_address) ++ + [Pleroma.Constants.as_public()] + + message = + message + |> Map.put("to", to) + |> Map.put("cc", cc) + |> Kernel.put_in(["object", "to"], to) + |> Kernel.put_in(["object", "cc"], cc) + + {:ok, message} + else + _e -> + {:reject, "[ObjectAgePolicy] Unhandled error"} + end + else + {:ok, message} + end + end + + defp check_strip_followers(message, actions) do + if :strip_followers in actions do + with %User{} = user <- User.get_cached_by_ap_id(message["actor"]) do + to = List.delete(message["to"] || [], user.follower_address) + cc = List.delete(message["cc"] || [], user.follower_address) + + message = + message + |> Map.put("to", to) + |> Map.put("cc", cc) + |> Kernel.put_in(["object", "to"], to) + |> Kernel.put_in(["object", "cc"], cc) + + {:ok, message} + else + _e -> + {:reject, "[ObjectAgePolicy] Unhandled error"} + end + else + {:ok, message} + end + end + + @impl true + def filter(%{"type" => "Create", "object" => %{"published" => _}} = message) do + with actions <- Config.get([:mrf_object_age, :actions]), + {:reject, _} <- check_date(message), + {:ok, message} <- check_reject(message, actions), + {:ok, message} <- check_delist(message, actions), + {:ok, message} <- check_strip_followers(message, actions) do + {:ok, message} + else + # check_date() is allowed to short-circuit the pipeline + e -> e + end + end + + @impl true + def filter(message), do: {:ok, message} + + @impl true + def describe do + mrf_object_age = + Config.get(:mrf_object_age) + |> Enum.into(%{}) + + {:ok, %{mrf_object_age: mrf_object_age}} + end + + @impl true + def config_description do + %{ + key: :mrf_object_age, + related_policy: "Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy", + label: "MRF Object Age", + description: + "Rejects or delists posts based on their timestamp deviance from your server's clock.", + children: [ + %{ + key: :threshold, + type: :integer, + description: "Required age (in seconds) of a post before actions are taken.", + suggestions: [172_800] + }, + %{ + key: :actions, + type: {:list, :atom}, + description: + "A list of actions to apply to the post. `:delist` removes the post from public timelines; " <> + "`:strip_followers` removes followers from the ActivityPub recipient list ensuring they won't be delivered to home timelines, additionally for followers-only it degrades to a direct message; " <> + "`:reject` rejects the message entirely", + suggestions: [:delist, :strip_followers, :reject] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/pipeline_filtering.ex b/lib/pleroma/web/activity_pub/mrf/pipeline_filtering.ex new file mode 100644 index 0000000..b2477fe --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/pipeline_filtering.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.PipelineFiltering do + @callback pipeline_filter(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} +end diff --git a/lib/pleroma/web/activity_pub/mrf/policy.ex b/lib/pleroma/web/activity_pub/mrf/policy.ex new file mode 100644 index 0000000..0234de4 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/policy.ex @@ -0,0 +1,17 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.Policy do + @callback filter(Map.t()) :: {:ok | :reject, Map.t()} + @callback describe() :: {:ok | :error, Map.t()} + @callback config_description() :: %{ + optional(:children) => [map()], + key: atom(), + related_policy: String.t(), + label: String.t(), + description: String.t() + } + @callback history_awareness() :: :auto | :manual + @optional_callbacks config_description: 0, history_awareness: 0 +end diff --git a/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex b/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex new file mode 100644 index 0000000..f1c573d --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.QuoteToLinkTagPolicy do + @moduledoc "Force a Link tag for posts quoting another post. (may break outgoing federation of quote posts with older Pleroma versions)" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + + require Pleroma.Constants + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def filter(%{"object" => %{"quoteUrl" => _} = object} = activity) do + {:ok, Map.put(activity, "object", filter_object(object))} + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def filter(object), do: {:ok, object} + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def describe, do: {:ok, %{}} + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def history_awareness, do: :auto + + defp filter_object(%{"quoteUrl" => quote_url} = object) do + tags = object["tag"] || [] + + if Enum.any?(tags, fn tag -> + CommonFixes.is_object_link_tag(tag) and tag["href"] == quote_url + end) do + object + else + object + |> Map.put( + "tag", + tags ++ + [ + %{ + "type" => "Link", + "mediaType" => Pleroma.Constants.activity_json_canonical_mime_type(), + "href" => quote_url + } + ] + ) + end + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex new file mode 100644 index 0000000..9d4a7a4 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex @@ -0,0 +1,74 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.RejectNonPublic do + @moduledoc "Rejects non-public (followers-only, direct) activities" + + alias Pleroma.Config + alias Pleroma.User + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + require Pleroma.Constants + + @impl true + def filter(%{"type" => "Create"} = object) do + user = User.get_cached_by_ap_id(object["actor"]) + + # Determine visibility + visibility = + cond do + Pleroma.Constants.as_public() in object["to"] -> "public" + Pleroma.Constants.as_public() in object["cc"] -> "unlisted" + user.follower_address in object["to"] -> "followers" + true -> "direct" + end + + policy = Config.get(:mrf_rejectnonpublic) + + cond do + visibility in ["public", "unlisted"] -> + {:ok, object} + + visibility == "followers" and Keyword.get(policy, :allow_followersonly) -> + {:ok, object} + + visibility == "direct" and Keyword.get(policy, :allow_direct) -> + {:ok, object} + + true -> + {:reject, "[RejectNonPublic] visibility: #{visibility}"} + end + end + + @impl true + def filter(object), do: {:ok, object} + + @impl true + def describe, + do: {:ok, %{mrf_rejectnonpublic: Config.get(:mrf_rejectnonpublic) |> Map.new()}} + + @impl true + def config_description do + %{ + key: :mrf_rejectnonpublic, + related_policy: "Pleroma.Web.ActivityPub.MRF.RejectNonPublic", + description: "RejectNonPublic drops posts with non-public visibility settings.", + label: "MRF Reject Non Public", + children: [ + %{ + key: :allow_followersonly, + label: "Allow followers-only", + type: :boolean, + description: "Whether to allow followers-only posts" + }, + %{ + key: :allow_direct, + type: :boolean, + description: "Whether to allow direct messages" + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex new file mode 100644 index 0000000..d330289 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex @@ -0,0 +1,118 @@ +defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy do + @moduledoc "Drop remote reports if they don't contain enough information." + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.Config + + @impl true + def filter(%{"type" => "Flag"} = object) do + with {_, false} <- {:local, local?(object)}, + {:ok, _} <- maybe_reject_all(object), + {:ok, _} <- maybe_reject_anonymous(object), + {:ok, _} <- maybe_reject_third_party(object), + {:ok, _} <- maybe_reject_empty_message(object) do + {:ok, object} + else + {:local, true} -> {:ok, object} + {:reject, message} -> {:reject, message} + error -> {:reject, error} + end + end + + def filter(object), do: {:ok, object} + + defp maybe_reject_all(object) do + if Config.get([:mrf_remote_report, :reject_all]) do + {:reject, "[RemoteReportPolicy] Remote report"} + else + {:ok, object} + end + end + + defp maybe_reject_anonymous(%{"actor" => actor} = object) do + with true <- Config.get([:mrf_remote_report, :reject_anonymous]), + %URI{path: "/actor"} <- URI.parse(actor) do + {:reject, "[RemoteReportPolicy] Anonymous: #{actor}"} + else + _ -> {:ok, object} + end + end + + defp maybe_reject_third_party(%{"object" => objects} = object) do + {_, to} = + case objects do + [head | tail] when is_binary(head) -> {tail, head} + s when is_binary(s) -> {[], s} + _ -> {[], ""} + end + + with true <- Config.get([:mrf_remote_report, :reject_third_party]), + String.starts_with?(to, Pleroma.Web.Endpoint.url()) do + {:reject, "[RemoteReportPolicy] Third-party: #{to}"} + else + _ -> {:ok, object} + end + end + + defp maybe_reject_empty_message(%{"content" => content} = object) + when is_binary(content) and content != "" do + {:ok, object} + end + + defp maybe_reject_empty_message(object) do + if Config.get([:mrf_remote_report, :reject_empty_message]) do + {:reject, ["RemoteReportPolicy] No content"]} + else + {:ok, object} + end + end + + defp local?(%{"actor" => actor}) do + String.starts_with?(actor, Pleroma.Web.Endpoint.url()) + end + + @impl true + def describe do + mrf_remote_report = + Config.get(:mrf_remote_report) + |> Enum.into(%{}) + + {:ok, %{mrf_remote_report: mrf_remote_report}} + end + + @impl true + def config_description do + %{ + key: :mrf_remote_report, + related_policy: "Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy", + label: "MRF Remote Report", + description: "Drop remote reports if they don't contain enough information.", + children: [ + %{ + key: :reject_all, + type: :boolean, + description: "Reject all remote reports? (this option takes precedence)", + suggestions: [false] + }, + %{ + key: :reject_anonymous, + type: :boolean, + description: "Reject anonymous remote reports?", + suggestions: [true] + }, + %{ + key: :reject_third_party, + type: :boolean, + description: "Reject reports on users from third-party instances?", + suggestions: [true] + }, + %{ + key: :reject_empty_message, + type: :boolean, + description: "Reject remote reports with no message?", + suggestions: [true] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex new file mode 100644 index 0000000..a82d6a4 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -0,0 +1,371 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do + @moduledoc "Filter activities depending on their origin instance" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.Config + alias Pleroma.FollowingRelationship + alias Pleroma.User + alias Pleroma.Web.ActivityPub.MRF + + require Pleroma.Constants + + defp check_accept(%{host: actor_host} = _actor_info, object) do + accepts = + instance_list(:accept) + |> MRF.subdomains_regex() + + cond do + accepts == [] -> {:ok, object} + actor_host == Config.get([Pleroma.Web.Endpoint, :url, :host]) -> {:ok, object} + MRF.subdomain_match?(accepts, actor_host) -> {:ok, object} + true -> {:reject, "[SimplePolicy] host not in accept list"} + end + end + + defp check_reject(%{host: actor_host} = _actor_info, object) do + rejects = + instance_list(:reject) + |> MRF.subdomains_regex() + + if MRF.subdomain_match?(rejects, actor_host) do + {:reject, "[SimplePolicy] host in reject list"} + else + {:ok, object} + end + end + + defp check_media_removal( + %{host: actor_host} = _actor_info, + %{"type" => type, "object" => %{"attachment" => child_attachment}} = object + ) + when length(child_attachment) > 0 and type in ["Create", "Update"] do + media_removal = + instance_list(:media_removal) + |> MRF.subdomains_regex() + + object = + if MRF.subdomain_match?(media_removal, actor_host) do + child_object = Map.delete(object["object"], "attachment") + Map.put(object, "object", child_object) + else + object + end + + {:ok, object} + end + + defp check_media_removal(_actor_info, object), do: {:ok, object} + + defp check_media_nsfw( + %{host: actor_host} = _actor_info, + %{ + "type" => type, + "object" => %{} = _child_object + } = object + ) + when type in ["Create", "Update"] do + media_nsfw = + instance_list(:media_nsfw) + |> MRF.subdomains_regex() + + object = + if MRF.subdomain_match?(media_nsfw, actor_host) do + Kernel.put_in(object, ["object", "sensitive"], true) + else + object + end + + {:ok, object} + end + + defp check_media_nsfw(_actor_info, object), do: {:ok, object} + + defp check_ftl_removal(%{host: actor_host} = _actor_info, object) do + timeline_removal = + instance_list(:federated_timeline_removal) + |> MRF.subdomains_regex() + + object = + with true <- MRF.subdomain_match?(timeline_removal, actor_host), + user <- User.get_cached_by_ap_id(object["actor"]), + true <- Pleroma.Constants.as_public() in object["to"] do + to = List.delete(object["to"], Pleroma.Constants.as_public()) ++ [user.follower_address] + + cc = List.delete(object["cc"], user.follower_address) ++ [Pleroma.Constants.as_public()] + + object + |> Map.put("to", to) + |> Map.put("cc", cc) + else + _ -> object + end + + {:ok, object} + end + + defp intersection(list1, list2) do + list1 -- list1 -- list2 + end + + defp check_followers_only(%{host: actor_host} = _actor_info, object) do + followers_only = + instance_list(:followers_only) + |> MRF.subdomains_regex() + + object = + with true <- MRF.subdomain_match?(followers_only, actor_host), + user <- User.get_cached_by_ap_id(object["actor"]) do + # Don't use Map.get/3 intentionally, these must not be nil + fixed_to = object["to"] || [] + fixed_cc = object["cc"] || [] + + to = FollowingRelationship.followers_ap_ids(user, fixed_to) + cc = FollowingRelationship.followers_ap_ids(user, fixed_cc) + + object + |> Map.put("to", intersection([user.follower_address | to], fixed_to)) + |> Map.put("cc", intersection([user.follower_address | cc], fixed_cc)) + else + _ -> object + end + + {:ok, object} + end + + defp check_report_removal(%{host: actor_host} = _actor_info, %{"type" => "Flag"} = object) do + report_removal = + instance_list(:report_removal) + |> MRF.subdomains_regex() + + if MRF.subdomain_match?(report_removal, actor_host) do + {:reject, "[SimplePolicy] host in report_removal list"} + else + {:ok, object} + end + end + + defp check_report_removal(_actor_info, object), do: {:ok, object} + + defp check_avatar_removal(%{host: actor_host} = _actor_info, %{"icon" => _icon} = object) do + avatar_removal = + instance_list(:avatar_removal) + |> MRF.subdomains_regex() + + if MRF.subdomain_match?(avatar_removal, actor_host) do + {:ok, Map.delete(object, "icon")} + else + {:ok, object} + end + end + + defp check_avatar_removal(_actor_info, object), do: {:ok, object} + + defp check_banner_removal(%{host: actor_host} = _actor_info, %{"image" => _image} = object) do + banner_removal = + instance_list(:banner_removal) + |> MRF.subdomains_regex() + + if MRF.subdomain_match?(banner_removal, actor_host) do + {:ok, Map.delete(object, "image")} + else + {:ok, object} + end + end + + defp check_banner_removal(_actor_info, object), do: {:ok, object} + + defp check_object(%{"object" => object} = activity) do + with {:ok, _object} <- filter(object) do + {:ok, activity} + end + end + + defp check_object(object), do: {:ok, object} + + defp instance_list(config_key) do + Config.get([:mrf_simple, config_key]) + |> MRF.instance_list_from_tuples() + end + + @impl true + def filter(%{"type" => "Delete", "actor" => actor} = object) do + %{host: actor_host} = URI.parse(actor) + + reject_deletes = + instance_list(:reject_deletes) + |> MRF.subdomains_regex() + + if MRF.subdomain_match?(reject_deletes, actor_host) do + {:reject, "[SimplePolicy] host in reject_deletes list"} + else + {:ok, object} + end + end + + @impl true + def filter(%{"actor" => actor} = object) do + actor_info = URI.parse(actor) + + with {:ok, object} <- check_accept(actor_info, object), + {:ok, object} <- check_reject(actor_info, object), + {:ok, object} <- check_media_removal(actor_info, object), + {:ok, object} <- check_media_nsfw(actor_info, object), + {:ok, object} <- check_ftl_removal(actor_info, object), + {:ok, object} <- check_followers_only(actor_info, object), + {:ok, object} <- check_report_removal(actor_info, object), + {:ok, object} <- check_object(object) do + {:ok, object} + else + {:reject, nil} -> {:reject, "[SimplePolicy]"} + {:reject, _} = e -> e + _ -> {:reject, "[SimplePolicy]"} + end + end + + def filter(%{"id" => actor, "type" => obj_type} = object) + when obj_type in ["Application", "Group", "Organization", "Person", "Service"] do + actor_info = URI.parse(actor) + + with {:ok, object} <- check_accept(actor_info, object), + {:ok, object} <- check_reject(actor_info, object), + {:ok, object} <- check_avatar_removal(actor_info, object), + {:ok, object} <- check_banner_removal(actor_info, object) do + {:ok, object} + else + {:reject, nil} -> {:reject, "[SimplePolicy]"} + {:reject, _} = e -> e + _ -> {:reject, "[SimplePolicy]"} + end + end + + def filter(object) when is_binary(object) do + uri = URI.parse(object) + + with {:ok, object} <- check_accept(uri, object), + {:ok, object} <- check_reject(uri, object) do + {:ok, object} + else + {:reject, nil} -> {:reject, "[SimplePolicy]"} + {:reject, _} = e -> e + _ -> {:reject, "[SimplePolicy]"} + end + end + + def filter(object), do: {:ok, object} + + @impl true + def describe do + exclusions = Config.get([:mrf, :transparency_exclusions]) |> MRF.instance_list_from_tuples() + + mrf_simple_excluded = + Config.get(:mrf_simple) + |> Enum.map(fn {rule, instances} -> + instances = MRF.normalize_instance_list(instances) + {rule, Enum.reject(instances, fn {host, _} -> host in exclusions end)} + end) + + mrf_simple = + mrf_simple_excluded + |> Enum.map(fn {rule, instances} -> + {rule, MRF.instance_list_from_tuples(instances)} + end) + |> Map.new() + + # This is for backwards compatibility. We originally didn't sent + # extra info like a reason why an instance was rejected/quarantined/etc. + # Because we didn't want to break backwards compatibility it was decided + # to add an extra "info" key. + mrf_simple_info = + mrf_simple_excluded + |> Enum.map(fn {rule, instances} -> + {rule, Enum.reject(instances, fn {_, reason} -> reason == "" end)} + end) + |> Enum.reject(fn {_, instances} -> instances == [] end) + |> Enum.map(fn {rule, instances} -> + instances = + instances + |> Enum.map(fn {host, reason} -> {host, %{"reason" => reason}} end) + |> Map.new() + + {rule, instances} + end) + |> Map.new() + + {:ok, %{mrf_simple: mrf_simple, mrf_simple_info: mrf_simple_info}} + end + + @impl true + def config_description do + %{ + key: :mrf_simple, + related_policy: "Pleroma.Web.ActivityPub.MRF.SimplePolicy", + label: "MRF Simple", + description: "Simple ingress policies", + children: + [ + %{ + key: :media_removal, + description: + "List of instances to strip media attachments from and the reason for doing so" + }, + %{ + key: :media_nsfw, + label: "Media NSFW", + description: + "List of instances to tag all media as NSFW (sensitive) from and the reason for doing so" + }, + %{ + key: :federated_timeline_removal, + description: + "List of instances to remove from the Federated (aka The Whole Known Network) Timeline and the reason for doing so" + }, + %{ + key: :reject, + description: + "List of instances to reject activities from (except deletes) and the reason for doing so" + }, + %{ + key: :accept, + description: + "List of instances to only accept activities from (except deletes) and the reason for doing so" + }, + %{ + key: :followers_only, + description: + "Force posts from the given instances to be visible by followers only and the reason for doing so" + }, + %{ + key: :report_removal, + description: "List of instances to reject reports from and the reason for doing so" + }, + %{ + key: :avatar_removal, + description: "List of instances to strip avatars from and the reason for doing so" + }, + %{ + key: :banner_removal, + description: "List of instances to strip banners from and the reason for doing so" + }, + %{ + key: :reject_deletes, + description: "List of instances to reject deletions from and the reason for doing so" + } + ] + |> Enum.map(fn setting -> + Map.merge( + setting, + %{ + type: {:list, :tuple}, + key_placeholder: "instance", + value_placeholder: "reason", + suggestions: [{"example.com", "Some reason"}, {"*.example.com", "Another reason"}] + } + ) + end) + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex new file mode 100644 index 0000000..28c2cf3 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex @@ -0,0 +1,154 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy do + require Logger + + alias Pleroma.Config + + @moduledoc "Detect new emojis by their shortcode and steals them" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + defp accept_host?(host), do: host in Config.get([:mrf_steal_emoji, :hosts], []) + + defp shortcode_matches?(shortcode, pattern) when is_binary(pattern) do + shortcode == pattern + end + + defp shortcode_matches?(shortcode, pattern) do + String.match?(shortcode, pattern) + end + + defp steal_emoji({shortcode, url}, emoji_dir_path) do + url = Pleroma.Web.MediaProxy.url(url) + + with {:ok, %{status: status} = response} when status in 200..299 <- Pleroma.HTTP.get(url) do + size_limit = Config.get([:mrf_steal_emoji, :size_limit], 50_000) + + if byte_size(response.body) <= size_limit do + extension = + url + |> URI.parse() + |> Map.get(:path) + |> Path.basename() + |> Path.extname() + + file_path = Path.join(emoji_dir_path, shortcode <> (extension || ".png")) + + case File.write(file_path, response.body) do + :ok -> + shortcode + + e -> + Logger.warning("MRF.StealEmojiPolicy: Failed to write to #{file_path}: #{inspect(e)}") + nil + end + else + Logger.debug( + "MRF.StealEmojiPolicy: :#{shortcode}: at #{url} (#{byte_size(response.body)} B) over size limit (#{size_limit} B)" + ) + + nil + end + else + e -> + Logger.warning("MRF.StealEmojiPolicy: Failed to fetch #{url}: #{inspect(e)}") + nil + end + end + + @impl true + def filter(%{"object" => %{"emoji" => foreign_emojis, "actor" => actor}} = message) do + host = URI.parse(actor).host + + if host != Pleroma.Web.Endpoint.host() and accept_host?(host) do + installed_emoji = Pleroma.Emoji.get_all() |> Enum.map(fn {k, _} -> k end) + + emoji_dir_path = + Config.get( + [:mrf_steal_emoji, :path], + Path.join(Config.get([:instance, :static_dir]), "emoji/stolen") + ) + + File.mkdir_p(emoji_dir_path) + + new_emojis = + foreign_emojis + |> Enum.reject(fn {shortcode, _url} -> shortcode in installed_emoji end) + |> Enum.filter(fn {shortcode, _url} -> + reject_emoji? = + [:mrf_steal_emoji, :rejected_shortcodes] + |> Config.get([]) + |> Enum.find(false, fn pattern -> shortcode_matches?(shortcode, pattern) end) + + !reject_emoji? + end) + |> Enum.map(&steal_emoji(&1, emoji_dir_path)) + |> Enum.filter(& &1) + + if !Enum.empty?(new_emojis) do + Logger.info("Stole new emojis: #{inspect(new_emojis)}") + Pleroma.Emoji.reload() + end + end + + {:ok, message} + end + + def filter(message), do: {:ok, message} + + @impl true + @spec config_description :: %{ + children: [ + %{ + description: <<_::272, _::_*256>>, + key: :hosts | :rejected_shortcodes | :size_limit, + suggestions: [any(), ...], + type: {:list, :string} | {:list, :string} | :integer + }, + ... + ], + description: <<_::448>>, + key: :mrf_steal_emoji, + label: <<_::80>>, + related_policy: <<_::352>> + } + def config_description do + %{ + key: :mrf_steal_emoji, + related_policy: "Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy", + label: "MRF Emojis", + description: "Steals emojis from selected instances when it sees them.", + children: [ + %{ + key: :hosts, + type: {:list, :string}, + description: "List of hosts to steal emojis from", + suggestions: [""] + }, + %{ + key: :rejected_shortcodes, + type: {:list, :string}, + description: """ + A list of patterns or matches to reject shortcodes with. + + Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + """, + suggestions: ["foo", ~r/foo/] + }, + %{ + key: :size_limit, + type: :integer, + description: "File size limit (in bytes), checked before an emoji is saved to the disk", + suggestions: ["100000"] + } + ] + } + end + + @impl true + def describe do + {:ok, %{}} + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex new file mode 100644 index 0000000..fdb9e51 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.SubchainPolicy do + alias Pleroma.Config + alias Pleroma.Web.ActivityPub.MRF + + require Logger + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + defp lookup_subchain(actor) do + with matches <- Config.get([:mrf_subchain, :match_actor]), + {match, subchain} <- Enum.find(matches, fn {k, _v} -> String.match?(actor, k) end) do + {:ok, match, subchain} + else + _e -> {:error, :notfound} + end + end + + @impl true + def filter(%{"actor" => actor} = message) do + with {:ok, match, subchain} <- lookup_subchain(actor) do + Logger.debug( + "[SubchainPolicy] Matched #{actor} against #{inspect(match)} with subchain #{inspect(subchain)}" + ) + + MRF.filter(subchain, message) + else + _e -> {:ok, message} + end + end + + @impl true + def filter(message), do: {:ok, message} + + @impl true + def describe, do: {:ok, %{}} + + @impl true + def config_description do + %{ + key: :mrf_subchain, + related_policy: "Pleroma.Web.ActivityPub.MRF.SubchainPolicy", + label: "MRF Subchain", + description: + "This policy processes messages through an alternate pipeline when a given message matches certain criteria." <> + " All criteria are configured as a map of regular expressions to lists of policy modules.", + children: [ + %{ + key: :match_actor, + type: {:map, {:list, :string}}, + description: "Matches a series of regular expressions against the actor field", + suggestions: [ + %{ + ~r/https:\/\/example.com/s => [Pleroma.Web.ActivityPub.MRF.DropPolicy] + } + ] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex new file mode 100644 index 0000000..73760ca --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex @@ -0,0 +1,163 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.TagPolicy do + alias Pleroma.User + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + @moduledoc """ + Apply policies based on user tags + + This policy applies policies on a user activities depending on their tags + on your instance. + + - `mrf_tag:media-force-nsfw`: Mark as sensitive on presence of attachments + - `mrf_tag:media-strip`: Remove attachments + - `mrf_tag:force-unlisted`: Mark as unlisted (removes from the federated timeline) + - `mrf_tag:sandbox`: Remove from public (local and federated) timelines + - `mrf_tag:disable-remote-subscription`: Reject non-local follow requests + - `mrf_tag:disable-any-subscription`: Reject any follow requests + """ + + require Pleroma.Constants + + defp get_tags(%User{tags: tags}) when is_list(tags), do: tags + defp get_tags(_), do: [] + + defp process_tag( + "mrf_tag:media-force-nsfw", + %{ + "type" => type, + "object" => %{"attachment" => child_attachment} + } = message + ) + when length(child_attachment) > 0 and type in ["Create", "Update"] do + {:ok, Kernel.put_in(message, ["object", "sensitive"], true)} + end + + defp process_tag( + "mrf_tag:media-strip", + %{ + "type" => type, + "object" => %{"attachment" => child_attachment} = object + } = message + ) + when length(child_attachment) > 0 and type in ["Create", "Update"] do + object = Map.delete(object, "attachment") + message = Map.put(message, "object", object) + + {:ok, message} + end + + defp process_tag( + "mrf_tag:force-unlisted", + %{ + "type" => "Create", + "to" => to, + "cc" => cc, + "actor" => actor, + "object" => object + } = message + ) do + user = User.get_cached_by_ap_id(actor) + + if Enum.member?(to, Pleroma.Constants.as_public()) do + to = List.delete(to, Pleroma.Constants.as_public()) ++ [user.follower_address] + cc = List.delete(cc, user.follower_address) ++ [Pleroma.Constants.as_public()] + + object = + object + |> Map.put("to", to) + |> Map.put("cc", cc) + + message = + message + |> Map.put("to", to) + |> Map.put("cc", cc) + |> Map.put("object", object) + + {:ok, message} + else + {:ok, message} + end + end + + defp process_tag( + "mrf_tag:sandbox", + %{ + "type" => "Create", + "to" => to, + "cc" => cc, + "actor" => actor, + "object" => object + } = message + ) do + user = User.get_cached_by_ap_id(actor) + + if Enum.member?(to, Pleroma.Constants.as_public()) or + Enum.member?(cc, Pleroma.Constants.as_public()) do + to = List.delete(to, Pleroma.Constants.as_public()) ++ [user.follower_address] + cc = List.delete(cc, Pleroma.Constants.as_public()) + + object = + object + |> Map.put("to", to) + |> Map.put("cc", cc) + + message = + message + |> Map.put("to", to) + |> Map.put("cc", cc) + |> Map.put("object", object) + + {:ok, message} + else + {:ok, message} + end + end + + defp process_tag( + "mrf_tag:disable-remote-subscription", + %{"type" => "Follow", "actor" => actor} = message + ) do + user = User.get_cached_by_ap_id(actor) + + if user.local == true do + {:ok, message} + else + {:reject, + "[TagPolicy] Follow from #{actor} tagged with mrf_tag:disable-remote-subscription"} + end + end + + defp process_tag("mrf_tag:disable-any-subscription", %{"type" => "Follow", "actor" => actor}), + do: {:reject, "[TagPolicy] Follow from #{actor} tagged with mrf_tag:disable-any-subscription"} + + defp process_tag(_, message), do: {:ok, message} + + def filter_message(actor, message) do + User.get_cached_by_ap_id(actor) + |> get_tags() + |> Enum.reduce({:ok, message}, fn + tag, {:ok, message} -> + process_tag(tag, message) + + _, error -> + error + end) + end + + @impl true + def filter(%{"object" => target_actor, "type" => "Follow"} = message), + do: filter_message(target_actor, message) + + @impl true + def filter(%{"actor" => actor, "type" => type} = message) when type in ["Create", "Update"], + do: filter_message(actor, message) + + @impl true + def filter(message), do: {:ok, message} + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex new file mode 100644 index 0000000..e14047d --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex @@ -0,0 +1,65 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy do + alias Pleroma.Config + + @moduledoc "Accept-list of users from specified instances" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + defp filter_by_list(object, []), do: {:ok, object} + + defp filter_by_list(%{"actor" => actor} = object, allow_list) do + if actor in allow_list do + {:ok, object} + else + {:reject, "[UserAllowListPolicy] #{actor} not in the list"} + end + end + + @impl true + def filter(%{"actor" => actor} = object) do + actor_info = URI.parse(actor) + + allow_list = + Config.get( + [:mrf_user_allowlist, actor_info.host], + [] + ) + + filter_by_list(object, allow_list) + end + + def filter(object), do: {:ok, object} + + @impl true + def describe do + mrf_user_allowlist = + Config.get([:mrf_user_allowlist], []) + |> Map.new(fn {k, v} -> {k, length(v)} end) + + {:ok, %{mrf_user_allowlist: mrf_user_allowlist}} + end + + # TODO: change way of getting settings on `lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex:18` to use `hosts` subkey + # @impl true + # def config_description do + # %{ + # key: :mrf_user_allowlist, + # related_policy: "Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy", + # description: "Accept-list of users from specified instances", + # children: [ + # %{ + # key: :hosts, + # type: :map, + # description: + # "The keys in this section are the domain names that the policy should apply to." <> + # " Each key should be assigned a list of users that should be allowed " <> + # "through by their ActivityPub ID", + # suggestions: [%{"example.org" => ["https://example.org/users/admin"]}] + # } + # ] + # } + # end +end diff --git a/lib/pleroma/web/activity_pub/mrf/utils.ex b/lib/pleroma/web/activity_pub/mrf/utils.ex new file mode 100644 index 0000000..f2dc9ee --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/utils.ex @@ -0,0 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.Utils do + @spec describe_regex_or_string(String.t() | Regex.t()) :: String.t() + def describe_regex_or_string(pattern) do + # This horror is needed to convert regex sigils to strings + if not is_binary(pattern) do + inspect(pattern) + else + pattern + end + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex new file mode 100644 index 0000000..d9deff3 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex @@ -0,0 +1,69 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.VocabularyPolicy do + @moduledoc "Filter messages which belong to certain activity vocabularies" + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + @impl true + def filter(%{"type" => "Undo", "object" => child_message} = message) do + with {:ok, _} <- filter(child_message) do + {:ok, message} + else + {:reject, _} = e -> e + end + end + + def filter(%{"type" => message_type} = message) do + with accepted_vocabulary <- Pleroma.Config.get([:mrf_vocabulary, :accept]), + rejected_vocabulary <- Pleroma.Config.get([:mrf_vocabulary, :reject]), + {_, true} <- + {:accepted, + Enum.empty?(accepted_vocabulary) || Enum.member?(accepted_vocabulary, message_type)}, + {_, false} <- + {:rejected, + length(rejected_vocabulary) > 0 && Enum.member?(rejected_vocabulary, message_type)}, + {:ok, _} <- filter(message["object"]) do + {:ok, message} + else + {:reject, _} = e -> e + {:accepted, _} -> {:reject, "[VocabularyPolicy] #{message_type} not in accept list"} + {:rejected, _} -> {:reject, "[VocabularyPolicy] #{message_type} in reject list"} + _ -> {:reject, "[VocabularyPolicy]"} + end + end + + def filter(message), do: {:ok, message} + + @impl true + def describe, + do: {:ok, %{mrf_vocabulary: Pleroma.Config.get(:mrf_vocabulary) |> Map.new()}} + + @impl true + def config_description do + %{ + key: :mrf_vocabulary, + related_policy: "Pleroma.Web.ActivityPub.MRF.VocabularyPolicy", + label: "MRF Vocabulary", + description: "Filter messages which belong to certain activity vocabularies", + children: [ + %{ + key: :accept, + type: {:list, :string}, + description: + "A list of ActivityStreams terms to accept. If empty, all supported messages are accepted.", + suggestions: ["Create", "Follow", "Mention", "Announce", "Like"] + }, + %{ + key: :reject, + type: {:list, :string}, + description: + "A list of ActivityStreams terms to reject. If empty, no messages are rejected.", + suggestions: ["Create", "Follow", "Mention", "Announce", "Like"] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex new file mode 100644 index 0000000..fad7336 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -0,0 +1,336 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidator do + @moduledoc """ + This module is responsible for validating an object (which can be an activity) + and checking if it is both well formed and also compatible with our view of + the system. + """ + + @behaviour Pleroma.Web.ActivityPub.ObjectValidator.Validating + + alias Pleroma.Activity + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Object + alias Pleroma.Object.Containment + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.AcceptRejectValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.AddRemoveValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.AudioImageVideoValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.EventValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.FollowValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.JoinValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.LeaveValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator + + @impl true + def validate(object, meta) + + def validate(%{"type" => "Block"} = block_activity, meta) do + with {:ok, block_activity} <- + block_activity + |> BlockValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + block_activity = stringify_keys(block_activity) + outgoing_blocks = Pleroma.Config.get([:activitypub, :outgoing_blocks]) + + meta = + if !outgoing_blocks do + Keyword.put(meta, :do_not_federate, true) + else + meta + end + + {:ok, block_activity, meta} + end + end + + def validate(%{"type" => "Undo"} = object, meta) do + with {:ok, object} <- + object + |> UndoValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + undone_object = Activity.get_by_ap_id(object["object"]) + + meta = + meta + |> Keyword.put(:object_data, undone_object.data) + + {:ok, object, meta} + end + end + + def validate(%{"type" => "Delete"} = object, meta) do + with cng <- DeleteValidator.cast_and_validate(object), + do_not_federate <- DeleteValidator.do_not_federate?(cng), + {:ok, object} <- Ecto.Changeset.apply_action(cng, :insert) do + object = stringify_keys(object) + meta = Keyword.put(meta, :do_not_federate, do_not_federate) + {:ok, object, meta} + end + end + + def validate( + %{"type" => "Create", "object" => %{"type" => "ChatMessage"} = object} = create_activity, + meta + ) do + with {:ok, object_data} <- cast_and_apply(object), + meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), + {:ok, create_activity} <- + create_activity + |> CreateChatMessageValidator.cast_and_validate(meta) + |> Ecto.Changeset.apply_action(:insert) do + create_activity = stringify_keys(create_activity) + {:ok, create_activity, meta} + end + end + + def validate( + %{"type" => "Create", "object" => %{"type" => objtype} = object} = create_activity, + meta + ) + when objtype in ~w[Question Answer Audio Video Image Event Article Note Page] do + with {:ok, object_data} <- cast_and_apply_and_stringify_with_history(object), + meta = Keyword.put(meta, :object_data, object_data), + {:ok, create_activity} <- + create_activity + |> CreateGenericValidator.cast_and_validate(meta) + |> Ecto.Changeset.apply_action(:insert) do + create_activity = stringify_keys(create_activity) + {:ok, create_activity, meta} + end + end + + def validate(%{"type" => type} = object, meta) + when type in ~w[Event Question Audio Video Image Article Note Page] do + validator = + case type do + "Event" -> EventValidator + "Question" -> QuestionValidator + "Audio" -> AudioImageVideoValidator + "Video" -> AudioImageVideoValidator + "Image" -> AudioImageVideoValidator + "Article" -> ArticleNotePageValidator + "Note" -> ArticleNotePageValidator + "Page" -> ArticleNotePageValidator + end + + with {:ok, object} <- + do_separate_with_history(object, fn object -> + with {:ok, object} <- + object + |> validator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + + # Insert copy of hashtags as strings for the non-hashtag table indexing + tag = (object["tag"] || []) ++ Object.hashtags(%Object{data: object}) + object = Map.put(object, "tag", tag) + + {:ok, object} + end + end) do + {:ok, object, meta} + end + end + + def validate( + %{"type" => "Update", "object" => %{"type" => objtype} = object} = update_activity, + meta + ) + when objtype in ~w[Question Answer Audio Video Event Article Note Page] do + with {_, false} <- {:local, Access.get(meta, :local, false)}, + {_, {:ok, object_data, _}} <- {:object_validation, validate(object, meta)}, + meta = Keyword.put(meta, :object_data, object_data), + {:ok, update_activity} <- + update_activity + |> UpdateValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + update_activity = stringify_keys(update_activity) + {:ok, update_activity, meta} + else + {:local, _} -> + with {:ok, object} <- + update_activity + |> UpdateValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + + {:object_validation, e} -> + e + end + end + + def validate(%{"type" => type} = object, meta) + when type in ~w[Accept Reject Follow Update Like EmojiReact Announce + ChatMessage Answer Join Leave] do + validator = + case type do + "Accept" -> AcceptRejectValidator + "Reject" -> AcceptRejectValidator + "Follow" -> FollowValidator + "Update" -> UpdateValidator + "Like" -> LikeValidator + "EmojiReact" -> EmojiReactValidator + "Announce" -> AnnounceValidator + "ChatMessage" -> ChatMessageValidator + "Answer" -> AnswerValidator + "Join" -> JoinValidator + "Leave" -> LeaveValidator + end + + with {:ok, object} <- + object + |> validator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + end + + def validate(%{"type" => type} = object, meta) when type in ~w(Add Remove) do + with {:ok, object} <- + object + |> AddRemoveValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + end + + def validate(o, m), do: {:error, {:validator_not_set, {o, m}}} + + def cast_and_apply_and_stringify_with_history(object) do + do_separate_with_history(object, fn object -> + with {:ok, object_data} <- cast_and_apply(object), + object_data <- object_data |> stringify_keys() do + {:ok, object_data} + end + end) + end + + def cast_and_apply(%{"type" => "ChatMessage"} = object) do + ChatMessageValidator.cast_and_apply(object) + end + + def cast_and_apply(%{"type" => "Question"} = object) do + QuestionValidator.cast_and_apply(object) + end + + def cast_and_apply(%{"type" => "Answer"} = object) do + AnswerValidator.cast_and_apply(object) + end + + def cast_and_apply(%{"type" => type} = object) when type in ~w[Audio Image Video] do + AudioImageVideoValidator.cast_and_apply(object) + end + + def cast_and_apply(%{"type" => "Event"} = object) do + EventValidator.cast_and_apply(object) + end + + def cast_and_apply(%{"type" => type} = object) when type in ~w[Article Note Page] do + ArticleNotePageValidator.cast_and_apply(object) + end + + def cast_and_apply(o), do: {:error, {:validator_not_set, o}} + + def stringify_keys(object) when is_struct(object) do + object + |> Map.from_struct() + |> stringify_keys + end + + def stringify_keys(object) when is_map(object) do + object + |> Enum.filter(fn {_, v} -> v != nil end) + |> Map.new(fn {key, val} -> {to_string(key), stringify_keys(val)} end) + end + + def stringify_keys(object) when is_list(object) do + object + |> Enum.map(&stringify_keys/1) + end + + def stringify_keys(object), do: object + + def fetch_actor(object) do + with actor <- Containment.get_actor(object), + {:ok, actor} <- ObjectValidators.ObjectID.cast(actor) do + User.get_or_fetch_by_ap_id(actor) + end + end + + def fetch_actor_and_object(object) do + fetch_actor(object) + Object.normalize(object["object"], fetch: true) + :ok + end + + defp for_each_history_item( + %{"type" => "OrderedCollection", "orderedItems" => items} = history, + object, + fun + ) do + processed_items = + Enum.map(items, fn item -> + with item <- Map.put(item, "id", object["id"]), + {:ok, item} <- fun.(item) do + item + else + _ -> nil + end + end) + + if Enum.all?(processed_items, &(not is_nil(&1))) do + {:ok, Map.put(history, "orderedItems", processed_items)} + else + {:error, :invalid_history} + end + end + + defp for_each_history_item(nil, _object, _fun) do + {:ok, nil} + end + + defp for_each_history_item(_, _object, _fun) do + {:error, :invalid_history} + end + + # fun is (object -> {:ok, validated_object_with_string_keys}) + defp do_separate_with_history(object, fun) do + with history <- object["formerRepresentations"], + object <- Map.drop(object, ["formerRepresentations"]), + {_, {:ok, object}} <- {:main_body, fun.(object)}, + {_, {:ok, history}} <- {:history_items, for_each_history_item(history, object, fun)} do + object = + if history do + Map.put(object, "formerRepresentations", history) + else + object + end + + {:ok, object} + else + {:main_body, e} -> e + {:history_items, e} -> e + end + end +end diff --git a/lib/pleroma/web/activity_pub/object_validator/validating.ex b/lib/pleroma/web/activity_pub/object_validator/validating.ex new file mode 100644 index 0000000..b695946 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validator/validating.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidator.Validating do + @callback validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} +end diff --git a/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex b/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex new file mode 100644 index 0000000..3b6d143 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex @@ -0,0 +1,66 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AcceptRejectValidator do + use Ecto.Schema + + alias Pleroma.Activity + alias Pleroma.Object + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + activity_fields() + end + end + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + defp validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Accept", "Reject"]) + |> validate_actor_presence() + |> validate_object_presence(allowed_types: ["Follow", "Join"]) + |> validate_accept_reject_rights() + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end + + def validate_accept_reject_rights(cng) do + with object_id when is_binary(object_id) <- get_field(cng, :object), + %Activity{} = activity <- Activity.get_by_ap_id(object_id), + true <- validate_actor(activity, get_field(cng, :actor)) do + cng + else + _e -> + cng + |> add_error(:actor, "can't accept or reject the given activity") + end + end + + defp validate_actor(%Activity{data: %{"type" => "Follow", "object" => followed_actor}}, actor) do + followed_actor == actor + end + + defp validate_actor(%Activity{data: %{"type" => "Join", "object" => joined_event}}, actor) do + %Object{data: %{"actor" => event_author}} = Object.get_cached_by_ap_id(joined_event) + event_author == actor + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex new file mode 100644 index 0000000..db32595 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex @@ -0,0 +1,79 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AddRemoveValidator do + use Ecto.Schema + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + require Pleroma.Constants + + alias Pleroma.User + + @primary_key false + + embedded_schema do + field(:target) + + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + activity_fields() + end + end + end + + def cast_and_validate(data) do + {:ok, actor} = User.get_or_fetch_by_ap_id(data["actor"]) + + {:ok, actor} = maybe_refetch_user(actor) + + data + |> maybe_fix_data_for_mastodon(actor) + |> cast_data() + |> validate_data(actor) + end + + defp maybe_fix_data_for_mastodon(data, actor) do + # Mastodon sends pin/unpin objects without id, to, cc fields + data + |> Map.put_new("id", Pleroma.Web.ActivityPub.Utils.generate_activity_id()) + |> Map.put_new("to", [Pleroma.Constants.as_public()]) + |> Map.put_new("cc", [actor.follower_address]) + end + + defp cast_data(data) do + cast(%__MODULE__{}, data, __schema__(:fields)) + end + + defp validate_data(changeset, actor) do + changeset + |> validate_required([:id, :target, :object, :actor, :type, :to, :cc]) + |> validate_inclusion(:type, ~w(Add Remove)) + |> validate_actor_presence() + |> validate_collection_belongs_to_actor(actor) + |> validate_object_presence() + end + + defp validate_collection_belongs_to_actor(changeset, actor) do + validate_change(changeset, :target, fn :target, target -> + if target == actor.featured_address do + [] + else + [target: "collection doesn't belong to actor"] + end + end) + end + + defp maybe_refetch_user(%User{featured_address: address} = user) when is_binary(address) do + {:ok, user} + end + + defp maybe_refetch_user(%User{ap_id: ap_id}) do + # Maybe it could use User.get_or_fetch_by_ap_id to avoid refreshing too often + User.fetch_by_ap_id(ap_id) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex new file mode 100644 index 0000000..c2c7ba1 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -0,0 +1,123 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + require Pleroma.Constants + + @primary_key false + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + activity_fields() + end + end + + field(:context, :string) + field(:published, ObjectValidators.DateTime) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + data = + data + |> fix() + + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + end + + defp fix(data) do + data = + data + |> CommonFixes.fix_actor() + |> CommonFixes.fix_activity_addressing() + + with %Object{} = object <- Object.normalize(data["object"]) do + data + |> CommonFixes.fix_activity_context(object) + |> CommonFixes.fix_object_action_recipients(object) + else + _ -> data + end + end + + defp validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Announce"]) + |> validate_required([:id, :type, :object, :actor, :to, :cc]) + |> validate_actor_presence() + |> validate_object_presence() + |> validate_existing_announce() + |> validate_announcable() + end + + defp validate_announcable(cng) do + with actor when is_binary(actor) <- get_field(cng, :actor), + object when is_binary(object) <- get_field(cng, :object), + %User{} = actor <- User.get_cached_by_ap_id(actor), + %Object{} = object <- Object.get_cached_by_ap_id(object), + false <- Visibility.is_public?(object) do + same_actor = object.data["actor"] == actor.ap_id + recipients = get_field(cng, :to) ++ get_field(cng, :cc) + local_public = Utils.as_local_public() + + is_public = + Enum.member?(recipients, Pleroma.Constants.as_public()) or + Enum.member?(recipients, local_public) + + cond do + same_actor && is_public -> + cng + |> add_error(:actor, "can not announce this object publicly") + + !same_actor -> + cng + |> add_error(:actor, "can not announce this object") + + true -> + cng + end + else + _ -> cng + end + end + + defp validate_existing_announce(cng) do + actor = get_field(cng, :actor) + object = get_field(cng, :object) + + if actor && object && Utils.get_existing_announce(actor, %{data: %{"id" => object}}) do + cng + |> add_error(:actor, "already announced this object") + |> add_error(:object, "already announced by this actor") + else + cng + end + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex new file mode 100644 index 0000000..2d9b8ba --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex @@ -0,0 +1,70 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + import Ecto.Changeset + + @primary_key false + @derive Jason.Encoder + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + end + end + + field(:name, :string) + field(:inReplyTo, ObjectValidators.ObjectID) + field(:attributedTo, ObjectValidators.ObjectID) + field(:context, :string) + + # TODO: Remove actor on objects + field(:actor, ObjectValidators.ObjectID) + end + + def cast_and_apply(data) do + data + |> cast_data() + |> apply_action(:insert) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + data = + data + |> CommonFixes.fix_actor() + |> CommonFixes.fix_object_defaults() + + struct + |> cast(data, __schema__(:fields)) + end + + defp validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Answer"]) + |> validate_required([:id, :inReplyTo, :name, :attributedTo, :actor]) + |> CommonValidations.validate_any_presence([:cc, :to]) + |> CommonValidations.validate_fields_match([:actor, :attributedTo]) + |> CommonValidations.validate_actor_presence() + |> CommonValidations.validate_host_match() + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex new file mode 100644 index 0000000..1569fdd --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex @@ -0,0 +1,133 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + alias Pleroma.Web.ActivityPub.Transmogrifier + + import Ecto.Changeset + + @primary_key false + @derive Jason.Encoder + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + object_fields() + status_object_fields() + end + end + + field(:replies, {:array, ObjectValidators.ObjectID}, default: []) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + defp fix_url(%{"url" => url} = data) when is_bitstring(url), do: data + defp fix_url(%{"url" => url} = data) when is_map(url), do: Map.put(data, "url", url["href"]) + defp fix_url(data), do: data + + defp fix_tag(%{"tag" => tag} = data) when is_list(tag) do + Map.put(data, "tag", Enum.filter(tag, &is_map/1)) + end + + defp fix_tag(%{"tag" => tag} = data) when is_map(tag), do: Map.put(data, "tag", [tag]) + defp fix_tag(data), do: Map.drop(data, ["tag"]) + + defp fix_replies(%{"replies" => %{"first" => %{"items" => replies}}} = data) + when is_list(replies), + do: Map.put(data, "replies", replies) + + defp fix_replies(%{"replies" => %{"items" => replies}} = data) when is_list(replies), + do: Map.put(data, "replies", replies) + + # TODO: Pleroma does not have any support for Collections at the moment. + # If the `replies` field is not something the ObjectID validator can handle, + # the activity/object would be rejected, which is bad behavior. + defp fix_replies(%{"replies" => replies} = data) when not is_list(replies), + do: Map.drop(data, ["replies"]) + + defp fix_replies(data), do: data + + defp fix_quote_url(%{"quoteUrl" => _quote_url} = data), do: data + + # Fedibird + # https://github.com/fedibird/mastodon/commit/dbd7ae6cf58a92ec67c512296b4daaea0d01e6ac + defp fix_quote_url(%{"quoteUri" => quote_url} = data) do + Map.put(data, "quoteUrl", quote_url) + end + + # Old Fedibird (bug) + # https://github.com/fedibird/mastodon/issues/9 + defp fix_quote_url(%{"quoteURL" => quote_url} = data) do + Map.put(data, "quoteUrl", quote_url) + end + + # Misskey fallback + defp fix_quote_url(%{"_misskey_quote" => quote_url} = data) do + Map.put(data, "quoteUrl", quote_url) + end + + defp fix_quote_url(data), do: data + + def fix_attachments(%{"attachment" => attachment} = data) when is_map(attachment), + do: Map.put(data, "attachment", [attachment]) + + def fix_attachments(data), do: data + + defp fix(data) do + data + |> CommonFixes.fix_actor() + |> CommonFixes.fix_object_defaults() + |> fix_url() + |> fix_tag() + |> fix_replies() + |> fix_quote_url() + |> fix_attachments() + |> CommonFixes.fix_quote_url() + |> Transmogrifier.fix_emoji() + |> Transmogrifier.fix_content_map() + |> Transmogrifier.maybe_add_language() + end + + def changeset(struct, data) do + data = fix(data) + + struct + |> cast(data, __schema__(:fields) -- [:attachment, :tag]) + |> cast_embed(:attachment) + |> cast_embed(:tag) + end + + defp validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Article", "Note", "Page"]) + |> validate_required([:id, :actor, :attributedTo, :type, :context]) + |> CommonValidations.validate_any_presence([:cc, :to]) + |> CommonValidations.validate_fields_match([:actor, :attributedTo]) + |> CommonValidations.validate_actor_presence() + |> CommonValidations.validate_host_match() + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex new file mode 100644 index 0000000..6d21db4 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -0,0 +1,94 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:id, :string) + field(:type, :string) + field(:mediaType, ObjectValidators.MIME, default: "application/octet-stream") + field(:name, :string) + field(:blurhash, :string) + + embeds_many :url, UrlObjectValidator, primary_key: false do + field(:type, :string) + field(:href, ObjectValidators.Uri) + field(:mediaType, ObjectValidators.MIME, default: "application/octet-stream") + field(:width, :integer) + field(:height, :integer) + end + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + data = + data + |> fix_media_type() + |> fix_url() + + struct + |> cast(data, [:id, :type, :mediaType, :name, :blurhash]) + |> cast_embed(:url, with: &url_changeset/2, required: true) + end + + def url_changeset(struct, data) do + data = fix_media_type(data) + + struct + |> cast(data, [:type, :href, :mediaType, :width, :height]) + |> validate_inclusion(:type, ["Link"]) + |> validate_required([:type, :href, :mediaType]) + end + + def fix_media_type(data) do + Map.put_new(data, "mediaType", data["mimeType"] || "application/octet-stream") + end + + defp handle_href(href, mediaType, data) do + [ + %{ + "href" => href, + "type" => "Link", + "mediaType" => mediaType, + "width" => data["width"], + "height" => data["height"] + } + ] + end + + defp fix_url(data) do + cond do + is_binary(data["url"]) -> + Map.put(data, "url", handle_href(data["url"], data["mediaType"], data)) + + is_binary(data["href"]) and data["url"] == nil -> + Map.put(data, "url", handle_href(data["href"], data["mediaType"], data)) + + true -> + data + end + end + + defp validate_data(cng) do + cng + |> validate_inclusion(:type, ~w[Link Document Audio Image Video]) + |> validate_required([:mediaType, :type]) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/audio_image_video_validator.ex b/lib/pleroma/web/activity_pub/object_validators/audio_image_video_validator.ex new file mode 100644 index 0000000..65ac6bb --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/audio_image_video_validator.ex @@ -0,0 +1,126 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AudioImageVideoValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + alias Pleroma.Web.ActivityPub.Transmogrifier + + import Ecto.Changeset + + @primary_key false + @derive Jason.Encoder + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + object_fields() + status_object_fields() + end + end + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + defp find_attachment(url) do + mpeg_url = + Enum.find(url, fn + %{"mediaType" => mime_type, "tag" => tags} when is_list(tags) -> + mime_type == "application/x-mpegURL" + + _ -> + false + end) + + url + |> Enum.concat(mpeg_url["tag"] || []) + |> Enum.find(fn + %{"mediaType" => mime_type} -> + String.starts_with?(mime_type, ["video/", "audio/", "image/"]) + + %{"mimeType" => mime_type} -> + String.starts_with?(mime_type, ["video/", "audio/", "image/"]) + + _ -> + false + end) + end + + defp fix_url(%{"url" => url} = data) when is_list(url) do + attachment = find_attachment(url) + + link_element = + Enum.find(url, fn + %{"mediaType" => "text/html"} -> true + %{"mimeType" => "text/html"} -> true + _ -> false + end) + + data + |> Map.put("attachment", [attachment]) + |> Map.put("url", link_element["href"]) + end + + defp fix_url(data), do: data + + defp fix_content(%{"mediaType" => "text/markdown", "content" => content} = data) + when is_binary(content) do + content = + content + |> Pleroma.Formatter.markdown_to_html() + |> Pleroma.HTML.filter_tags() + + Map.put(data, "content", content) + end + + defp fix_content(data), do: data + + defp fix(data) do + data + |> CommonFixes.fix_actor() + |> CommonFixes.fix_object_defaults() + |> CommonFixes.fix_quote_url() + |> Transmogrifier.fix_emoji() + |> fix_url() + |> fix_content() + end + + def changeset(struct, data) do + data = fix(data) + + struct + |> cast(data, __schema__(:fields) -- [:attachment, :tag]) + |> cast_embed(:attachment, required: true) + |> cast_embed(:tag) + end + + defp validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ~w[Audio Image Video]) + |> validate_required([:id, :actor, :attributedTo, :type, :context]) + |> CommonValidations.validate_any_presence([:cc, :to]) + |> CommonValidations.validate_fields_match([:actor, :attributedTo]) + |> CommonValidations.validate_actor_presence() + |> CommonValidations.validate_host_match() + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex new file mode 100644 index 0000000..0de87a2 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex @@ -0,0 +1,43 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator do + use Ecto.Schema + + alias Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + import Ecto.Changeset + + @primary_key false + @derive Jason.Encoder + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + activity_fields() + end + end + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + defp validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Block"]) + |> CommonValidations.validate_actor_presence() + |> CommonValidations.validate_actor_presence(field_name: :object) + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex new file mode 100644 index 0000000..efae48c --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -0,0 +1,129 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.Transmogrifier, only: [fix_emoji: 1] + + @primary_key false + @derive Jason.Encoder + + embedded_schema do + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:to, ObjectValidators.Recipients, default: []) + field(:type, :string) + field(:content, ObjectValidators.SafeText) + field(:actor, ObjectValidators.ObjectID) + field(:published, ObjectValidators.DateTime) + field(:emoji, ObjectValidators.Emoji, default: %{}) + + embeds_one(:attachment, AttachmentValidator) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def fix(data) do + data + |> fix_emoji() + |> fix_attachment() + |> Map.put_new("actor", data["attributedTo"]) + end + + # Throws everything but the first one away + def fix_attachment(%{"attachment" => [attachment | _]} = data) do + data + |> Map.put("attachment", attachment) + end + + def fix_attachment(data), do: data + + def changeset(struct, data) do + data = fix(data) + + struct + |> cast(data, List.delete(__schema__(:fields), :attachment)) + |> cast_embed(:attachment) + end + + defp validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["ChatMessage"]) + |> validate_required([:id, :actor, :to, :type, :published]) + |> validate_content_or_attachment() + |> validate_length(:to, is: 1) + |> validate_length(:content, max: Pleroma.Config.get([:instance, :remote_limit])) + |> validate_local_concern() + end + + def validate_content_or_attachment(cng) do + attachment = get_field(cng, :attachment) + + if attachment do + cng + else + cng + |> validate_required([:content]) + end + end + + @doc """ + Validates the following + - If both users are in our system + - If at least one of the users in this ChatMessage is a local user + - If the recipient is not blocking the actor + - If the recipient is explicitly not accepting chat messages + """ + def validate_local_concern(cng) do + with actor_ap <- get_field(cng, :actor), + {_, %User{} = actor} <- {:find_actor, User.get_cached_by_ap_id(actor_ap)}, + {_, %User{} = recipient} <- + {:find_recipient, User.get_cached_by_ap_id(get_field(cng, :to) |> hd())}, + {_, false} <- {:not_accepting_chats?, recipient.accepts_chat_messages == false}, + {_, false} <- {:blocking_actor?, User.blocks?(recipient, actor)}, + {_, true} <- {:local?, Enum.any?([actor, recipient], & &1.local)} do + cng + else + {:blocking_actor?, true} -> + cng + |> add_error(:actor, "actor is blocked by recipient") + + {:not_accepting_chats?, true} -> + cng + |> add_error(:to, "recipient does not accept chat messages") + + {:local?, false} -> + cng + |> add_error(:actor, "actor and recipient are both remote") + + {:find_actor, _} -> + cng + |> add_error(:actor, "can't find user") + + {:find_recipient, _} -> + cng + |> add_error(:to, "can't find user") + end + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fields.ex b/lib/pleroma/web/activity_pub/object_validators/common_fields.ex new file mode 100644 index 0000000..2aa0678 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/common_fields.ex @@ -0,0 +1,86 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFields do + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.PlaceValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.TagValidator + + # Activities and Objects, except (Create)ChatMessage + defmacro message_fields do + quote bind_quoted: binding() do + field(:type, :string) + field(:id, ObjectValidators.ObjectID, primary_key: true) + + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:bto, ObjectValidators.Recipients, default: []) + field(:bcc, ObjectValidators.Recipients, default: []) + end + end + + defmacro activity_fields do + quote bind_quoted: binding() do + field(:object, ObjectValidators.ObjectID) + field(:actor, ObjectValidators.ObjectID) + end + end + + # All objects except Answer and ChatMessage + defmacro object_fields do + quote bind_quoted: binding() do + field(:content, :string) + + field(:published, ObjectValidators.DateTime) + field(:updated, ObjectValidators.DateTime) + field(:emoji, ObjectValidators.Emoji, default: %{}) + embeds_many(:attachment, AttachmentValidator) + end + end + + # Basically objects that aren't ChatMessage and Answer + defmacro status_object_fields do + quote bind_quoted: binding() do + # TODO: Remove actor on objects + field(:actor, ObjectValidators.ObjectID) + field(:attributedTo, ObjectValidators.ObjectID) + + embeds_many(:tag, TagValidator) + + field(:name, :string) + field(:summary, :string) + + field(:context, :string) + + field(:sensitive, :boolean, default: false) + field(:replies_count, :integer, default: 0) + field(:like_count, :integer, default: 0) + field(:announcement_count, :integer, default: 0) + field(:quotes_count, :integer, default: 0) + field(:language, :string) + field(:inReplyTo, ObjectValidators.ObjectID) + field(:quoteUrl, ObjectValidators.ObjectID) + field(:url, ObjectValidators.BareUri) + + field(:likes, {:array, ObjectValidators.ObjectID}, default: []) + field(:announcements, {:array, ObjectValidators.ObjectID}, default: []) + end + end + + defmacro event_object_fields do + quote bind_quoted: binding() do + field(:startTime, ObjectValidators.DateTime) + field(:endTime, ObjectValidators.DateTime) + + field(:joinMode, :string, default: "free") + + embeds_one(:location, PlaceValidator) + + field(:participation_count, :integer, default: 0) + field(:participations, {:array, ObjectValidators.ObjectID}, default: []) + field(:participation_request_count, :integer, default: 0) + end + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex new file mode 100644 index 0000000..4d9be0b --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex @@ -0,0 +1,125 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Object + alias Pleroma.Object.Containment + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.Utils + + require Pleroma.Constants + + def cast_and_filter_recipients(message, field, follower_collection, field_fallback \\ []) do + {:ok, data} = ObjectValidators.Recipients.cast(message[field] || field_fallback) + + data = + Enum.reject(data, fn x -> + String.ends_with?(x, "/followers") and x != follower_collection + end) + + Map.put(message, field, data) + end + + def fix_object_defaults(data) do + context = + Utils.maybe_create_context( + data["context"] || data["conversation"] || data["inReplyTo"] || data["id"] + ) + + %User{follower_address: follower_collection} = User.get_cached_by_ap_id(data["attributedTo"]) + + data + |> Map.put("context", context) + |> cast_and_filter_recipients("to", follower_collection) + |> cast_and_filter_recipients("cc", follower_collection) + |> cast_and_filter_recipients("bto", follower_collection) + |> cast_and_filter_recipients("bcc", follower_collection) + |> Transmogrifier.fix_implicit_addressing(follower_collection) + end + + def fix_activity_addressing(activity) do + %User{follower_address: follower_collection} = User.get_cached_by_ap_id(activity["actor"]) + + activity + |> cast_and_filter_recipients("to", follower_collection) + |> cast_and_filter_recipients("cc", follower_collection) + |> cast_and_filter_recipients("bto", follower_collection) + |> cast_and_filter_recipients("bcc", follower_collection) + |> Transmogrifier.fix_implicit_addressing(follower_collection) + end + + def fix_actor(data) do + actor = + data + |> Map.put_new("actor", data["attributedTo"]) + |> Containment.get_actor() + + data + |> Map.put("actor", actor) + |> Map.put("attributedTo", actor) + end + + def fix_activity_context(data, %Object{data: %{"context" => object_context}}) do + data + |> Map.put("context", object_context) + end + + def fix_object_action_recipients(%{"actor" => actor} = data, %Object{data: %{"actor" => actor}}) do + to = ((data["to"] || []) -- [actor]) |> Enum.uniq() + + Map.put(data, "to", to) + end + + def fix_object_action_recipients(data, %Object{data: %{"actor" => actor}}) do + to = ((data["to"] || []) ++ [actor]) |> Enum.uniq() + + Map.put(data, "to", to) + end + + def fix_quote_url(%{"quoteUrl" => _quote_url} = data), do: data + + # Fedibird + # https://github.com/fedibird/mastodon/commit/dbd7ae6cf58a92ec67c512296b4daaea0d01e6ac + def fix_quote_url(%{"quoteUri" => quote_url} = data) do + Map.put(data, "quoteUrl", quote_url) + end + + # Old Fedibird (bug) + # https://github.com/fedibird/mastodon/issues/9 + def fix_quote_url(%{"quoteURL" => quote_url} = data) do + Map.put(data, "quoteUrl", quote_url) + end + + # Misskey fallback + def fix_quote_url(%{"_misskey_quote" => quote_url} = data) do + Map.put(data, "quoteUrl", quote_url) + end + + def fix_quote_url(%{"tag" => [_ | _] = tags} = data) do + tag = Enum.find(tags, &is_object_link_tag/1) + + if not is_nil(tag) do + data + |> Map.put("quoteUrl", tag["href"]) + else + data + end + end + + def fix_quote_url(data), do: data + + # https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md + def is_object_link_tag(%{ + "type" => "Link", + "mediaType" => media_type, + "href" => href + }) + when media_type in Pleroma.Constants.activity_json_mime_types() and is_binary(href) do + true + end + + def is_object_link_tag(_), do: false +end diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex new file mode 100644 index 0000000..1c5b1a0 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -0,0 +1,150 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do + import Ecto.Changeset + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.User + + @spec validate_any_presence(Ecto.Changeset.t(), [atom()]) :: Ecto.Changeset.t() + def validate_any_presence(cng, fields) do + non_empty = + fields + |> Enum.map(fn field -> get_field(cng, field) end) + |> Enum.any?(fn + nil -> false + [] -> false + _ -> true + end) + + if non_empty do + cng + else + fields + |> Enum.reduce(cng, fn field, cng -> + cng + |> add_error(field, "none of #{inspect(fields)} present") + end) + end + end + + @spec validate_actor_presence(Ecto.Changeset.t(), keyword()) :: Ecto.Changeset.t() + def validate_actor_presence(cng, options \\ []) do + field_name = Keyword.get(options, :field_name, :actor) + + cng + |> validate_change(field_name, fn field_name, actor -> + case User.get_cached_by_ap_id(actor) do + %User{is_active: false} -> + [{field_name, "user is deactivated"}] + + %User{} -> + [] + + _ -> + [{field_name, "can't find user"}] + end + end) + end + + @spec validate_object_presence(Ecto.Changeset.t(), keyword()) :: Ecto.Changeset.t() + def validate_object_presence(cng, options \\ []) do + field_name = Keyword.get(options, :field_name, :object) + allowed_types = Keyword.get(options, :allowed_types, false) + + cng + |> validate_change(field_name, fn field_name, object_id -> + object = Object.get_cached_by_ap_id(object_id) || Activity.get_by_ap_id(object_id) + + cond do + !object -> + [{field_name, "can't find object"}] + + object && allowed_types && object.data["type"] not in allowed_types -> + [{field_name, "object not in allowed types"}] + + true -> + [] + end + end) + end + + @spec validate_object_or_user_presence(Ecto.Changeset.t(), keyword()) :: Ecto.Changeset.t() + def validate_object_or_user_presence(cng, options \\ []) do + field_name = Keyword.get(options, :field_name, :object) + options = Keyword.put(options, :field_name, field_name) + + actor_cng = + cng + |> validate_actor_presence(options) + + object_cng = + cng + |> validate_object_presence(options) + + if actor_cng.valid?, do: actor_cng, else: object_cng + end + + @spec validate_host_match(Ecto.Changeset.t(), [atom()]) :: Ecto.Changeset.t() + def validate_host_match(cng, fields \\ [:id, :actor]) do + if same_domain?(cng, fields) do + cng + else + fields + |> Enum.reduce(cng, fn field, cng -> + cng + |> add_error(field, "hosts of #{inspect(fields)} aren't matching") + end) + end + end + + @spec validate_fields_match(Ecto.Changeset.t(), [atom()]) :: Ecto.Changeset.t() + def validate_fields_match(cng, fields) do + if map_unique?(cng, fields) do + cng + else + fields + |> Enum.reduce(cng, fn field, cng -> + cng + |> add_error(field, "Fields #{inspect(fields)} aren't matching") + end) + end + end + + defp map_unique?(cng, fields, func \\ & &1) do + Enum.reduce_while(fields, nil, fn field, acc -> + value = + cng + |> get_field(field) + |> func.() + + case {value, acc} do + {value, nil} -> {:cont, value} + {value, value} -> {:cont, value} + _ -> {:halt, false} + end + end) + end + + @spec same_domain?(Ecto.Changeset.t(), [atom()]) :: boolean() + def same_domain?(cng, fields \\ [:actor, :object]) do + map_unique?(cng, fields, fn value -> URI.parse(value).host end) + end + + # This figures out if a user is able to create, delete or modify something + # based on the domain and superuser status + @spec validate_modification_rights(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t() + def validate_modification_rights(cng, privilege) do + actor = User.get_cached_by_ap_id(get_field(cng, :actor)) + + if User.privileged?(actor, privilege) || same_domain?(cng) do + cng + else + cng + |> add_error(:actor, "is not allowed to modify object") + end + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex new file mode 100644 index 0000000..b299647 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -0,0 +1,96 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +# NOTES +# - Can probably be a generic create validator +# - doesn't embed, will only get the object id +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator do + use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators + + alias Pleroma.Object + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + activity_fields() + end + end + + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:type, :string) + field(:to, ObjectValidators.Recipients, default: []) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_data(data) do + cast(%__MODULE__{}, data, __schema__(:fields)) + end + + def cast_and_validate(data, meta \\ []) do + cast_data(data) + |> validate_data(meta) + end + + defp validate_data(cng, meta) do + cng + |> validate_required([:id, :actor, :to, :type, :object]) + |> validate_inclusion(:type, ["Create"]) + |> validate_actor_presence() + |> validate_recipients_match(meta) + |> validate_actors_match(meta) + |> validate_object_nonexistence() + end + + def validate_object_nonexistence(cng) do + cng + |> validate_change(:object, fn :object, object_id -> + if Object.get_cached_by_ap_id(object_id) do + [{:object, "The object to create already exists"}] + else + [] + end + end) + end + + def validate_actors_match(cng, meta) do + object_actor = meta[:object_data]["actor"] + + cng + |> validate_change(:actor, fn :actor, actor -> + if actor == object_actor do + [] + else + [{:actor, "Actor doesn't match with object actor"}] + end + end) + end + + def validate_recipients_match(cng, meta) do + object_recipients = meta[:object_data]["to"] || [] + + cng + |> validate_change(:to, fn :to, recipients -> + activity_set = MapSet.new(recipients) + object_set = MapSet.new(object_recipients) + + if MapSet.equal?(activity_set, object_set) do + [] + else + [{:to, "Recipients don't match with object recipients"}] + end + end) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex new file mode 100644 index 0000000..2395abf --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex @@ -0,0 +1,161 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +# Code based on CreateChatMessageValidator +# NOTES +# - doesn't embed, will only get the object id +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + alias Pleroma.Web.ActivityPub.Transmogrifier + + import Ecto.Changeset + + @primary_key false + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + activity_fields() + end + end + + field(:expires_at, ObjectValidators.DateTime) + + # Should be moved to object, done for CommonAPI.Utils.make_context + field(:context, :string) + end + + def cast_data(data, meta \\ []) do + data = fix(data, meta) + + %__MODULE__{} + |> changeset(data) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_and_validate(data, meta \\ []) do + data + |> cast_data(meta) + |> validate_data(meta) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + end + + # CommonFixes.fix_activity_addressing adapted for Create specific behavior + defp fix_addressing(data, object) do + %User{follower_address: follower_collection} = User.get_cached_by_ap_id(data["actor"]) + + data + |> CommonFixes.cast_and_filter_recipients("to", follower_collection, object["to"]) + |> CommonFixes.cast_and_filter_recipients("cc", follower_collection, object["cc"]) + |> CommonFixes.cast_and_filter_recipients("bto", follower_collection, object["bto"]) + |> CommonFixes.cast_and_filter_recipients("bcc", follower_collection, object["bcc"]) + |> Transmogrifier.fix_implicit_addressing(follower_collection) + end + + def fix(data, meta) do + object = meta[:object_data] + + data + |> CommonFixes.fix_actor() + |> Map.put("context", object["context"]) + |> fix_addressing(object) + end + + defp validate_data(cng, meta) do + object = meta[:object_data] + + cng + |> validate_required([:actor, :type, :object, :to, :cc]) + |> validate_inclusion(:type, ["Create"]) + |> CommonValidations.validate_actor_presence() + |> validate_actors_match(object) + |> validate_context_match(object) + |> validate_addressing_match(object) + |> validate_object_nonexistence() + |> validate_object_containment() + end + + def validate_object_containment(cng) do + actor = get_field(cng, :actor) + + cng + |> validate_change(:object, fn :object, object_id -> + %URI{host: object_id_host} = URI.parse(object_id) + %URI{host: actor_host} = URI.parse(actor) + + if object_id_host == actor_host do + [] + else + [{:object, "The host of the object id doesn't match with the host of the actor"}] + end + end) + end + + def validate_object_nonexistence(cng) do + cng + |> validate_change(:object, fn :object, object_id -> + if Object.get_cached_by_ap_id(object_id) do + [{:object, "The object to create already exists"}] + else + [] + end + end) + end + + def validate_actors_match(cng, object) do + attributed_to = object["attributedTo"] || object["actor"] + + cng + |> validate_change(:actor, fn :actor, actor -> + if actor == attributed_to do + [] + else + [{:actor, "Actor doesn't match with object attributedTo"}] + end + end) + end + + def validate_context_match(cng, %{"context" => object_context}) do + cng + |> validate_change(:context, fn :context, context -> + if context == object_context do + [] + else + [{:context, "context field not matching between Create and object (#{object_context})"}] + end + end) + end + + def validate_addressing_match(cng, object) do + [:to, :cc, :bcc, :bto] + |> Enum.reduce(cng, fn field, cng -> + object_data = object[to_string(field)] + + validate_change(cng, field, fn field, data -> + if data == object_data do + [] + else + [{field, "field doesn't match with object (#{inspect(object_data)})"}] + end + end) + end) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex new file mode 100644 index 0000000..4d8502a --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -0,0 +1,87 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do + use Ecto.Schema + + alias Pleroma.Activity + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.User + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + activity_fields() + end + end + + field(:deleted_activity_id, ObjectValidators.ObjectID) + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + def add_deleted_activity_id(cng) do + object = + cng + |> get_field(:object) + + with %Activity{id: id} <- Activity.get_create_by_object_ap_id(object) do + cng + |> put_change(:deleted_activity_id, id) + else + _ -> cng + end + end + + @deletable_types ~w{ + Answer + Article + Audio + ChatMessage + Event + Note + Page + Question + Tombstone + Video + } + defp validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Delete"]) + |> validate_delete_actor(:actor) + |> validate_modification_rights(:messages_delete) + |> validate_object_or_user_presence(allowed_types: @deletable_types) + |> add_deleted_activity_id() + end + + def do_not_federate?(cng) do + !same_domain?(cng) + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end + + defp validate_delete_actor(cng, field_name) do + validate_change(cng, field_name, fn field_name, actor -> + case User.get_cached_by_ap_id(actor) do + %User{} -> [] + _ -> [{field_name, "can't find user"}] + end + end) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex new file mode 100644 index 0000000..a0b82b3 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex @@ -0,0 +1,130 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do + use Ecto.Schema + + alias Pleroma.Emoji + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + alias Pleroma.Web.ActivityPub.ObjectValidators.TagValidator + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + activity_fields() + embeds_many(:tag, TagValidator) + end + end + + field(:context, :string) + field(:content, :string) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + data = + data + |> fix() + + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields) -- [:tag]) + |> cast_embed(:tag) + end + + defp fix(data) do + data = + data + |> fix_emoji_qualification() + |> CommonFixes.fix_actor() + |> CommonFixes.fix_activity_addressing() + + data = Map.put_new(data, "tag", []) + + case Object.normalize(data["object"]) do + %Object{} = object -> + data + |> CommonFixes.fix_activity_context(object) + |> CommonFixes.fix_object_action_recipients(object) + + _ -> + data + end + end + + defp fix_emoji_qualification(%{"content" => emoji} = data) do + new_emoji = Pleroma.Emoji.fully_qualify_emoji(emoji) + + cond do + Pleroma.Emoji.is_unicode_emoji?(emoji) -> + data + + Pleroma.Emoji.is_unicode_emoji?(new_emoji) -> + data |> Map.put("content", new_emoji) + + true -> + data + end + end + + defp fix_emoji_qualification(data), do: data + + defp validate_emoji(cng) do + content = get_field(cng, :content) + + if Emoji.is_unicode_emoji?(content) || Emoji.is_custom_emoji?(content) do + cng + else + cng + |> add_error(:content, "is not a valid emoji") + end + end + + defp maybe_validate_tag_presence(cng) do + content = get_field(cng, :content) + + if Emoji.is_unicode_emoji?(content) do + cng + else + tag = get_field(cng, :tag) + emoji_name = Emoji.maybe_strip_name(content) + + case tag do + [%{name: ^emoji_name, type: "Emoji", icon: %{url: _}}] -> + cng + + _ -> + cng + |> add_error(:tag, "does not contain an Emoji tag") + end + end + end + + defp validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["EmojiReact"]) + |> validate_required([:id, :type, :object, :actor, :context, :to, :cc, :content]) + |> validate_actor_presence() + |> validate_object_presence() + |> validate_emoji() + |> maybe_validate_tag_presence() + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/event_validator.ex b/lib/pleroma/web/activity_pub/object_validators/event_validator.ex new file mode 100644 index 0000000..46cdaa0 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/event_validator.ex @@ -0,0 +1,74 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.EventValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + alias Pleroma.Web.ActivityPub.Transmogrifier + + import Ecto.Changeset + + @primary_key false + @derive Jason.Encoder + + # Extends from NoteValidator + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + object_fields() + status_object_fields() + event_object_fields() + end + end + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + defp fix(data) do + data + |> CommonFixes.fix_actor() + |> CommonFixes.fix_object_defaults() + |> Transmogrifier.fix_emoji() + end + + def changeset(struct, data) do + data = fix(data) + + struct + |> cast(data, __schema__(:fields) -- [:attachment, :tag, :location]) + |> cast_embed(:attachment) + |> cast_embed(:tag) + |> cast_embed(:location) + end + + defp validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Event"]) + |> validate_inclusion(:joinMode, ~w[free restricted invite]) + |> validate_required([:id, :actor, :attributedTo, :type, :context]) + |> CommonValidations.validate_any_presence([:cc, :to]) + |> CommonValidations.validate_fields_match([:actor, :attributedTo]) + |> CommonValidations.validate_actor_presence() + |> CommonValidations.validate_host_match() + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex b/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex new file mode 100644 index 0000000..b3ca5b6 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex @@ -0,0 +1,44 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.FollowValidator do + use Ecto.Schema + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + activity_fields() + end + end + + field(:state, :string, default: "pending") + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + defp validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Follow"]) + |> validate_inclusion(:state, ~w{pending reject accept}) + |> validate_actor_presence() + |> validate_actor_presence(field_name: :object) + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/join_validator.ex b/lib/pleroma/web/activity_pub/object_validators/join_validator.ex new file mode 100644 index 0000000..495215e --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/join_validator.ex @@ -0,0 +1,61 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.JoinValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.Utils + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + activity_fields() + end + end + + field(:state, :string, default: "pending") + + field(:participationMessage, :string) + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + defp validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Join"]) + |> validate_inclusion(:state, ~w{pending reject accept}) + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_actor_presence() + |> validate_object_presence(allowed_types: ["Event"]) + |> validate_existing_join() + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + defp validate_existing_join(%{changes: %{actor: actor, object: object}} = cng) do + if Utils.get_existing_join(actor, object) do + cng + |> add_error(:actor, "already joined this event") + |> add_error(:object, "already joined by this actor") + else + cng + end + end + + defp validate_existing_join(cng), do: cng +end diff --git a/lib/pleroma/web/activity_pub/object_validators/leave_validator.ex b/lib/pleroma/web/activity_pub/object_validators/leave_validator.ex new file mode 100644 index 0000000..efa3e20 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/leave_validator.ex @@ -0,0 +1,56 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.LeaveValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.Utils + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + activity_fields() + end + end + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + defp validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Leave"]) + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_actor_presence() + |> validate_object_presence(allowed_types: ["Event"]) + |> validate_existing_join() + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + defp validate_existing_join(%{changes: %{actor: actor, object: object}} = cng) do + if !Utils.get_existing_join(actor, object) do + cng + |> add_error(:actor, "not joined this event") + |> add_error(:object, "not joined by this actor") + else + cng + end + end + + defp validate_existing_join(cng), do: cng +end diff --git a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex new file mode 100644 index 0000000..bdc4d71 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -0,0 +1,84 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do + use Ecto.Schema + + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + alias Pleroma.Web.ActivityPub.Utils + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + activity_fields() + end + end + + field(:context, :string) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + data = + data + |> fix() + + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + end + + defp fix(data) do + data = + data + |> CommonFixes.fix_actor() + |> CommonFixes.fix_activity_addressing() + + with %Object{} = object <- Object.normalize(data["object"]) do + data + |> CommonFixes.fix_activity_context(object) + |> CommonFixes.fix_object_action_recipients(object) + else + _ -> data + end + end + + defp validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Like"]) + |> validate_required([:id, :type, :object, :actor, :context, :to, :cc]) + |> validate_actor_presence() + |> validate_object_presence() + |> validate_existing_like() + end + + defp validate_existing_like(%{changes: %{actor: actor, object: object}} = cng) do + if Utils.get_existing_like(actor, %{data: %{"id" => object}}) do + cng + |> add_error(:actor, "already liked this object") + |> add_error(:object, "already liked by this actor") + else + cng + end + end + + defp validate_existing_like(cng), do: cng +end diff --git a/lib/pleroma/web/activity_pub/object_validators/place_validator.ex b/lib/pleroma/web/activity_pub/object_validators/place_validator.ex new file mode 100644 index 0000000..77c29df --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/place_validator.ex @@ -0,0 +1,54 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.PlaceValidator do + use Ecto.Schema + + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:type, :string) + field(:name, :string) + field(:longitude, :float) + field(:latitude, :float) + field(:accuracy, :float) + field(:altitude, :float) + field(:radius, :float) + field(:units, :string) + + embeds_one :address, Address do + field(:type, :string) + field(:postalCode, :string) + field(:addressRegion, :string) + field(:streetAddress, :string) + field(:addressCountry, :string) + field(:addressLocality, :string) + end + end + + def changeset(struct, data) do + struct + |> cast(data, [:type, :name, :longitude, :latitude, :accuracy, :altitude, :radius, :units]) + |> cast_embed(:address, with: &address_changeset/2) + |> validate_inclusion(:type, ["Place"]) + |> validate_inclusion(:radius, ~w[cm feet inches km m miles]) + |> validate_number(:accuracy, greater_than_or_equal_to: 0, less_than_or_equal_to: 100) + |> validate_number(:radius, greater_than_or_equal_to: 0) + |> validate_required([:type, :name]) + end + + def address_changeset(struct, data) do + struct + |> cast(data, [ + :type, + :postalCode, + :addressRegion, + :streetAddress, + :addressCountry, + :addressLocality + ]) + |> validate_inclusion(:type, ["PostalAddress"]) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex new file mode 100644 index 0000000..541945f --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator do + use Ecto.Schema + + import Ecto.Changeset + + @primary_key false + + embedded_schema do + field(:name, :string) + + embeds_one :replies, Replies, primary_key: false do + field(:totalItems, :integer) + field(:type, :string) + end + + field(:type, :string) + end + + def changeset(struct, data) do + struct + |> cast(data, [:name, :type]) + |> cast_embed(:replies, with: &replies_changeset/2) + |> validate_inclusion(:type, ["Note"]) + |> validate_required([:name, :type]) + end + + def replies_changeset(struct, data) do + struct + |> cast(data, [:totalItems, :type]) + |> validate_inclusion(:type, ["Collection"]) + |> validate_required([:type]) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex new file mode 100644 index 0000000..b28f096 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -0,0 +1,93 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator + alias Pleroma.Web.ActivityPub.Transmogrifier + + import Ecto.Changeset + + @primary_key false + @derive Jason.Encoder + + # Extends from NoteValidator + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + object_fields() + status_object_fields() + end + end + + field(:closed, ObjectValidators.DateTime) + field(:voters, {:array, ObjectValidators.ObjectID}, default: []) + field(:votersCount, :integer) + field(:nonAnonymous, :boolean) + embeds_many(:anyOf, QuestionOptionsValidator) + embeds_many(:oneOf, QuestionOptionsValidator) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + defp fix_closed(data) do + cond do + is_binary(data["closed"]) -> data + is_binary(data["endTime"]) -> Map.put(data, "closed", data["endTime"]) + true -> Map.drop(data, ["closed"]) + end + end + + defp fix(data) do + data + |> CommonFixes.fix_actor() + |> CommonFixes.fix_object_defaults() + |> CommonFixes.fix_quote_url() + |> Transmogrifier.fix_emoji() + |> fix_closed() + end + + def changeset(struct, data) do + data = fix(data) + + struct + |> cast(data, __schema__(:fields) -- [:anyOf, :oneOf, :attachment, :tag]) + |> cast_embed(:attachment) + |> cast_embed(:anyOf) + |> cast_embed(:oneOf) + |> cast_embed(:tag) + end + + defp validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Question"]) + |> validate_required([:id, :actor, :attributedTo, :type, :context]) + |> CommonValidations.validate_any_presence([:cc, :to]) + |> CommonValidations.validate_fields_match([:actor, :attributedTo]) + |> CommonValidations.validate_actor_presence() + |> CommonValidations.validate_any_presence([:oneOf, :anyOf]) + |> CommonValidations.validate_host_match() + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/tag_validator.ex b/lib/pleroma/web/activity_pub/object_validators/tag_validator.ex new file mode 100644 index 0000000..0c3835b --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/tag_validator.ex @@ -0,0 +1,93 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + + import Ecto.Changeset + + require Pleroma.Constants + + @primary_key false + embedded_schema do + # Common + field(:type, :string) + field(:name, :string) + + # Mention, Hashtag, Link + field(:href, ObjectValidators.Uri) + + # Emoji + embeds_one :icon, IconObjectValidator, primary_key: false do + field(:type, :string) + field(:url, ObjectValidators.Uri) + end + + field(:mediaType, ObjectValidators.MIME) + field(:updated, ObjectValidators.DateTime) + field(:id, ObjectValidators.Uri) + end + + def cast_and_validate(data) do + data + |> cast_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, %{"type" => "Mention"} = data) do + struct + |> cast(data, [:type, :name, :href]) + |> validate_required([:type, :href]) + end + + def changeset(struct, %{"type" => "Hashtag", "name" => name} = data) do + name = + cond do + "#" <> name -> name + name -> name + end + |> String.downcase() + + data = Map.put(data, "name", name) + + struct + |> cast(data, [:type, :name, :href]) + |> validate_required([:type, :name]) + end + + def changeset(struct, %{"type" => "Emoji"} = data) do + data = Map.put(data, "name", String.trim(data["name"], ":")) + + struct + |> cast(data, [:type, :name, :updated, :id]) + |> cast_embed(:icon, with: &icon_changeset/2) + |> validate_required([:type, :name, :icon]) + end + + def changeset(struct, %{"type" => "Link"} = data) do + struct + |> cast(data, [:type, :name, :mediaType, :href]) + |> validate_inclusion(:mediaType, Pleroma.Constants.activity_json_mime_types()) + |> validate_required([:type, :href, :mediaType]) + end + + def changeset(struct, %{"type" => _} = data) do + struct + |> cast(data, []) + |> Map.put(:action, :ignore) + end + + def icon_changeset(struct, data) do + struct + |> cast(data, [:type, :url]) + |> validate_inclusion(:type, ~w[Image]) + |> validate_required([:type, :url]) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex new file mode 100644 index 0000000..f030514 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex @@ -0,0 +1,72 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator do + use Ecto.Schema + + alias Pleroma.Activity + alias Pleroma.User + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + activity_fields() + end + end + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + end + + defp validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Undo"]) + |> validate_required([:id, :type, :object, :actor, :to, :cc]) + |> validate_undo_actor(:actor) + |> validate_object_presence() + |> validate_undo_rights() + end + + def validate_undo_rights(cng) do + actor = get_field(cng, :actor) + object = get_field(cng, :object) + + with %Activity{data: %{"actor" => object_actor}} <- Activity.get_by_ap_id(object), + true <- object_actor != actor do + cng + |> add_error(:actor, "not the same as object actor") + else + _ -> cng + end + end + + defp validate_undo_actor(cng, field_name) do + validate_change(cng, field_name, fn field_name, actor -> + case User.get_cached_by_ap_id(actor) do + %User{} -> [] + _ -> [{field_name, "can't find user"}] + end + end) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex new file mode 100644 index 0000000..1e940a4 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + quote do + unquote do + import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields + message_fields() + end + end + + field(:actor, ObjectValidators.ObjectID) + # In this case, we save the full object in this activity instead of just a + # reference, so we can always see what was actually changed by this. + field(:object, :map) + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + defp validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Update"]) + |> validate_actor_presence() + |> validate_updating_rights() + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end + + # For now we only support updating users, and here the rule is easy: + # object id == actor id + def validate_updating_rights(cng) do + with actor = get_field(cng, :actor), + object = get_field(cng, :object), + {:ok, object_id} <- ObjectValidators.ObjectID.cast(object), + actor_uri <- URI.parse(actor), + object_uri <- URI.parse(object_id), + true <- actor_uri.host == object_uri.host do + cng + else + _e -> + cng + |> add_error(:object, "Can't be updated by this actor") + end + end +end diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex new file mode 100644 index 0000000..ca8653a --- /dev/null +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -0,0 +1,82 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Pipeline do + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.Utils + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.MRF + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.SideEffects + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.Federator + + defp side_effects, do: Config.get([:pipeline, :side_effects], SideEffects) + defp federator, do: Config.get([:pipeline, :federator], Federator) + defp object_validator, do: Config.get([:pipeline, :object_validator], ObjectValidator) + defp mrf, do: Config.get([:pipeline, :mrf], MRF) + defp activity_pub, do: Config.get([:pipeline, :activity_pub], ActivityPub) + defp config, do: Config.get([:pipeline, :config], Config) + + @spec common_pipeline(map(), keyword()) :: + {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} + def common_pipeline(object, meta) do + case Repo.transaction(fn -> do_common_pipeline(object, meta) end, Utils.query_timeout()) do + {:ok, {:ok, activity, meta}} -> + side_effects().handle_after_transaction(meta) + {:ok, activity, meta} + + {:ok, value} -> + value + + {:error, e} -> + {:error, e} + + {:reject, e} -> + {:reject, e} + end + end + + def do_common_pipeline(%{__struct__: _}, _meta), do: {:error, :is_struct} + + def do_common_pipeline(message, meta) do + with {_, {:ok, message, meta}} <- {:validate, object_validator().validate(message, meta)}, + {_, {:ok, message, meta}} <- {:mrf, mrf().pipeline_filter(message, meta)}, + {_, {:ok, message, meta}} <- {:persist, activity_pub().persist(message, meta)}, + {_, {:ok, message, meta}} <- {:side_effects, side_effects().handle(message, meta)}, + {_, {:ok, _}} <- {:federation, maybe_federate(message, meta)} do + {:ok, message, meta} + else + {:mrf, {:reject, message, _}} -> {:reject, message} + e -> {:error, e} + end + end + + defp maybe_federate(%Object{}, _), do: {:ok, :not_federated} + + defp maybe_federate(%Activity{} = activity, meta) do + with {:ok, local} <- Keyword.fetch(meta, :local) do + do_not_federate = meta[:do_not_federate] || !config().get([:instance, :federating]) + + if !do_not_federate and local and not Visibility.is_local_public?(activity) do + activity = + if object = Keyword.get(meta, :object_data) do + %{activity | data: Map.put(activity.data, "object", object)} + else + activity + end + + federator().publish(activity) + {:ok, :federated} + else + {:ok, :not_federated} + end + else + _e -> {:error, :badarg} + end + end +end diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex new file mode 100644 index 0000000..af6aa07 --- /dev/null +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -0,0 +1,279 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Publisher do + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.Delivery + alias Pleroma.HTTP + alias Pleroma.Instances + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Relay + alias Pleroma.Web.ActivityPub.Transmogrifier + + require Pleroma.Constants + + import Pleroma.Web.ActivityPub.Visibility + + @behaviour Pleroma.Web.Federator.Publisher + + require Logger + + @moduledoc """ + ActivityPub outgoing federation module. + """ + + @doc """ + Determine if an activity can be represented by running it through Transmogrifier. + """ + def is_representable?(%Activity{} = activity) do + with {:ok, _data} <- Transmogrifier.prepare_outgoing(activity.data) do + true + else + _e -> + false + end + end + + @doc """ + Publish a single message to a peer. Takes a struct with the following + parameters set: + + * `inbox`: the inbox to publish to + * `json`: the JSON message body representing the ActivityPub message + * `actor`: the actor which is signing the message + * `id`: the ActivityStreams URI of the message + """ + def publish_one(%{inbox: inbox, json: json, actor: %User{} = actor, id: id} = params) do + Logger.debug("Federating #{id} to #{inbox}") + uri = %{path: path} = URI.parse(inbox) + digest = "SHA-256=" <> (:crypto.hash(:sha256, json) |> Base.encode64()) + + date = Pleroma.Signature.signed_date() + + signature = + Pleroma.Signature.sign(actor, %{ + "(request-target)": "post #{path}", + host: signature_host(uri), + "content-length": byte_size(json), + digest: digest, + date: date + }) + + with {:ok, %{status: code}} = result when code in 200..299 <- + HTTP.post( + inbox, + json, + [ + {"Content-Type", "application/activity+json"}, + {"Date", date}, + {"signature", signature}, + {"digest", digest} + ] + ) do + if not Map.has_key?(params, :unreachable_since) || params[:unreachable_since] do + Instances.set_reachable(inbox) + end + + result + else + {_post_result, response} -> + unless params[:unreachable_since], do: Instances.set_unreachable(inbox) + {:error, response} + end + end + + def publish_one(%{actor_id: actor_id} = params) do + actor = User.get_cached_by_id(actor_id) + + params + |> Map.delete(:actor_id) + |> Map.put(:actor, actor) + |> publish_one() + end + + defp signature_host(%URI{port: port, scheme: scheme, host: host}) do + if port == URI.default_port(scheme) do + host + else + "#{host}:#{port}" + end + end + + defp should_federate?(inbox, public) do + if public do + true + else + %{host: host} = URI.parse(inbox) + + quarantined_instances = + Config.get([:instance, :quarantined_instances], []) + |> Pleroma.Web.ActivityPub.MRF.instance_list_from_tuples() + |> Pleroma.Web.ActivityPub.MRF.subdomains_regex() + + !Pleroma.Web.ActivityPub.MRF.subdomain_match?(quarantined_instances, host) + end + end + + @spec recipients(User.t(), Activity.t()) :: list(User.t()) | [] + defp recipients(actor, activity) do + followers = + if actor.follower_address in activity.recipients do + User.get_external_followers(actor) + else + [] + end + + fetchers = + with %Activity{data: %{"type" => "Delete"}} <- activity, + %Object{id: object_id} <- Object.normalize(activity, fetch: false), + fetchers <- User.get_delivered_users_by_object_id(object_id), + _ <- Delivery.delete_all_by_object_id(object_id) do + fetchers + else + _ -> + [] + end + + Pleroma.Web.Federator.Publisher.remote_users(actor, activity) ++ followers ++ fetchers + end + + defp get_cc_ap_ids(ap_id, recipients) do + host = Map.get(URI.parse(ap_id), :host) + + recipients + |> Enum.filter(fn %User{ap_id: ap_id} -> Map.get(URI.parse(ap_id), :host) == host end) + |> Enum.map(& &1.ap_id) + end + + defp maybe_use_sharedinbox(%User{shared_inbox: nil, inbox: inbox}), do: inbox + defp maybe_use_sharedinbox(%User{shared_inbox: shared_inbox}), do: shared_inbox + + @doc """ + Determine a user inbox to use based on heuristics. These heuristics + are based on an approximation of the ``sharedInbox`` rules in the + [ActivityPub specification][ap-sharedinbox]. + + Please do not edit this function (or its children) without reading + the spec, as editing the code is likely to introduce some breakage + without some familiarity. + + [ap-sharedinbox]: https://www.w3.org/TR/activitypub/#shared-inbox-delivery + """ + def determine_inbox( + %Activity{data: activity_data}, + %User{inbox: inbox} = user + ) do + to = activity_data["to"] || [] + cc = activity_data["cc"] || [] + type = activity_data["type"] + + cond do + type == "Delete" -> + maybe_use_sharedinbox(user) + + Pleroma.Constants.as_public() in to || Pleroma.Constants.as_public() in cc -> + maybe_use_sharedinbox(user) + + length(to) + length(cc) > 1 -> + maybe_use_sharedinbox(user) + + true -> + inbox + end + end + + @doc """ + Publishes an activity with BCC to all relevant peers. + """ + + def publish(%User{} = actor, %{data: %{"bcc" => bcc}} = activity) + when is_list(bcc) and bcc != [] do + public = is_public?(activity) + {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) + + recipients = recipients(actor, activity) + + inboxes = + recipients + |> Enum.map(fn actor -> actor.inbox end) + |> Enum.filter(fn inbox -> should_federate?(inbox, public) end) + |> Instances.filter_reachable() + + Repo.checkout(fn -> + Enum.each(inboxes, fn {inbox, unreachable_since} -> + %User{ap_id: ap_id} = Enum.find(recipients, fn actor -> actor.inbox == inbox end) + + # Get all the recipients on the same host and add them to cc. Otherwise, a remote + # instance would only accept a first message for the first recipient and ignore the rest. + cc = get_cc_ap_ids(ap_id, recipients) + + json = + data + |> Map.put("cc", cc) + |> Jason.encode!() + + Pleroma.Web.Federator.Publisher.enqueue_one(__MODULE__, %{ + inbox: inbox, + json: json, + actor_id: actor.id, + id: activity.data["id"], + unreachable_since: unreachable_since + }) + end) + end) + end + + # Publishes an activity to all relevant peers. + def publish(%User{} = actor, %Activity{} = activity) do + public = is_public?(activity) + + if public && Config.get([:instance, :allow_relay]) do + Logger.debug(fn -> "Relaying #{activity.data["id"]} out" end) + Relay.publish(activity) + end + + {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) + json = Jason.encode!(data) + + recipients(actor, activity) + |> Enum.map(fn %User{} = user -> + determine_inbox(activity, user) + end) + |> Enum.uniq() + |> Enum.filter(fn inbox -> should_federate?(inbox, public) end) + |> Instances.filter_reachable() + |> Enum.each(fn {inbox, unreachable_since} -> + Pleroma.Web.Federator.Publisher.enqueue_one( + __MODULE__, + %{ + inbox: inbox, + json: json, + actor_id: actor.id, + id: activity.data["id"], + unreachable_since: unreachable_since + } + ) + end) + end + + def gather_webfinger_links(%User{} = user) do + [ + %{"rel" => "self", "type" => "application/activity+json", "href" => user.ap_id}, + %{ + "rel" => "self", + "type" => "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", + "href" => user.ap_id + }, + %{ + "rel" => "http://ostatus.org/schema/1.0/subscribe", + "template" => "#{Pleroma.Web.Endpoint.url()}/ostatus_subscribe?acct={uri}" + } + ] + end + + def gather_nodeinfo_protocol_names, do: ["activitypub"] +end diff --git a/lib/pleroma/web/activity_pub/relay.ex b/lib/pleroma/web/activity_pub/relay.ex new file mode 100644 index 0000000..2010351 --- /dev/null +++ b/lib/pleroma/web/activity_pub/relay.ex @@ -0,0 +1,108 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Relay do + alias Pleroma.Activity + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI + require Logger + + @nickname "relay" + + @spec ap_id() :: String.t() + def ap_id, do: "#{Pleroma.Web.Endpoint.url()}/#{@nickname}" + + @spec get_actor() :: User.t() | nil + def get_actor, do: User.get_or_create_service_actor_by_ap_id(ap_id(), @nickname) + + @spec follow(String.t()) :: {:ok, Activity.t()} | {:error, any()} + def follow(target_instance) do + with %User{} = local_user <- get_actor(), + {:ok, %User{} = target_user} <- User.get_or_fetch_by_ap_id(target_instance), + {:ok, _, _, activity} <- CommonAPI.follow(local_user, target_user) do + Logger.info("relay: followed instance: #{target_instance}; id=#{activity.data["id"]}") + {:ok, activity} + else + error -> format_error(error) + end + end + + @spec unfollow(String.t(), map()) :: {:ok, Activity.t()} | {:error, any()} + def unfollow(target_instance, opts \\ %{}) do + with %User{} = local_user <- get_actor(), + {:ok, target_user} <- fetch_target_user(target_instance, opts), + {:ok, activity} <- ActivityPub.unfollow(local_user, target_user) do + case target_user.id do + nil -> User.update_following_count(local_user) + _ -> User.unfollow(local_user, target_user) + end + + Logger.info("relay: unfollowed instance: #{target_instance}: id=#{activity.data["id"]}") + {:ok, activity} + else + error -> format_error(error) + end + end + + defp fetch_target_user(ap_id, opts) do + case {opts[:force], User.get_or_fetch_by_ap_id(ap_id)} do + {_, {:ok, %User{} = user}} -> {:ok, user} + {true, _} -> {:ok, %User{ap_id: ap_id}} + {_, error} -> error + end + end + + @spec publish(any()) :: {:ok, Activity.t()} | {:error, any()} + def publish(%Activity{data: %{"type" => "Create"}} = activity) do + with %User{} = user <- get_actor(), + true <- Visibility.is_public?(activity) do + CommonAPI.repeat(activity.id, user) + else + error -> format_error(error) + end + end + + def publish(_), do: {:error, "Not implemented"} + + @spec list() :: {:ok, [%{actor: String.t(), followed_back: boolean()}]} | {:error, any()} + def list do + with %User{} = user <- get_actor() do + accepted = + user + |> following() + |> Enum.map(fn actor -> %{actor: actor, followed_back: true} end) + + without_accept = + user + |> Pleroma.Activity.following_requests_for_actor() + |> Enum.map(fn activity -> %{actor: activity.data["object"], followed_back: false} end) + |> Enum.uniq() + + {:ok, accepted ++ without_accept} + else + error -> format_error(error) + end + end + + @spec following() :: [String.t()] + def following do + get_actor() + |> following() + end + + defp following(user) do + user + |> User.following_ap_ids() + |> Enum.uniq() + end + + defp format_error({:error, error}), do: format_error(error) + + defp format_error(error) do + Logger.error("error: #{inspect(error)}") + {:error, error} + end +end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex new file mode 100644 index 0000000..b8804ea --- /dev/null +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -0,0 +1,708 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.SideEffects do + @moduledoc """ + This module looks at an inserted object and executes the side effects that it + implies. For example, a `Like` activity will increase the like count on the + liked object, a `Follow` activity will add the user to the follower + collection, and so on. + """ + alias Pleroma.Activity + alias Pleroma.Chat + alias Pleroma.Chat.MessageReference + alias Pleroma.FollowingRelationship + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.Push + alias Pleroma.Web.Streamer + alias Pleroma.Workers.EventReminderWorker + alias Pleroma.Workers.PollWorker + + require Pleroma.Constants + require Logger + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + @logger Pleroma.Config.get([:side_effects, :logger], Logger) + + @behaviour Pleroma.Web.ActivityPub.SideEffects.Handling + + defp ap_streamer, do: Pleroma.Config.get([:side_effects, :ap_streamer], ActivityPub) + + @impl true + def handle(object, meta \\ []) + + # Task this handles + # - Follows + # - Sends a notification + @impl true + def handle( + %{data: %{"actor" => actor, "type" => "Accept", "object" => activity_id}} = object, + meta + ) do + with %Activity{} = activity <- + Activity.get_by_ap_id(activity_id) do + handle_accepted(activity, actor) + + if activity.data["type"] === "Join" do + Notification.create_notifications(object) + end + end + + {:ok, object, meta} + end + + # Task this handles + # - Rejects all existing follow activities for this person + # - Updates the follow state + # - Dismisses notification + @impl true + def handle( + %{ + data: %{ + "actor" => actor, + "type" => "Reject", + "object" => activity_id + } + } = object, + meta + ) do + with %Activity{} = activity <- + Activity.get_by_ap_id(activity_id) do + handle_rejected(activity, actor) + end + + {:ok, object, meta} + end + + # Tasks this handle + # - Follows if possible + # - Sends a notification + # - Generates accept or reject if appropriate + @impl true + def handle( + %{ + data: %{ + "id" => follow_id, + "type" => "Follow", + "object" => followed_user, + "actor" => following_user + } + } = object, + meta + ) do + with %User{} = follower <- User.get_cached_by_ap_id(following_user), + %User{} = followed <- User.get_cached_by_ap_id(followed_user), + {_, {:ok, _, _}, _, _} <- + {:following, User.follow(follower, followed, :follow_pending), follower, followed} do + if followed.local && !followed.is_locked do + {:ok, accept_data, _} = Builder.accept(followed, object) + {:ok, _activity, _} = Pipeline.common_pipeline(accept_data, local: true) + end + else + {:following, {:error, _}, _follower, followed} -> + {:ok, reject_data, _} = Builder.reject(followed, object) + {:ok, _activity, _} = Pipeline.common_pipeline(reject_data, local: true) + + _ -> + nil + end + + {:ok, notifications} = Notification.create_notifications(object, do_send: false) + + meta = + meta + |> add_notifications(notifications) + + updated_object = Activity.get_by_ap_id(follow_id) + + {:ok, updated_object, meta} + end + + # Tasks this handles: + # - Unfollow and block + @impl true + def handle( + %{data: %{"type" => "Block", "object" => blocked_user, "actor" => blocking_user}} = + object, + meta + ) do + with %User{} = blocker <- User.get_cached_by_ap_id(blocking_user), + %User{} = blocked <- User.get_cached_by_ap_id(blocked_user) do + User.block(blocker, blocked) + end + + {:ok, object, meta} + end + + # Tasks this handles: + # - Update the user + # - Update a non-user object (Note, Question, etc.) + # + # For a local user, we also get a changeset with the full information, so we + # can update non-federating, non-activitypub settings as well. + @impl true + def handle(%{data: %{"type" => "Update", "object" => updated_object}} = object, meta) do + updated_object_id = updated_object["id"] + + with {_, true} <- {:has_id, is_binary(updated_object_id)}, + %{"type" => type} <- updated_object, + {_, is_user} <- {:is_user, type in Pleroma.Constants.actor_types()} do + if is_user do + handle_update_user(object, meta) + else + handle_update_object(object, meta) + end + else + _ -> + {:ok, object, meta} + end + end + + # Tasks this handles: + # - Add like to object + # - Set up notification + @impl true + def handle(%{data: %{"type" => "Like"}} = object, meta) do + liked_object = Object.get_by_ap_id(object.data["object"]) + Utils.add_like_to_object(object, liked_object) + + Notification.create_notifications(object) + + {:ok, object, meta} + end + + # Tasks this handles + # - Actually create object + # - Rollback if we couldn't create it + # - Increase the user note count + # - Increase the reply count + # - Increase replies count + # - Set up ActivityExpiration + # - Set up notifications + # - Index incoming posts for search (if needed) + @impl true + def handle(%{data: %{"type" => "Create"}} = activity, meta) do + with {:ok, object, meta} <- handle_object_creation(meta[:object_data], activity, meta), + %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do + {:ok, notifications} = Notification.create_notifications(activity, do_send: false) + {:ok, _user} = ActivityPub.increase_note_count_if_public(user, object) + {:ok, _user} = ActivityPub.update_last_status_at_if_public(user, object) + + if in_reply_to = object.data["type"] != "Answer" && object.data["inReplyTo"] do + Object.increase_replies_count(in_reply_to) + end + + if quote_url = object.data["quoteUrl"] do + Object.increase_quotes_count(quote_url) + end + + reply_depth = (meta[:depth] || 0) + 1 + + # FIXME: Force inReplyTo to replies + if Pleroma.Web.Federator.allowed_thread_distance?(reply_depth) and + object.data["replies"] != nil do + for reply_id <- object.data["replies"] do + Pleroma.Workers.RemoteFetcherWorker.enqueue("fetch_remote", %{ + "id" => reply_id, + "depth" => reply_depth + }) + end + end + + ConcurrentLimiter.limit(Pleroma.Web.RichMedia.Helpers, fn -> + Task.start(fn -> Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) end) + end) + + Pleroma.Search.add_to_index(Map.put(activity, :object, object)) + + meta = + meta + |> add_notifications(notifications) + + # ChatMessages are special, as they get streamed in handle_object_creation/3 + # TODO: maybe whitelist allowed object types to stream? + if object.data["type"] != "ChatMessage" do + ap_streamer().stream_out(activity) + end + + {:ok, activity, meta} + else + e -> Repo.rollback(e) + end + end + + # Tasks this handles: + # - Add announce to object + # - Set up notification + # - Stream out the announce + @impl true + def handle(%{data: %{"type" => "Announce"}} = object, meta) do + announced_object = Object.get_by_ap_id(object.data["object"]) + user = User.get_cached_by_ap_id(object.data["actor"]) + + Utils.add_announce_to_object(object, announced_object) + + if !User.is_internal_user?(user) do + Notification.create_notifications(object) + + ap_streamer().stream_out(object) + end + + {:ok, object, meta} + end + + @impl true + def handle(%{data: %{"type" => "Undo", "object" => undone_object}} = object, meta) do + with undone_object <- Activity.get_by_ap_id(undone_object), + :ok <- handle_undoing(undone_object) do + {:ok, object, meta} + end + end + + # Tasks this handles: + # - Add reaction to object + # - Set up notification + @impl true + def handle(%{data: %{"type" => "EmojiReact"}} = object, meta) do + reacted_object = Object.get_by_ap_id(object.data["object"]) + Utils.add_emoji_reaction_to_object(object, reacted_object) + + Notification.create_notifications(object) + + {:ok, object, meta} + end + + # Tasks this handles: + # - Delete and unpins the create activity + # - Replace object with Tombstone + # - Reduce the user note count + # - Reduce the reply count + # - Stream out the activity + # - Removes posts from search index (if needed) + @impl true + def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do + deleted_object = + Object.normalize(deleted_object, fetch: false) || + User.get_cached_by_ap_id(deleted_object) + + result = + case deleted_object do + %Object{} -> + with {:ok, deleted_object, _activity} <- Object.delete(deleted_object), + {_, actor} when is_binary(actor) <- {:actor, deleted_object.data["actor"]}, + %User{} = user <- User.get_cached_by_ap_id(actor) do + User.remove_pinned_object_id(user, deleted_object.data["id"]) + + {:ok, user} = ActivityPub.decrease_note_count_if_public(user, deleted_object) + + if in_reply_to = deleted_object.data["inReplyTo"] do + Object.decrease_replies_count(in_reply_to) + end + + if quote_url = deleted_object.data["quoteUrl"] do + Object.decrease_quotes_count(quote_url) + end + + MessageReference.delete_for_object(deleted_object) + + ap_streamer().stream_out(object) + ap_streamer().stream_out_participations(deleted_object, user) + :ok + else + {:actor, _} -> + @logger.error("The object doesn't have an actor: #{inspect(deleted_object)}") + :no_object_actor + end + + %User{} -> + with {:ok, _} <- User.delete(deleted_object) do + :ok + end + end + + if result == :ok do + # Only remove from index when deleting actual objects, not users or anything else + with %Pleroma.Object{} <- deleted_object do + Pleroma.Search.remove_from_index(deleted_object) + end + + {:ok, object, meta} + else + {:error, result} + end + end + + # Tasks this handles: + # - adds pin to user + # - removes expiration job for pinned activity, if was set for expiration + @impl true + def handle(%{data: %{"type" => "Add"} = data} = object, meta) do + with %User{} = user <- User.get_cached_by_ap_id(data["actor"]), + {:ok, _user} <- User.add_pinned_object_id(user, data["object"]) do + # if pinned activity was scheduled for deletion, we remove job + if expiration = Pleroma.Workers.PurgeExpiredActivity.get_expiration(meta[:activity_id]) do + Oban.cancel_job(expiration.id) + end + + {:ok, object, meta} + else + nil -> + {:error, :user_not_found} + + {:error, changeset} -> + if changeset.errors[:pinned_objects] do + {:error, :pinned_statuses_limit_reached} + else + changeset.errors + end + end + end + + # Tasks this handles: + # - removes pin from user + # - removes corresponding Add activity + # - if activity had expiration, recreates activity expiration job + @impl true + def handle(%{data: %{"type" => "Remove"} = data} = object, meta) do + with %User{} = user <- User.get_cached_by_ap_id(data["actor"]), + {:ok, _user} <- User.remove_pinned_object_id(user, data["object"]) do + data["object"] + |> Activity.add_by_params_query(user.ap_id, user.featured_address) + |> Repo.delete_all() + + # if pinned activity was scheduled for deletion, we reschedule it for deletion + if meta[:expires_at] do + # MRF.ActivityExpirationPolicy used UTC timestamps for expires_at in original implementation + {:ok, expires_at} = + Pleroma.EctoType.ActivityPub.ObjectValidators.DateTime.cast(meta[:expires_at]) + + Pleroma.Workers.PurgeExpiredActivity.enqueue(%{ + activity_id: meta[:activity_id], + expires_at: expires_at + }) + end + + {:ok, object, meta} + else + nil -> {:error, :user_not_found} + error -> error + end + end + + # Tasks this handles: + # - accepts join if event is local and public + @impl true + def handle(%{data: %{"type" => "Join"}} = object, meta) do + joined_event = Object.get_by_ap_id(object.data["object"]) + + if Object.local?(joined_event) and + (joined_event.data["joinMode"] == "free" or + object.data["actor"] == joined_event.data["actor"]) do + {:ok, accept_data, _} = Builder.accept(joined_event, object) + {:ok, _activity, _} = Pipeline.common_pipeline(accept_data, local: true) + end + + if Object.local?(joined_event) and joined_event.data["joinMode"] != "free" and + object.data["actor"] != joined_event.data["actor"] do + Utils.update_participation_request_count_in_object(joined_event) + end + + Notification.create_notifications(object) + + {:ok, object, meta} + end + + @impl true + def handle(%{actor: actor_id, data: %{"type" => "Leave", "object" => event_id}} = object, meta) do + with undone_object <- Utils.get_existing_join(actor_id, event_id), + :ok <- handle_undoing(undone_object) do + event = Object.get_by_ap_id(event_id) + + if Object.local?(event) and event.data["joinMode"] != "free" do + Utils.update_participation_request_count_in_object(event) + end + + {:ok, object, meta} + end + end + + # Nothing to do + @impl true + def handle(object, meta) do + {:ok, object, meta} + end + + defp handle_accepted( + %Activity{actor: follower_id, data: %{"type" => "Follow"}} = follow_activity, + actor + ) do + with %User{} = followed <- User.get_cached_by_ap_id(actor), + %User{} = follower <- User.get_cached_by_ap_id(follower_id), + {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"), + {:ok, _follower, followed} <- + FollowingRelationship.update(follower, followed, :follow_accept) do + Notification.update_notification_type(followed, follow_activity) + end + end + + defp handle_accepted( + %Activity{data: %{"type" => "Join", "object" => event_id}} = join_activity, + actor + ) do + with %Object{data: %{"actor" => ^actor}} = joined_event <- Object.get_by_ap_id(event_id), + {:ok, join_activity} <- Utils.update_join_state(join_activity, "accept") do + Utils.add_participation_to_object(join_activity, joined_event) + end + end + + defp handle_rejected( + %Activity{actor: follower_id, data: %{"type" => "Follow"}} = follow_activity, + actor + ) do + with %User{} = followed <- User.get_cached_by_ap_id(actor), + %User{} = follower <- User.get_cached_by_ap_id(follower_id), + {:ok, _follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject") do + FollowingRelationship.update(follower, followed, :follow_reject) + Notification.dismiss(follow_activity) + end + end + + defp handle_rejected( + %Activity{data: %{"type" => "Join", "object" => event_id}} = join_activity, + actor + ) do + with %Object{data: %{"actor" => ^actor}} = joined_event <- Object.get_by_ap_id(event_id), + {:o, join_activity} <- Utils.update_join_state(join_activity, "reject") do + Utils.remove_participation_from_object(join_activity, joined_event) + Notification.dismiss(join_activity) + end + end + + defp handle_update_user( + %{data: %{"type" => "Update", "object" => updated_object}} = object, + meta + ) do + if changeset = Keyword.get(meta, :user_update_changeset) do + changeset + |> User.update_and_set_cache() + else + {:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object) + + User.get_by_ap_id(updated_object["id"]) + |> User.remote_user_changeset(new_user_data) + |> User.update_and_set_cache() + end + + {:ok, object, meta} + end + + defp handle_update_object( + %{data: %{"type" => "Update", "object" => updated_object}} = object, + meta + ) do + orig_object_ap_id = updated_object["id"] + orig_object = Object.get_by_ap_id(orig_object_ap_id) + orig_object_data = orig_object.data + + updated_object = + if meta[:local] do + # If this is a local Update, we don't process it by transmogrifier, + # so we use the embedded object as-is. + updated_object + else + meta[:object_data] + end + + if orig_object_data["type"] in Pleroma.Constants.updatable_object_types() do + {:ok, _, updated} = + Object.Updater.do_update_and_invalidate_cache(orig_object, updated_object) + + if updated do + object + |> Activity.normalize() + |> ActivityPub.notify_and_stream() + end + end + + {:ok, object, meta} + end + + def handle_object_creation(%{"type" => "ChatMessage"} = object, _activity, meta) do + with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do + actor = User.get_cached_by_ap_id(object.data["actor"]) + recipient = User.get_cached_by_ap_id(hd(object.data["to"])) + + streamables = + [[actor, recipient], [recipient, actor]] + |> Enum.uniq() + |> Enum.map(fn [user, other_user] -> + if user.local do + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + {:ok, cm_ref} = MessageReference.create(chat, object, user.ap_id != actor.ap_id) + + @cachex.put( + :chat_message_id_idempotency_key_cache, + cm_ref.id, + meta[:idempotency_key] + ) + + { + ["user", "user:pleroma_chat"], + {user, %{cm_ref | chat: chat, object: object}} + } + end + end) + |> Enum.filter(& &1) + + meta = + meta + |> add_streamables(streamables) + + {:ok, object, meta} + end + end + + def handle_object_creation(%{"type" => "Question"} = object, activity, meta) do + with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do + PollWorker.schedule_poll_end(activity) + {:ok, object, meta} + end + end + + def handle_object_creation(%{"type" => "Answer"} = object_map, _activity, meta) do + with {:ok, object, meta} <- Pipeline.common_pipeline(object_map, meta) do + Object.increase_vote_count( + object.data["inReplyTo"], + object.data["name"], + object.data["actor"] + ) + + {:ok, object, meta} + end + end + + def handle_object_creation(%{"type" => "Event"} = object, activity, meta) do + with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do + EventReminderWorker.schedule_event_reminder(activity) + {:ok, object, meta} + end + end + + def handle_object_creation(%{"type" => objtype} = object, _activity, meta) + when objtype in ~w[Audio Video Image Article Note Page] do + with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do + {:ok, object, meta} + end + end + + # Nothing to do + def handle_object_creation(object, _activity, meta) do + {:ok, object, meta} + end + + defp undo_like(nil, object), do: delete_object(object) + + defp undo_like(%Object{} = liked_object, object) do + with {:ok, _} <- Utils.remove_like_from_object(object, liked_object) do + delete_object(object) + end + end + + def handle_undoing(%{data: %{"type" => "Like"}} = object) do + object.data["object"] + |> Object.get_by_ap_id() + |> undo_like(object) + end + + def handle_undoing(%{data: %{"type" => "EmojiReact"}} = object) do + with %Object{} = reacted_object <- Object.get_by_ap_id(object.data["object"]), + {:ok, _} <- Utils.remove_emoji_reaction_from_object(object, reacted_object), + {:ok, _} <- Repo.delete(object) do + :ok + end + end + + def handle_undoing(%{data: %{"type" => "Announce"}} = object) do + with %Object{} = liked_object <- Object.get_by_ap_id(object.data["object"]), + {:ok, _} <- Utils.remove_announce_from_object(object, liked_object), + {:ok, _} <- Repo.delete(object) do + :ok + end + end + + def handle_undoing( + %{data: %{"type" => "Block", "actor" => blocker, "object" => blocked}} = object + ) do + with %User{} = blocker <- User.get_cached_by_ap_id(blocker), + %User{} = blocked <- User.get_cached_by_ap_id(blocked), + {:ok, _} <- User.unblock(blocker, blocked), + {:ok, _} <- Repo.delete(object) do + :ok + end + end + + def handle_undoing( + %{data: %{"type" => "Join", "actor" => _actor_id, "object" => event_id}} = object + ) do + with %Object{} = event_object <- Object.get_by_ap_id(event_id), + {:ok, _} <- Utils.remove_participation_from_object(object, event_object), + {:ok, _} <- Repo.delete(object) do + :ok + end + end + + def handle_undoing(object), do: {:error, ["don't know how to handle", object]} + + @spec delete_object(Object.t()) :: :ok | {:error, Ecto.Changeset.t()} + defp delete_object(object) do + with {:ok, _} <- Repo.delete(object), do: :ok + end + + defp send_notifications(meta) do + Keyword.get(meta, :notifications, []) + |> Enum.each(fn notification -> + Streamer.stream(["user", "user:notification"], notification) + Push.send(notification) + end) + + meta + end + + defp send_streamables(meta) do + Keyword.get(meta, :streamables, []) + |> Enum.each(fn {topics, items} -> + Streamer.stream(topics, items) + end) + + meta + end + + defp add_streamables(meta, streamables) do + existing = Keyword.get(meta, :streamables, []) + + meta + |> Keyword.put(:streamables, streamables ++ existing) + end + + defp add_notifications(meta, notifications) do + existing = Keyword.get(meta, :notifications, []) + + meta + |> Keyword.put(:notifications, notifications ++ existing) + end + + @impl true + def handle_after_transaction(meta) do + meta + |> send_notifications() + |> send_streamables() + end +end diff --git a/lib/pleroma/web/activity_pub/side_effects/handling.ex b/lib/pleroma/web/activity_pub/side_effects/handling.ex new file mode 100644 index 0000000..eb012f5 --- /dev/null +++ b/lib/pleroma/web/activity_pub/side_effects/handling.ex @@ -0,0 +1,8 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.SideEffects.Handling do + @callback handle(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} + @callback handle_after_transaction(map()) :: map() +end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex new file mode 100644 index 0000000..3a18c84 --- /dev/null +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -0,0 +1,1065 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier do + @moduledoc """ + A module to handle coding from internal to wire ActivityPub and back. + """ + alias Pleroma.Activity + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Language.LanguageDetector + alias Pleroma.Maps + alias Pleroma.Object + alias Pleroma.Object.Containment + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.Federator + + import Ecto.Query + import Pleroma.Web.CommonAPI.Utils, only: [get_valid_language: 1] + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] + + require Logger + require Pleroma.Constants + + @doc """ + Modifies an incoming AP object (mastodon format) to our internal format. + """ + def fix_object(object, options \\ []) do + object + |> strip_internal_fields() + |> fix_actor() + |> fix_url() + |> fix_attachments() + |> fix_context() + |> fix_in_reply_to(options) + |> fix_emoji() + |> fix_tag() + |> fix_content_map() + |> fix_addressing() + |> fix_summary() + |> maybe_add_language() + end + + def fix_summary(%{"summary" => nil} = object) do + Map.put(object, "summary", "") + end + + def fix_summary(%{"summary" => _} = object) do + # summary is present, nothing to do + object + end + + def fix_summary(object), do: Map.put(object, "summary", "") + + def fix_addressing_list(map, field) do + addrs = map[field] + + cond do + is_list(addrs) -> + Map.put(map, field, Enum.filter(addrs, &is_binary/1)) + + is_binary(addrs) -> + Map.put(map, field, [addrs]) + + true -> + Map.put(map, field, []) + end + end + + # if directMessage flag is set to true, leave the addressing alone + def fix_explicit_addressing(%{"directMessage" => true} = object, _follower_collection), + do: object + + def fix_explicit_addressing(%{"to" => to, "cc" => cc} = object, follower_collection) do + explicit_mentions = + Utils.determine_explicit_mentions(object) ++ + [Pleroma.Constants.as_public(), follower_collection] + + explicit_to = Enum.filter(to, fn x -> x in explicit_mentions end) + explicit_cc = Enum.filter(to, fn x -> x not in explicit_mentions end) + + final_cc = + (cc ++ explicit_cc) + |> Enum.filter(& &1) + |> Enum.reject(fn x -> String.ends_with?(x, "/followers") and x != follower_collection end) + |> Enum.uniq() + + object + |> Map.put("to", explicit_to) + |> Map.put("cc", final_cc) + end + + # if as:Public is addressed, then make sure the followers collection is also addressed + # so that the activities will be delivered to local users. + def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do + recipients = to ++ cc + + if followers_collection not in recipients do + cond do + Pleroma.Constants.as_public() in cc -> + to = to ++ [followers_collection] + Map.put(object, "to", to) + + Pleroma.Constants.as_public() in to -> + cc = cc ++ [followers_collection] + Map.put(object, "cc", cc) + + true -> + object + end + else + object + end + end + + def fix_addressing(object) do + {:ok, %User{follower_address: follower_collection}} = + object + |> Containment.get_actor() + |> User.get_or_fetch_by_ap_id() + + object + |> fix_addressing_list("to") + |> fix_addressing_list("cc") + |> fix_addressing_list("bto") + |> fix_addressing_list("bcc") + |> fix_explicit_addressing(follower_collection) + |> fix_implicit_addressing(follower_collection) + end + + def fix_actor(%{"attributedTo" => actor} = object) do + actor = Containment.get_actor(%{"actor" => actor}) + + # TODO: Remove actor field for Objects + object + |> Map.put("actor", actor) + |> Map.put("attributedTo", actor) + end + + def fix_in_reply_to(object, options \\ []) + + def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options) + when not is_nil(in_reply_to) do + in_reply_to_id = prepare_in_reply_to(in_reply_to) + depth = (options[:depth] || 0) + 1 + + if Federator.allowed_thread_distance?(depth) do + with {:ok, replied_object} <- get_obj_helper(in_reply_to_id, options), + %Activity{} <- Activity.get_create_by_object_ap_id(replied_object.data["id"]) do + object + |> Map.put("inReplyTo", replied_object.data["id"]) + |> Map.put("context", replied_object.data["context"] || object["conversation"]) + |> Map.drop(["conversation", "inReplyToAtomUri"]) + else + e -> + Logger.warning("Couldn't fetch #{inspect(in_reply_to_id)}, error: #{inspect(e)}") + object + end + else + object + end + end + + def fix_in_reply_to(object, _options), do: object + + def fix_quote_url_and_maybe_fetch(object, options \\ []) do + quote_url = + case Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes.fix_quote_url(object) do + %{"quoteUrl" => quote_url} -> quote_url + _ -> nil + end + + with {:quoting?, true} <- {:quoting?, not is_nil(quote_url)}, + {:ok, quoted_object} <- get_obj_helper(quote_url, options), + %Activity{} <- Activity.get_create_by_object_ap_id(quoted_object.data["id"]) do + Map.put(object, "quoteUrl", quoted_object.data["id"]) + else + {:quoting?, _} -> + object + + e -> + Logger.warning("Couldn't fetch #{inspect(quote_url)}, error: #{inspect(e)}") + object + end + end + + defp prepare_in_reply_to(in_reply_to) do + cond do + is_bitstring(in_reply_to) -> + in_reply_to + + is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) -> + in_reply_to["id"] + + is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) -> + Enum.at(in_reply_to, 0) + + true -> + "" + end + end + + def fix_context(object) do + context = object["context"] || object["conversation"] || Utils.generate_context_id() + + object + |> Map.put("context", context) + |> Map.drop(["conversation"]) + end + + def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do + attachments = + Enum.map(attachment, fn data -> + url = + cond do + is_list(data["url"]) -> List.first(data["url"]) + is_map(data["url"]) -> data["url"] + true -> nil + end + + media_type = + cond do + is_map(url) && url =~ Pleroma.Constants.mime_regex() -> + url["mediaType"] + + is_bitstring(data["mediaType"]) && data["mediaType"] =~ Pleroma.Constants.mime_regex() -> + data["mediaType"] + + is_bitstring(data["mimeType"]) && data["mimeType"] =~ Pleroma.Constants.mime_regex() -> + data["mimeType"] + + true -> + nil + end + + href = + cond do + is_map(url) && is_binary(url["href"]) -> url["href"] + is_binary(data["url"]) -> data["url"] + is_binary(data["href"]) -> data["href"] + true -> nil + end + + if href do + attachment_url = + %{ + "href" => href, + "type" => Map.get(url || %{}, "type", "Link") + } + |> Maps.put_if_present("mediaType", media_type) + |> Maps.put_if_present("width", (url || %{})["width"] || data["width"]) + |> Maps.put_if_present("height", (url || %{})["height"] || data["height"]) + + %{ + "url" => [attachment_url], + "type" => data["type"] || "Document" + } + |> Maps.put_if_present("mediaType", media_type) + |> Maps.put_if_present("name", data["name"]) + |> Maps.put_if_present("blurhash", data["blurhash"]) + else + nil + end + end) + |> Enum.filter(& &1) + + Map.put(object, "attachment", attachments) + end + + def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do + object + |> Map.put("attachment", [attachment]) + |> fix_attachments() + end + + def fix_attachments(object), do: object + + def fix_url(%{"url" => url} = object) when is_map(url) do + Map.put(object, "url", url["href"]) + end + + def fix_url(%{"url" => url} = object) when is_list(url) do + first_element = Enum.at(url, 0) + + url_string = + cond do + is_bitstring(first_element) -> first_element + is_map(first_element) -> first_element["href"] || "" + true -> "" + end + + Map.put(object, "url", url_string) + end + + def fix_url(object), do: object + + def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do + emoji = + tags + |> Enum.filter(fn data -> is_map(data) and data["type"] == "Emoji" and data["icon"] end) + |> Enum.reduce(%{}, fn data, mapping -> + name = String.trim(data["name"], ":") + + Map.put(mapping, name, data["icon"]["url"]) + end) + + Map.put(object, "emoji", emoji) + end + + def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do + name = String.trim(tag["name"], ":") + emoji = %{name => tag["icon"]["url"]} + + Map.put(object, "emoji", emoji) + end + + def fix_emoji(object), do: object + + def fix_tag(%{"tag" => tag} = object) when is_list(tag) do + tags = + tag + |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end) + |> Enum.map(fn + %{"name" => "#" <> hashtag} -> String.downcase(hashtag) + %{"name" => hashtag} -> String.downcase(hashtag) + end) + + Map.put(object, "tag", tag ++ tags) + end + + def fix_tag(%{"tag" => %{} = tag} = object) do + object + |> Map.put("tag", [tag]) + |> fix_tag + end + + def fix_tag(object), do: object + + def fix_content_map(%{"content" => content} = object) when not_empty_string(content), do: object + + # content map usually only has one language so this will do for now. + def fix_content_map(%{"contentMap" => content_map} = object) do + content_groups = Map.to_list(content_map) + {_, content} = Enum.at(content_groups, 0) + + Map.put(object, "content", content) + end + + def fix_content_map(object), do: object + + defp fix_type(%{"type" => "Note", "inReplyTo" => reply_id, "name" => _} = object, options) + when is_binary(reply_id) do + options = Keyword.put(options, :fetch, true) + + with %Object{data: %{"type" => "Question"}} <- Object.normalize(reply_id, options) do + Map.put(object, "type", "Answer") + else + _ -> object + end + end + + defp fix_type(object, _options), do: object + + # Reduce the object list to find the reported user. + defp get_reported(objects) do + Enum.reduce_while(objects, nil, fn ap_id, _ -> + with %User{} = user <- User.get_cached_by_ap_id(ap_id) do + {:halt, user} + else + _ -> {:cont, nil} + end + end) + end + + def handle_incoming(data, options \\ []) + + # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them + # with nil ID. + def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data, _options) do + with context <- data["context"] || Utils.generate_context_id(), + content <- data["content"] || "", + %User{} = actor <- User.get_cached_by_ap_id(actor), + # Reduce the object list to find the reported user. + %User{} = account <- get_reported(objects), + # Remove the reported user from the object list. + statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do + %{ + actor: actor, + context: context, + account: account, + statuses: statuses, + content: content, + additional: %{"cc" => [account.ap_id]} + } + |> ActivityPub.flag() + end + end + + # disallow objects with bogus IDs + def handle_incoming(%{"id" => nil}, _options), do: :error + def handle_incoming(%{"id" => ""}, _options), do: :error + # length of https:// = 8, should validate better, but good enough for now. + def handle_incoming(%{"id" => id}, _options) when is_binary(id) and byte_size(id) < 8, + do: :error + + def handle_incoming( + %{"type" => "Listen", "object" => %{"type" => "Audio"} = object} = data, + options + ) do + actor = Containment.get_actor(data) + + data = + Map.put(data, "actor", actor) + |> fix_addressing + + with {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do + reply_depth = (options[:depth] || 0) + 1 + options = Keyword.put(options, :depth, reply_depth) + object = fix_object(object, options) + + params = %{ + to: data["to"], + object: object, + actor: user, + context: nil, + local: false, + published: data["published"], + additional: Map.take(data, ["cc", "id"]) + } + + ActivityPub.listen(params) + else + _e -> :error + end + end + + @misskey_reactions %{ + "like" => "👍", + "love" => "❤️", + "laugh" => "😆", + "hmm" => "🤔", + "surprise" => "😮", + "congrats" => "🎉", + "angry" => "💢", + "confused" => "😥", + "rip" => "😇", + "pudding" => "🍮", + "star" => "⭐" + } + + @doc "Rewrite misskey likes into EmojiReacts" + def handle_incoming( + %{ + "type" => "Like", + "_misskey_reaction" => reaction + } = data, + options + ) do + data + |> Map.put("type", "EmojiReact") + |> Map.put("content", @misskey_reactions[reaction] || reaction) + |> handle_incoming(options) + end + + def handle_incoming( + %{"type" => "Create", "object" => %{"type" => objtype, "id" => obj_id}} = data, + options + ) + when objtype in ~w{Question Answer ChatMessage Audio Video Event Article Note Page Image} do + fetch_options = Keyword.put(options, :depth, (options[:depth] || 0) + 1) + + object = + data["object"] + |> strip_internal_fields() + |> fix_type(fetch_options) + |> fix_in_reply_to(fetch_options) + |> fix_quote_url_and_maybe_fetch(fetch_options) + |> maybe_add_language_from_activity(data) + + data = Map.put(data, "object", object) + options = Keyword.put(options, :local, false) + + with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), + nil <- Activity.get_create_by_object_ap_id(obj_id), + {:ok, activity, _} <- Pipeline.common_pipeline(data, options) do + {:ok, activity} + else + %Activity{} = activity -> {:ok, activity} + e -> e + end + end + + def handle_incoming(%{"type" => type} = data, _options) + when type in ~w{Like EmojiReact Announce Add Remove} do + with :ok <- ObjectValidator.fetch_actor_and_object(data), + {:ok, activity, _meta} <- + Pipeline.common_pipeline(data, local: false) do + {:ok, activity} + else + e -> {:error, e} + end + end + + def handle_incoming( + %{"type" => type} = data, + _options + ) + when type in ~w{Update Block Follow Accept Reject Join Leave} do + with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), + {:ok, activity, _} <- + Pipeline.common_pipeline(data, local: false) do + {:ok, activity} + end + end + + def handle_incoming( + %{"type" => "Delete"} = data, + _options + ) do + with {:ok, activity, _} <- + Pipeline.common_pipeline(data, local: false) do + {:ok, activity} + else + {:error, {:validate, _}} = e -> + # Check if we have a create activity for this + with {:ok, object_id} <- ObjectValidators.ObjectID.cast(data["object"]), + %Activity{data: %{"actor" => actor}} <- + Activity.create_by_object_ap_id(object_id) |> Repo.one(), + # We have one, insert a tombstone and retry + {:ok, tombstone_data, _} <- Builder.tombstone(actor, object_id), + {:ok, _tombstone} <- Object.create(tombstone_data) do + handle_incoming(data) + else + _ -> e + end + end + end + + def handle_incoming( + %{ + "type" => "Undo", + "object" => %{"type" => "Follow", "object" => followed}, + "actor" => follower, + "id" => id + } = _data, + _options + ) do + with %User{local: true} = followed <- User.get_cached_by_ap_id(followed), + {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower), + {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do + User.unfollow(follower, followed) + {:ok, activity} + else + _e -> :error + end + end + + def handle_incoming( + %{ + "type" => "Undo", + "object" => %{"type" => type} + } = data, + _options + ) + when type in ["Like", "EmojiReact", "Announce", "Block", "Join"] do + with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do + {:ok, activity} + end + end + + # For Undos that don't have the complete object attached, try to find it in our database. + def handle_incoming( + %{ + "type" => "Undo", + "object" => object + } = activity, + options + ) + when is_binary(object) do + with %Activity{data: data} <- Activity.get_by_ap_id(object) do + activity + |> Map.put("object", data) + |> handle_incoming(options) + else + _e -> :error + end + end + + def handle_incoming( + %{ + "type" => "Move", + "actor" => origin_actor, + "object" => origin_actor, + "target" => target_actor + }, + _options + ) do + with %User{} = origin_user <- User.get_cached_by_ap_id(origin_actor), + {:ok, %User{} = target_user} <- User.get_or_fetch_by_ap_id(target_actor), + true <- origin_actor in target_user.also_known_as do + ActivityPub.move(origin_user, target_user, false) + else + _e -> :error + end + end + + def handle_incoming(_, _), do: :error + + @spec get_obj_helper(String.t(), Keyword.t()) :: {:ok, Object.t()} | nil + def get_obj_helper(id, options \\ []) do + options = Keyword.put(options, :fetch, true) + + case Object.normalize(id, options) do + %Object{} = object -> {:ok, object} + _ -> nil + end + end + + @spec get_embedded_obj_helper(String.t() | Object.t(), User.t()) :: {:ok, Object.t()} | nil + def get_embedded_obj_helper(%{"attributedTo" => attributed_to, "id" => object_id} = data, %User{ + ap_id: ap_id + }) + when attributed_to == ap_id do + with {:ok, activity} <- + handle_incoming(%{ + "type" => "Create", + "to" => data["to"], + "cc" => data["cc"], + "actor" => attributed_to, + "object" => data + }) do + {:ok, Object.normalize(activity, fetch: false)} + else + _ -> get_obj_helper(object_id) + end + end + + def get_embedded_obj_helper(object_id, _) do + get_obj_helper(object_id) + end + + def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do + with false <- String.starts_with?(in_reply_to, "http"), + {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do + Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to) + else + _e -> object + end + end + + def set_reply_to_uri(obj), do: obj + + @doc """ + Fedibird compatibility + https://github.com/fedibird/mastodon/commit/dbd7ae6cf58a92ec67c512296b4daaea0d01e6ac + """ + def set_quote_url(%{"quoteUrl" => quote_url} = object) when is_binary(quote_url) do + Map.put(object, "quoteUri", quote_url) + end + + def set_quote_url(obj), do: obj + + @doc """ + Serialized Mastodon-compatible `replies` collection containing _self-replies_. + Based on Mastodon's ActivityPub::NoteSerializer#replies. + """ + def set_replies(obj_data) do + replies_uris = + with limit when limit > 0 <- + Pleroma.Config.get([:activitypub, :note_replies_output_limit], 0), + %Object{} = object <- Object.get_cached_by_ap_id(obj_data["id"]) do + object + |> Object.self_replies() + |> select([o], fragment("?->>'id'", o.data)) + |> limit(^limit) + |> Repo.all() + else + _ -> [] + end + + set_replies(obj_data, replies_uris) + end + + defp set_replies(obj, []) do + obj + end + + defp set_replies(obj, replies_uris) do + replies_collection = %{ + "type" => "Collection", + "items" => replies_uris + } + + Map.merge(obj, %{"replies" => replies_collection}) + end + + def replies(%{"replies" => %{"first" => %{"items" => items}}}) when not is_nil(items) do + items + end + + def replies(%{"replies" => %{"items" => items}}) when not is_nil(items) do + items + end + + def replies(_), do: [] + + defp set_voters_count(%{"voters" => [_ | _] = voters} = obj) do + obj + |> Map.merge(%{"votersCount" => length(voters)}) + |> Map.delete("voters") + end + + defp set_voters_count(obj), do: obj + + # Prepares the object of an outgoing create activity. + def prepare_object(object) do + object + |> add_hashtags + |> add_mention_tags + |> add_emoji_tags + |> add_attributed_to + |> maybe_add_content_map + |> prepare_attachments + |> set_conversation + |> set_reply_to_uri + |> set_quote_url + |> set_replies + |> set_voters_count + |> strip_internal_fields + |> strip_internal_tags + |> set_type + |> maybe_process_history + end + + defp maybe_process_history(%{"formerRepresentations" => %{"orderedItems" => history}} = object) do + processed_history = + Enum.map( + history, + fn + item when is_map(item) -> prepare_object(item) + item -> item + end + ) + + put_in(object, ["formerRepresentations", "orderedItems"], processed_history) + end + + defp maybe_process_history(object) do + object + end + + # @doc + # """ + # internal -> Mastodon + # """ + + def prepare_outgoing(%{"type" => activity_type, "object" => object_id} = data) + when activity_type in ["Create", "Listen"] do + object = + object_id + |> Object.normalize(fetch: false) + |> Map.get(:data) + |> prepare_object + + data = + data + |> Map.put("object", object) + |> Map.merge(Utils.make_json_ld_header(data)) + |> Map.delete("bcc") + + {:ok, data} + end + + def prepare_outgoing(%{"type" => "Update", "object" => %{"type" => objtype} = object} = data) + when objtype in Pleroma.Constants.updatable_object_types() do + object = + object + |> prepare_object + + data = + data + |> Map.put("object", object) + |> Map.merge(Utils.make_json_ld_header(data)) + |> Map.delete("bcc") + + {:ok, data} + end + + def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do + object = + object_id + |> Object.normalize(fetch: false) + + data = + if Visibility.is_private?(object) && object.data["actor"] == ap_id do + data |> Map.put("object", object |> Map.get(:data) |> prepare_object) + else + data |> maybe_fix_object_url + end + + data = + data + |> strip_internal_fields + |> Map.merge(Utils.make_json_ld_header(data)) + |> Map.delete("bcc") + + {:ok, data} + end + + # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs, + # because of course it does. + def prepare_outgoing(%{"type" => "Accept"} = data) do + with follow_activity <- Activity.normalize(data["object"]) do + object = %{ + "actor" => follow_activity.actor, + "object" => follow_activity.data["object"], + "id" => follow_activity.data["id"], + "type" => "Follow" + } + + data = + data + |> Map.put("object", object) + |> Map.merge(Utils.make_json_ld_header(data)) + + {:ok, data} + end + end + + def prepare_outgoing(%{"type" => "Reject"} = data) do + with follow_activity <- Activity.normalize(data["object"]) do + object = %{ + "actor" => follow_activity.actor, + "object" => follow_activity.data["object"], + "id" => follow_activity.data["id"], + "type" => "Follow" + } + + data = + data + |> Map.put("object", object) + |> Map.merge(Utils.make_json_ld_header(data)) + + {:ok, data} + end + end + + def prepare_outgoing(%{"type" => _type} = data) do + data = + data + |> strip_internal_fields + |> maybe_fix_object_url + |> Map.merge(Utils.make_json_ld_header(data)) + + {:ok, data} + end + + def maybe_fix_object_url(%{"object" => object} = data) when is_binary(object) do + with false <- String.starts_with?(object, "http"), + {:fetch, {:ok, relative_object}} <- {:fetch, get_obj_helper(object)}, + %{data: %{"external_url" => external_url}} when not is_nil(external_url) <- + relative_object do + Map.put(data, "object", external_url) + else + {:fetch, e} -> + Logger.error("Couldn't fetch #{object} #{inspect(e)}") + data + + _ -> + data + end + end + + def maybe_fix_object_url(data), do: data + + def add_hashtags(object) do + tags = + (object["tag"] || []) + |> Enum.map(fn + # Expand internal representation tags into AS2 tags. + tag when is_binary(tag) -> + %{ + "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}", + "name" => "##{tag}", + "type" => "Hashtag" + } + + # Do not process tags which are already AS2 tag objects. + tag when is_map(tag) -> + tag + end) + + Map.put(object, "tag", tags) + end + + # TODO These should be added on our side on insertion, it doesn't make much + # sense to regenerate these all the time + def add_mention_tags(object) do + to = object["to"] || [] + cc = object["cc"] || [] + mentioned = User.get_users_from_set(to ++ cc, local_only: false) + + mentions = Enum.map(mentioned, &build_mention_tag/1) + + tags = object["tag"] || [] + Map.put(object, "tag", tags ++ mentions) + end + + defp build_mention_tag(%{ap_id: ap_id, nickname: nickname} = _) do + %{"type" => "Mention", "href" => ap_id, "name" => "@#{nickname}"} + end + + def take_emoji_tags(%User{emoji: emoji}) do + emoji + |> Map.to_list() + |> Enum.map(&build_emoji_tag/1) + end + + # TODO: we should probably send mtime instead of unix epoch time for updated + def add_emoji_tags(%{"emoji" => emoji} = object) do + tags = object["tag"] || [] + + out = Enum.map(emoji, &build_emoji_tag/1) + + Map.put(object, "tag", tags ++ out) + end + + def add_emoji_tags(object), do: object + + defp build_emoji_tag({name, url}) do + %{ + "icon" => %{"url" => "#{URI.encode(url)}", "type" => "Image"}, + "name" => ":" <> name <> ":", + "type" => "Emoji", + "updated" => "1970-01-01T00:00:00Z", + "id" => url + } + end + + def set_conversation(object) do + Map.put(object, "conversation", object["context"]) + end + + def set_type(%{"type" => "Answer"} = object) do + Map.put(object, "type", "Note") + end + + def set_type(object), do: object + + def add_attributed_to(object) do + attributed_to = object["attributedTo"] || object["actor"] + Map.put(object, "attributedTo", attributed_to) + end + + # TODO: Revisit this + def prepare_attachments(%{"type" => "ChatMessage"} = object), do: object + + def prepare_attachments(object) do + attachments = + object + |> Map.get("attachment", []) + |> Enum.map(fn data -> + [%{"mediaType" => media_type, "href" => href} = url | _] = data["url"] + + %{ + "url" => href, + "mediaType" => media_type, + "name" => data["name"], + "type" => "Document" + } + |> Maps.put_if_present("width", url["width"]) + |> Maps.put_if_present("height", url["height"]) + |> Maps.put_if_present("blurhash", data["blurhash"]) + end) + + Map.put(object, "attachment", attachments) + end + + def strip_internal_fields(object) do + Map.drop(object, Pleroma.Constants.object_internal_fields()) + end + + defp strip_internal_tags(%{"tag" => tags} = object) do + tags = Enum.filter(tags, fn x -> is_map(x) end) + + Map.put(object, "tag", tags) + end + + defp strip_internal_tags(object), do: object + + def maybe_fix_user_url(%{"url" => url} = data) when is_map(url) do + Map.put(data, "url", url["href"]) + end + + def maybe_fix_user_url(data), do: data + + def maybe_fix_user_object(data), do: maybe_fix_user_url(data) + + defp maybe_add_content_map(%{"language" => language, "content" => content} = object) + when not_empty_string(language) do + Map.put(object, "contentMap", Map.put(%{}, language, content)) + end + + defp maybe_add_content_map(object), do: object + + def maybe_add_language(object) do + language = + get_language_from_context(object) |> get_valid_language() || + get_language_from_content_map(object) |> get_valid_language() || + get_language_from_content(object) |> get_valid_language() + + if language do + Map.put(object, "language", language) + else + object + end + end + + def maybe_add_language_from_activity(object, activity) do + language = get_language_from_context(activity) |> get_valid_language() + + if language do + Map.put(object, "language", language) + else + object + end + end + + defp get_language_from_context(%{"@context" => context}) when is_list(context) do + case context + |> Enum.find(fn + %{"@language" => language} -> language != "und" + _ -> nil + end) do + %{"@language" => language} -> language + _ -> nil + end + end + + defp get_language_from_context(_), do: nil + + defp get_language_from_content_map(%{"contentMap" => content_map, "content" => source_content}) do + content_groups = Map.to_list(content_map) + + case Enum.find(content_groups, fn {_, content} -> content == source_content end) do + {language, _} -> language + _ -> nil + end + end + + defp get_language_from_content_map(_), do: nil + + defp get_language_from_content(%{"content" => content}) do + LanguageDetector.detect(content) + end + + defp get_language_from_content(_), do: nil +end diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex new file mode 100644 index 0000000..3a1fac1 --- /dev/null +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -0,0 +1,1038 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Utils do + alias Ecto.Changeset + alias Ecto.UUID + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.Maps + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.AdminAPI.AccountView + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Router.Helpers + + import Ecto.Query + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] + + require Logger + require Pleroma.Constants + + @supported_object_types [ + "Article", + "Note", + "Event", + "Video", + "Page", + "Question", + "Answer", + "Audio", + "Image" + ] + @strip_status_report_states ~w(closed resolved) + @supported_report_states ~w(open closed resolved) + @valid_visibilities ~w(public unlisted private direct) + + def as_local_public, do: Endpoint.url() <> "/#Public" + + # Some implementations send the actor URI as the actor field, others send the entire actor object, + # so figure out what the actor's URI is based on what we have. + def get_ap_id(%{"id" => id} = _), do: id + def get_ap_id(id), do: id + + def normalize_params(params) do + Map.put(params, "actor", get_ap_id(params["actor"])) + end + + @spec determine_explicit_mentions(map()) :: [any] + def determine_explicit_mentions(%{"tag" => tag}) when is_list(tag) do + Enum.flat_map(tag, fn + %{"type" => "Mention", "href" => href} -> [href] + _ -> [] + end) + end + + def determine_explicit_mentions(%{"tag" => tag} = object) when is_map(tag) do + object + |> Map.put("tag", [tag]) + |> determine_explicit_mentions() + end + + def determine_explicit_mentions(_), do: [] + + @spec label_in_collection?(any(), any()) :: boolean() + defp label_in_collection?(ap_id, coll) when is_binary(coll), do: ap_id == coll + defp label_in_collection?(ap_id, coll) when is_list(coll), do: ap_id in coll + defp label_in_collection?(_, _), do: false + + @spec label_in_message?(String.t(), map()) :: boolean() + def label_in_message?(label, params), + do: + [params["to"], params["cc"], params["bto"], params["bcc"]] + |> Enum.any?(&label_in_collection?(label, &1)) + + @spec unaddressed_message?(map()) :: boolean() + def unaddressed_message?(params), + do: + [params["to"], params["cc"], params["bto"], params["bcc"]] + |> Enum.all?(&is_nil(&1)) + + @spec recipient_in_message(User.t(), User.t(), map()) :: boolean() + def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params), + do: + label_in_message?(ap_id, params) || unaddressed_message?(params) || + User.following?(recipient, actor) + + defp extract_list(target) when is_binary(target), do: [target] + defp extract_list(lst) when is_list(lst), do: lst + defp extract_list(_), do: [] + + def maybe_splice_recipient(ap_id, params) do + need_splice? = + !label_in_collection?(ap_id, params["to"]) && + !label_in_collection?(ap_id, params["cc"]) + + if need_splice? do + cc = [ap_id | extract_list(params["cc"])] + + params + |> Map.put("cc", cc) + |> Maps.safe_put_in(["object", "cc"], cc) + else + params + end + end + + def make_json_ld_header(data \\ %{}) do + %{ + "@context" => [ + "https://www.w3.org/ns/activitystreams", + "#{Endpoint.url()}/schemas/litepub-0.1.jsonld", + %{ + "@language" => get_language(data) + } + ] + } + end + + defp get_language(%{"language" => language}) when not_empty_string(language) do + language + end + + defp get_language(_), do: "und" + + def make_date do + DateTime.utc_now() |> DateTime.to_iso8601() + end + + def generate_activity_id do + generate_id("activities") + end + + def generate_context_id do + generate_id("contexts") + end + + def generate_object_id do + Helpers.o_status_url(Endpoint, :object, UUID.generate()) + end + + def generate_id(type) do + "#{Endpoint.url()}/#{type}/#{UUID.generate()}" + end + + def get_notified_from_object(%{"type" => type} = object) when type in @supported_object_types do + fake_create_activity = %{ + "to" => object["to"], + "cc" => object["cc"], + "type" => "Create", + "object" => object + } + + get_notified_from_object(fake_create_activity) + end + + def get_notified_from_object(object) do + Notification.get_notified_from_activity(%Activity{data: object}, false) + end + + def maybe_create_context(context), do: context || generate_id("contexts") + + @doc """ + Enqueues an activity for federation if it's local + """ + @spec maybe_federate(any()) :: :ok + def maybe_federate(%Activity{local: true, data: %{"type" => type}} = activity) do + outgoing_blocks = Config.get([:activitypub, :outgoing_blocks]) + + with true <- Config.get!([:instance, :federating]), + true <- type != "Block" || outgoing_blocks, + false <- Visibility.is_local_public?(activity) do + Pleroma.Web.Federator.publish(activity) + end + + :ok + end + + def maybe_federate(_), do: :ok + + @doc """ + Adds an id and a published data if they aren't there, + also adds it to an included object + """ + @spec lazy_put_activity_defaults(map(), boolean) :: map() + def lazy_put_activity_defaults(map, fake? \\ false) + + def lazy_put_activity_defaults(map, true) do + map + |> Map.put_new("id", "pleroma:fakeid") + |> Map.put_new_lazy("published", &make_date/0) + |> Map.put_new("context", "pleroma:fakecontext") + |> lazy_put_object_defaults(true) + end + + def lazy_put_activity_defaults(map, _fake?) do + context = maybe_create_context(map["context"]) + + map + |> Map.put_new_lazy("id", &generate_activity_id/0) + |> Map.put_new_lazy("published", &make_date/0) + |> Map.put_new("context", context) + |> lazy_put_object_defaults(false) + end + + # Adds an id and published date if they aren't there. + # + @spec lazy_put_object_defaults(map(), boolean()) :: map() + defp lazy_put_object_defaults(%{"object" => map} = activity, true) + when is_map(map) do + object = + map + |> Map.put_new("id", "pleroma:fake_object_id") + |> Map.put_new_lazy("published", &make_date/0) + |> Map.put_new("context", activity["context"]) + |> Map.put_new("fake", true) + + %{activity | "object" => object} + end + + defp lazy_put_object_defaults(%{"object" => map} = activity, _) + when is_map(map) do + object = + map + |> Map.put_new_lazy("id", &generate_object_id/0) + |> Map.put_new_lazy("published", &make_date/0) + |> Map.put_new("context", activity["context"]) + + %{activity | "object" => object} + end + + defp lazy_put_object_defaults(activity, _), do: activity + + @doc """ + Inserts a full object if it is contained in an activity. + """ + def insert_full_object(%{"object" => %{"type" => type} = object_data} = map) + when type in @supported_object_types do + with {:ok, object} <- Object.create(object_data) do + map = Map.put(map, "object", object.data["id"]) + + {:ok, map, object} + end + end + + def insert_full_object(map), do: {:ok, map, nil} + + #### Like-related helpers + + @doc """ + Returns an existing like if a user already liked an object + """ + @spec get_existing_like(String.t(), map()) :: Activity.t() | nil + def get_existing_like(actor, %{data: %{"id" => id}}) do + actor + |> Activity.Queries.by_actor() + |> Activity.Queries.by_object_id(id) + |> Activity.Queries.by_type("Like") + |> limit(1) + |> Repo.one() + end + + @doc """ + Returns like activities targeting an object + """ + def get_object_likes(%{data: %{"id" => id}}) do + id + |> Activity.Queries.by_object_id() + |> Activity.Queries.by_type("Like") + |> Repo.all() + end + + @spec make_like_data(User.t(), map(), String.t()) :: map() + def make_like_data( + %User{ap_id: ap_id} = actor, + %{data: %{"actor" => object_actor_id, "id" => id}} = object, + activity_id + ) do + object_actor = User.get_cached_by_ap_id(object_actor_id) + + to = + if Visibility.is_public?(object) do + [actor.follower_address, object.data["actor"]] + else + [object.data["actor"]] + end + + cc = + (object.data["to"] ++ (object.data["cc"] || [])) + |> List.delete(actor.ap_id) + |> List.delete(object_actor.follower_address) + + %{ + "type" => "Like", + "actor" => ap_id, + "object" => id, + "to" => to, + "cc" => cc, + "context" => object.data["context"] + } + |> Maps.put_if_present("id", activity_id) + end + + def make_emoji_reaction_data(user, object, emoji, activity_id) do + make_like_data(user, object, activity_id) + |> Map.put("type", "EmojiReact") + |> Map.put("content", emoji) + end + + @spec update_element_in_object(String.t(), list(any), Object.t(), integer() | nil) :: + {:ok, Object.t()} | {:error, Ecto.Changeset.t()} + def update_element_in_object(property, element, object, count \\ nil) do + length = + count || + length(element) + + data = + Map.merge( + object.data, + %{"#{property}_count" => length, "#{property}s" => element} + ) + + object + |> Changeset.change(data: data) + |> Object.update_and_set_cache() + end + + @spec add_emoji_reaction_to_object(Activity.t(), Object.t()) :: + {:ok, Object.t()} | {:error, Ecto.Changeset.t()} + + def add_emoji_reaction_to_object( + %Activity{data: %{"content" => emoji, "actor" => actor}} = activity, + object + ) do + reactions = get_cached_emoji_reactions(object) + emoji = Pleroma.Emoji.maybe_strip_name(emoji) + url = maybe_emoji_url(emoji, activity) + + new_reactions = + case Enum.find_index(reactions, fn [candidate, _, candidate_url] -> + if is_nil(candidate_url) do + emoji == candidate + else + url == candidate_url + end + end) do + nil -> + reactions ++ [[emoji, [actor], url]] + + index -> + List.update_at( + reactions, + index, + fn [emoji, users, url] -> [emoji, Enum.uniq([actor | users]), url] end + ) + end + + count = emoji_count(new_reactions) + + update_element_in_object("reaction", new_reactions, object, count) + end + + defp maybe_emoji_url( + name, + %Activity{ + data: %{ + "tag" => [ + %{"type" => "Emoji", "name" => name, "icon" => %{"url" => url}} + ] + } + } + ), + do: url + + defp maybe_emoji_url(_, _), do: nil + + def emoji_count(reactions_list) do + Enum.reduce(reactions_list, 0, fn [_, users, _], acc -> acc + length(users) end) + end + + def remove_emoji_reaction_from_object( + %Activity{data: %{"content" => emoji, "actor" => actor}} = activity, + object + ) do + emoji = Pleroma.Emoji.maybe_strip_name(emoji) + reactions = get_cached_emoji_reactions(object) + url = maybe_emoji_url(emoji, activity) + + new_reactions = + case Enum.find_index(reactions, fn [candidate, _, candidate_url] -> + if is_nil(candidate_url) do + emoji == candidate + else + url == candidate_url + end + end) do + nil -> + reactions + + index -> + List.update_at( + reactions, + index, + fn [emoji, users, url] -> [emoji, List.delete(users, actor), url] end + ) + |> Enum.reject(fn [_, users, _] -> Enum.empty?(users) end) + end + + count = emoji_count(new_reactions) + update_element_in_object("reaction", new_reactions, object, count) + end + + def get_cached_emoji_reactions(object) do + Object.get_emoji_reactions(object) + end + + @spec add_like_to_object(Activity.t(), Object.t()) :: + {:ok, Object.t()} | {:error, Ecto.Changeset.t()} + def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do + [actor | fetch_likes(object)] + |> Enum.uniq() + |> update_likes_in_object(object) + end + + @spec remove_like_from_object(Activity.t(), Object.t()) :: + {:ok, Object.t()} | {:error, Ecto.Changeset.t()} + def remove_like_from_object(%Activity{data: %{"actor" => actor}}, object) do + object + |> fetch_likes() + |> List.delete(actor) + |> update_likes_in_object(object) + end + + defp update_likes_in_object(likes, object) do + update_element_in_object("like", likes, object) + end + + defp fetch_likes(object) do + if is_list(object.data["likes"]) do + object.data["likes"] + else + [] + end + end + + def add_participation_to_object(%Activity{data: %{"actor" => actor}}, object) do + [actor | fetch_participations(object)] + |> Enum.uniq() + |> update_participations_in_object(object) + end + + def remove_participation_from_object(%Activity{data: %{"actor" => actor}}, object) do + List.delete(fetch_participations(object), actor) + |> update_participations_in_object(object) + end + + defp update_participations_in_object(participations, object) do + update_element_in_object("participation", participations, object) + end + + def update_participation_request_count_in_object(object) do + params = %{ + type: "Join", + object: object.data["id"], + state: "pending" + } + + count = + [] + |> ActivityPub.fetch_activities_query(params) + |> Repo.aggregate(:count) + + data = Map.put(object.data, "participation_request_count", count) + + object + |> Changeset.change(data: data) + |> Object.update_and_set_cache() + end + + defp fetch_participations(object) do + if is_list(object.data["participations"]) do + object.data["participations"] + else + [] + end + end + + #### Follow-related helpers + + @doc """ + Updates a follow activity's state (for locked accounts). + """ + @spec update_follow_state_for_all(Activity.t(), String.t()) :: {:ok, Activity | nil} + def update_follow_state_for_all( + %Activity{data: %{"actor" => actor, "object" => object}} = activity, + state + ) do + "Follow" + |> Activity.Queries.by_type() + |> Activity.Queries.by_actor(actor) + |> Activity.Queries.by_object_id(object) + |> where(fragment("data->>'state' = 'pending'") or fragment("data->>'state' = 'accept'")) + |> update(set: [data: fragment("jsonb_set(data, '{state}', ?)", ^state)]) + |> Repo.update_all([]) + + activity = Activity.get_by_id(activity.id) + + {:ok, activity} + end + + def update_follow_state( + %Activity{} = activity, + state + ) do + new_data = Map.put(activity.data, "state", state) + changeset = Changeset.change(activity, data: new_data) + + with {:ok, activity} <- Repo.update(changeset) do + {:ok, activity} + end + end + + @doc """ + Makes a follow activity data for the given follower and followed + """ + def make_follow_data( + %User{ap_id: follower_id}, + %User{ap_id: followed_id} = _followed, + activity_id + ) do + %{ + "type" => "Follow", + "actor" => follower_id, + "to" => [followed_id], + "cc" => [Pleroma.Constants.as_public()], + "object" => followed_id, + "state" => "pending" + } + |> Maps.put_if_present("id", activity_id) + end + + def fetch_latest_follow(%User{ap_id: follower_id}, %User{ap_id: followed_id}) do + "Follow" + |> Activity.Queries.by_type() + |> where(actor: ^follower_id) + # this is to use the index + |> Activity.Queries.by_object_id(followed_id) + |> order_by([activity], fragment("? desc nulls last", activity.id)) + |> limit(1) + |> Repo.one() + end + + def fetch_latest_undo(%User{ap_id: ap_id}) do + "Undo" + |> Activity.Queries.by_type() + |> where(actor: ^ap_id) + |> order_by([activity], fragment("? desc nulls last", activity.id)) + |> limit(1) + |> Repo.one() + end + + def get_latest_reaction(internal_activity_id, %{ap_id: ap_id}, emoji) do + %{data: %{"object" => object_ap_id}} = Activity.get_by_id(internal_activity_id) + emoji = Pleroma.Emoji.maybe_quote(emoji) + + "EmojiReact" + |> Activity.Queries.by_type() + |> where(actor: ^ap_id) + |> custom_emoji_discriminator(emoji) + |> Activity.Queries.by_object_id(object_ap_id) + |> order_by([activity], fragment("? desc nulls last", activity.id)) + |> limit(1) + |> Repo.one() + end + + defp custom_emoji_discriminator(query, emoji) do + if String.contains?(emoji, "@") do + stripped = Pleroma.Emoji.maybe_strip_name(emoji) + [name, domain] = String.split(stripped, "@") + domain_pattern = "%/" <> domain <> "/%" + emoji_pattern = Pleroma.Emoji.maybe_quote(name) + + query + |> where([activity], fragment("?->>'content' = ? + AND EXISTS ( + SELECT FROM jsonb_array_elements(?->'tag') elem + WHERE elem->>'id' ILIKE ? + )", activity.data, ^emoji_pattern, activity.data, ^domain_pattern)) + else + query + |> where([activity], fragment("?->>'content' = ?", activity.data, ^emoji)) + end + end + + #### Announce-related helpers + + @doc """ + Returns an existing announce activity if the notice has already been announced + """ + @spec get_existing_announce(String.t(), map()) :: Activity.t() | nil + def get_existing_announce(actor, %{data: %{"id" => ap_id}}) do + "Announce" + |> Activity.Queries.by_type() + |> where(actor: ^actor) + # this is to use the index + |> Activity.Queries.by_object_id(ap_id) + |> Repo.one() + end + + @doc """ + Make announce activity data for the given actor and object + """ + # for relayed messages, we only want to send to subscribers + def make_announce_data( + %User{ap_id: ap_id} = user, + %Object{data: %{"id" => id}} = object, + activity_id, + false + ) do + %{ + "type" => "Announce", + "actor" => ap_id, + "object" => id, + "to" => [user.follower_address], + "cc" => [], + "context" => object.data["context"] + } + |> Maps.put_if_present("id", activity_id) + end + + def make_announce_data( + %User{ap_id: ap_id} = user, + %Object{data: %{"id" => id}} = object, + activity_id, + true + ) do + %{ + "type" => "Announce", + "actor" => ap_id, + "object" => id, + "to" => [user.follower_address, object.data["actor"]], + "cc" => [Pleroma.Constants.as_public()], + "context" => object.data["context"] + } + |> Maps.put_if_present("id", activity_id) + end + + def make_undo_data( + %User{ap_id: actor, follower_address: follower_address}, + %Activity{ + data: %{"id" => undone_activity_id, "context" => context}, + actor: undone_activity_actor + }, + activity_id \\ nil + ) do + %{ + "type" => "Undo", + "actor" => actor, + "object" => undone_activity_id, + "to" => [follower_address, undone_activity_actor], + "cc" => [Pleroma.Constants.as_public()], + "context" => context + } + |> Maps.put_if_present("id", activity_id) + end + + @spec add_announce_to_object(Activity.t(), Object.t()) :: + {:ok, Object.t()} | {:error, Ecto.Changeset.t()} + def add_announce_to_object( + %Activity{data: %{"actor" => actor}}, + object + ) do + unless actor |> User.get_cached_by_ap_id() |> User.invisible?() do + announcements = take_announcements(object) + + with announcements <- Enum.uniq([actor | announcements]) do + update_element_in_object("announcement", announcements, object) + end + else + {:ok, object} + end + end + + def add_announce_to_object(_, object), do: {:ok, object} + + @spec remove_announce_from_object(Activity.t(), Object.t()) :: + {:ok, Object.t()} | {:error, Ecto.Changeset.t()} + def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do + with announcements <- List.delete(take_announcements(object), actor) do + update_element_in_object("announcement", announcements, object) + end + end + + defp take_announcements(%{data: %{"announcements" => announcements}} = _) + when is_list(announcements), + do: announcements + + defp take_announcements(_), do: [] + + #### Unfollow-related helpers + + def make_unfollow_data(follower, followed, follow_activity, activity_id) do + %{ + "type" => "Undo", + "actor" => follower.ap_id, + "to" => [followed.ap_id], + "object" => follow_activity.data + } + |> Maps.put_if_present("id", activity_id) + end + + #### Block-related helpers + @spec fetch_latest_block(User.t(), User.t()) :: Activity.t() | nil + def fetch_latest_block(%User{ap_id: blocker_id}, %User{ap_id: blocked_id}) do + "Block" + |> Activity.Queries.by_type() + |> where(actor: ^blocker_id) + # this is to use the index + |> Activity.Queries.by_object_id(blocked_id) + |> order_by([activity], fragment("? desc nulls last", activity.id)) + |> limit(1) + |> Repo.one() + end + + def make_block_data(blocker, blocked, activity_id) do + %{ + "type" => "Block", + "actor" => blocker.ap_id, + "to" => [blocked.ap_id], + "object" => blocked.ap_id + } + |> Maps.put_if_present("id", activity_id) + end + + #### Create-related helpers + + def make_create_data(params, additional) do + published = params.published || make_date() + + %{ + "type" => "Create", + "to" => params.to |> Enum.uniq(), + "actor" => params.actor.ap_id, + "object" => params.object, + "published" => published, + "context" => params.context + } + |> Map.merge(additional) + end + + #### Listen-related helpers + def make_listen_data(params, additional) do + published = params.published || make_date() + + %{ + "type" => "Listen", + "to" => params.to |> Enum.uniq(), + "actor" => params.actor.ap_id, + "object" => params.object, + "published" => published, + "context" => params.context + } + |> Map.merge(additional) + end + + #### Flag-related helpers + @spec make_flag_data(map(), map()) :: map() + def make_flag_data( + %{actor: actor, context: context, content: content} = params, + additional + ) do + %{ + "type" => "Flag", + "actor" => actor.ap_id, + "content" => content, + "object" => build_flag_object(params), + "context" => context, + "state" => "open", + "rules" => Map.get(params, :rules, nil) + } + |> Map.merge(additional) + end + + def make_flag_data(_, _), do: %{} + + defp build_flag_object(%{account: account, statuses: statuses}) do + [account.ap_id | build_flag_object(%{statuses: statuses})] + end + + defp build_flag_object(%{statuses: statuses}) do + Enum.map(statuses || [], &build_flag_object/1) + end + + defp build_flag_object(%Activity{} = activity) do + object = Object.normalize(activity, fetch: false) + + # Do not allow people to report Creates. Instead, report the Object that is Created. + if activity.data["type"] != "Create" do + build_flag_object_with_actor_and_id( + object, + User.get_by_ap_id(activity.data["actor"]), + activity.data["id"] + ) + else + build_flag_object(object) + end + end + + defp build_flag_object(%Object{} = object) do + actor = User.get_by_ap_id(object.data["actor"]) + build_flag_object_with_actor_and_id(object, actor, object.data["id"]) + end + + defp build_flag_object(act) when is_map(act) or is_binary(act) do + id = + case act do + %Activity{} = act -> act.data["id"] + act when is_map(act) -> act["id"] + act when is_binary(act) -> act + end + + case Activity.get_by_ap_id_with_object(id) do + %Activity{object: object} = _ -> + build_flag_object(object) + + nil -> + if %Object{} = object = Object.get_by_ap_id(id) do + build_flag_object(object) + else + %{"id" => id, "deleted" => true} + end + end + end + + defp build_flag_object(_), do: [] + + defp build_flag_object_with_actor_and_id(%Object{data: data}, actor, id) do + %{ + "type" => "Note", + "id" => id, + "content" => data["content"], + "published" => data["published"], + "actor" => + AccountView.render( + "show.json", + %{user: actor, skip_visibility_check: true} + ) + } + end + + #### Report-related helpers + def get_reports(params, page, page_size) do + params = + params + |> Map.put(:type, "Flag") + |> Map.put(:skip_preload, true) + |> Map.put(:preload_report_notes, true) + |> Map.put(:total, true) + |> Map.put(:limit, page_size) + |> Map.put(:offset, (page - 1) * page_size) + + ActivityPub.fetch_activities([], params, :offset) + end + + defp maybe_strip_report_status(data, state) do + with true <- Config.get([:instance, :report_strip_status]), + true <- state in @strip_status_report_states, + {:ok, stripped_activity} = strip_report_status_data(%Activity{data: data}) do + data |> Map.put("object", stripped_activity.data["object"]) + else + _ -> data + end + end + + def update_report_state(%Activity{} = activity, state) when state in @supported_report_states do + new_data = + activity.data + |> Map.put("state", state) + |> maybe_strip_report_status(state) + + activity + |> Changeset.change(data: new_data) + |> Repo.update() + end + + def update_report_state(activity_ids, state) when state in @supported_report_states do + activities_num = length(activity_ids) + + from(a in Activity, where: a.id in ^activity_ids) + |> update(set: [data: fragment("jsonb_set(data, '{state}', ?)", ^state)]) + |> Repo.update_all([]) + |> case do + {^activities_num, _} -> :ok + _ -> {:error, activity_ids} + end + end + + def update_report_state(_, _), do: {:error, "Unsupported state"} + + def assign_report_to_account(%Activity{} = activity, nil = _account) do + new_data = Map.delete(activity.data, "assigned_account") + + activity + |> Changeset.change(data: new_data) + |> Repo.update() + end + + def assign_report_to_account(%Activity{} = activity, account) do + new_data = Map.put(activity.data, "assigned_account", account) + + activity + |> Changeset.change(data: new_data) + |> Repo.update() + end + + def assign_report_to_account(activity_ids, account) do + activities_num = length(activity_ids) + + from(a in Activity, where: a.id in ^activity_ids) + |> update(set: [data: fragment("jsonb_set(data, '{assigned_account}', ?)", ^account)]) + |> Repo.update_all([]) + |> case do + {^activities_num, _} -> :ok + _ -> {:error, activity_ids} + end + end + + def strip_report_status_data(activity) do + [actor | reported_activities] = activity.data["object"] + + stripped_activities = + Enum.map(reported_activities, fn + act when is_map(act) -> act["id"] + act when is_binary(act) -> act + end) + + new_data = put_in(activity.data, ["object"], [actor | stripped_activities]) + + {:ok, %{activity | data: new_data}} + end + + def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities do + [to, cc, recipients] = + activity + |> get_updated_targets(visibility) + |> Enum.map(&Enum.uniq/1) + + object_data = + activity.object.data + |> Map.put("to", to) + |> Map.put("cc", cc) + + {:ok, object} = + activity.object + |> Object.change(%{data: object_data}) + |> Object.update_and_set_cache() + + activity_data = + activity.data + |> Map.put("to", to) + |> Map.put("cc", cc) + + activity + |> Map.put(:object, object) + |> Activity.change(%{data: activity_data, recipients: recipients}) + |> Repo.update() + end + + def update_activity_visibility(_, _), do: {:error, "Unsupported visibility"} + + defp get_updated_targets( + %Activity{data: %{"to" => to} = data, recipients: recipients}, + visibility + ) do + cc = Map.get(data, "cc", []) + follower_address = User.get_cached_by_ap_id(data["actor"]).follower_address + public = Pleroma.Constants.as_public() + + case visibility do + "public" -> + to = [public | List.delete(to, follower_address)] + cc = [follower_address | List.delete(cc, public)] + recipients = [public | recipients] + [to, cc, recipients] + + "private" -> + to = [follower_address | List.delete(to, public)] + cc = List.delete(cc, public) + recipients = List.delete(recipients, public) + [to, cc, recipients] + + "unlisted" -> + to = [follower_address | List.delete(to, public)] + cc = [public | List.delete(cc, follower_address)] + recipients = recipients ++ [follower_address, public] + [to, cc, recipients] + + _ -> + [to, cc, recipients] + end + end + + def get_existing_votes(actor, %{data: %{"id" => id}}) do + actor + |> Activity.Queries.by_actor() + |> Activity.Queries.by_type("Create") + |> Activity.with_preloaded_object() + |> where([a, object: o], fragment("(?)->>'inReplyTo' = ?", o.data, ^to_string(id))) + |> where([a, object: o], fragment("(?)->>'type' = 'Answer'", o.data)) + |> Repo.all() + end + + def get_existing_join(actor, id) do + actor + |> Activity.Queries.by_actor() + |> Activity.Queries.by_object_id(id) + |> Activity.Queries.by_type("Join") + |> order_by([activity], fragment("? desc nulls last", activity.id)) + |> limit(1) + |> Repo.one() + end + + def update_join_state( + %Activity{} = activity, + state + ) do + new_data = Map.put(activity.data, "state", state) + changeset = Changeset.change(activity, data: new_data) + + with {:ok, activity} <- Repo.update(changeset) do + {:ok, activity} + end + end +end diff --git a/lib/pleroma/web/activity_pub/views/object_view.ex b/lib/pleroma/web/activity_pub/views/object_view.ex new file mode 100644 index 0000000..13b5b25 --- /dev/null +++ b/lib/pleroma/web/activity_pub/views/object_view.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectView do + use Pleroma.Web, :view + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Transmogrifier + + def render("object.json", %{object: %Object{} = object}) do + base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header(object.data) + + additional = Transmogrifier.prepare_object(object.data) + Map.merge(base, additional) + end + + def render("object.json", %{object: %Activity{data: %{"type" => activity_type}} = activity}) + when activity_type in ["Create", "Listen"] do + base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header(activity.data) + object = Object.normalize(activity, fetch: false) + + additional = + Transmogrifier.prepare_object(activity.data) + |> Map.put("object", Transmogrifier.prepare_object(object.data)) + + Map.merge(base, additional) + end + + def render("object.json", %{object: %Activity{} = activity}) do + base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header(activity.data) + object_id = Object.normalize(activity, id_only: true) + + additional = + Transmogrifier.prepare_object(activity.data) + |> Map.put("object", object_id) + + Map.merge(base, additional) + end +end diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex new file mode 100644 index 0000000..0f884fa --- /dev/null +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -0,0 +1,317 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.UserView do + use Pleroma.Web, :view + + alias Pleroma.Keys + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectView + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Router.Helpers + + import Ecto.Query + + def render("endpoints.json", %{user: %User{nickname: nil, local: true} = _user}) do + %{"sharedInbox" => Helpers.activity_pub_url(Endpoint, :inbox)} + end + + def render("endpoints.json", %{user: %User{local: true} = _user}) do + %{ + "oauthAuthorizationEndpoint" => Helpers.o_auth_url(Endpoint, :authorize), + "oauthRegistrationEndpoint" => Helpers.app_url(Endpoint, :create), + "oauthTokenEndpoint" => Helpers.o_auth_url(Endpoint, :token_exchange), + "sharedInbox" => Helpers.activity_pub_url(Endpoint, :inbox), + "uploadMedia" => Helpers.activity_pub_url(Endpoint, :upload_media) + } + end + + def render("endpoints.json", _), do: %{} + + def render("service.json", %{user: user}) do + {:ok, _, public_key} = Keys.keys_from_pem(user.keys) + public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) + public_key = :public_key.pem_encode([public_key]) + + endpoints = render("endpoints.json", %{user: user}) + + %{ + "id" => user.ap_id, + "type" => "Application", + "following" => "#{user.ap_id}/following", + "followers" => "#{user.ap_id}/followers", + "inbox" => "#{user.ap_id}/inbox", + "outbox" => "#{user.ap_id}/outbox", + "name" => "Pleroma", + "summary" => + "An internal service actor for this Pleroma instance. No user-serviceable parts inside.", + "url" => user.ap_id, + "manuallyApprovesFollowers" => false, + "publicKey" => %{ + "id" => "#{user.ap_id}#main-key", + "owner" => user.ap_id, + "publicKeyPem" => public_key + }, + "endpoints" => endpoints, + "invisible" => User.invisible?(user) + } + |> Map.merge(Utils.make_json_ld_header()) + end + + # the instance itself is not a Person, but instead an Application + def render("user.json", %{user: %User{nickname: nil} = user}), + do: render("service.json", %{user: user}) + + def render("user.json", %{user: %User{nickname: "internal." <> _} = user}) do + nickname = user.nickname |> String.split("@") |> List.first() + render("service.json", %{user: user}) |> Map.put("preferredUsername", nickname) + end + + def render("user.json", %{user: user}) do + {:ok, _, public_key} = Keys.keys_from_pem(user.keys) + public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) + public_key = :public_key.pem_encode([public_key]) + user = User.sanitize_html(user) + + endpoints = render("endpoints.json", %{user: user}) + + emoji_tags = Transmogrifier.take_emoji_tags(user) + + fields = Enum.map(user.fields, &Map.put(&1, "type", "PropertyValue")) + + capabilities = + if is_boolean(user.accepts_chat_messages) do + %{ + "acceptsChatMessages" => user.accepts_chat_messages + } + else + %{} + end + + birthday = + if user.show_birthday && user.birthday, + do: Date.to_iso8601(user.birthday), + else: nil + + %{ + "id" => user.ap_id, + "type" => user.actor_type, + "following" => "#{user.ap_id}/following", + "followers" => "#{user.ap_id}/followers", + "inbox" => "#{user.ap_id}/inbox", + "outbox" => "#{user.ap_id}/outbox", + "featured" => "#{user.ap_id}/collections/featured", + "preferredUsername" => user.nickname, + "name" => user.name, + "summary" => user.bio, + "url" => user.ap_id, + "manuallyApprovesFollowers" => user.is_locked, + "publicKey" => %{ + "id" => "#{user.ap_id}#main-key", + "owner" => user.ap_id, + "publicKeyPem" => public_key + }, + "endpoints" => endpoints, + "attachment" => fields, + "tag" => emoji_tags, + # Note: key name is indeed "discoverable" (not an error) + "discoverable" => user.is_discoverable, + "capabilities" => capabilities, + "alsoKnownAs" => user.also_known_as, + "vcard:bday" => birthday, + "vcard:Address" => user.location + } + |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) + |> Map.merge(maybe_make_image(&User.banner_url/2, "image", user)) + |> Map.merge(Utils.make_json_ld_header()) + end + + def render("following.json", %{user: user, page: page} = opts) do + showing_items = (opts[:for] && opts[:for] == user) || !user.hide_follows + showing_count = showing_items || !user.hide_follows_count + + query = User.get_friends_query(user) + query = from(user in query, select: [:ap_id]) + following = Repo.all(query) + + total = + if showing_count do + length(following) + else + 0 + end + + collection(following, "#{user.ap_id}/following", page, showing_items, total) + |> Map.merge(Utils.make_json_ld_header()) + end + + def render("following.json", %{user: user} = opts) do + showing_items = (opts[:for] && opts[:for] == user) || !user.hide_follows + showing_count = showing_items || !user.hide_follows_count + + query = User.get_friends_query(user) + query = from(user in query, select: [:ap_id]) + following = Repo.all(query) + + total = + if showing_count do + length(following) + else + 0 + end + + %{ + "id" => "#{user.ap_id}/following", + "type" => "OrderedCollection", + "totalItems" => total, + "first" => + if showing_items do + collection(following, "#{user.ap_id}/following", 1, !user.hide_follows) + else + "#{user.ap_id}/following?page=1" + end + } + |> Map.merge(Utils.make_json_ld_header()) + end + + def render("followers.json", %{user: user, page: page} = opts) do + showing_items = (opts[:for] && opts[:for] == user) || !user.hide_followers + showing_count = showing_items || !user.hide_followers_count + + query = User.get_followers_query(user) + query = from(user in query, select: [:ap_id]) + followers = Repo.all(query) + + total = + if showing_count do + length(followers) + else + 0 + end + + collection(followers, "#{user.ap_id}/followers", page, showing_items, total) + |> Map.merge(Utils.make_json_ld_header()) + end + + def render("followers.json", %{user: user} = opts) do + showing_items = (opts[:for] && opts[:for] == user) || !user.hide_followers + showing_count = showing_items || !user.hide_followers_count + + query = User.get_followers_query(user) + query = from(user in query, select: [:ap_id]) + followers = Repo.all(query) + + total = + if showing_count do + length(followers) + else + 0 + end + + %{ + "id" => "#{user.ap_id}/followers", + "type" => "OrderedCollection", + "first" => + if showing_items do + collection(followers, "#{user.ap_id}/followers", 1, showing_items, total) + else + "#{user.ap_id}/followers?page=1" + end + } + |> maybe_put_total_items(showing_count, total) + |> Map.merge(Utils.make_json_ld_header()) + end + + def render("activity_collection.json", %{iri: iri}) do + %{ + "id" => iri, + "type" => "OrderedCollection", + "first" => "#{iri}?page=true" + } + |> Map.merge(Utils.make_json_ld_header()) + end + + def render("activity_collection_page.json", %{ + activities: activities, + iri: iri, + pagination: pagination + }) do + collection = + Enum.map(activities, fn activity -> + {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) + data + end) + + %{ + "type" => "OrderedCollectionPage", + "partOf" => iri, + "orderedItems" => collection + } + |> Map.merge(Utils.make_json_ld_header()) + |> Map.merge(pagination) + end + + def render("featured.json", %{ + user: %{featured_address: featured_address, pinned_objects: pinned_objects} + }) do + objects = + pinned_objects + |> Enum.sort_by(fn {_, pinned_at} -> pinned_at end, &>=/2) + |> Enum.map(fn {id, _} -> + ObjectView.render("object.json", %{object: Object.get_cached_by_ap_id(id)}) + end) + + %{ + "id" => featured_address, + "type" => "OrderedCollection", + "orderedItems" => objects, + "totalItems" => length(objects) + } + |> Map.merge(Utils.make_json_ld_header()) + end + + defp maybe_put_total_items(map, false, _total), do: map + + defp maybe_put_total_items(map, true, total) do + Map.put(map, "totalItems", total) + end + + def collection(collection, iri, page, show_items \\ true, total \\ nil) do + offset = (page - 1) * 10 + items = Enum.slice(collection, offset, 10) + items = Enum.map(items, fn user -> user.ap_id end) + total = total || length(collection) + + map = %{ + "id" => "#{iri}?page=#{page}", + "type" => "OrderedCollectionPage", + "partOf" => iri, + "totalItems" => total, + "orderedItems" => if(show_items, do: items, else: []) + } + + if offset < total do + Map.put(map, "next", "#{iri}?page=#{page + 1}") + else + map + end + end + + defp maybe_make_image(func, key, user) do + if image = func.(user, no_default: true) do + %{ + key => %{ + "type" => "Image", + "url" => image + } + } + else + %{} + end + end +end diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex new file mode 100644 index 0000000..7c57f88 --- /dev/null +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -0,0 +1,154 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Visibility do + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Utils + + require Pleroma.Constants + + @spec is_public?(Object.t() | Activity.t() | map()) :: boolean() + def is_public?(%Object{data: %{"type" => "Tombstone"}}), do: false + def is_public?(%Object{data: data}), do: is_public?(data) + def is_public?(%Activity{data: %{"type" => "Move"}}), do: true + def is_public?(%Activity{data: data}), do: is_public?(data) + def is_public?(%{"directMessage" => true}), do: false + + def is_public?(data) do + Utils.label_in_message?(Pleroma.Constants.as_public(), data) or + Utils.label_in_message?(Utils.as_local_public(), data) + end + + def is_local_public?(%Object{data: data}), do: is_local_public?(data) + def is_local_public?(%Activity{data: data}), do: is_local_public?(data) + + def is_local_public?(data) do + Utils.label_in_message?(Utils.as_local_public(), data) and + not Utils.label_in_message?(Pleroma.Constants.as_public(), data) + end + + def is_private?(activity) do + with false <- is_public?(activity), + %User{follower_address: follower_address} <- + User.get_cached_by_ap_id(activity.data["actor"]) do + follower_address in activity.data["to"] + else + _ -> false + end + end + + def is_announceable?(activity, user, public \\ true) do + is_public?(activity) || + (!public && is_private?(activity) && activity.data["actor"] == user.ap_id) + end + + def is_direct?(%Activity{data: %{"directMessage" => true}}), do: true + def is_direct?(%Object{data: %{"directMessage" => true}}), do: true + + def is_direct?(activity) do + !is_public?(activity) && !is_private?(activity) + end + + def is_list?(%{data: %{"listMessage" => _}}), do: true + def is_list?(_), do: false + + @spec visible_for_user?(Object.t() | Activity.t() | nil, User.t() | nil) :: boolean() + def visible_for_user?(%Object{data: %{"type" => "Tombstone"}}, _), do: false + def visible_for_user?(%Activity{actor: ap_id}, %User{ap_id: ap_id}), do: true + def visible_for_user?(%Object{data: %{"actor" => ap_id}}, %User{ap_id: ap_id}), do: true + def visible_for_user?(nil, _), do: false + def visible_for_user?(%Activity{data: %{"listMessage" => _}}, nil), do: false + + def visible_for_user?( + %Activity{data: %{"listMessage" => list_ap_id}} = activity, + %User{} = user + ) do + user.ap_id in activity.data["to"] || + list_ap_id + |> Pleroma.List.get_by_ap_id() + |> Pleroma.List.member?(user) + end + + def visible_for_user?(%{__struct__: module} = message, nil) + when module in [Activity, Object] do + if restrict_unauthenticated_access?(message), + do: false, + else: is_public?(message) and not is_local_public?(message) + end + + def visible_for_user?(%{__struct__: module} = message, user) + when module in [Activity, Object] do + x = [user.ap_id | User.following(user)] + y = [message.data["actor"]] ++ message.data["to"] ++ (message.data["cc"] || []) + + user_is_local = user.local + federatable = not is_local_public?(message) + (is_public?(message) || Enum.any?(x, &(&1 in y))) and (user_is_local || federatable) + end + + def entire_thread_visible_for_user?(%Activity{} = activity, %User{} = user) do + {:ok, %{rows: [[result]]}} = + Ecto.Adapters.SQL.query(Repo, "SELECT thread_visibility($1, $2)", [ + user.ap_id, + activity.data["id"] + ]) + + result + end + + def restrict_unauthenticated_access?(%Activity{local: local}) do + restrict_unauthenticated_access_to_activity?(local) + end + + def restrict_unauthenticated_access?(%Object{} = object) do + object + |> Object.local?() + |> restrict_unauthenticated_access_to_activity?() + end + + def restrict_unauthenticated_access?(%User{} = user) do + User.visible_for(user, _reading_user = nil) + end + + defp restrict_unauthenticated_access_to_activity?(local?) when is_boolean(local?) do + cfg_key = if local?, do: :local, else: :remote + + Pleroma.Config.restrict_unauthenticated_access?(:activities, cfg_key) + end + + def get_visibility(object) do + to = object.data["to"] || [] + cc = object.data["cc"] || [] + + cond do + Pleroma.Constants.as_public() in to -> + "public" + + Pleroma.Constants.as_public() in cc -> + "unlisted" + + Utils.as_local_public() in to -> + "local" + + # this should use the sql for the object's activity + Enum.any?(to, &String.contains?(&1, "/followers")) -> + "private" + + object.data["directMessage"] == true -> + "direct" + + is_binary(object.data["listMessage"]) -> + "list" + + length(cc) > 0 -> + "private" + + true -> + "direct" + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex new file mode 100644 index 0000000..1894000 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -0,0 +1,445 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.AdminAPIController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [json_response: 3, fetch_integer_param: 3] + + alias Pleroma.Config + alias Pleroma.MFA + alias Pleroma.ModerationLog + alias Pleroma.Stats + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.AdminAPI.AccountView + alias Pleroma.Web.AdminAPI.ModerationLogView + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Web.Router + + @users_page_size 50 + + plug( + OAuthScopesPlug, + %{scopes: ["admin:read:accounts"]} + when action in [:right_get, :show_user_credentials, :create_backup] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:accounts"]} + when action in [ + :get_password_reset, + :force_password_reset, + :tag_users, + :untag_users, + :right_add, + :right_add_multiple, + :right_delete, + :disable_mfa, + :right_delete_multiple, + :update_user_credentials + ] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:read:statuses"]} + when action in [:list_user_statuses] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:read:chats"]} + when action in [:list_user_chats] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:read"]} + when action in [ + :list_log, + :stats, + :need_reboot + ] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write"]} + when action in [ + :restart, + :resend_confirmation_email, + :confirm_email, + :reload_emoji + ] + ) + + action_fallback(AdminAPI.FallbackController) + + def list_user_statuses(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname} = params) do + with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true + godmode = params["godmode"] == "true" || params["godmode"] == true + + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname, for: admin) do + {page, page_size} = page_params(params) + + result = + ActivityPub.fetch_user_activities(user, nil, %{ + limit: page_size, + offset: (page - 1) * page_size, + godmode: godmode, + exclude_reblogs: not with_reblogs, + pagination_type: :offset, + total: true + }) + + conn + |> put_view(AdminAPI.StatusView) + |> render("index.json", %{total: result[:total], activities: result[:items], as: :activity}) + else + _ -> {:error, :not_found} + end + end + + def list_user_chats(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname} = _params) do + with %User{id: user_id} <- User.get_cached_by_nickname_or_id(nickname, for: admin) do + chats = + Pleroma.Chat.for_user_query(user_id) + |> Pleroma.Repo.all() + + conn + |> put_view(AdminAPI.ChatView) + |> render("index.json", chats: chats) + else + _ -> {:error, :not_found} + end + end + + def tag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do + with {:ok, _} <- User.tag(nicknames, tags) do + ModerationLog.insert_log(%{ + actor: admin, + nicknames: nicknames, + tags: tags, + action: "tag" + }) + + json_response(conn, :no_content, "") + end + end + + def untag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do + with {:ok, _} <- User.untag(nicknames, tags) do + ModerationLog.insert_log(%{ + actor: admin, + nicknames: nicknames, + tags: tags, + action: "untag" + }) + + json_response(conn, :no_content, "") + end + end + + def right_add_multiple(%{assigns: %{user: admin}} = conn, %{ + "permission_group" => permission_group, + "nicknames" => nicknames + }) + when permission_group in ["moderator", "admin"] do + update = %{:"is_#{permission_group}" => true} + + users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) + + for u <- users, do: User.admin_api_update(u, update) + + ModerationLog.insert_log(%{ + action: "grant", + actor: admin, + subject: users, + permission: permission_group + }) + + json(conn, update) + end + + def right_add_multiple(conn, _) do + render_error(conn, :not_found, "No such permission_group") + end + + def right_add(%{assigns: %{user: admin}} = conn, %{ + "permission_group" => permission_group, + "nickname" => nickname + }) + when permission_group in ["moderator", "admin"] do + fields = %{:"is_#{permission_group}" => true} + + {:ok, user} = + nickname + |> User.get_cached_by_nickname() + |> User.admin_api_update(fields) + + ModerationLog.insert_log(%{ + action: "grant", + actor: admin, + subject: [user], + permission: permission_group + }) + + json(conn, fields) + end + + def right_add(conn, _) do + render_error(conn, :not_found, "No such permission_group") + end + + def right_get(conn, %{"nickname" => nickname}) do + user = User.get_cached_by_nickname(nickname) + + conn + |> json(%{ + is_moderator: user.is_moderator, + is_admin: user.is_admin + }) + end + + def right_delete_multiple( + %{assigns: %{user: %{nickname: admin_nickname} = admin}} = conn, + %{ + "permission_group" => permission_group, + "nicknames" => nicknames + } + ) + when permission_group in ["moderator", "admin"] do + with false <- Enum.member?(nicknames, admin_nickname) do + update = %{:"is_#{permission_group}" => false} + + users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) + + for u <- users, do: User.admin_api_update(u, update) + + ModerationLog.insert_log(%{ + action: "revoke", + actor: admin, + subject: users, + permission: permission_group + }) + + json(conn, update) + else + _ -> render_error(conn, :forbidden, "You can't revoke your own admin/moderator status.") + end + end + + def right_delete_multiple(conn, _) do + render_error(conn, :not_found, "No such permission_group") + end + + def right_delete( + %{assigns: %{user: admin}} = conn, + %{ + "permission_group" => permission_group, + "nickname" => nickname + } + ) + when permission_group in ["moderator", "admin"] do + fields = %{:"is_#{permission_group}" => false} + + {:ok, user} = + nickname + |> User.get_cached_by_nickname() + |> User.admin_api_update(fields) + + ModerationLog.insert_log(%{ + action: "revoke", + actor: admin, + subject: [user], + permission: permission_group + }) + + json(conn, fields) + end + + def right_delete(%{assigns: %{user: %{nickname: nickname}}} = conn, %{"nickname" => nickname}) do + render_error(conn, :forbidden, "You can't revoke your own admin status.") + end + + @doc "Get a password reset token (base64 string) for given nickname" + def get_password_reset(conn, %{"nickname" => nickname}) do + (%User{local: true} = user) = User.get_cached_by_nickname(nickname) + {:ok, token} = Pleroma.PasswordResetToken.create_token(user) + + conn + |> json(%{ + token: token.token, + link: Router.Helpers.reset_password_url(Endpoint, :reset, token.token) + }) + end + + @doc "Force password reset for a given user" + def force_password_reset(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) + + Enum.each(users, &User.force_password_reset_async/1) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "force_password_reset" + }) + + json_response(conn, :no_content, "") + end + + @doc "Disable mfa for user's account." + def disable_mfa(conn, %{"nickname" => nickname}) do + case User.get_by_nickname(nickname) do + %User{} = user -> + MFA.disable(user) + json(conn, nickname) + + _ -> + {:error, :not_found} + end + end + + @doc "Show a given user's credentials" + def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname, for: admin) do + conn + |> put_view(AccountView) + |> render("credentials.json", %{user: user, for: admin}) + else + _ -> {:error, :not_found} + end + end + + @doc "Updates a given user" + def update_user_credentials( + %{assigns: %{user: admin}} = conn, + %{"nickname" => nickname} = params + ) do + with {_, %User{} = user} <- {:user, User.get_cached_by_nickname(nickname)}, + {:ok, _user} <- + User.update_as_admin(user, params) do + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "updated_users" + }) + + if params["password"] do + User.force_password_reset_async(user) + end + + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "force_password_reset" + }) + + json(conn, %{status: "success"}) + else + {:error, changeset} -> + errors = Map.new(changeset.errors, fn {key, {error, _}} -> {key, error} end) + + {:errors, errors} + + _ -> + {:error, :not_found} + end + end + + def list_log(conn, params) do + {page, page_size} = page_params(params) + + log = + ModerationLog.get_all(%{ + page: page, + page_size: page_size, + start_date: params["start_date"], + end_date: params["end_date"], + user_id: params["user_id"], + search: params["search"] + }) + + conn + |> put_view(ModerationLogView) + |> render("index.json", %{log: log}) + end + + def restart(conn, _params) do + with :ok <- configurable_from_database() do + Restarter.Pleroma.restart(Config.get(:env), 50) + + json(conn, %{}) + end + end + + def need_reboot(conn, _params) do + json(conn, %{need_reboot: Restarter.Pleroma.need_reboot?()}) + end + + defp configurable_from_database do + if Config.get(:configurable_from_database) do + :ok + else + {:error, "You must enable configurable_from_database in your config file."} + end + end + + def reload_emoji(conn, _params) do + Pleroma.Emoji.reload() + + json(conn, "ok") + end + + def confirm_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + + User.confirm(users) + + ModerationLog.insert_log(%{actor: admin, subject: users, action: "confirm_email"}) + + json(conn, "") + end + + def resend_confirmation_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = + Enum.map(nicknames, fn nickname -> + nickname + |> User.get_cached_by_nickname() + |> User.send_confirmation_email() + end) + + ModerationLog.insert_log(%{actor: admin, subject: users, action: "resend_confirmation_email"}) + + json(conn, "") + end + + def stats(conn, params) do + counters = Stats.get_status_visibility_count(params["instance"]) + + json(conn, %{"status_visibility" => counters}) + end + + def create_backup(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_by_nickname(nickname), + {:ok, _} <- Pleroma.User.Backup.create(user, admin.id) do + ModerationLog.insert_log(%{actor: admin, subject: user, action: "create_backup"}) + + json(conn, "") + end + end + + defp page_params(params) do + { + fetch_integer_param(params, "page", 1), + fetch_integer_param(params, "page_size", @users_page_size) + } + end +end diff --git a/lib/pleroma/web/admin_api/controllers/announcement_controller.ex b/lib/pleroma/web/admin_api/controllers/announcement_controller.ex new file mode 100644 index 0000000..6ad5fc1 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/announcement_controller.ex @@ -0,0 +1,83 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.AnnouncementController do + use Pleroma.Web, :controller + + alias Pleroma.Announcement + alias Pleroma.Web.ControllerHelper + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["admin:write"]} when action in [:create, :delete, :change]) + plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action in [:index, :show]) + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.AnnouncementOperation + + defp default_limit, do: 20 + + def index(conn, params) do + limit = Map.get(params, :limit, default_limit()) + offset = Map.get(params, :offset, 0) + + announcements = Announcement.list_paginated(%{limit: limit, offset: offset}) + + render(conn, "index.json", announcements: announcements) + end + + def show(conn, %{id: id} = _params) do + announcement = Announcement.get_by_id(id) + + if is_nil(announcement) do + {:error, :not_found} + else + render(conn, "show.json", announcement: announcement) + end + end + + def create(%{body_params: params} = conn, _params) do + with {:ok, announcement} <- Announcement.add(change_params(params)) do + render(conn, "show.json", announcement: announcement) + else + _ -> + {:error, 400} + end + end + + def change_params(orig_params) do + data = + %{} + |> Pleroma.Maps.put_if_present("content", orig_params, &Map.fetch(&1, :content)) + |> Pleroma.Maps.put_if_present("all_day", orig_params, &Map.fetch(&1, :all_day)) + + orig_params + |> Map.merge(%{data: data}) + end + + def change(%{body_params: params} = conn, %{id: id} = _params) do + with announcement <- Announcement.get_by_id(id), + {:exists, true} <- {:exists, not is_nil(announcement)}, + {:ok, announcement} <- Announcement.update(announcement, change_params(params)) do + render(conn, "show.json", announcement: announcement) + else + {:exists, false} -> + {:error, :not_found} + + _ -> + {:error, 400} + end + end + + def delete(conn, %{id: id} = _params) do + case Announcement.delete_by_id(id) do + :ok -> + conn + |> ControllerHelper.json_response(:ok, %{}) + + _ -> + {:error, :not_found} + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/chat_controller.ex b/lib/pleroma/web/admin_api/controllers/chat_controller.ex new file mode 100644 index 0000000..298543f --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/chat_controller.ex @@ -0,0 +1,78 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ChatController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.Chat + alias Pleroma.Chat.MessageReference + alias Pleroma.Pagination + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView + alias Pleroma.Web.Plugs.OAuthScopesPlug + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:read:chats"]} when action in [:show, :messages] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:chats"]} when action in [:delete_message] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.ChatOperation + + def delete_message(%{assigns: %{user: user}} = conn, %{ + message_id: message_id, + id: chat_id + }) do + with %MessageReference{object: %{data: %{"id" => object_ap_id}}} = cm_ref <- + MessageReference.get_by_id(message_id), + ^chat_id <- to_string(cm_ref.chat_id), + %Activity{id: activity_id} <- Activity.get_create_by_object_ap_id(object_ap_id), + {:ok, _} <- CommonAPI.delete(activity_id, user) do + conn + |> put_view(MessageReferenceView) + |> render("show.json", chat_message_reference: cm_ref) + else + _e -> + {:error, :could_not_delete} + end + end + + def messages(conn, %{id: id} = params) do + with %Chat{} = chat <- Chat.get_by_id(id) do + cm_refs = + chat + |> MessageReference.for_chat_query() + |> Pagination.fetch_paginated(params) + + conn + |> put_view(MessageReferenceView) + |> render("index.json", chat_message_references: cm_refs) + else + _ -> + conn + |> put_status(:not_found) + |> json(%{error: "not found"}) + end + end + + def show(conn, %{id: id}) do + with %Chat{} = chat <- Chat.get_by_id(id) do + conn + |> put_view(AdminAPI.ChatView) + |> render("show.json", chat: chat) + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/config_controller.ex b/lib/pleroma/web/admin_api/controllers/config_controller.ex new file mode 100644 index 0000000..a03318c --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/config_controller.ex @@ -0,0 +1,198 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ConfigController do + use Pleroma.Web, :controller + + alias Pleroma.Config + alias Pleroma.ConfigDB + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["admin:write"]} when action == :update) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:read"]} + when action in [:show, :descriptions] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.ConfigOperation + + defp translate_descriptions(descriptions, path \\ []) do + Enum.map(descriptions, fn desc -> translate_item(desc, path) end) + end + + defp translate_string(str, path, type) do + Gettext.dpgettext( + Pleroma.Web.Gettext, + "config_descriptions", + Pleroma.Docs.Translator.Compiler.msgctxt_for(path, type), + str + ) + end + + defp maybe_put_translated(item, key, path) do + if item[key] do + Map.put( + item, + key, + translate_string( + item[key], + path ++ [Pleroma.Docs.Translator.Compiler.key_for(item)], + to_string(key) + ) + ) + else + item + end + end + + defp translate_item(item, path) do + item + |> maybe_put_translated(:label, path) + |> maybe_put_translated(:description, path) + |> translate_children(path) + end + + defp translate_children(%{children: children} = item, path) when is_list(children) do + item + |> Map.put( + :children, + translate_descriptions(children, path ++ [Pleroma.Docs.Translator.Compiler.key_for(item)]) + ) + end + + defp translate_children(item, _path) do + item + end + + def descriptions(conn, _params) do + descriptions = Enum.filter(Pleroma.Docs.JSON.compiled_descriptions(), &whitelisted_config?/1) + + json(conn, translate_descriptions(descriptions)) + end + + def show(conn, %{only_db: true}) do + with :ok <- configurable_from_database() do + configs = Pleroma.Repo.all(ConfigDB) + + render(conn, "index.json", %{ + configs: configs, + need_reboot: Restarter.Pleroma.need_reboot?() + }) + end + end + + def show(conn, _params) do + with :ok <- configurable_from_database() do + configs = ConfigDB.get_all_as_keyword() + + merged = + Config.Holder.default_config() + |> ConfigDB.merge(configs) + |> Enum.map(fn {group, values} -> + Enum.map(values, fn {key, value} -> + db = + if configs[group][key] do + ConfigDB.get_db_keys(configs[group][key], key) + end + + db_value = configs[group][key] + + merged_value = + if not is_nil(db_value) and Keyword.keyword?(db_value) and + ConfigDB.sub_key_full_update?(group, key, Keyword.keys(db_value)) do + ConfigDB.merge_group(group, key, value, db_value) + else + value + end + + %ConfigDB{ + group: group, + key: key, + value: merged_value + } + |> Pleroma.Maps.put_if_present(:db, db) + end) + end) + |> List.flatten() + + render(conn, "index.json", %{ + configs: merged, + need_reboot: Restarter.Pleroma.need_reboot?() + }) + end + end + + def update(%{body_params: %{configs: configs}} = conn, _) do + with :ok <- configurable_from_database() do + results = + configs + |> Enum.filter(&whitelisted_config?/1) + |> Enum.map(fn + %{group: group, key: key, delete: true} = params -> + ConfigDB.delete(%{group: group, key: key, subkeys: params[:subkeys]}) + + %{group: group, key: key, value: value} -> + ConfigDB.update_or_create(%{group: group, key: key, value: value}) + end) + |> Enum.reject(fn {result, _} -> result == :error end) + + {deleted, updated} = + results + |> Enum.map(fn {:ok, %{key: key, value: value} = config} -> + Map.put(config, :db, ConfigDB.get_db_keys(value, key)) + end) + |> Enum.split_with(&(Ecto.get_meta(&1, :state) == :deleted)) + + Config.TransferTask.load_and_update_env(deleted, false) + + if not Restarter.Pleroma.need_reboot?() do + changed_reboot_settings? = + (updated ++ deleted) + |> Enum.any?(&Config.TransferTask.pleroma_need_restart?(&1.group, &1.key, &1.value)) + + if changed_reboot_settings?, do: Restarter.Pleroma.need_reboot() + end + + render(conn, "index.json", %{ + configs: updated, + need_reboot: Restarter.Pleroma.need_reboot?() + }) + end + end + + defp configurable_from_database do + if Config.get(:configurable_from_database) do + :ok + else + {:error, "You must enable configurable_from_database in your config file."} + end + end + + defp whitelisted_config?(group, key) do + if whitelisted_configs = Config.get(:database_config_whitelist) do + Enum.any?(whitelisted_configs, fn + {whitelisted_group} -> + group == inspect(whitelisted_group) + + {whitelisted_group, whitelisted_key} -> + group == inspect(whitelisted_group) && key == inspect(whitelisted_key) + end) + else + true + end + end + + defp whitelisted_config?(%{group: group, key: key}) do + whitelisted_config?(group, key) + end + + defp whitelisted_config?(%{group: group} = config) do + whitelisted_config?(group, config[:key]) + end +end diff --git a/lib/pleroma/web/admin_api/controllers/email_list_controller.ex b/lib/pleroma/web/admin_api/controllers/email_list_controller.ex new file mode 100644 index 0000000..861ad7a --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/email_list_controller.ex @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.EmailListController do + use Pleroma.Web, :controller + + alias Pleroma.User.EmailList + alias Pleroma.Web.Plugs.OAuthScopesPlug + + require Logger + + plug(OAuthScopesPlug, %{scopes: ["admin:read:accounts"]}) + + def subscribers(conn, _params) do + render_csv(conn, :subscribers) + end + + def unsubscribers(conn, _params) do + render_csv(conn, :unsubscribers) + end + + def combined(conn, _params) do + render_csv(conn, :combined) + end + + defp render_csv(conn, audience) when is_atom(audience) do + csv = EmailList.generate_csv(audience) + + conn + |> put_resp_content_type("text/csv") + |> resp(200, csv) + |> send_resp() + end +end diff --git a/lib/pleroma/web/admin_api/controllers/fallback_controller.ex b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex new file mode 100644 index 0000000..d8fb54c --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex @@ -0,0 +1,43 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.FallbackController do + use Pleroma.Web, :controller + + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> json(%{error: dgettext("errors", "Not found")}) + end + + def call(conn, {:error, :forbidden}) do + conn + |> put_status(:forbidden) + |> json(%{error: dgettext("errors", "Forbidden")}) + end + + def call(conn, {:error, reason}) do + conn + |> put_status(:bad_request) + |> json(%{error: reason}) + end + + def call(conn, {:errors, errors}) do + conn + |> put_status(:bad_request) + |> json(%{errors: errors}) + end + + def call(conn, {:param_cast, _}) do + conn + |> put_status(:bad_request) + |> json(dgettext("errors", "Invalid parameters")) + end + + def call(conn, _) do + conn + |> put_status(:internal_server_error) + |> json(%{error: dgettext("errors", "Something went wrong")}) + end +end diff --git a/lib/pleroma/web/admin_api/controllers/frontend_controller.ex b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex new file mode 100644 index 0000000..3336dae --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex @@ -0,0 +1,71 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.FrontendController do + use Pleroma.Web, :controller + + alias Pleroma.Config + alias Pleroma.Frontend + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["admin:write"]} when action == :install) + plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action == :index) + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.FrontendOperation + + def index(conn, _params) do + installed = installed() + + # FIrst get frontends from config, + # then add frontends that are installed but not in the config + frontends = + Config.get([:frontends, :available], []) + |> Enum.map(fn {name, desc} -> + desc + |> Map.put("installed", name in installed) + |> Map.put("installed_refs", installed_refs(name)) + end) + + frontends = + frontends ++ + (installed + |> Enum.filter(fn n -> not Enum.any?(frontends, fn f -> f["name"] == n end) end) + |> Enum.map(fn name -> + %{"name" => name, "installed" => true, "installed_refs" => installed_refs(name)} + end)) + + render(conn, "index.json", frontends: frontends) + end + + def install(%{body_params: params} = conn, _params) do + with %Frontend{} = frontend <- params_to_frontend(params), + %Frontend{} <- Frontend.install(frontend) do + index(conn, %{}) + end + end + + defp installed do + frontend_directory = Pleroma.Frontend.dir() + + if File.exists?(frontend_directory) do + File.ls!(frontend_directory) + else + [] + end + end + + defp params_to_frontend(params) when is_map(params) do + struct(Frontend, params) + end + + def installed_refs(name) do + if name in installed() do + File.ls!(Path.join(Pleroma.Frontend.dir(), name)) + else + [] + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/instance_controller.ex b/lib/pleroma/web/admin_api/controllers/instance_controller.ex new file mode 100644 index 0000000..117a722 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/instance_controller.ex @@ -0,0 +1,63 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.InstanceController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [fetch_integer_param: 3] + + alias Pleroma.Instances.Instance + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.Plugs.OAuthScopesPlug + + require Logger + + @default_page_size 50 + + plug( + OAuthScopesPlug, + %{scopes: ["admin:read:statuses"]} + when action in [:list_statuses] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:accounts", "admin:write:statuses"]} + when action in [:delete] + ) + + action_fallback(AdminAPI.FallbackController) + + def list_statuses(conn, %{"instance" => instance} = params) do + with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true + {page, page_size} = page_params(params) + + result = + ActivityPub.fetch_statuses(nil, %{ + instance: instance, + limit: page_size, + offset: (page - 1) * page_size, + exclude_reblogs: not with_reblogs, + total: true + }) + + conn + |> put_view(AdminAPI.StatusView) + |> render("index.json", %{total: result[:total], activities: result[:items], as: :activity}) + end + + def delete(conn, %{"instance" => instance}) do + with {:ok, _job} <- Instance.delete_users_and_activities(instance) do + json(conn, instance) + end + end + + defp page_params(params) do + { + fetch_integer_param(params, "page", 1), + fetch_integer_param(params, "page_size", @default_page_size) + } + end +end diff --git a/lib/pleroma/web/admin_api/controllers/instance_document_controller.ex b/lib/pleroma/web/admin_api/controllers/instance_document_controller.ex new file mode 100644 index 0000000..990a943 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/instance_document_controller.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.InstanceDocumentController do + use Pleroma.Web, :controller + + alias Pleroma.Web.InstanceDocument + alias Pleroma.Web.Plugs.InstanceStatic + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.InstanceDocumentOperation + + plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action == :show) + plug(OAuthScopesPlug, %{scopes: ["admin:write"]} when action in [:update, :delete]) + + def show(conn, %{name: document_name}) do + with {:ok, url} <- InstanceDocument.get(document_name), + {:ok, content} <- File.read(InstanceStatic.file_path(url)) do + conn + |> put_resp_content_type("text/html") + |> send_resp(200, content) + end + end + + def update(%{body_params: %{file: file}} = conn, %{name: document_name}) do + with {:ok, url} <- InstanceDocument.put(document_name, file.path) do + json(conn, %{"url" => url}) + end + end + + def delete(conn, %{name: document_name}) do + with :ok <- InstanceDocument.delete(document_name) do + json(conn, %{}) + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/invite_controller.ex b/lib/pleroma/web/admin_api/controllers/invite_controller.ex new file mode 100644 index 0000000..c5d759b --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/invite_controller.ex @@ -0,0 +1,78 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.InviteController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.Config + alias Pleroma.UserInviteToken + alias Pleroma.Web.Plugs.OAuthScopesPlug + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["admin:read:invites"]} when action == :index) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:invites"]} when action in [:create, :revoke, :email] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.InviteOperation + + @doc "Get list of created invites" + def index(conn, _params) do + invites = UserInviteToken.list_invites() + + render(conn, "index.json", invites: invites) + end + + @doc "Create an account registration invite token" + def create(%{body_params: params} = conn, _) do + {:ok, invite} = UserInviteToken.create_invite(params) + + render(conn, "show.json", invite: invite) + end + + @doc "Revokes invite by token" + def revoke(%{body_params: %{token: token}} = conn, _) do + with {:ok, invite} <- UserInviteToken.find_by_token(token), + {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do + render(conn, "show.json", invite: updated_invite) + else + nil -> {:error, :not_found} + error -> error + end + end + + @doc "Sends registration invite via email" + def email(%{assigns: %{user: user}, body_params: %{email: email} = params} = conn, _) do + with {_, false} <- {:registrations_open, Config.get([:instance, :registrations_open])}, + {_, true} <- {:invites_enabled, Config.get([:instance, :invites_enabled])}, + {:ok, invite_token} <- UserInviteToken.create_invite(), + {:ok, _} <- + user + |> Pleroma.Emails.UserEmail.user_invitation_email( + invite_token, + email, + params[:name] + ) + |> Pleroma.Emails.Mailer.deliver() do + json_response(conn, :no_content, "") + else + {:registrations_open, _} -> + {:error, "To send invites you need to set the `registrations_open` option to false."} + + {:invites_enabled, _} -> + {:error, "To send invites you need to set the `invites_enabled` option to true."} + + {:error, error} -> + {:error, error} + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex new file mode 100644 index 0000000..4d53f54 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex @@ -0,0 +1,76 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do + use Pleroma.Web, :controller + + alias Pleroma.Web.ApiSpec.Admin, as: Spec + alias Pleroma.Web.MediaProxy + alias Pleroma.Web.Plugs.OAuthScopesPlug + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:read:media_proxy_caches"]} when action in [:index] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:media_proxy_caches"]} when action in [:purge, :delete] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Spec.MediaProxyCacheOperation + + def index(%{assigns: %{user: _}} = conn, params) do + entries = fetch_entries(params) + urls = paginate_entries(entries, params.page, params.page_size) + + render(conn, "index.json", + urls: urls, + page_size: params.page_size, + count: length(entries) + ) + end + + defp fetch_entries(params) do + MediaProxy.cache_table() + |> @cachex.stream!(Cachex.Query.create(true, :key)) + |> filter_entries(params[:query]) + end + + defp filter_entries(stream, query) when is_binary(query) do + regex = ~r/#{query}/i + + stream + |> Enum.filter(fn url -> String.match?(url, regex) end) + |> Enum.to_list() + end + + defp filter_entries(stream, _), do: Enum.to_list(stream) + + defp paginate_entries(entries, page, page_size) do + offset = page_size * (page - 1) + Enum.slice(entries, offset, page_size) + end + + def delete(%{assigns: %{user: _}, body_params: %{urls: urls}} = conn, _) do + MediaProxy.remove_from_banned_urls(urls) + json(conn, %{}) + end + + def purge(%{assigns: %{user: _}, body_params: %{urls: urls, ban: ban}} = conn, _) do + MediaProxy.Invalidation.purge(urls) + + if ban do + MediaProxy.put_in_banned_urls(urls) + end + + json(conn, %{}) + end +end diff --git a/lib/pleroma/web/admin_api/controllers/o_auth_app_controller.ex b/lib/pleroma/web/admin_api/controllers/o_auth_app_controller.ex new file mode 100644 index 0000000..879e8b2 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/o_auth_app_controller.ex @@ -0,0 +1,76 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.OAuthAppController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.Plugs.OAuthScopesPlug + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write"]} + when action in [:create, :index, :update, :delete] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.OAuthAppOperation + + def index(conn, params) do + search_params = + params + |> Map.take([:client_id, :page, :page_size, :trusted]) + |> Map.put(:client_name, params[:name]) + + with {:ok, apps, count} <- App.search(search_params) do + render(conn, "index.json", + apps: apps, + count: count, + page_size: params.page_size, + admin: true + ) + end + end + + def create(%{body_params: params} = conn, _) do + params = Pleroma.Maps.put_if_present(params, :client_name, params[:name]) + + case App.create(params) do + {:ok, app} -> + render(conn, "show.json", app: app, admin: true) + + {:error, changeset} -> + json(conn, App.errors(changeset)) + end + end + + def update(%{body_params: params} = conn, %{id: id}) do + params = Pleroma.Maps.put_if_present(params, :client_name, params[:name]) + + with {:ok, app} <- App.update(id, params) do + render(conn, "show.json", app: app, admin: true) + else + {:error, changeset} -> + json(conn, App.errors(changeset)) + + nil -> + json_response(conn, :bad_request, "") + end + end + + def delete(conn, params) do + with {:ok, _app} <- App.destroy(params.id) do + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/relay_controller.ex b/lib/pleroma/web/admin_api/controllers/relay_controller.ex new file mode 100644 index 0000000..2e83fe1 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/relay_controller.ex @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.RelayController do + use Pleroma.Web, :controller + + alias Pleroma.ModerationLog + alias Pleroma.Web.ActivityPub.Relay + alias Pleroma.Web.Plugs.OAuthScopesPlug + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:follows"]} + when action in [:follow, :unfollow] + ) + + plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action == :index) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.RelayOperation + + def index(conn, _params) do + with {:ok, list} <- Relay.list() do + json(conn, %{relays: list}) + end + end + + def follow(%{assigns: %{user: admin}, body_params: %{relay_url: target}} = conn, _) do + with {:ok, _message} <- Relay.follow(target) do + ModerationLog.insert_log(%{action: "relay_follow", actor: admin, target: target}) + + json(conn, %{actor: target, followed_back: target in Relay.following()}) + else + _ -> + conn + |> put_status(500) + |> json(target) + end + end + + def unfollow(%{assigns: %{user: admin}, body_params: %{relay_url: target} = params} = conn, _) do + with {:ok, _message} <- Relay.unfollow(target, %{force: params[:force]}) do + ModerationLog.insert_log(%{action: "relay_unfollow", actor: admin, target: target}) + + json(conn, target) + else + _ -> + conn + |> put_status(500) + |> json(target) + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/report_controller.ex b/lib/pleroma/web/admin_api/controllers/report_controller.ex new file mode 100644 index 0000000..8a1b23b --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/report_controller.ex @@ -0,0 +1,161 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ReportController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.Activity + alias Pleroma.ModerationLog + alias Pleroma.ReportNote + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Plugs.OAuthScopesPlug + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["admin:read:reports"]} when action in [:index, :show]) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:reports"]} + when action in [:update, :assign_account, :notes_create, :notes_delete] + ) + + action_fallback(AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.ReportOperation + + def index(conn, params) do + reports = Utils.get_reports(params, params.page, params.page_size) + + render(conn, "index.json", reports: reports) + end + + def show(conn, %{id: id}) do + with %Activity{} = report <- Activity.get_report(id) do + render(conn, "show.json", Report.extract_report_info(report)) + else + _ -> {:error, :not_found} + end + end + + def update(%{assigns: %{user: admin}, body_params: %{reports: reports}} = conn, _) do + result = + Enum.map(reports, fn report -> + case CommonAPI.update_report_state(report.id, report.state) do + {:ok, activity} -> + report = Activity.get_by_id_with_user_actor(activity.id) + + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: activity, + subject_actor: report.user_actor + }) + + activity + + {:error, message} -> + %{id: report.id, error: message} + end + end) + + if Enum.any?(result, &Map.has_key?(&1, :error)) do + json_response(conn, :bad_request, result) + else + json_response(conn, :no_content, "") + end + end + + def assign_account(%{assigns: %{user: admin}, body_params: %{reports: reports}} = conn, _) do + result = Enum.map(reports, &do_assign_account(&1, admin)) + + if Enum.any?(result, &Map.has_key?(&1, :error)) do + json_response(conn, :bad_request, result) + else + json_response(conn, :no_content, "") + end + end + + def notes_create(%{assigns: %{user: user}, body_params: %{content: content}} = conn, %{ + id: report_id + }) do + with {:ok, _} <- ReportNote.create(user.id, report_id, content), + report <- Activity.get_by_id_with_user_actor(report_id) do + ModerationLog.insert_log(%{ + action: "report_note", + actor: user, + subject: report, + subject_actor: report.user_actor, + text: content + }) + + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end + + def notes_delete(%{assigns: %{user: user}} = conn, %{ + id: note_id, + report_id: report_id + }) do + with {:ok, note} <- ReportNote.destroy(note_id), + report <- Activity.get_by_id_with_user_actor(report_id) do + ModerationLog.insert_log(%{ + action: "report_note_delete", + actor: user, + subject: report, + subject_actor: report.user_actor, + text: note.content + }) + + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end + + defp do_assign_account(%{assigned_account: nil, id: id}, admin) do + with {:ok, activity} <- CommonAPI.assign_report_to_account(id, nil), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_unassigned", + actor: admin, + subject: activity, + subject_actor: report.user_actor + }) + + activity + else + {:error, message} -> + %{id: id, error: message} + end + end + + defp do_assign_account(%{assigned_account: assigned_account, id: id}, admin) do + with %User{id: account} = user <- User.get_cached_by_nickname(assigned_account), + {:ok, activity} <- CommonAPI.assign_report_to_account(id, account), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_assigned", + actor: admin, + subject: activity, + subject_actor: report.user_actor, + assigned_account: user.nickname + }) + + activity + else + {:error, message} -> + %{id: id, error: message} + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/rule_controller.ex b/lib/pleroma/web/admin_api/controllers/rule_controller.ex new file mode 100644 index 0000000..43b2f20 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/rule_controller.ex @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.RuleController do + use Pleroma.Web, :controller + + alias Pleroma.Repo + alias Pleroma.Rule + alias Pleroma.Web.Plugs.OAuthScopesPlug + + import Pleroma.Web.ControllerHelper, + only: [ + json_response: 3 + ] + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write"]} + when action in [:create, :update, :delete] + ) + + plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action == :index) + + action_fallback(AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.RuleOperation + + def index(conn, _) do + rules = + Rule.query() + |> Repo.all() + + render(conn, "index.json", rules: rules) + end + + def create(%{body_params: params} = conn, _) do + rule = + params + |> Rule.create() + + render(conn, "show.json", rule: rule) + end + + def update(%{body_params: params} = conn, %{id: id}) do + rule = + params + |> Rule.update(id) + + render(conn, "show.json", rule: rule) + end + + def delete(conn, %{id: id}) do + with {:ok, _} <- Rule.delete(id) do + json(conn, %{}) + else + _ -> json_response(conn, :bad_request, "") + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/status_controller.ex b/lib/pleroma/web/admin_api/controllers/status_controller.ex new file mode 100644 index 0000000..9a3d49b --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/status_controller.ex @@ -0,0 +1,71 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.StatusController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.ModerationLog + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI + alias Pleroma.Web.Plugs.OAuthScopesPlug + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["admin:read:statuses"]} when action in [:index, :show]) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:statuses"]} when action in [:update, :delete] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.StatusOperation + + def index(%{assigns: %{user: _admin}} = conn, params) do + activities = + ActivityPub.fetch_statuses(nil, %{ + godmode: params.godmode, + local_only: params.local_only, + limit: params.page_size, + offset: (params.page - 1) * params.page_size, + exclude_reblogs: not params.with_reblogs + }) + + render(conn, "index.json", activities: activities, as: :activity) + end + + def show(conn, %{id: id}) do + with %Activity{} = activity <- Activity.get_by_id(id) do + render(conn, "show.json", %{activity: activity}) + else + nil -> {:error, :not_found} + end + end + + def update(%{assigns: %{user: admin}, body_params: params} = conn, %{id: id}) do + with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do + ModerationLog.insert_log(%{ + action: "status_update", + actor: admin, + subject: activity, + sensitive: params[:sensitive], + visibility: params[:visibility] + }) + + conn + |> put_view(MastodonAPI.StatusView) + |> render("show.json", %{activity: activity}) + end + end + + def delete(%{assigns: %{user: user}} = conn, %{id: id}) do + with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do + json(conn, %{}) + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex new file mode 100644 index 0000000..2bc170a --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -0,0 +1,324 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.UserController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [fetch_integer_param: 3] + + alias Pleroma.ModerationLog + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.AdminAPI.Search + alias Pleroma.Web.Plugs.OAuthScopesPlug + + @users_page_size 50 + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:read:accounts"]} + when action in [:index, :show] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:accounts"]} + when action in [ + :delete, + :create, + :toggle_activation, + :activate, + :deactivate, + :approve, + :suggest, + :unsuggest + ] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:follows"]} + when action in [:follow, :unfollow] + ) + + action_fallback(AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.UserOperation + + def delete(conn, %{nickname: nickname}) do + conn + |> Map.put(:body_params, %{nicknames: [nickname]}) + |> delete(%{}) + end + + def delete(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + + if Enum.all?(users, &is_higher_role(admin, &1)) do + Enum.each(users, fn user -> + {:ok, delete_data, _} = Builder.delete(admin, user.ap_id) + Pipeline.common_pipeline(delete_data, local: true) + end) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "delete" + }) + + json(conn, nicknames) + else + conn + |> put_status(:forbidden) + |> json(%{error: dgettext("errors", "Forbidden")}) + end + end + + # true if actor is greater OR EQUAL in role to target + defp is_higher_role(%User{} = actor, %User{} = target) do + role_weight(actor) >= role_weight(target) + end + + defp role_weight(%User{is_admin: true}), do: 2 + defp role_weight(%User{is_moderator: true}), do: 1 + defp role_weight(_), do: 0 + + def follow( + %{ + assigns: %{user: admin}, + body_params: %{ + follower: follower_nick, + followed: followed_nick + } + } = conn, + _ + ) do + with %User{} = follower <- User.get_cached_by_nickname(follower_nick), + %User{} = followed <- User.get_cached_by_nickname(followed_nick) do + User.follow(follower, followed) + + ModerationLog.insert_log(%{ + actor: admin, + followed: followed, + follower: follower, + action: "follow" + }) + end + + json(conn, "ok") + end + + def unfollow( + %{ + assigns: %{user: admin}, + body_params: %{ + follower: follower_nick, + followed: followed_nick + } + } = conn, + _ + ) do + with %User{} = follower <- User.get_cached_by_nickname(follower_nick), + %User{} = followed <- User.get_cached_by_nickname(followed_nick) do + User.unfollow(follower, followed) + + ModerationLog.insert_log(%{ + actor: admin, + followed: followed, + follower: follower, + action: "unfollow" + }) + end + + json(conn, "ok") + end + + def create(%{assigns: %{user: admin}, body_params: %{users: users}} = conn, _) do + changesets = + users + |> Enum.map(fn %{nickname: nickname, email: email, password: password} -> + user_data = %{ + nickname: nickname, + name: nickname, + email: email, + password: password, + password_confirmation: password, + bio: "." + } + + User.register_changeset(%User{}, user_data, need_confirmation: false) + end) + |> Enum.reduce(Ecto.Multi.new(), fn changeset, multi -> + Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset) + end) + + case Pleroma.Repo.transaction(changesets) do + {:ok, users_map} -> + users = + users_map + |> Map.values() + |> Enum.map(fn user -> + {:ok, user} = User.post_register_action(user) + + user + end) + + ModerationLog.insert_log(%{ + actor: admin, + subjects: users, + action: "create" + }) + + render(conn, "created_many.json", users: users) + + {:error, id, changeset, _} -> + changesets = + Enum.map(changesets.operations, fn + {^id, {:changeset, _current_changeset, _}} -> + changeset + + {_, {:changeset, current_changeset, _}} -> + current_changeset + end) + + conn + |> put_status(:conflict) + |> render("create_errors.json", changesets: changesets) + end + end + + def show(%{assigns: %{user: admin}} = conn, %{nickname: nickname}) do + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname, for: admin) do + render(conn, "show.json", %{user: user}) + else + _ -> {:error, :not_found} + end + end + + def toggle_activation(%{assigns: %{user: admin}} = conn, %{nickname: nickname}) do + user = User.get_cached_by_nickname(nickname) + + {:ok, updated_user} = User.set_activation(user, !user.is_active) + + action = if !user.is_active, do: "activate", else: "deactivate" + + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: action + }) + + render(conn, "show.json", user: updated_user) + end + + def activate(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + {:ok, updated_users} = User.set_activation(users, true) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "activate" + }) + + render(conn, "index.json", users: Keyword.values(updated_users)) + end + + def deactivate(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + {:ok, updated_users} = User.set_activation(users, false) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "deactivate" + }) + + render(conn, "index.json", users: Keyword.values(updated_users)) + end + + def approve(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + {:ok, updated_users} = User.approve(users) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "approve" + }) + + render(conn, "index.json", users: updated_users) + end + + def suggest(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + {:ok, updated_users} = User.set_suggestion(users, true) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "add_suggestion" + }) + + render(conn, "index.json", users: updated_users) + end + + def unsuggest(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + {:ok, updated_users} = User.set_suggestion(users, false) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "remove_suggestion" + }) + + render(conn, "index.json", users: updated_users) + end + + def index(conn, params) do + {page, page_size} = page_params(params) + filters = maybe_parse_filters(params[:filters]) + + search_params = + %{ + query: params[:query], + page: page, + page_size: page_size, + tags: params[:tags], + name: params[:name], + email: params[:email], + actor_types: params[:actor_types] + } + |> Map.merge(filters) + + with {:ok, users, count} <- Search.user(search_params) do + render(conn, "index.json", users: users, count: count, page_size: page_size) + end + end + + @filters ~w(local external active deactivated need_approval unconfirmed is_admin is_moderator) + + @spec maybe_parse_filters(String.t()) :: %{required(String.t()) => true} | %{} + defp maybe_parse_filters(filters) when is_nil(filters) or filters == "", do: %{} + + defp maybe_parse_filters(filters) do + filters + |> String.split(",") + |> Enum.filter(&Enum.member?(@filters, &1)) + |> Map.new(&{String.to_existing_atom(&1), true}) + end + + defp page_params(params) do + { + fetch_integer_param(params, :page, 1), + fetch_integer_param(params, :page_size, @users_page_size) + } + end +end diff --git a/lib/pleroma/web/admin_api/controllers/webhook_controller.ex b/lib/pleroma/web/admin_api/controllers/webhook_controller.ex new file mode 100644 index 0000000..dd9712a --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/webhook_controller.ex @@ -0,0 +1,95 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.WebhookController do + use Pleroma.Web, :controller + + alias Pleroma.Repo + alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Webhook + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write"]} + when action in [:update, :create, :delete, :enable, :disable, :rotate_secret] + ) + + plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action in [:index, :show]) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.WebhookOperation + + def index(conn, _) do + webhooks = + Webhook + |> Repo.all() + + render(conn, "index.json", webhooks: webhooks) + end + + def show(conn, %{id: id}) do + with %Webhook{} = webhook <- Webhook.get(id) do + render(conn, "show.json", webhook: webhook) + else + nil -> {:error, :not_found} + end + end + + def create(%{body_params: params} = conn, _) do + with webhook <- Webhook.create(params) do + render(conn, "show.json", webhook: webhook) + end + end + + def update(%{body_params: params} = conn, %{id: id}) do + with %Webhook{internal: false} = webhook <- Webhook.get(id), + webhook <- Webhook.update(webhook, params) do + render(conn, "show.json", webhook: webhook) + else + %Webhook{internal: true} -> {:error, :forbidden} + end + end + + def delete(conn, %{id: id}) do + with %Webhook{internal: false} = webhook <- Webhook.get(id), + {:ok, webhook} <- Webhook.delete(webhook) do + render(conn, "show.json", webhook: webhook) + else + %Webhook{internal: true} -> {:error, :forbidden} + end + end + + def enable(conn, %{id: id}) do + with %Webhook{internal: false} = webhook <- Webhook.get(id), + {:ok, webhook} <- Webhook.set_enabled(webhook, true) do + render(conn, "show.json", webhook: webhook) + else + %Webhook{internal: true} -> {:error, :forbidden} + nil -> {:error, :not_found} + end + end + + def disable(conn, %{id: id}) do + with %Webhook{internal: false} = webhook <- Webhook.get(id), + {:ok, webhook} <- Webhook.set_enabled(webhook, false) do + render(conn, "show.json", webhook: webhook) + else + %Webhook{internal: true} -> {:error, :forbidden} + nil -> {:error, :not_found} + end + end + + def rotate_secret(conn, %{id: id}) do + with %Webhook{internal: false} = webhook <- Webhook.get(id), + {:ok, webhook} <- Webhook.rotate_secret(webhook) do + render(conn, "show.json", webhook: webhook) + else + %Webhook{internal: true} -> {:error, :forbidden} + nil -> {:error, :not_found} + end + end +end diff --git a/lib/pleroma/web/admin_api/report.ex b/lib/pleroma/web/admin_api/report.ex new file mode 100644 index 0000000..753b92d --- /dev/null +++ b/lib/pleroma/web/admin_api/report.ex @@ -0,0 +1,71 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.Report do + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.User + + def extract_report_info( + %{data: %{"actor" => actor, "object" => [account_ap_id | status_ap_ids]}} = report + ) do + user = User.get_cached_by_ap_id(actor) + account = User.get_cached_by_ap_id(account_ap_id) + + assigned_account = + if Map.has_key?(report.data, "assigned_account") do + User.get_cached_by_id(report.data["assigned_account"]) + end + + statuses = + status_ap_ids + |> Enum.reject(&is_nil(&1)) + |> Enum.map(fn + act when is_map(act) -> + Activity.get_create_by_object_ap_id_with_object(act["id"]) || + Activity.get_by_ap_id_with_object(act["id"]) || make_fake_activity(act, user) + + act when is_binary(act) -> + Activity.get_create_by_object_ap_id_with_object(act) || + Activity.get_by_ap_id_with_object(act) + end) + + %{ + report: report, + user: user, + account: account, + statuses: statuses, + assigned_account: assigned_account + } + end + + defp make_fake_activity(act, user) do + %Activity{ + id: "pleroma:fake:#{act["id"]}", + data: %{ + "actor" => user.ap_id, + "type" => "Create", + "to" => [], + "cc" => [], + "object" => act["id"], + "published" => act["published"], + "id" => act["id"], + "context" => "pleroma:fake" + }, + recipients: [user.ap_id], + object: %Object{ + data: %{ + "actor" => user.ap_id, + "type" => "Note", + "content" => act["content"], + "published" => act["published"], + "to" => [], + "cc" => [], + "id" => act["id"], + "context" => "pleroma:fake" + } + } + } + end +end diff --git a/lib/pleroma/web/admin_api/search.ex b/lib/pleroma/web/admin_api/search.ex new file mode 100644 index 0000000..f5195ac --- /dev/null +++ b/lib/pleroma/web/admin_api/search.ex @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.Search do + import Ecto.Query + + alias Pleroma.Repo + alias Pleroma.User + + @page_size 50 + + @spec user(map()) :: {:ok, [User.t()], pos_integer()} + def user(params \\ %{}) do + query = + params + |> Map.drop([:page, :page_size]) + |> Map.put(:invisible, false) + |> User.Query.build() + |> order_by(desc: :id) + + paginated_query = + User.Query.paginate(query, params[:page] || 1, params[:page_size] || @page_size) + + count = Repo.aggregate(query, :count, :id) + + results = Repo.all(paginated_query) + {:ok, results, count} + end +end diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex new file mode 100644 index 0000000..2801522 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -0,0 +1,161 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.AccountView do + use Pleroma.Web, :view + + alias Pleroma.User + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.AdminAPI.AccountView + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI + alias Pleroma.Web.MediaProxy + + def render("index.json", %{users: users, count: count, page_size: page_size}) do + %{ + users: render_many(users, AccountView, "show.json", as: :user), + count: count, + page_size: page_size + } + end + + def render("index.json", %{users: users}) do + %{ + users: render_many(users, AccountView, "show.json", as: :user) + } + end + + def render("credentials.json", %{user: user, for: for_user}) do + user = User.sanitize_html(user, User.html_filter_policy(for_user)) + avatar = User.avatar_url(user) |> MediaProxy.url() + banner = User.banner_url(user) |> MediaProxy.url() + background = image_url(user.background) |> MediaProxy.url() + + user + |> Map.take([ + :id, + :bio, + :email, + :fields, + :name, + :nickname, + :is_locked, + :no_rich_text, + :default_scope, + :hide_follows, + :hide_followers_count, + :hide_follows_count, + :hide_followers, + :hide_favorites, + :allow_following_move, + :show_role, + :skip_thread_containment, + :pleroma_settings_store, + :raw_fields, + :is_discoverable, + :actor_type + ]) + |> Map.merge(%{ + "avatar" => avatar, + "banner" => banner, + "background" => background + }) + end + + def render("show.json", %{user: user}) do + avatar = User.avatar_url(user) |> MediaProxy.url() + display_name = Pleroma.HTML.strip_tags(user.name || user.nickname) + user = User.sanitize_html(user, FastSanitize.Sanitizer.StripTags) + + %{ + "id" => user.id, + "email" => user.email, + "avatar" => avatar, + "nickname" => user.nickname, + "display_name" => display_name, + "is_active" => user.is_active, + "local" => user.local, + "roles" => roles(user), + "tags" => user.tags || [], + "is_confirmed" => user.is_confirmed, + "is_approved" => user.is_approved, + "is_suggested" => user.is_suggested, + "url" => user.uri || user.ap_id, + "registration_reason" => user.registration_reason, + "actor_type" => user.actor_type, + "created_at" => CommonAPI.Utils.to_masto_date(user.inserted_at) + } + end + + def render("created_many.json", %{users: users}) do + render_many(users, AccountView, "created.json", as: :user) + end + + def render("created.json", %{user: user}) do + %{ + type: "success", + code: 200, + data: %{ + nickname: user.nickname, + email: user.email + } + } + end + + def render("create_errors.json", %{changesets: changesets}) do + render_many(changesets, AccountView, "create_error.json", as: :changeset) + end + + def render("create_error.json", %{changeset: %Ecto.Changeset{changes: changes, errors: errors}}) do + %{ + type: "error", + code: 409, + error: parse_error(errors), + data: %{ + nickname: Map.get(changes, :nickname), + email: Map.get(changes, :email) + } + } + end + + def merge_account_views(%User{} = user) do + MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true}) + |> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user})) + end + + def merge_account_views(_), do: %{} + + defp parse_error([]), do: "" + + defp parse_error(errors) do + ## when nickname is duplicate ap_id constraint error is raised + nickname_error = Keyword.get(errors, :nickname) || Keyword.get(errors, :ap_id) + email_error = Keyword.get(errors, :email) + password_error = Keyword.get(errors, :password) + + cond do + nickname_error -> + "nickname #{elem(nickname_error, 0)}" + + email_error -> + "email #{elem(email_error, 0)}" + + password_error -> + "password #{elem(password_error, 0)}" + + true -> + "" + end + end + + defp image_url(%{"url" => [%{"href" => href} | _]}), do: href + defp image_url(_), do: nil + + defp roles(%{is_moderator: is_moderator, is_admin: is_admin}) do + %{ + admin: is_admin, + moderator: is_moderator + } + end +end diff --git a/lib/pleroma/web/admin_api/views/announcement_view.ex b/lib/pleroma/web/admin_api/views/announcement_view.ex new file mode 100644 index 0000000..a35bd60 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/announcement_view.ex @@ -0,0 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.AnnouncementView do + use Pleroma.Web, :view + + def render("index.json", %{announcements: announcements}) do + render_many(announcements, __MODULE__, "show.json") + end + + def render("show.json", %{announcement: announcement}) do + Pleroma.Announcement.render_json(announcement, admin: true) + end +end diff --git a/lib/pleroma/web/admin_api/views/chat_view.ex b/lib/pleroma/web/admin_api/views/chat_view.ex new file mode 100644 index 0000000..d58bf8e --- /dev/null +++ b/lib/pleroma/web/admin_api/views/chat_view.ex @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ChatView do + use Pleroma.Web, :view + + alias Pleroma.Chat + alias Pleroma.User + alias Pleroma.Web.MastodonAPI + alias Pleroma.Web.PleromaAPI + + def render("index.json", %{chats: chats} = opts) do + render_many(chats, __MODULE__, "show.json", Map.delete(opts, :chats)) + end + + def render("show.json", %{chat: %Chat{user_id: user_id}} = opts) do + user = User.get_by_id(user_id) + sender = MastodonAPI.AccountView.render("show.json", user: user, skip_visibility_check: true) + + serialized_chat = PleromaAPI.ChatView.render("show.json", opts) + + serialized_chat + |> Map.put(:sender, sender) + |> Map.put(:receiver, serialized_chat[:account]) + |> Map.delete(:account) + end + + def render(view, opts), do: PleromaAPI.ChatView.render(view, opts) +end diff --git a/lib/pleroma/web/admin_api/views/config_view.ex b/lib/pleroma/web/admin_api/views/config_view.ex new file mode 100644 index 0000000..f582ad4 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/config_view.ex @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ConfigView do + use Pleroma.Web, :view + + alias Pleroma.ConfigDB + + def render("index.json", %{configs: configs} = params) do + %{ + configs: render_many(configs, __MODULE__, "show.json", as: :config), + need_reboot: params[:need_reboot] + } + end + + def render("show.json", %{config: config}) do + map = %{ + key: ConfigDB.to_json_types(config.key), + group: ConfigDB.to_json_types(config.group), + value: ConfigDB.to_json_types(config.value) + } + + if config.db != [] do + Map.put(map, :db, config.db) + else + map + end + end +end diff --git a/lib/pleroma/web/admin_api/views/frontend_view.ex b/lib/pleroma/web/admin_api/views/frontend_view.ex new file mode 100644 index 0000000..ae40165 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/frontend_view.ex @@ -0,0 +1,22 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.FrontendView do + use Pleroma.Web, :view + + def render("index.json", %{frontends: frontends}) do + render_many(frontends, __MODULE__, "show.json") + end + + def render("show.json", %{frontend: frontend}) do + %{ + name: frontend["name"], + git: frontend["git"], + build_url: frontend["build_url"], + ref: frontend["ref"], + installed: frontend["installed"], + installed_refs: frontend["installed_refs"] + } + end +end diff --git a/lib/pleroma/web/admin_api/views/invite_view.ex b/lib/pleroma/web/admin_api/views/invite_view.ex new file mode 100644 index 0000000..76cee3b --- /dev/null +++ b/lib/pleroma/web/admin_api/views/invite_view.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.InviteView do + use Pleroma.Web, :view + + def render("index.json", %{invites: invites}) do + %{ + invites: render_many(invites, __MODULE__, "show.json", as: :invite) + } + end + + def render("show.json", %{invite: invite}) do + %{ + "id" => invite.id, + "token" => invite.token, + "used" => invite.used, + "expires_at" => invite.expires_at, + "uses" => invite.uses, + "max_use" => invite.max_use, + "invite_type" => invite.invite_type + } + end +end diff --git a/lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex b/lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex new file mode 100644 index 0000000..b46f54e --- /dev/null +++ b/lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex @@ -0,0 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.MediaProxyCacheView do + use Pleroma.Web, :view + + def render("index.json", %{urls: urls, page_size: page_size, count: count}) do + %{ + urls: urls, + count: count, + page_size: page_size + } + end +end diff --git a/lib/pleroma/web/admin_api/views/moderation_log_view.ex b/lib/pleroma/web/admin_api/views/moderation_log_view.ex new file mode 100644 index 0000000..1f25f19 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/moderation_log_view.ex @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ModerationLogView do + use Pleroma.Web, :view + + alias Pleroma.ModerationLog + + def render("index.json", %{log: log}) do + %{ + items: render_many(log.items, __MODULE__, "show.json", as: :log_entry), + total: log.count + } + end + + def render("show.json", %{log_entry: log_entry}) do + time = + log_entry.inserted_at + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_unix() + + %{ + id: log_entry.id, + data: log_entry.data, + time: time, + message: ModerationLog.get_log_entry_message(log_entry) + } + end +end diff --git a/lib/pleroma/web/admin_api/views/o_auth_app_view.ex b/lib/pleroma/web/admin_api/views/o_auth_app_view.ex new file mode 100644 index 0000000..d1aef0e --- /dev/null +++ b/lib/pleroma/web/admin_api/views/o_auth_app_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.OAuthAppView do + use Pleroma.Web, :view + alias Pleroma.Web.MastodonAPI + + def render(view, opts), do: MastodonAPI.AppView.render(view, opts) +end diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex new file mode 100644 index 0000000..9b386fa --- /dev/null +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -0,0 +1,99 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ReportView do + use Pleroma.Web, :view + + alias Pleroma.HTML + alias Pleroma.Rule + alias Pleroma.User + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.InstanceView + alias Pleroma.Web.MastodonAPI.StatusView + + defdelegate merge_account_views(user), to: AdminAPI.AccountView + + def render("index.json", %{reports: reports}) do + %{ + reports: + reports[:items] + |> Enum.map(&Report.extract_report_info/1) + |> Enum.map(&render(__MODULE__, "show.json", &1)), + total: reports[:total] + } + end + + def render("show.json", %{ + report: report, + user: user, + account: account, + statuses: statuses, + assigned_account: assigned_account + }) do + created_at = Utils.to_masto_date(report.data["published"]) + + content = + unless is_nil(report.data["content"]) do + HTML.filter_tags(report.data["content"]) + else + nil + end + + assigned_account = + if assigned_account do + merge_account_views(assigned_account) + end + + %{ + id: report.id, + account: merge_account_views(account), + actor: merge_account_views(user), + content: content, + created_at: created_at, + statuses: + StatusView.render("index.json", %{ + activities: statuses, + as: :activity + }), + state: report.data["state"], + notes: render(__MODULE__, "index_notes.json", %{notes: report.report_notes}), + assigned_account: assigned_account, + rules: rules(Map.get(report.data, "rules", nil)) + } + end + + def render("index_notes.json", %{notes: notes}) when is_list(notes) do + Enum.map(notes, &render(__MODULE__, "show_note.json", Map.from_struct(&1))) + end + + def render("index_notes.json", _), do: [] + + def render("show_note.json", %{ + id: id, + content: content, + user_id: user_id, + inserted_at: inserted_at + }) do + user = User.get_by_id(user_id) + + %{ + id: id, + content: content, + user: merge_account_views(user), + created_at: Utils.to_masto_date(inserted_at) + } + end + + defp rules(nil) do + [] + end + + defp rules(rule_ids) do + rule_ids + |> Rule.get() + |> render_many(InstanceView, "rule.json", as: :rule) + end +end diff --git a/lib/pleroma/web/admin_api/views/rule_view.ex b/lib/pleroma/web/admin_api/views/rule_view.ex new file mode 100644 index 0000000..f291452 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/rule_view.ex @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.RuleView do + use Pleroma.Web, :view + + require Pleroma.Constants + + def render("index.json", %{rules: rules} = _opts) do + render_many(rules, __MODULE__, "show.json") + end + + def render("show.json", %{rule: rule} = _opts) do + %{ + id: rule.id, + priority: rule.priority, + text: rule.text + } + end +end diff --git a/lib/pleroma/web/admin_api/views/status_view.ex b/lib/pleroma/web/admin_api/views/status_view.ex new file mode 100644 index 0000000..03b5c44 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/status_view.ex @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.StatusView do + use Pleroma.Web, :view + + require Pleroma.Constants + + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI + + defdelegate merge_account_views(user), to: AdminAPI.AccountView + + def render("index.json", %{total: total} = opts) do + %{total: total, activities: safe_render_many(opts.activities, __MODULE__, "show.json", opts)} + end + + def render("index.json", opts) do + safe_render_many(opts.activities, __MODULE__, "show.json", opts) + end + + def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do + user = CommonAPI.get_user(activity.data["actor"]) + + MastodonAPI.StatusView.render("show.json", opts) + |> Map.merge(%{account: merge_account_views(user)}) + end +end diff --git a/lib/pleroma/web/admin_api/views/user_view.ex b/lib/pleroma/web/admin_api/views/user_view.ex new file mode 100644 index 0000000..f198921 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/user_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.UserView do + use Pleroma.Web, :view + alias Pleroma.Web.AdminAPI + + def render(view, opts), do: AdminAPI.AccountView.render(view, opts) +end diff --git a/lib/pleroma/web/admin_api/views/webhook_view.ex b/lib/pleroma/web/admin_api/views/webhook_view.ex new file mode 100644 index 0000000..552aba4 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/webhook_view.ex @@ -0,0 +1,34 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.WebhookView do + use Pleroma.Web, :view + + alias Pleroma.Web.CommonAPI.Utils + + def render("index.json", %{webhooks: webhooks}) do + render_many(webhooks, __MODULE__, "show.json") + end + + def render("show.json", %{webhook: webhook}) do + %{ + id: webhook.id |> to_string(), + url: webhook.url, + events: webhook.events, + secret: webhook.secret, + enabled: webhook.enabled, + internal: webhook.internal, + created_at: Utils.to_masto_date(webhook.inserted_at), + updated_at: Utils.to_masto_date(webhook.updated_at) + } + end + + def render("event.json", %{type: type, object: object}) do + %{ + type: type, + created_at: Utils.to_masto_date(NaiveDateTime.utc_now()), + object: object + } + end +end diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex new file mode 100644 index 0000000..193246b --- /dev/null +++ b/lib/pleroma/web/api_spec.ex @@ -0,0 +1,170 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec do + alias OpenApiSpex.OpenApi + alias OpenApiSpex.Operation + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Router + + @behaviour OpenApi + + defp streaming_paths do + %{ + "/api/v1/streaming" => %OpenApiSpex.PathItem{ + get: Pleroma.Web.ApiSpec.StreamingOperation.streaming_operation() + } + } + end + + @impl OpenApi + def spec(opts \\ []) do + %OpenApi{ + servers: + if opts[:server_specific] do + [ + # Populate the Server info from a phoenix endpoint + OpenApiSpex.Server.from_endpoint(Endpoint) + ] + else + [] + end, + info: %OpenApiSpex.Info{ + title: "Pleroma API", + description: """ + This is documentation for client Pleroma API. Most of the endpoints and entities come + from Mastodon API and have custom extensions on top. + + While this document aims to be a complete guide to the client API Pleroma exposes, + the details are still being worked out. Some endpoints may have incomplete or poorly worded documentation. + You might want to check the following resources if something is not clear: + - [Legacy Pleroma-specific endpoint documentation](https://docs-develop.pleroma.social/backend/development/API/pleroma_api/) + - [Mastodon API documentation](https://docs.joinmastodon.org/client/intro/) + - [Differences in Mastodon API responses from vanilla Mastodon](https://docs-develop.pleroma.social/backend/development/API/differences_in_mastoapi_responses/) + + Please report such occurences on our [issue tracker](https://git.pleroma.social/pleroma/pleroma/-/issues). Feel free to submit API questions or proposals there too! + """, + # Strip environment from the version + version: Application.spec(:pleroma, :vsn) |> to_string() |> String.replace(~r/\+.*$/, ""), + extensions: %{ + # Logo path should be picked so that the path exists both on Pleroma instances and on api.pleroma.social + "x-logo": %{"url" => "/static/logo.svg", "altText" => "Pleroma logo"} + } + }, + # populate the paths from a phoenix router + paths: Map.merge(streaming_paths(), OpenApiSpex.Paths.from_router(Router)), + components: %OpenApiSpex.Components{ + parameters: %{ + "accountIdOrNickname" => + Operation.parameter(:id, :path, :string, "Account ID or nickname", + example: "123", + required: true + ) + }, + securitySchemes: %{ + "oAuth" => %OpenApiSpex.SecurityScheme{ + type: "oauth2", + flows: %OpenApiSpex.OAuthFlows{ + password: %OpenApiSpex.OAuthFlow{ + authorizationUrl: "/oauth/authorize", + tokenUrl: "/oauth/token", + scopes: %{ + # TODO: Document granular scopes + "read" => "Read everything", + "write" => "Write everything", + "follow" => "Manage relationships", + "push" => "Web Push API subscriptions" + } + } + } + } + } + }, + extensions: %{ + # Redoc-specific extension, every time a new tag is added it should be reflected here, + # otherwise it won't be shown. + "x-tagGroups": [ + %{ + "name" => "Accounts", + "tags" => ["Account actions", "Retrieve account information", "Scrobbles"] + }, + %{ + "name" => "Administration", + "tags" => [ + "Chat administration", + "Emoji pack administration", + "Frontend managment", + "Instance configuration", + "Instance documents", + "Invites", + "MediaProxy cache", + "OAuth application managment", + "Relays", + "Report managment", + "Status administration", + "User administration", + "Announcement management", + "Instance rule managment", + "Webhooks" + ] + }, + %{ + "name" => "Administration (MastoAPI)", + "tags" => [ + "User administration", + "Report methods" + ] + }, + %{"name" => "Applications", "tags" => ["Applications", "Push subscriptions"]}, + %{ + "name" => "Current account", + "tags" => [ + "Account credentials", + "Backups", + "Blocks and mutes", + "Data import", + "Domain blocks", + "Follow requests", + "Mascot", + "Markers", + "Notifications", + "Filters", + "Settings" + ] + }, + %{"name" => "Instance", "tags" => ["Custom emojis", "Instance misc"]}, + %{"name" => "Messaging", "tags" => ["Chats", "Conversations"]}, + %{ + "name" => "Statuses", + "tags" => [ + "Emoji reactions", + "Lists", + "Polls", + "Timelines", + "Retrieve status information", + "Scheduled statuses", + "Search", + "Status actions", + "Media attachments", + "Event actions" + ] + }, + %{ + "name" => "Miscellaneous", + "tags" => [ + "Emoji packs", + "Reports", + "Suggestions", + "Announcements", + "Remote interaction", + "Others" + ] + } + ] + } + } + # discover request/response schemas from path specs + |> OpenApiSpex.resolve_schema_modules() + end +end diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex new file mode 100644 index 0000000..add59eb --- /dev/null +++ b/lib/pleroma/web/api_spec/cast_and_validate.ex @@ -0,0 +1,138 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2019-2020 Moxley Stratton, Mike Buhot , MPL-2.0 +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.CastAndValidate do + @moduledoc """ + This plug is based on [`OpenApiSpex.Plug.CastAndValidate`] + (https://github.com/open-api-spex/open_api_spex/blob/master/lib/open_api_spex/plug/cast_and_validate.ex). + The main difference is ignoring unexpected query params instead of throwing + an error and a config option (`[Pleroma.Web.ApiSpec.CastAndValidate, :strict]`) + to disable this behavior. Also, the default rendering error module + is `Pleroma.Web.ApiSpec.RenderError`. + """ + + @behaviour Plug + + alias OpenApiSpex.Plug.PutApiSpec + alias Plug.Conn + + @impl Plug + def init(opts) do + opts + |> Map.new() + |> Map.put_new(:render_error, Pleroma.Web.ApiSpec.RenderError) + end + + @impl Plug + + def call(conn, %{operation_id: operation_id, render_error: render_error}) do + {spec, operation_lookup} = PutApiSpec.get_spec_and_operation_lookup(conn) + operation = operation_lookup[operation_id] + + content_type = + case Conn.get_req_header(conn, "content-type") do + [header_value | _] -> + header_value + |> String.split(";") + |> List.first() + + _ -> + "application/json" + end + + conn = Conn.put_private(conn, :operation_id, operation_id) + + case cast_and_validate(spec, operation, conn, content_type, strict?()) do + {:ok, conn} -> + conn + + {:error, reason} -> + opts = render_error.init(reason) + + conn + |> render_error.call(opts) + |> Plug.Conn.halt() + end + end + + def call( + %{ + private: %{ + phoenix_controller: controller, + phoenix_action: action, + open_api_spex: %{spec_module: spec_module} + } + } = conn, + opts + ) do + {spec, operation_lookup} = PutApiSpec.get_spec_and_operation_lookup(conn) + + operation = + case operation_lookup[{controller, action}] do + nil -> + operation_id = controller.open_api_operation(action).operationId + operation = operation_lookup[operation_id] + + operation_lookup = Map.put(operation_lookup, {controller, action}, operation) + + OpenApiSpex.Plug.Cache.adapter().put(spec_module, {spec, operation_lookup}) + + operation + + operation -> + operation + end + + if operation.operationId do + call(conn, Map.put(opts, :operation_id, operation.operationId)) + else + raise "operationId was not found in action API spec" + end + end + + def call(conn, opts), do: OpenApiSpex.Plug.CastAndValidate.call(conn, opts) + + defp cast_and_validate(spec, operation, conn, content_type, true = _strict) do + OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) + end + + defp cast_and_validate(spec, operation, conn, content_type, false = _strict) do + case OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) do + {:ok, conn} -> + {:ok, conn} + + # Remove unexpected query params and cast/validate again + {:error, errors} -> + query_params = + Enum.reduce(errors, conn.query_params, fn + %{reason: :unexpected_field, name: name, path: [name]}, params -> + Map.delete(params, name) + + # Filter out empty params + %{reason: :invalid_type, path: [name_atom], value: ""}, params -> + Map.delete(params, to_string(name_atom)) + + %{reason: :invalid_enum, name: nil, path: path, value: value}, params -> + path = path |> Enum.reverse() |> tl() |> Enum.reverse() |> list_items_to_string() + update_in(params, path, &List.delete(&1, value)) + + _, params -> + params + end) + + conn = %Conn{conn | query_params: query_params} + OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) + end + end + + defp list_items_to_string(list) do + Enum.map(list, fn + i when is_atom(i) -> to_string(i) + i -> i + end) + end + + defp strict?, do: Pleroma.Config.get([__MODULE__, :strict], false) +end diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex new file mode 100644 index 0000000..f20a916 --- /dev/null +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Helpers do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + + def request_body(description, schema_ref, opts \\ []) do + media_types = ["application/json", "multipart/form-data", "application/x-www-form-urlencoded"] + + content = + media_types + |> Enum.map(fn type -> + {type, + %OpenApiSpex.MediaType{ + schema: schema_ref, + example: opts[:example], + examples: opts[:examples] + }} + end) + |> Enum.into(%{}) + + %OpenApiSpex.RequestBody{ + description: description, + content: content, + required: opts[:required] || false + } + end + + def admin_api_params do + [Operation.parameter(:admin_token, :query, :string, "Allows authorization via admin token.")] + end + + def pagination_params do + [ + Operation.parameter(:max_id, :query, :string, "Return items older than this ID"), + Operation.parameter(:min_id, :query, :string, "Return the oldest items newer than this ID"), + Operation.parameter( + :since_id, + :query, + :string, + "Return the newest items newer than this ID" + ), + Operation.parameter( + :offset, + :query, + %Schema{type: :integer, default: 0}, + "Return items past this number of items" + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 20}, + "Maximum number of items to return. Will be ignored if it's more than 40" + ) + ] + end + + def with_relationships_param do + Operation.parameter( + :with_relationships, + :query, + BooleanLike, + "Embed relationships into accounts. **If this parameter is not set account's `pleroma.relationship` is going to be `null`.**" + ) + end + + def empty_object_response do + Operation.response("Empty object", "application/json", %Schema{type: :object, example: %{}}) + end + + def empty_array_response do + Operation.response("Empty array", "application/json", %Schema{ + type: :array, + items: %Schema{type: :object, example: %{}}, + example: [] + }) + end + + def no_content_response do + Operation.response("No Content", "application/json", %Schema{type: :string, example: ""}) + end +end diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex new file mode 100644 index 0000000..d04460a --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -0,0 +1,1040 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.AccountOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Reference + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship + alias Pleroma.Web.ApiSpec.Schemas.ActorType + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.List + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + @spec open_api_operation(atom) :: Operation.t() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + @spec create_operation() :: Operation.t() + def create_operation do + %Operation{ + tags: ["Account credentials"], + summary: "Register an account", + description: + "Creates a user and account records. Returns an account access token for the app that initiated the request. The app should save this token for later, and should wait for the user to confirm their account by clicking a link in their email inbox.", + operationId: "AccountController.create", + requestBody: request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Account", "application/json", create_response()), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError), + 429 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def verify_credentials_operation do + %Operation{ + tags: ["Account credentials"], + description: "Test to make sure that the user token works.", + summary: "Verify account credentials", + operationId: "AccountController.verify_credentials", + security: [%{"oAuth" => ["read:accounts"]}], + responses: %{ + 200 => Operation.response("Account", "application/json", Account) + } + } + end + + def update_credentials_operation do + %Operation{ + tags: ["Account credentials"], + summary: "Update account credentials", + description: "Update the user's display and preferences.", + operationId: "AccountController.update_credentials", + security: [%{"oAuth" => ["write:accounts"]}], + requestBody: request_body("Parameters", update_credentials_request(), required: true), + responses: %{ + 200 => Operation.response("Account", "application/json", Account), + 403 => Operation.response("Error", "application/json", ApiError), + 413 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def relationships_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Relationship with current account", + operationId: "AccountController.relationships", + description: "Find out whether a given account is followed, blocked, muted, etc.", + security: [%{"oAuth" => ["read:follows"]}], + parameters: [ + Operation.parameter( + :id, + :query, + %Schema{ + oneOf: [%Schema{type: :array, items: %Schema{type: :string}}, %Schema{type: :string}] + }, + "Account IDs", + example: "123" + ) + ], + responses: %{ + 200 => Operation.response("Account", "application/json", array_of_relationships()) + } + } + end + + def show_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Account", + operationId: "AccountController.show", + description: "View information about a profile.", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + with_relationships_param() + ], + responses: %{ + 200 => Operation.response("Account", "application/json", Account), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def statuses_operation do + %Operation{ + summary: "Statuses", + tags: ["Retrieve account information"], + operationId: "AccountController.statuses", + description: + "Statuses posted to the given account. Public (for public statuses only), or user token + `read:statuses` (for private statuses the user is authorized to see)", + parameters: + [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter(:pinned, :query, BooleanLike, "Include only pinned statuses"), + Operation.parameter(:tagged, :query, :string, "With tag"), + Operation.parameter( + :only_media, + :query, + BooleanLike, + "Include only statuses with media attached" + ), + Operation.parameter( + :only_events, + :query, + BooleanLike, + "Include only objects with Event type" + ), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include statuses from muted accounts." + ), + Operation.parameter(:exclude_reblogs, :query, BooleanLike, "Exclude reblogs"), + Operation.parameter(:exclude_replies, :query, BooleanLike, "Exclude replies"), + Operation.parameter( + :exclude_visibilities, + :query, + %Schema{type: :array, items: VisibilityScope}, + "Exclude visibilities" + ), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include reactions from muted accounts." + ) + ] ++ pagination_params(), + responses: %{ + 200 => Operation.response("Statuses", "application/json", array_of_statuses()), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def followers_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Followers", + operationId: "AccountController.followers", + security: [%{"oAuth" => ["read:accounts"]}], + description: + "Accounts which follow the given account, if network is not hidden by the account owner.", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter(:id, :query, :string, "ID of the resource owner"), + with_relationships_param() | pagination_params() + ], + responses: %{ + 200 => Operation.response("Accounts", "application/json", array_of_accounts()) + } + } + end + + def following_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Following", + operationId: "AccountController.following", + security: [%{"oAuth" => ["read:accounts"]}], + description: + "Accounts which the given account is following, if network is not hidden by the account owner.", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter(:id, :query, :string, "ID of the resource owner"), + with_relationships_param() | pagination_params() + ], + responses: %{200 => Operation.response("Accounts", "application/json", array_of_accounts())} + } + end + + def lists_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Lists containing this account", + operationId: "AccountController.lists", + security: [%{"oAuth" => ["read:lists"]}], + description: "User lists that you have added this account to.", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{200 => Operation.response("Lists", "application/json", array_of_lists())} + } + end + + def follow_operation do + %Operation{ + tags: ["Account actions"], + summary: "Follow", + operationId: "AccountController.follow", + security: [%{"oAuth" => ["follow", "write:follows"]}], + description: "Follow the given account", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"} + ], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + reblogs: %Schema{ + allOf: [BooleanLike], + description: "Receive this account's reblogs in home timeline? Defaults to true.", + default: true + }, + notify: %Schema{ + allOf: [BooleanLike], + description: + "Receive notifications for all statuses posted by the account? Defaults to false.", + default: false + } + } + }, + required: false + ), + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def unfollow_operation do + %Operation{ + tags: ["Account actions"], + summary: "Unfollow", + operationId: "AccountController.unfollow", + security: [%{"oAuth" => ["follow", "write:follows"]}], + description: "Unfollow the given account", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def mute_operation do + %Operation{ + tags: ["Account actions"], + summary: "Mute", + operationId: "AccountController.mute", + security: [%{"oAuth" => ["follow", "write:mutes"]}], + requestBody: request_body("Parameters", mute_request()), + description: + "Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline).", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter( + :notifications, + :query, + %Schema{allOf: [BooleanLike], default: true}, + "Mute notifications in addition to statuses? Defaults to `true`." + ), + Operation.parameter( + :duration, + :query, + %Schema{type: :integer}, + "Expire the mute in `duration` seconds. Default 0 for infinity" + ), + Operation.parameter( + :expires_in, + :query, + %Schema{type: :integer, default: 0}, + "Deprecated, use `duration` instead" + ) + ], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def unmute_operation do + %Operation{ + tags: ["Account actions"], + summary: "Unmute", + operationId: "AccountController.unmute", + security: [%{"oAuth" => ["follow", "write:mutes"]}], + description: "Unmute the given account.", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def block_operation do + %Operation{ + tags: ["Account actions"], + summary: "Block", + operationId: "AccountController.block", + security: [%{"oAuth" => ["follow", "write:blocks"]}], + description: + "Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline)", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def unblock_operation do + %Operation{ + tags: ["Account actions"], + summary: "Unblock", + operationId: "AccountController.unblock", + security: [%{"oAuth" => ["follow", "write:blocks"]}], + description: "Unblock the given account.", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def endorse_operation do + %Operation{ + tags: ["Account actions"], + summary: "Endorse", + operationId: "AccountController.endorse", + security: [%{"oAuth" => ["follow", "write:accounts"]}], + description: "Addds the given account to endorsed accounts list.", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 400 => + Operation.response("Bad Request", "application/json", %Schema{ + allOf: [ApiError], + title: "Unprocessable Entity", + example: %{ + "error" => "You have already pinned the maximum number of users" + } + }) + } + } + end + + def unendorse_operation do + %Operation{ + tags: ["Account actions"], + summary: "Unendorse", + operationId: "AccountController.unendorse", + security: [%{"oAuth" => ["follow", "write:accounts"]}], + description: "Removes the given account from endorsed accounts list.", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def remove_from_followers_operation do + %Operation{ + tags: ["Account actions"], + summary: "Remove from followers", + operationId: "AccountController.remove_from_followers", + security: [%{"oAuth" => ["follow", "write:follows"]}], + description: "Remove the given account from followers", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def note_operation do + %Operation{ + tags: ["Account actions"], + summary: "Set a private note about a user.", + operationId: "AccountController.note", + security: [%{"oAuth" => ["follow", "write:accounts"]}], + requestBody: request_body("Parameters", note_request()), + description: "Create a note for the given account.", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter( + :comment, + :query, + %Schema{type: :string}, + "Account note body" + ) + ], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def follow_by_uri_operation do + %Operation{ + tags: ["Account actions"], + summary: "Follow by URI", + operationId: "AccountController.follows", + security: [%{"oAuth" => ["follow", "write:follows"]}], + requestBody: request_body("Parameters", follow_by_uri_request(), required: true), + responses: %{ + 200 => Operation.response("Account", "application/json", AccountRelationship), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def mutes_operation do + %Operation{ + tags: ["Blocks and mutes"], + summary: "Retrieve list of mutes", + operationId: "AccountController.mutes", + description: "Accounts the user has muted.", + security: [%{"oAuth" => ["follow", "read:mutes"]}], + parameters: [with_relationships_param() | pagination_params()], + responses: %{ + 200 => Operation.response("Accounts", "application/json", array_of_accounts()) + } + } + end + + def blocks_operation do + %Operation{ + tags: ["Blocks and mutes"], + summary: "Retrieve list of blocks", + operationId: "AccountController.blocks", + description: "View your blocks. See also accounts/:id/{block,unblock}", + security: [%{"oAuth" => ["read:blocks"]}], + parameters: [with_relationships_param() | pagination_params()], + responses: %{ + 200 => Operation.response("Accounts", "application/json", array_of_accounts()) + } + } + end + + def lookup_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Find a user by nickname", + operationId: "AccountController.lookup", + parameters: [ + Operation.parameter( + :acct, + :query, + :string, + "User nickname" + ) + ], + responses: %{ + 200 => Operation.response("Account", "application/json", Account), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def endorsements_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Endorsements", + operationId: "AccountController.endorsements", + description: "Returns endorsed accounts", + security: [%{"oAuth" => ["read:accounts"]}], + responses: %{ + 200 => Operation.response("Array of Accounts", "application/json", array_of_accounts()) + } + } + end + + def identity_proofs_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Identity proofs", + operationId: "AccountController.identity_proofs", + # Validators complains about unused path params otherwise + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"} + ], + description: "Not implemented", + responses: %{ + 200 => empty_array_response() + } + } + end + + def familiar_followers_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Followers you know", + operationId: "AccountController.relationships", + description: "Returns followers of given account you know.", + security: [%{"oAuth" => ["read:follows"]}], + parameters: [ + Operation.parameter( + :id, + :query, + %Schema{ + oneOf: [%Schema{type: :array, items: %Schema{type: :string}}, %Schema{type: :string}] + }, + "Account IDs", + example: "123" + ) + ], + responses: %{ + 200 => + Operation.response("Accounts", "application/json", %Schema{ + title: "ArrayOfAccounts", + type: :array, + items: %Schema{ + title: "Account", + type: :object, + properties: %{ + id: FlakeID, + accounts: %Schema{ + title: "ArrayOfAccounts", + type: :array, + items: Account, + example: [Account.schema().example] + } + } + } + }) + } + } + end + + defp create_request do + %Schema{ + title: "AccountCreateRequest", + description: "POST body for creating an account", + type: :object, + required: [:username, :password, :agreement], + properties: %{ + reason: %Schema{ + type: :string, + nullable: true, + description: + "Text that will be reviewed by moderators if registrations require manual approval" + }, + username: %Schema{type: :string, description: "The desired username for the account"}, + email: %Schema{ + type: :string, + nullable: true, + description: + "The email address to be used for login. Required when `account_activation_required` is enabled.", + format: :email + }, + password: %Schema{ + type: :string, + description: "The password to be used for login", + format: :password + }, + agreement: %Schema{ + allOf: [BooleanLike], + description: + "Whether the user agrees to the local rules, terms, and policies. These should be presented to the user in order to allow them to consent before setting this parameter to TRUE." + }, + locale: %Schema{ + type: :string, + nullable: true, + description: "The language of the confirmation email that will be sent" + }, + # Pleroma-specific properties: + fullname: %Schema{type: :string, nullable: true, description: "Full name"}, + bio: %Schema{type: :string, description: "Bio", nullable: true, default: ""}, + captcha_solution: %Schema{ + type: :string, + nullable: true, + description: "Provider-specific captcha solution" + }, + captcha_token: %Schema{ + type: :string, + nullable: true, + description: "Provider-specific captcha token" + }, + captcha_answer_data: %Schema{ + type: :string, + nullable: true, + description: "Provider-specific captcha data" + }, + token: %Schema{ + type: :string, + nullable: true, + description: "Invite token required when the registrations aren't public" + }, + accepts_email_list: %Schema{ + allOf: [BooleanLike], + description: + "Whether the user opts-in to receiving news and marketing updates from site admins." + }, + birthday: %Schema{ + nullable: true, + description: "User's birthday", + anyOf: [ + %Schema{ + type: :string, + format: :date + }, + %Schema{ + type: :string, + maxLength: 0 + } + ] + }, + language: %Schema{ + type: :string, + nullable: true, + description: "User's preferred language for emails" + } + }, + example: %{ + "username" => "cofe", + "email" => "cofe@example.com", + "password" => "secret", + "agreement" => "true", + "bio" => "☕️" + } + } + end + + # Note: this is a token response (if login succeeds!), but there's no oauth operation file yet. + defp create_response do + %Schema{ + title: "AccountCreateResponse", + description: "Response schema for an account", + type: :object, + properties: %{ + # The response when auto-login on create succeeds (token is issued): + token_type: %Schema{type: :string}, + access_token: %Schema{type: :string}, + refresh_token: %Schema{type: :string}, + scope: %Schema{type: :string}, + created_at: %Schema{type: :integer, format: :"date-time"}, + me: %Schema{type: :string}, + expires_in: %Schema{type: :integer}, + # + # The response when registration succeeds but auto-login fails (no token): + identifier: %Schema{type: :string}, + message: %Schema{type: :string} + }, + # Note: example of successful registration with failed login response: + # example: %{ + # "identifier" => "missing_confirmed_email", + # "message" => "You have been registered. Please check your email for further instructions." + # }, + example: %{ + "token_type" => "Bearer", + "access_token" => "i9hAVVzGld86Pl5JtLtizKoXVvtTlSCJvwaugCxvZzk", + "refresh_token" => "i9hAVVzGld86Pl5JtLtizKoXVvtTlSCJvwaugCxvZzz", + "created_at" => 1_585_918_714, + "expires_in" => 600, + "scope" => "read write follow push", + "me" => "https://gensokyo.2hu/users/raymoo" + } + } + end + + defp update_credentials_request do + %Schema{ + title: "AccountUpdateCredentialsRequest", + description: "POST body for creating an account", + type: :object, + properties: %{ + bot: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Whether the account has a bot flag." + }, + display_name: %Schema{ + type: :string, + nullable: true, + description: "The display name to use for the profile." + }, + note: %Schema{type: :string, description: "The account bio."}, + avatar: %Schema{ + type: :string, + nullable: true, + description: "Avatar image encoded using multipart/form-data", + format: :binary + }, + header: %Schema{ + type: :string, + nullable: true, + description: "Header image encoded using multipart/form-data", + format: :binary + }, + locked: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Whether manual approval of follow requests is required." + }, + accepts_chat_messages: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Whether the user accepts receiving chat messages." + }, + fields_attributes: %Schema{ + nullable: true, + oneOf: [ + %Schema{type: :array, items: attribute_field()}, + %Schema{type: :object, additionalProperties: attribute_field()} + ] + }, + # NOTE: `source` field is not supported + # + # source: %Schema{ + # type: :object, + # properties: %{ + # privacy: %Schema{type: :string}, + # sensitive: %Schema{type: :boolean}, + # language: %Schema{type: :string} + # } + # }, + + # Pleroma-specific fields + no_rich_text: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "html tags are stripped from all statuses requested from the API" + }, + hide_followers: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "user's followers will be hidden" + }, + hide_follows: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "user's follows will be hidden" + }, + hide_followers_count: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "user's follower count will be hidden" + }, + hide_follows_count: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "user's follow count will be hidden" + }, + hide_favorites: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "user's favorites timeline will be hidden" + }, + show_role: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "user's role (e.g admin, moderator) will be exposed to anyone in the + API" + }, + default_scope: VisibilityScope, + pleroma_settings_store: %Schema{ + type: :object, + nullable: true, + description: "Opaque user settings to be saved on the backend." + }, + skip_thread_containment: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Skip filtering out broken threads" + }, + allow_following_move: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Allows automatically follow moved following accounts" + }, + also_known_as: %Schema{ + type: :array, + items: %Schema{type: :string}, + nullable: true, + description: "List of alternate ActivityPub IDs" + }, + pleroma_background_image: %Schema{ + type: :string, + nullable: true, + description: "Sets the background image of the user.", + format: :binary + }, + discoverable: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: + "Discovery (listing, indexing) of this account by external services (search bots etc.) is allowed." + }, + actor_type: ActorType, + accepts_email_list: %Schema{ + allOf: [BooleanLike], + description: + "Whether the user opts-in to receiving news and marketing updates from site admins." + }, + birthday: %Schema{ + nullable: true, + description: "User's birthday", + anyOf: [ + %Schema{ + type: :string, + format: :date + }, + %Schema{ + type: :string, + maxLength: 0 + } + ] + }, + show_birthday: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "User's birthday will be visible" + }, + location: %Schema{ + type: :string, + nullable: true, + description: "User location" + } + }, + example: %{ + bot: false, + display_name: "cofe", + note: "foobar", + fields_attributes: [%{name: "foo", value: "bar"}], + no_rich_text: false, + hide_followers: true, + hide_follows: false, + hide_followers_count: false, + hide_follows_count: false, + hide_favorites: false, + show_role: false, + default_scope: "private", + pleroma_settings_store: %{"pleroma-fe" => %{"key" => "val"}}, + skip_thread_containment: false, + allow_following_move: false, + also_known_as: ["https://foo.bar/users/foo"], + discoverable: false, + actor_type: "Person", + show_birthday: false, + birthday: "2001-02-12" + } + } + end + + def array_of_accounts do + %Schema{ + title: "ArrayOfAccounts", + type: :array, + items: Account, + example: [Account.schema().example] + } + end + + defp array_of_relationships do + %Schema{ + title: "ArrayOfRelationships", + description: "Response schema for account relationships", + type: :array, + items: AccountRelationship, + example: [ + %{ + "id" => "1", + "following" => true, + "showing_reblogs" => true, + "followed_by" => true, + "blocking" => false, + "blocked_by" => true, + "muting" => false, + "muting_notifications" => false, + "note" => "", + "requested" => false, + "domain_blocking" => false, + "subscribing" => false, + "notifying" => false, + "endorsed" => true + }, + %{ + "id" => "2", + "following" => true, + "showing_reblogs" => true, + "followed_by" => true, + "blocking" => false, + "blocked_by" => true, + "muting" => true, + "muting_notifications" => false, + "note" => "", + "requested" => true, + "domain_blocking" => false, + "subscribing" => false, + "notifying" => false, + "endorsed" => false + }, + %{ + "id" => "3", + "following" => true, + "showing_reblogs" => true, + "followed_by" => true, + "blocking" => true, + "blocked_by" => false, + "muting" => true, + "muting_notifications" => false, + "note" => "", + "requested" => false, + "domain_blocking" => true, + "subscribing" => true, + "notifying" => true, + "endorsed" => false + } + ] + } + end + + defp follow_by_uri_request do + %Schema{ + title: "AccountFollowsRequest", + description: "POST body for muting an account", + type: :object, + properties: %{ + uri: %Schema{type: :string, nullable: true, format: :uri} + }, + required: [:uri] + } + end + + defp mute_request do + %Schema{ + title: "AccountMuteRequest", + description: "POST body for muting an account", + type: :object, + properties: %{ + notifications: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Mute notifications in addition to statuses? Defaults to true.", + default: true + }, + duration: %Schema{ + type: :integer, + nullable: true, + description: "Expire the mute in `expires_in` seconds. Default 0 for infinity" + }, + expires_in: %Schema{ + type: :integer, + nullable: true, + description: "Deprecated, use `duration` instead", + default: 0 + } + }, + example: %{ + "notifications" => true, + "expires_in" => 86_400 + } + } + end + + defp note_request do + %Schema{ + title: "AccountNoteRequest", + description: "POST body for adding a note for an account", + type: :object, + properties: %{ + comment: %Schema{ + type: :string, + description: "Account note body" + } + }, + example: %{ + "comment" => "Example note" + } + } + end + + defp array_of_lists do + %Schema{ + title: "ArrayOfLists", + description: "Response schema for lists", + type: :array, + items: List, + example: [ + %{"id" => "123", "title" => "my list"}, + %{"id" => "1337", "title" => "anotehr list"} + ] + } + end + + defp array_of_statuses do + %Schema{ + title: "ArrayOfStatuses", + type: :array, + items: Status + } + end + + defp attribute_field do + %Schema{ + title: "AccountAttributeField", + description: "Request schema for account custom fields", + type: :object, + properties: %{ + name: %Schema{type: :string}, + value: %Schema{type: :string} + }, + required: [:name, :value], + example: %{ + "name" => "Website", + "value" => "https://pleroma.com" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex b/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex new file mode 100644 index 0000000..49850e5 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/announcement_operation.ex @@ -0,0 +1,165 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.AnnouncementOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Announcement + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Announcement management"], + summary: "Retrieve a list of announcements", + operationId: "AdminAPI.AnnouncementController.index", + security: [%{"oAuth" => ["admin:read"]}], + parameters: [ + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, minimum: 1}, + "the maximum number of announcements to return" + ), + Operation.parameter( + :offset, + :query, + %Schema{type: :integer, minimum: 0}, + "the offset of the first announcement to return" + ) + | admin_api_params() + ], + responses: %{ + 200 => Operation.response("Response", "application/json", list_of_announcements()), + 400 => Operation.response("Forbidden", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Announcement management"], + summary: "Display one announcement", + operationId: "AdminAPI.AnnouncementController.show", + security: [%{"oAuth" => ["admin:read"]}], + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "announcement id" + ) + | admin_api_params() + ], + responses: %{ + 200 => Operation.response("Response", "application/json", Announcement), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Announcement management"], + summary: "Delete one announcement", + operationId: "AdminAPI.AnnouncementController.delete", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "announcement id" + ) + | admin_api_params() + ], + responses: %{ + 200 => Operation.response("Response", "application/json", %Schema{type: :object}), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def create_operation do + %Operation{ + tags: ["Announcement management"], + summary: "Create one announcement", + operationId: "AdminAPI.AnnouncementController.create", + security: [%{"oAuth" => ["admin:write"]}], + requestBody: request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Response", "application/json", Announcement), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def change_operation do + %Operation{ + tags: ["Announcement management"], + summary: "Change one announcement", + operationId: "AdminAPI.AnnouncementController.change", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "announcement id" + ) + | admin_api_params() + ], + requestBody: request_body("Parameters", change_request(), required: true), + responses: %{ + 200 => Operation.response("Response", "application/json", Announcement), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp create_or_change_props do + %{ + content: %Schema{type: :string}, + starts_at: %Schema{type: :string, format: "date-time", nullable: true}, + ends_at: %Schema{type: :string, format: "date-time", nullable: true}, + all_day: %Schema{type: :boolean} + } + end + + def create_request do + %Schema{ + title: "AnnouncementCreateRequest", + type: :object, + required: [:content], + properties: create_or_change_props() + } + end + + def change_request do + %Schema{ + title: "AnnouncementChangeRequest", + type: :object, + properties: create_or_change_props() + } + end + + def list_of_announcements do + %Schema{ + type: :array, + items: Announcement + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/chat_operation.ex b/lib/pleroma/web/api_spec/operations/admin/chat_operation.ex new file mode 100644 index 0000000..2a274e0 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/chat_operation.ex @@ -0,0 +1,96 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.ChatOperation do + alias OpenApiSpex.Operation + alias Pleroma.Web.ApiSpec.Schemas.Chat + alias Pleroma.Web.ApiSpec.Schemas.ChatMessage + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def delete_message_operation do + %Operation{ + tags: ["Chat administration"], + summary: "Delete an individual chat message", + operationId: "AdminAPI.ChatController.delete_message", + parameters: [ + Operation.parameter(:id, :path, :string, "The ID of the Chat"), + Operation.parameter(:message_id, :path, :string, "The ID of the message") + ], + responses: %{ + 200 => + Operation.response( + "The deleted ChatMessage", + "application/json", + ChatMessage + ) + }, + security: [ + %{ + "oAuth" => ["admin:write:chats"] + } + ] + } + end + + def messages_operation do + %Operation{ + tags: ["Chat administration"], + summary: "Get chat's messages", + operationId: "AdminAPI.ChatController.messages", + parameters: + [Operation.parameter(:id, :path, :string, "The ID of the Chat")] ++ + pagination_params(), + responses: %{ + 200 => + Operation.response( + "The messages in the chat", + "application/json", + Pleroma.Web.ApiSpec.ChatOperation.chat_messages_response() + ) + }, + security: [ + %{ + "oAuth" => ["admin:read:chats"] + } + ] + } + end + + def show_operation do + %Operation{ + tags: ["Chat administration"], + summary: "Create a chat", + operationId: "AdminAPI.ChatController.show", + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "The id of the chat", + required: true, + example: "1234" + ) + ], + responses: %{ + 200 => + Operation.response( + "The existing chat", + "application/json", + Chat + ) + }, + security: [ + %{ + "oAuth" => ["admin:read"] + } + ] + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/config_operation.ex b/lib/pleroma/web/api_spec/operations/admin/config_operation.ex new file mode 100644 index 0000000..487dd5c --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/config_operation.ex @@ -0,0 +1,145 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.ConfigOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Instance configuration"], + summary: "Retrieve instance configuration", + operationId: "AdminAPI.ConfigController.show", + parameters: [ + Operation.parameter( + :only_db, + :query, + %Schema{type: :boolean, default: false}, + "Get only saved in database settings" + ) + | admin_api_params() + ], + security: [%{"oAuth" => ["admin:read"]}], + responses: %{ + 200 => Operation.response("Config", "application/json", config_response()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Instance configuration"], + summary: "Update instance configuration", + operationId: "AdminAPI.ConfigController.update", + security: [%{"oAuth" => ["admin:write"]}], + parameters: admin_api_params(), + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + configs: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + group: %Schema{type: :string}, + key: %Schema{type: :string}, + value: any(), + delete: %Schema{type: :boolean}, + subkeys: %Schema{type: :array, items: %Schema{type: :string}} + } + } + } + } + }), + responses: %{ + 200 => Operation.response("Config", "application/json", config_response()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def descriptions_operation do + %Operation{ + tags: ["Instance configuration"], + summary: "Retrieve config description", + operationId: "AdminAPI.ConfigController.descriptions", + security: [%{"oAuth" => ["admin:read"]}], + parameters: admin_api_params(), + responses: %{ + 200 => + Operation.response("Config Descriptions", "application/json", %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + group: %Schema{type: :string}, + key: %Schema{type: :string}, + type: %Schema{oneOf: [%Schema{type: :string}, %Schema{type: :array}]}, + description: %Schema{type: :string}, + children: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + key: %Schema{type: :string}, + type: %Schema{oneOf: [%Schema{type: :string}, %Schema{type: :array}]}, + description: %Schema{type: :string}, + suggestions: %Schema{type: :array} + } + } + } + } + } + }), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + defp any do + %Schema{ + oneOf: [ + %Schema{type: :array}, + %Schema{type: :object}, + %Schema{type: :string}, + %Schema{type: :integer}, + %Schema{type: :boolean} + ] + } + end + + defp config_response do + %Schema{ + type: :object, + properties: %{ + configs: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + group: %Schema{type: :string}, + key: %Schema{type: :string}, + value: any() + } + } + }, + need_reboot: %Schema{ + type: :boolean, + description: + "If `need_reboot` is `true`, instance must be restarted, so reboot time settings can take effect" + } + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex new file mode 100644 index 0000000..3e85c44 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex @@ -0,0 +1,86 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.FrontendOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Frontend managment"], + summary: "Retrieve a list of available frontends", + operationId: "AdminAPI.FrontendController.index", + security: [%{"oAuth" => ["admin:read"]}], + responses: %{ + 200 => Operation.response("Response", "application/json", list_of_frontends()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def install_operation do + %Operation{ + tags: ["Frontend managment"], + summary: "Install a frontend", + operationId: "AdminAPI.FrontendController.install", + security: [%{"oAuth" => ["admin:read"]}], + requestBody: request_body("Parameters", install_request(), required: true), + responses: %{ + 200 => Operation.response("Response", "application/json", list_of_frontends()), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp list_of_frontends do + %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + name: %Schema{type: :string}, + git: %Schema{type: :string, format: :uri, nullable: true}, + build_url: %Schema{type: :string, format: :uri, nullable: true}, + ref: %Schema{type: :string, nullable: true}, + installed: %Schema{type: :boolean}, + installed_refs: %Schema{type: :array, items: %Schema{type: :string}} + } + } + } + end + + defp install_request do + %Schema{ + title: "FrontendInstallRequest", + type: :object, + required: [:name], + properties: %{ + name: %Schema{ + type: :string + }, + ref: %Schema{ + type: :string + }, + file: %Schema{ + type: :string + }, + build_url: %Schema{ + type: :string + }, + build_dir: %Schema{ + type: :string + } + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/instance_document_operation.ex b/lib/pleroma/web/api_spec/operations/admin/instance_document_operation.ex new file mode 100644 index 0000000..fc0de49 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/instance_document_operation.ex @@ -0,0 +1,115 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.InstanceDocumentOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Instance documents"], + summary: "Retrieve an instance document", + operationId: "AdminAPI.InstanceDocumentController.show", + security: [%{"oAuth" => ["admin:read"]}], + parameters: [ + Operation.parameter(:name, :path, %Schema{type: :string}, "The document name", + required: true + ) + | Helpers.admin_api_params() + ], + responses: %{ + 200 => document_content(), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Instance documents"], + summary: "Update an instance document", + operationId: "AdminAPI.InstanceDocumentController.update", + security: [%{"oAuth" => ["admin:write"]}], + requestBody: Helpers.request_body("Parameters", update_request()), + parameters: [ + Operation.parameter(:name, :path, %Schema{type: :string}, "The document name", + required: true + ) + | Helpers.admin_api_params() + ], + responses: %{ + 200 => Operation.response("InstanceDocument", "application/json", instance_document()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp update_request do + %Schema{ + title: "UpdateRequest", + description: "POST body for uploading the file", + type: :object, + required: [:file], + properties: %{ + file: %Schema{ + type: :string, + format: :binary, + description: "The file to be uploaded, using multipart form data." + } + } + } + end + + def delete_operation do + %Operation{ + tags: ["Instance documents"], + summary: "Delete an instance document", + operationId: "AdminAPI.InstanceDocumentController.delete", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [ + Operation.parameter(:name, :path, %Schema{type: :string}, "The document name", + required: true + ) + | Helpers.admin_api_params() + ], + responses: %{ + 200 => Operation.response("InstanceDocument", "application/json", instance_document()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp instance_document do + %Schema{ + title: "InstanceDocument", + type: :object, + properties: %{ + url: %Schema{type: :string} + }, + example: %{ + "url" => "https://example.com/static/terms-of-service.html" + } + } + end + + defp document_content do + Operation.response("InstanceDocumentContent", "text/html", %Schema{ + type: :string, + example: "

    Instance panel

    " + }) + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex b/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex new file mode 100644 index 0000000..e4a9ffa --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex @@ -0,0 +1,152 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.InviteOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Invites"], + summary: "Get a list of generated invites", + operationId: "AdminAPI.InviteController.index", + security: [%{"oAuth" => ["admin:read:invites"]}], + parameters: admin_api_params(), + responses: %{ + 200 => + Operation.response("Invites", "application/json", %Schema{ + type: :object, + properties: %{ + invites: %Schema{type: :array, items: invite()} + }, + example: %{ + "invites" => [ + %{ + "id" => 123, + "token" => "kSQtDj_GNy2NZsL9AQDFIsHN5qdbguB6qRg3WHw6K1U=", + "used" => true, + "expires_at" => nil, + "uses" => 0, + "max_use" => nil, + "invite_type" => "one_time" + } + ] + } + }) + } + } + end + + def create_operation do + %Operation{ + tags: ["Invites"], + summary: "Create an account registration invite token", + operationId: "AdminAPI.InviteController.create", + security: [%{"oAuth" => ["admin:write:invites"]}], + parameters: admin_api_params(), + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + max_use: %Schema{type: :integer}, + expires_at: %Schema{type: :string, format: :date, example: "2020-04-20"} + } + }), + responses: %{ + 200 => Operation.response("Invite", "application/json", invite()) + } + } + end + + def revoke_operation do + %Operation{ + tags: ["Invites"], + summary: "Revoke invite by token", + operationId: "AdminAPI.InviteController.revoke", + security: [%{"oAuth" => ["admin:write:invites"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:token], + properties: %{ + token: %Schema{type: :string} + } + }, + required: true + ), + responses: %{ + 200 => Operation.response("Invite", "application/json", invite()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def email_operation do + %Operation{ + tags: ["Invites"], + summary: "Sends registration invite via email", + operationId: "AdminAPI.InviteController.email", + security: [%{"oAuth" => ["admin:write:invites"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:email], + properties: %{ + email: %Schema{type: :string, format: :email}, + name: %Schema{type: :string} + } + }, + required: true + ), + responses: %{ + 204 => no_content_response(), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + defp invite do + %Schema{ + title: "Invite", + type: :object, + properties: %{ + id: %Schema{type: :integer}, + token: %Schema{type: :string}, + used: %Schema{type: :boolean}, + expires_at: %Schema{type: :string, format: :date, nullable: true}, + uses: %Schema{type: :integer}, + max_use: %Schema{type: :integer, nullable: true}, + invite_type: %Schema{ + type: :string, + enum: ["one_time", "reusable", "date_limited", "reusable_date_limited"] + } + }, + example: %{ + "id" => 123, + "token" => "kSQtDj_GNy2NZsL9AQDFIsHN5qdbguB6qRg3WHw6K1U=", + "used" => true, + "expires_at" => nil, + "uses" => 0, + "max_use" => nil, + "invite_type" => "one_time" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex b/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex new file mode 100644 index 0000000..0b1eb39 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex @@ -0,0 +1,121 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.MediaProxyCacheOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["MediaProxy cache"], + summary: "Retrieve a list of banned MediaProxy URLs", + operationId: "AdminAPI.MediaProxyCacheController.index", + security: [%{"oAuth" => ["admin:read:media_proxy_caches"]}], + parameters: [ + Operation.parameter( + :query, + :query, + %Schema{type: :string, default: nil}, + "Page" + ), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of statuses to return" + ) + | admin_api_params() + ], + responses: %{ + 200 => + Operation.response( + "Array of MediaProxy URLs", + "application/json", + %Schema{ + type: :object, + properties: %{ + count: %Schema{type: :integer}, + page_size: %Schema{type: :integer}, + urls: %Schema{ + type: :array, + items: %Schema{ + type: :string, + format: :uri, + description: "MediaProxy URLs" + } + } + } + } + ) + } + } + end + + def delete_operation do + %Operation{ + tags: ["MediaProxy cache"], + summary: "Remove a banned MediaProxy URL", + operationId: "AdminAPI.MediaProxyCacheController.delete", + security: [%{"oAuth" => ["admin:write:media_proxy_caches"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:urls], + properties: %{ + urls: %Schema{type: :array, items: %Schema{type: :string, format: :uri}} + } + }, + required: true + ), + responses: %{ + 200 => empty_object_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def purge_operation do + %Operation{ + tags: ["MediaProxy cache"], + summary: "Purge a URL from MediaProxy cache and optionally ban it", + operationId: "AdminAPI.MediaProxyCacheController.purge", + security: [%{"oAuth" => ["admin:write:media_proxy_caches"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:urls], + properties: %{ + urls: %Schema{type: :array, items: %Schema{type: :string, format: :uri}}, + ban: %Schema{type: :boolean, default: true} + } + }, + required: true + ), + responses: %{ + 200 => empty_object_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex b/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex new file mode 100644 index 0000000..1a05aff --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex @@ -0,0 +1,217 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.OAuthAppOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + summary: "Retrieve a list of OAuth applications", + tags: ["OAuth application managment"], + operationId: "AdminAPI.OAuthAppController.index", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [ + Operation.parameter(:name, :query, %Schema{type: :string}, "App name"), + Operation.parameter(:client_id, :query, %Schema{type: :string}, "Client ID"), + Operation.parameter(:page, :query, %Schema{type: :integer, default: 1}, "Page"), + Operation.parameter( + :trusted, + :query, + %Schema{type: :boolean, default: false}, + "Trusted apps" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of apps to return" + ) + | admin_api_params() + ], + responses: %{ + 200 => + Operation.response("List of apps", "application/json", %Schema{ + type: :object, + properties: %{ + apps: %Schema{type: :array, items: oauth_app()}, + count: %Schema{type: :integer}, + page_size: %Schema{type: :integer} + }, + example: %{ + "apps" => [ + %{ + "id" => 1, + "name" => "App name", + "client_id" => "yHoDSiWYp5mPV6AfsaVOWjdOyt5PhWRiafi6MRd1lSk", + "client_secret" => "nLmis486Vqrv2o65eM9mLQx_m_4gH-Q6PcDpGIMl6FY", + "redirect_uri" => "https://example.com/oauth-callback", + "website" => "https://example.com", + "trusted" => true + } + ], + "count" => 1, + "page_size" => 50 + } + }) + } + } + end + + def create_operation do + %Operation{ + tags: ["OAuth application managment"], + summary: "Create an OAuth application", + operationId: "AdminAPI.OAuthAppController.create", + requestBody: request_body("Parameters", create_request()), + parameters: admin_api_params(), + security: [%{"oAuth" => ["admin:write"]}], + responses: %{ + 200 => Operation.response("App", "application/json", oauth_app()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["OAuth application managment"], + summary: "Update OAuth application", + operationId: "AdminAPI.OAuthAppController.update", + parameters: [id_param() | admin_api_params()], + security: [%{"oAuth" => ["admin:write"]}], + requestBody: request_body("Parameters", update_request()), + responses: %{ + 200 => Operation.response("App", "application/json", oauth_app()), + 400 => + Operation.response("Bad Request", "application/json", %Schema{ + oneOf: [ApiError, %Schema{type: :string}] + }) + } + } + end + + def delete_operation do + %Operation{ + tags: ["OAuth application managment"], + summary: "Delete OAuth application", + operationId: "AdminAPI.OAuthAppController.delete", + parameters: [id_param() | admin_api_params()], + security: [%{"oAuth" => ["admin:write"]}], + responses: %{ + 204 => no_content_response(), + 400 => no_content_response() + } + } + end + + defp create_request do + %Schema{ + title: "oAuthAppCreateRequest", + type: :object, + required: [:name, :redirect_uris], + properties: %{ + name: %Schema{type: :string, description: "Application Name"}, + scopes: %Schema{type: :array, items: %Schema{type: :string}, description: "oAuth scopes"}, + redirect_uris: %Schema{ + type: :string, + description: + "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." + }, + website: %Schema{ + type: :string, + nullable: true, + description: "A URL to the homepage of the app" + }, + trusted: %Schema{ + type: :boolean, + nullable: true, + default: false, + description: "Is the app trusted?" + } + }, + example: %{ + "name" => "My App", + "redirect_uris" => "https://myapp.com/auth/callback", + "website" => "https://myapp.com/", + "scopes" => ["read", "write"], + "trusted" => true + } + } + end + + defp update_request do + %Schema{ + title: "oAuthAppUpdateRequest", + type: :object, + properties: %{ + name: %Schema{type: :string, description: "Application Name"}, + scopes: %Schema{type: :array, items: %Schema{type: :string}, description: "oAuth scopes"}, + redirect_uris: %Schema{ + type: :string, + description: + "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." + }, + website: %Schema{ + type: :string, + nullable: true, + description: "A URL to the homepage of the app" + }, + trusted: %Schema{ + type: :boolean, + nullable: true, + default: false, + description: "Is the app trusted?" + } + }, + example: %{ + "name" => "My App", + "redirect_uris" => "https://myapp.com/auth/callback", + "website" => "https://myapp.com/", + "scopes" => ["read", "write"], + "trusted" => true + } + } + end + + defp oauth_app do + %Schema{ + title: "oAuthApp", + type: :object, + properties: %{ + id: %Schema{type: :integer}, + name: %Schema{type: :string}, + client_id: %Schema{type: :string}, + client_secret: %Schema{type: :string}, + redirect_uri: %Schema{type: :string}, + website: %Schema{type: :string, nullable: true}, + trusted: %Schema{type: :boolean} + }, + example: %{ + "id" => 123, + "name" => "My App", + "client_id" => "TWhM-tNSuncnqN7DBJmoyeLnk6K3iJJ71KKXxgL1hPM", + "client_secret" => "ZEaFUFmF0umgBX1qKJDjaU99Q31lDkOU8NutzTOoliw", + "redirect_uri" => "https://myapp.com/oauth-callback", + "website" => "https://myapp.com/", + "trusted" => false + } + } + end + + def id_param do + Operation.parameter(:id, :path, :integer, "App ID", + example: 1337, + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex b/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex new file mode 100644 index 0000000..8b241bd --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex @@ -0,0 +1,104 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.RelayOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Relays"], + summary: "Retrieve a list of relays", + operationId: "AdminAPI.RelayController.index", + security: [%{"oAuth" => ["admin:read"]}], + parameters: admin_api_params(), + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{ + relays: %Schema{ + type: :array, + items: relay() + } + } + }) + } + } + end + + def follow_operation do + %Operation{ + tags: ["Relays"], + summary: "Follow a relay", + operationId: "AdminAPI.RelayController.follow", + security: [%{"oAuth" => ["admin:write:follows"]}], + parameters: admin_api_params(), + requestBody: request_body("Parameters", relay_url()), + responses: %{ + 200 => Operation.response("Status", "application/json", relay()) + } + } + end + + def unfollow_operation do + %Operation{ + tags: ["Relays"], + summary: "Unfollow a relay", + operationId: "AdminAPI.RelayController.unfollow", + security: [%{"oAuth" => ["admin:write:follows"]}], + parameters: admin_api_params(), + requestBody: request_body("Parameters", relay_unfollow()), + responses: %{ + 200 => + Operation.response("Status", "application/json", %Schema{ + type: :string, + example: "http://mastodon.example.org/users/admin" + }) + } + } + end + + defp relay do + %Schema{ + type: :object, + properties: %{ + actor: %Schema{ + type: :string, + example: "https://example.com/relay" + }, + followed_back: %Schema{ + type: :boolean, + description: "Is relay followed back by this actor?" + } + } + } + end + + defp relay_url do + %Schema{ + type: :object, + properties: %{ + relay_url: %Schema{type: :string, format: :uri} + } + } + end + + defp relay_unfollow do + %Schema{ + type: :object, + properties: %{ + relay_url: %Schema{type: :string, format: :uri}, + force: %Schema{type: :boolean, default: false} + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/report_operation.ex b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex new file mode 100644 index 0000000..316f617 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex @@ -0,0 +1,303 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.ReportOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Report managment"], + summary: "Retrieve a list of reports", + operationId: "AdminAPI.ReportController.index", + security: [%{"oAuth" => ["admin:read:reports"]}], + parameters: [ + Operation.parameter( + :state, + :query, + report_state(), + "Filter by report state" + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer}, + "The number of records to retrieve" + ), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page number" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number number of log entries per page" + ), + Operation.parameter( + :assigned_account, + :query, + %Schema{type: :string}, + "Filter by assigned account ID" + ) + | admin_api_params() + ], + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{ + total: %Schema{type: :integer}, + reports: %Schema{ + type: :array, + items: report() + } + } + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Report managment"], + summary: "Retrieve a report", + operationId: "AdminAPI.ReportController.show", + parameters: [id_param() | admin_api_params()], + security: [%{"oAuth" => ["admin:read:reports"]}], + responses: %{ + 200 => Operation.response("Report", "application/json", report()), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Report managment"], + summary: "Change state of specified reports", + operationId: "AdminAPI.ReportController.update", + security: [%{"oAuth" => ["admin:write:reports"]}], + parameters: admin_api_params(), + requestBody: request_body("Parameters", update_request(), required: true), + responses: %{ + 204 => no_content_response(), + 400 => Operation.response("Bad Request", "application/json", update_400_response()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def assign_account_operation do + %Operation{ + tags: ["Report managment"], + summary: "Assign account to specified reports", + operationId: "AdminAPI.ReportController.assign_account", + security: [%{"oAuth" => ["admin:write:reports"]}], + parameters: admin_api_params(), + requestBody: request_body("Parameters", assign_account_request(), required: true), + responses: %{ + 204 => no_content_response(), + 400 => Operation.response("Bad Request", "application/json", update_400_response()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def notes_create_operation do + %Operation{ + tags: ["Report managment"], + summary: "Add a note to the report", + operationId: "AdminAPI.ReportController.notes_create", + parameters: [id_param() | admin_api_params()], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + content: %Schema{type: :string, description: "The message"} + } + }), + security: [%{"oAuth" => ["admin:write:reports"]}], + responses: %{ + 204 => no_content_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def notes_delete_operation do + %Operation{ + tags: ["Report managment"], + summary: "Delete note attached to the report", + operationId: "AdminAPI.ReportController.notes_delete", + parameters: [ + Operation.parameter(:report_id, :path, :string, "Report ID"), + Operation.parameter(:id, :path, :string, "Note ID") + | admin_api_params() + ], + security: [%{"oAuth" => ["admin:write:reports"]}], + responses: %{ + 204 => no_content_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def report_state do + %Schema{type: :string, enum: ["open", "closed", "resolved"]} + end + + def id_param do + Operation.parameter(:id, :path, FlakeID, "Report ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end + + defp report do + %Schema{ + type: :object, + properties: %{ + id: FlakeID, + state: report_state(), + account: account_admin(), + actor: account_admin(), + content: %Schema{type: :string}, + created_at: %Schema{type: :string, format: :"date-time"}, + statuses: %Schema{type: :array, items: Status}, + notes: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :integer}, + user_id: FlakeID, + content: %Schema{type: :string}, + inserted_at: %Schema{type: :string, format: :"date-time"} + } + } + }, + assigned_account: + account_admin() + |> Map.put(:nullable, true), + rules: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :integer}, + text: %Schema{type: :string} + } + } + } + } + } + end + + defp account_admin do + %Schema{ + title: "Account", + description: "Account view for admins", + type: :object, + properties: + Map.merge(Account.schema().properties, %{ + nickname: %Schema{type: :string}, + is_active: %Schema{type: :boolean}, + local: %Schema{type: :boolean}, + roles: %Schema{ + type: :object, + properties: %{ + admin: %Schema{type: :boolean}, + moderator: %Schema{type: :boolean} + } + }, + is_confirmed: %Schema{type: :boolean} + }) + } + end + + defp update_request do + %Schema{ + type: :object, + required: [:reports], + properties: %{ + reports: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{allOf: [FlakeID], description: "Required, report ID"}, + state: %Schema{ + type: :string, + description: + "Required, the new state. Valid values are `open`, `closed` and `resolved`" + } + } + }, + example: %{ + "reports" => [ + %{"id" => "123", "state" => "closed"}, + %{"id" => "1337", "state" => "resolved"} + ] + } + } + } + } + end + + defp assign_account_request do + %Schema{ + type: :object, + required: [:reports], + properties: %{ + reports: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{allOf: [FlakeID], description: "Required, report ID"}, + assigned_account: %Schema{ + type: :string, + description: "User nickname", + nullable: true + } + } + }, + example: %{ + "reports" => [ + %{"id" => "123", "assigned_account" => "pleroma"} + ] + } + } + } + } + end + + defp update_400_response do + %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{allOf: [FlakeID], description: "Report ID"}, + error: %Schema{type: :string, description: "Error message"} + } + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/rule_operation.ex b/lib/pleroma/web/api_spec/operations/admin/rule_operation.ex new file mode 100644 index 0000000..ed0d9ea --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/rule_operation.ex @@ -0,0 +1,113 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.RuleOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Instance rule managment"], + summary: "Retrieve list of instance rules", + operationId: "AdminAPI.RuleController.index", + security: [%{"oAuth" => ["admin:read"]}], + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :array, + items: rule() + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def create_operation do + %Operation{ + tags: ["Instance rule managment"], + summary: "Create new rule", + operationId: "AdminAPI.RuleController.create", + security: [%{"oAuth" => ["admin:write"]}], + parameters: admin_api_params(), + requestBody: request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Response", "application/json", rule()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Instance rule managment"], + summary: "Modify existing rule", + operationId: "AdminAPI.RuleController.update", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [Operation.parameter(:id, :path, :string, "Rule ID")], + requestBody: request_body("Parameters", update_request(), required: true), + responses: %{ + 200 => Operation.response("Response", "application/json", rule()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Instance rule managment"], + summary: "Delete rule", + operationId: "AdminAPI.RuleController.delete", + parameters: [Operation.parameter(:id, :path, :string, "Rule ID")], + security: [%{"oAuth" => ["admin:write"]}], + responses: %{ + 200 => empty_object_response(), + 404 => Operation.response("Not Found", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + defp create_request do + %Schema{ + type: :object, + required: [:text], + properties: %{ + priority: %Schema{type: :integer}, + text: %Schema{type: :string} + } + } + end + + defp update_request do + %Schema{ + type: :object, + properties: %{ + priority: %Schema{type: :integer}, + text: %Schema{type: :string} + } + } + end + + defp rule do + %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :integer}, + priority: %Schema{type: :integer}, + text: %Schema{type: :string}, + created_at: %Schema{type: :string, format: :"date-time"} + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex new file mode 100644 index 0000000..17383f1 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex @@ -0,0 +1,167 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.StatusOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + import Pleroma.Web.ApiSpec.StatusOperation, only: [id_param: 0] + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Status administration"], + operationId: "AdminAPI.StatusController.index", + summary: "Get all statuses", + security: [%{"oAuth" => ["admin:read:statuses"]}], + parameters: [ + Operation.parameter( + :godmode, + :query, + %Schema{type: :boolean, default: false}, + "Allows to see private statuses" + ), + Operation.parameter( + :local_only, + :query, + %Schema{type: :boolean, default: false}, + "Excludes remote statuses" + ), + Operation.parameter( + :with_reblogs, + :query, + %Schema{type: :boolean, default: false}, + "Allows to see reblogs" + ), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of statuses to return" + ) + | admin_api_params() + ], + responses: %{ + 200 => + Operation.response("Array of statuses", "application/json", %Schema{ + type: :array, + items: status() + }) + } + } + end + + def show_operation do + %Operation{ + tags: ["Status administration"], + summary: "Get status", + operationId: "AdminAPI.StatusController.show", + parameters: [id_param() | admin_api_params()], + security: [%{"oAuth" => ["admin:read:statuses"]}], + responses: %{ + 200 => Operation.response("Status", "application/json", status()), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Status administration"], + summary: "Change the scope of a status", + operationId: "AdminAPI.StatusController.update", + parameters: [id_param() | admin_api_params()], + security: [%{"oAuth" => ["admin:write:statuses"]}], + requestBody: request_body("Parameters", update_request(), required: true), + responses: %{ + 200 => Operation.response("Status", "application/json", Status), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Status administration"], + summary: "Delete status", + operationId: "AdminAPI.StatusController.delete", + parameters: [id_param() | admin_api_params()], + security: [%{"oAuth" => ["admin:write:statuses"]}], + responses: %{ + 200 => empty_object_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp status do + %Schema{ + anyOf: [ + Status, + %Schema{ + type: :object, + properties: %{ + account: %Schema{allOf: [Account, admin_account()]} + } + } + ] + } + end + + def admin_account do + %Schema{ + type: :object, + properties: %{ + id: FlakeID, + avatar: %Schema{type: :string}, + nickname: %Schema{type: :string}, + display_name: %Schema{type: :string}, + is_active: %Schema{type: :boolean}, + local: %Schema{type: :boolean}, + roles: %Schema{ + type: :object, + properties: %{ + admin: %Schema{type: :boolean}, + moderator: %Schema{type: :boolean} + } + }, + tags: %Schema{type: :string}, + is_confirmed: %Schema{type: :boolean} + } + } + end + + defp update_request do + %Schema{ + type: :object, + properties: %{ + sensitive: %Schema{ + type: :boolean, + description: "Mark status and attached media as sensitive?" + }, + visibility: VisibilityScope + }, + example: %{ + "visibility" => "private", + "sensitive" => "false" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/user_operation.ex b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex new file mode 100644 index 0000000..a5179ac --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex @@ -0,0 +1,453 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ActorType + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["User administration"], + summary: "List users", + operationId: "AdminAPI.UserController.index", + security: [%{"oAuth" => ["admin:read:accounts"]}], + parameters: [ + Operation.parameter(:filters, :query, :string, "Comma separated list of filters"), + Operation.parameter(:query, :query, :string, "Search users query"), + Operation.parameter(:name, :query, :string, "Search by display name"), + Operation.parameter(:email, :query, :string, "Search by email"), + Operation.parameter(:page, :query, :integer, "Page Number"), + Operation.parameter(:page_size, :query, :integer, "Number of users to return per page"), + Operation.parameter( + :actor_types, + :query, + %Schema{type: :array, items: ActorType}, + "Filter by actor type" + ), + Operation.parameter( + :tags, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Filter by tags" + ) + | admin_api_params() + ], + responses: %{ + 200 => + Operation.response( + "Response", + "application/json", + %Schema{ + type: :object, + properties: %{ + users: %Schema{type: :array, items: user()}, + count: %Schema{type: :integer}, + page_size: %Schema{type: :integer} + } + } + ), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def create_operation do + %Operation{ + tags: ["User administration"], + summary: "Create a single or multiple users", + operationId: "AdminAPI.UserController.create", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for creating users", + type: :object, + properties: %{ + users: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + nickname: %Schema{type: :string}, + email: %Schema{type: :string}, + password: %Schema{type: :string} + } + } + } + } + } + ), + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + code: %Schema{type: :integer}, + type: %Schema{type: :string}, + data: %Schema{ + type: :object, + properties: %{ + email: %Schema{type: :string, format: :email}, + nickname: %Schema{type: :string} + } + } + } + } + }), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 409 => + Operation.response("Conflict", "application/json", %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + code: %Schema{type: :integer}, + error: %Schema{type: :string}, + type: %Schema{type: :string}, + data: %Schema{ + type: :object, + properties: %{ + email: %Schema{type: :string, format: :email}, + nickname: %Schema{type: :string} + } + } + } + } + }) + } + } + end + + def show_operation do + %Operation{ + tags: ["User administration"], + summary: "Show user", + operationId: "AdminAPI.UserController.show", + security: [%{"oAuth" => ["admin:read:accounts"]}], + parameters: [ + Operation.parameter( + :nickname, + :path, + :string, + "User nickname or ID" + ) + | admin_api_params() + ], + responses: %{ + 200 => Operation.response("Response", "application/json", user()), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def follow_operation do + %Operation{ + tags: ["User administration"], + summary: "Follow", + operationId: "AdminAPI.UserController.follow", + security: [%{"oAuth" => ["admin:write:follows"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + follower: %Schema{type: :string, description: "Follower nickname"}, + followed: %Schema{type: :string, description: "Followed nickname"} + } + } + ), + responses: %{ + 200 => Operation.response("Response", "application/json", %Schema{type: :string}), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def unfollow_operation do + %Operation{ + tags: ["User administration"], + summary: "Unfollow", + operationId: "AdminAPI.UserController.unfollow", + security: [%{"oAuth" => ["admin:write:follows"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + follower: %Schema{type: :string, description: "Follower nickname"}, + followed: %Schema{type: :string, description: "Followed nickname"} + } + } + ), + responses: %{ + 200 => Operation.response("Response", "application/json", %Schema{type: :string}), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def approve_operation do + %Operation{ + tags: ["User administration"], + summary: "Approve multiple users", + operationId: "AdminAPI.UserController.approve", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for approving multiple users", + type: :object, + properties: %{ + nicknames: %Schema{ + type: :array, + items: %Schema{type: :string} + } + } + } + ), + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{user: %Schema{type: :array, items: user()}} + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def suggest_operation do + %Operation{ + tags: ["User administration"], + summary: "Suggest multiple users", + operationId: "AdminAPI.UserController.suggest", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for adding multiple suggested users", + type: :object, + properties: %{ + nicknames: %Schema{ + type: :array, + items: %Schema{type: :string} + } + } + } + ), + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{user: %Schema{type: :array, items: user()}} + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def unsuggest_operation do + %Operation{ + tags: ["User administration"], + summary: "Unsuggest multiple users", + operationId: "AdminAPI.UserController.unsuggest", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for removing multiple suggested users", + type: :object, + properties: %{ + nicknames: %Schema{ + type: :array, + items: %Schema{type: :string} + } + } + } + ), + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{user: %Schema{type: :array, items: user()}} + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def toggle_activation_operation do + %Operation{ + tags: ["User administration"], + summary: "Toggle user activation", + operationId: "AdminAPI.UserController.toggle_activation", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: [ + Operation.parameter(:nickname, :path, :string, "User nickname") + | admin_api_params() + ], + responses: %{ + 200 => Operation.response("Response", "application/json", user()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def activate_operation do + %Operation{ + tags: ["User administration"], + summary: "Activate multiple users", + operationId: "AdminAPI.UserController.activate", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for deleting multiple users", + type: :object, + properties: %{ + nicknames: %Schema{ + type: :array, + items: %Schema{type: :string} + } + } + } + ), + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{user: %Schema{type: :array, items: user()}} + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def deactivate_operation do + %Operation{ + tags: ["User administration"], + summary: "Deactivates multiple users", + operationId: "AdminAPI.UserController.deactivate", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for deleting multiple users", + type: :object, + properties: %{ + nicknames: %Schema{ + type: :array, + items: %Schema{type: :string} + } + } + } + ), + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{user: %Schema{type: :array, items: user()}} + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["User administration"], + summary: "Removes a single or multiple users", + operationId: "AdminAPI.UserController.delete", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: [ + Operation.parameter( + :nickname, + :query, + :string, + "User nickname" + ) + | admin_api_params() + ], + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for deleting multiple users", + type: :object, + properties: %{ + nicknames: %Schema{ + type: :array, + items: %Schema{type: :string} + } + } + } + ), + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + description: "Array of nicknames", + type: :array, + items: %Schema{type: :string} + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + defp user do + %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + email: %Schema{type: :string, format: :email}, + avatar: %Schema{type: :string, format: :uri}, + nickname: %Schema{type: :string}, + display_name: %Schema{type: :string}, + is_active: %Schema{type: :boolean}, + local: %Schema{type: :boolean}, + roles: %Schema{ + type: :object, + properties: %{ + admin: %Schema{type: :boolean}, + moderator: %Schema{type: :boolean} + } + }, + tags: %Schema{type: :array, items: %Schema{type: :string}}, + is_confirmed: %Schema{type: :boolean}, + is_approved: %Schema{type: :boolean}, + url: %Schema{type: :string, format: :uri}, + registration_reason: %Schema{type: :string, nullable: true}, + actor_type: %Schema{type: :string} + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/webhook_operation.ex b/lib/pleroma/web/api_spec/operations/admin/webhook_operation.ex new file mode 100644 index 0000000..7ea646d --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/webhook_operation.ex @@ -0,0 +1,201 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.WebhookOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Retrieve a list of webhooks", + operationId: "AdminAPI.WebhookController.index", + security: [%{"oAuth" => ["admin:show"]}], + responses: %{ + 200 => + Operation.response("Array of webhooks", "application/json", %Schema{ + type: :array, + items: webhook() + }) + } + } + end + + def show_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Retrieve a webhook", + operationId: "AdminAPI.WebhookController.show", + security: [%{"oAuth" => ["admin:show"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()) + } + } + end + + def create_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Create a webhook", + operationId: "AdminAPI.WebhookController.create", + security: [%{"oAuth" => ["admin:write"]}], + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for creating a webhook", + type: :object, + properties: %{ + url: %Schema{type: :string, format: :uri, required: true}, + events: event_type(true), + enabled: %Schema{type: :boolean} + } + } + ), + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()) + } + } + end + + def update_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Update a webhook", + operationId: "AdminAPI.WebhookController.update", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [id_param()], + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for updating a webhook", + type: :object, + properties: %{ + url: %Schema{type: :string, format: :uri}, + events: event_type(), + enabled: %Schema{type: :boolean} + } + } + ), + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Delete a webhook", + operationId: "AdminAPI.WebhookController.delete", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def enable_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Enable a webhook", + operationId: "AdminAPI.WebhookController.enable", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def disable_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Disable a webhook", + operationId: "AdminAPI.WebhookController.disable", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def rotate_secret_operation do + %Operation{ + tags: ["Webhooks"], + summary: "Rotate webhook signing secret", + operationId: "AdminAPI.WebhookController.rotate_secret", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Webhook", "application/json", webhook()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + defp webhook do + %Schema{ + title: "Webhook", + description: "Schema for a webhook", + type: :object, + properties: %{ + id: %Schema{type: :string}, + url: %Schema{type: :string, format: :uri}, + events: event_type(), + secret: %Schema{type: :string}, + enabled: %Schema{type: :boolean}, + internal: %Schema{type: :boolean}, + created_at: %Schema{type: :string, format: :"date-time"}, + updated_at: %Schema{type: :string, format: :"date-time"} + }, + example: %{ + "id" => "1", + "url" => "https://example.com/webhook", + "events" => ["report.created"], + "secret" => "D3D8CF4BC11FD9C41FD34DCC38D282E451C8BD34", + "enabled" => true, + "internal" => false, + "created_at" => "2022-06-24T16:19:38.523Z", + "updated_at" => "2022-06-24T16:19:38.523Z" + } + } + end + + defp event_type(required \\ nil) do + %Schema{ + type: :array, + items: %Schema{ + title: "Webhook event", + description: "Event type", + type: :string, + enum: ["account.created", "report.created"], + required: required + } + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "Webhook ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/announcement_operation.ex b/lib/pleroma/web/api_spec/operations/announcement_operation.ex new file mode 100644 index 0000000..6f70319 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/announcement_operation.ex @@ -0,0 +1,57 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.AnnouncementOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Announcement + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Announcements"], + summary: "Retrieve a list of announcements", + operationId: "MastodonAPI.AnnouncementController.index", + security: [%{"oAuth" => []}], + responses: %{ + 200 => Operation.response("Response", "application/json", list_of_announcements()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def mark_read_operation do + %Operation{ + tags: ["Announcements"], + summary: "Mark one announcement as read", + operationId: "MastodonAPI.AnnouncementController.mark_read", + security: [%{"oAuth" => ["write:accounts"]}], + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "announcement id" + ) + ], + responses: %{ + 200 => Operation.response("Response", "application/json", %Schema{type: :object}), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def list_of_announcements do + %Schema{ + type: :array, + items: Announcement + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/app_operation.ex b/lib/pleroma/web/api_spec/operations/app_operation.ex new file mode 100644 index 0000000..dfa2237 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/app_operation.ex @@ -0,0 +1,123 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.AppOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.App + + @spec open_api_operation(atom) :: Operation.t() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + @spec create_operation() :: Operation.t() + def create_operation do + %Operation{ + tags: ["Applications"], + summary: "Create an application", + description: "Create a new application to obtain OAuth2 credentials", + operationId: "AppController.create", + requestBody: Helpers.request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("App", "application/json", App), + 422 => + Operation.response( + "Unprocessable Entity", + "application/json", + %Schema{ + type: :object, + description: + "If a required parameter is missing or improperly formatted, the request will fail.", + properties: %{ + error: %Schema{type: :string} + }, + example: %{ + "error" => "Validation failed: Redirect URI must be an absolute URI." + } + } + ) + } + } + end + + def verify_credentials_operation do + %Operation{ + tags: ["Applications"], + summary: "Verify the application works", + description: "Confirm that the app's OAuth2 credentials work.", + operationId: "AppController.verify_credentials", + security: [%{"oAuth" => ["read"]}], + responses: %{ + 200 => + Operation.response("App", "application/json", %Schema{ + type: :object, + description: + "If the Authorization header was provided with a valid token, you should see your app returned as an Application entity.", + properties: %{ + name: %Schema{type: :string}, + vapid_key: %Schema{type: :string}, + website: %Schema{type: :string, nullable: true} + }, + example: %{ + "name" => "My App", + "vapid_key" => + "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=", + "website" => "https://myapp.com/" + } + }), + 422 => + Operation.response( + "Unauthorized", + "application/json", + %Schema{ + type: :object, + description: + "If the Authorization header contains an invalid token, is malformed, or is not present, an error will be returned indicating an authorization failure.", + properties: %{ + error: %Schema{type: :string} + }, + example: %{ + "error" => "The access token is invalid." + } + } + ) + } + } + end + + defp create_request do + %Schema{ + title: "AppCreateRequest", + description: "POST body for creating an app", + type: :object, + properties: %{ + client_name: %Schema{type: :string, description: "A name for your application."}, + redirect_uris: %Schema{ + type: :string, + description: + "Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter." + }, + scopes: %Schema{ + type: :string, + description: "Space separated list of scopes", + default: "read" + }, + website: %Schema{ + type: :string, + nullable: true, + description: "A URL to the homepage of your app" + } + }, + required: [:client_name, :redirect_uris], + example: %{ + "client_name" => "My App", + "redirect_uris" => "https://myapp.com/auth/callback", + "website" => "https://myapp.com/" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex new file mode 100644 index 0000000..31292c4 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -0,0 +1,407 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ChatOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.Chat + alias Pleroma.Web.ApiSpec.Schemas.ChatMessage + + import Pleroma.Web.ApiSpec.Helpers + + @spec open_api_operation(atom) :: Operation.t() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def mark_as_read_operation do + %Operation{ + tags: ["Chats"], + summary: "Mark all messages in the chat as read", + operationId: "ChatController.mark_as_read", + parameters: [Operation.parameter(:id, :path, :string, "The ID of the Chat")], + requestBody: request_body("Parameters", mark_as_read()), + responses: %{ + 200 => + Operation.response( + "The updated chat", + "application/json", + Chat + ) + }, + security: [ + %{ + "oAuth" => ["write:chats"] + } + ] + } + end + + def mark_message_as_read_operation do + %Operation{ + tags: ["Chats"], + summary: "Mark a message as read", + operationId: "ChatController.mark_message_as_read", + parameters: [ + Operation.parameter(:id, :path, :string, "The ID of the Chat"), + Operation.parameter(:message_id, :path, :string, "The ID of the message") + ], + responses: %{ + 200 => + Operation.response( + "The read ChatMessage", + "application/json", + ChatMessage + ) + }, + security: [ + %{ + "oAuth" => ["write:chats"] + } + ] + } + end + + def show_operation do + %Operation{ + tags: ["Chats"], + summary: "Retrieve a chat", + operationId: "ChatController.show", + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "The id of the chat", + required: true, + example: "1234" + ) + ], + responses: %{ + 200 => + Operation.response( + "The existing chat", + "application/json", + Chat + ) + }, + security: [ + %{ + "oAuth" => ["read"] + } + ] + } + end + + def create_operation do + %Operation{ + tags: ["Chats"], + summary: "Create a chat", + operationId: "ChatController.create", + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "The account id of the recipient of this chat", + required: true, + example: "someflakeid" + ) + ], + responses: %{ + 200 => + Operation.response( + "The created or existing chat", + "application/json", + Chat + ) + }, + security: [ + %{ + "oAuth" => ["write:chats"] + } + ] + } + end + + def index_operation do + %Operation{ + tags: ["Chats"], + summary: "Retrieve list of chats (unpaginated)", + deprecated: true, + description: + "Deprecated due to no support for pagination. Using [/api/v2/pleroma/chats](#operation/ChatController.index2) instead is recommended.", + operationId: "ChatController.index", + parameters: [ + Operation.parameter(:with_muted, :query, BooleanLike, "Include chats from muted users") + ], + responses: %{ + 200 => Operation.response("The chats of the user", "application/json", chats_response()) + }, + security: [ + %{ + "oAuth" => ["read:chats"] + } + ] + } + end + + def index2_operation do + %Operation{ + tags: ["Chats"], + summary: "Retrieve list of chats", + operationId: "ChatController.index2", + parameters: [ + Operation.parameter(:with_muted, :query, BooleanLike, "Include chats from muted users") + | pagination_params() + ], + responses: %{ + 200 => Operation.response("The chats of the user", "application/json", chats_response()) + }, + security: [ + %{ + "oAuth" => ["read:chats"] + } + ] + } + end + + def messages_operation do + %Operation{ + tags: ["Chats"], + summary: "Retrieve chat's messages", + operationId: "ChatController.messages", + parameters: + [Operation.parameter(:id, :path, :string, "The ID of the Chat")] ++ + pagination_params(), + responses: %{ + 200 => + Operation.response( + "The messages in the chat", + "application/json", + chat_messages_response() + ), + 404 => Operation.response("Not Found", "application/json", ApiError) + }, + security: [ + %{ + "oAuth" => ["read:chats"] + } + ] + } + end + + def post_chat_message_operation do + %Operation{ + tags: ["Chats"], + summary: "Post a message to the chat", + operationId: "ChatController.post_chat_message", + parameters: [ + Operation.parameter(:id, :path, :string, "The ID of the Chat") + ], + requestBody: request_body("Parameters", chat_message_create()), + responses: %{ + 200 => + Operation.response( + "The newly created ChatMessage", + "application/json", + ChatMessage + ), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 422 => Operation.response("MRF Rejection", "application/json", ApiError) + }, + security: [ + %{ + "oAuth" => ["write:chats"] + } + ] + } + end + + def delete_operation do + %Operation{ + tags: ["Chats"], + summary: "delete", + operationId: "ChatController.delete", + parameters: [ + Operation.parameter(:id, :path, :string, "The ID of the Chat") + ], + responses: %{ + 200 => + Operation.response( + "The deleted Chat", + "application/json", + Chat + ) + }, + security: [ + %{ + "oAuth" => ["write:chats"] + } + ] + } + end + + def delete_message_operation do + %Operation{ + tags: ["Chats"], + summary: "Delete message", + operationId: "ChatController.delete_message", + parameters: [ + Operation.parameter(:id, :path, :string, "The ID of the Chat"), + Operation.parameter(:message_id, :path, :string, "The ID of the message") + ], + responses: %{ + 200 => + Operation.response( + "The deleted ChatMessage", + "application/json", + ChatMessage + ) + }, + security: [ + %{ + "oAuth" => ["write:chats"] + } + ] + } + end + + def chats_response do + %Schema{ + title: "ChatsResponse", + description: "Response schema for multiple Chats", + type: :array, + items: Chat, + example: [ + %{ + "account" => %{ + "pleroma" => %{ + "is_admin" => false, + "is_confirmed" => true, + "hide_followers_count" => false, + "is_moderator" => false, + "hide_favorites" => true, + "ap_id" => "https://dontbulling.me/users/lain", + "hide_follows_count" => false, + "hide_follows" => false, + "background_image" => nil, + "skip_thread_containment" => false, + "hide_followers" => false, + "relationship" => %{}, + "tags" => [] + }, + "avatar" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "following_count" => 0, + "header_static" => "https://originalpatchou.li/images/banner.png", + "source" => %{ + "sensitive" => false, + "note" => "lain", + "pleroma" => %{ + "discoverable" => false, + "actor_type" => "Person" + }, + "fields" => [] + }, + "statuses_count" => 1, + "locked" => false, + "created_at" => "2020-04-16T13:40:15.000Z", + "display_name" => "lain", + "fields" => [], + "acct" => "lain@dontbulling.me", + "id" => "9u6Qw6TAZANpqokMkK", + "emojis" => [], + "avatar_static" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "username" => "lain", + "followers_count" => 0, + "header" => "https://originalpatchou.li/images/banner.png", + "bot" => false, + "note" => "lain", + "url" => "https://dontbulling.me/users/lain" + }, + "id" => "1", + "unread" => 2 + } + ] + } + end + + def chat_messages_response do + %Schema{ + title: "ChatMessagesResponse", + description: "Response schema for multiple ChatMessages", + type: :array, + items: ChatMessage, + example: [ + %{ + "emojis" => [ + %{ + "static_url" => "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker" => false, + "shortcode" => "firefox", + "url" => "https://dontbulling.me/emoji/Firefox.gif" + } + ], + "created_at" => "2020-04-21T15:11:46.000Z", + "content" => "Check this out :firefox:", + "id" => "13", + "chat_id" => "1", + "account_id" => "someflakeid", + "unread" => false + }, + %{ + "account_id" => "someflakeid", + "content" => "Whats' up?", + "id" => "12", + "chat_id" => "1", + "emojis" => [], + "created_at" => "2020-04-21T15:06:45.000Z", + "unread" => false + } + ] + } + end + + def chat_message_create do + %Schema{ + title: "ChatMessageCreateRequest", + description: "POST body for creating an chat message", + type: :object, + properties: %{ + content: %Schema{ + type: :string, + description: "The content of your message. Optional if media_id is present" + }, + media_id: %Schema{type: :string, description: "The id of an upload"} + }, + example: %{ + "content" => "Hey wanna buy feet pics?", + "media_id" => "134234" + } + } + end + + def mark_as_read do + %Schema{ + title: "MarkAsReadRequest", + description: "POST body for marking a number of chat messages as read", + type: :object, + required: [:last_read_id], + properties: %{ + last_read_id: %Schema{ + type: :string, + description: "The content of your message." + } + }, + example: %{ + "last_read_id" => "abcdef12456" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/conversation_operation.ex b/lib/pleroma/web/api_spec/operations/conversation_operation.ex new file mode 100644 index 0000000..82ccf41 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/conversation_operation.ex @@ -0,0 +1,76 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ConversationOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Conversation + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Conversations"], + summary: "List of conversations", + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "ConversationController.index", + parameters: [ + Operation.parameter( + :recipients, + :query, + %Schema{type: :array, items: FlakeID}, + "Only return conversations with the given recipients (a list of user ids)" + ) + | pagination_params() + ], + responses: %{ + 200 => + Operation.response("Array of Conversation", "application/json", %Schema{ + type: :array, + items: Conversation, + example: [Conversation.schema().example] + }) + } + } + end + + def mark_as_read_operation do + %Operation{ + tags: ["Conversations"], + summary: "Mark conversation as read", + operationId: "ConversationController.mark_as_read", + parameters: [id_param()], + security: [%{"oAuth" => ["write:conversations"]}], + responses: %{ + 200 => Operation.response("Conversation", "application/json", Conversation) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Conversations"], + summary: "Remove conversation", + operationId: "ConversationController.delete", + parameters: [id_param()], + security: [%{"oAuth" => ["write:conversations"]}], + responses: %{ + 200 => empty_object_response() + } + } + end + + def id_param do + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex b/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex new file mode 100644 index 0000000..77823f1 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex @@ -0,0 +1,88 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.CustomEmojiOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Emoji + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Custom emojis"], + summary: "Retrieve a list of custom emojis", + description: "Returns custom emojis that are available on the server.", + operationId: "CustomEmojiController.index", + responses: %{ + 200 => Operation.response("Custom Emojis", "application/json", resposnse()) + } + } + end + + defp resposnse do + %Schema{ + title: "CustomEmojisResponse", + description: "Response schema for custom emojis", + type: :array, + items: custom_emoji(), + example: [ + %{ + "category" => "Fun", + "shortcode" => "blank", + "static_url" => "https://lain.com/emoji/blank.png", + "tags" => ["Fun"], + "url" => "https://lain.com/emoji/blank.png", + "visible_in_picker" => false + }, + %{ + "category" => "Gif,Fun", + "shortcode" => "firefox", + "static_url" => "https://lain.com/emoji/Firefox.gif", + "tags" => ["Gif", "Fun"], + "url" => "https://lain.com/emoji/Firefox.gif", + "visible_in_picker" => true + }, + %{ + "category" => "pack:mixed", + "shortcode" => "sadcat", + "static_url" => "https://lain.com/emoji/mixed/sadcat.png", + "tags" => ["pack:mixed"], + "url" => "https://lain.com/emoji/mixed/sadcat.png", + "visible_in_picker" => true + } + ] + } + end + + defp custom_emoji do + %Schema{ + title: "CustomEmoji", + description: "Schema for a CustomEmoji", + allOf: [ + Emoji, + %Schema{ + type: :object, + properties: %{ + category: %Schema{type: :string}, + tags: %Schema{type: :array, items: %Schema{type: :string}} + } + } + ], + example: %{ + "category" => "Fun", + "shortcode" => "aaaa", + "url" => + "https://files.mastodon.social/custom_emojis/images/000/007/118/original/aaaa.png", + "static_url" => + "https://files.mastodon.social/custom_emojis/images/000/007/118/static/aaaa.png", + "visible_in_picker" => true, + "tags" => ["Gif", "Fun"] + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/directory_operation.ex b/lib/pleroma/web/api_spec/operations/directory_operation.ex new file mode 100644 index 0000000..23fa84d --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/directory_operation.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.DirectoryOperation do + alias OpenApiSpex.Operation + alias Pleroma.Web.ApiSpec.AccountOperation + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Others"], + summary: "Profile directory", + operationId: "DirectoryController.index", + parameters: + [ + Operation.parameter( + :order, + :query, + :string, + "Order by recent activity or account creation", + required: nil + ), + Operation.parameter(:local, :query, BooleanLike, "Include local users only") + ] ++ pagination_params(), + responses: %{ + 200 => + Operation.response("Accounts", "application/json", AccountOperation.array_of_accounts()), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/domain_block_operation.ex b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex new file mode 100644 index 0000000..2340fd9 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.DomainBlockOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Domain blocks"], + summary: "Retrieve a list of blocked domains", + security: [%{"oAuth" => ["follow", "read:blocks"]}], + operationId: "DomainBlockController.index", + responses: %{ + 200 => + Operation.response("Domain blocks", "application/json", %Schema{ + description: "Response schema for domain blocks", + type: :array, + items: %Schema{type: :string}, + example: ["google.com", "facebook.com"] + }) + } + } + end + + # Supporting domain query parameter is deprecated in Mastodon API + def create_operation do + %Operation{ + tags: ["Domain blocks"], + summary: "Block a domain", + description: """ + Block a domain to: + + - hide all public posts from it + - hide all notifications from it + - remove all followers from it + - prevent following new users from it (but does not remove existing follows) + """, + operationId: "DomainBlockController.create", + requestBody: domain_block_request(), + parameters: [Operation.parameter(:domain, :query, %Schema{type: :string}, "Domain name")], + security: [%{"oAuth" => ["follow", "write:blocks"]}], + responses: %{200 => empty_object_response()} + } + end + + # Supporting domain query parameter is deprecated in Mastodon API + def delete_operation do + %Operation{ + tags: ["Domain blocks"], + summary: "Unblock a domain", + description: "Remove a domain block, if it exists in the user's array of blocked domains.", + operationId: "DomainBlockController.delete", + requestBody: domain_block_request(), + parameters: [Operation.parameter(:domain, :query, %Schema{type: :string}, "Domain name")], + security: [%{"oAuth" => ["follow", "write:blocks"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) + } + } + end + + defp domain_block_request do + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + domain: %Schema{type: :string} + } + }, + required: false, + example: %{ + "domain" => "facebook.com" + } + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex b/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex new file mode 100644 index 0000000..74341d6 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex @@ -0,0 +1,110 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.EmojiReactionOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Emoji reactions"], + summary: + "Get an object of emoji to account mappings with accounts that reacted to the post", + parameters: [ + Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:emoji, :path, :string, "Filter by a single unicode emoji", + required: nil + ), + Operation.parameter( + :with_muted, + :query, + :boolean, + "Include reactions from muted acccounts." + ) + ], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "EmojiReactionController.index", + responses: %{ + 200 => array_of_reactions_response() + } + } + end + + def create_operation do + %Operation{ + tags: ["Emoji reactions"], + summary: "React to a post with a unicode emoji", + parameters: [ + Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:emoji, :path, :string, "A single character unicode emoji", + required: true + ) + ], + security: [%{"oAuth" => ["write:statuses"]}], + operationId: "EmojiReactionController.create", + responses: %{ + 200 => Operation.response("Status", "application/json", Status), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Emoji reactions"], + summary: "Remove a reaction to a post with a unicode emoji", + parameters: [ + Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:emoji, :path, :string, "A single character unicode emoji", + required: true + ) + ], + security: [%{"oAuth" => ["write:statuses"]}], + operationId: "EmojiReactionController.delete", + responses: %{ + 200 => Operation.response("Status", "application/json", Status) + } + } + end + + defp array_of_reactions_response do + Operation.response("Array of Emoji reactions", "application/json", %Schema{ + type: :array, + items: emoji_reaction(), + example: [emoji_reaction().example] + }) + end + + defp emoji_reaction do + %Schema{ + title: "EmojiReaction", + type: :object, + properties: %{ + name: %Schema{type: :string, description: "Emoji"}, + count: %Schema{type: :integer, description: "Count of reactions with this emoji"}, + me: %Schema{type: :boolean, description: "Did I react with this emoji?"}, + accounts: %Schema{ + type: :array, + items: Account, + description: "Array of accounts reacted with this emoji" + } + }, + example: %{ + "name" => "😱", + "count" => 1, + "me" => false, + "accounts" => [Account.schema().example] + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/filter_operation.ex b/lib/pleroma/web/api_spec/operations/filter_operation.ex new file mode 100644 index 0000000..a1700b7 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/filter_operation.ex @@ -0,0 +1,237 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.FilterOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Filters"], + summary: "All filters", + operationId: "FilterController.index", + security: [%{"oAuth" => ["read:filters"]}], + responses: %{ + 200 => Operation.response("Filters", "application/json", array_of_filters()), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def create_operation do + %Operation{ + tags: ["Filters"], + summary: "Create a filter", + operationId: "FilterController.create", + requestBody: Helpers.request_body("Parameters", create_request(), required: true), + security: [%{"oAuth" => ["write:filters"]}], + responses: %{ + 200 => Operation.response("Filter", "application/json", filter()), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Filters"], + summary: "Filter", + parameters: [id_param()], + operationId: "FilterController.show", + security: [%{"oAuth" => ["read:filters"]}], + responses: %{ + 200 => Operation.response("Filter", "application/json", filter()), + 403 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Filters"], + summary: "Update a filter", + parameters: [id_param()], + operationId: "FilterController.update", + requestBody: Helpers.request_body("Parameters", update_request(), required: true), + security: [%{"oAuth" => ["write:filters"]}], + responses: %{ + 200 => Operation.response("Filter", "application/json", filter()), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Filters"], + summary: "Remove a filter", + parameters: [id_param()], + operationId: "FilterController.delete", + security: [%{"oAuth" => ["write:filters"]}], + responses: %{ + 200 => + Operation.response("Filter", "application/json", %Schema{ + type: :object, + description: "Empty object" + }), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "Filter ID", example: "123", required: true) + end + + defp filter do + %Schema{ + title: "Filter", + type: :object, + properties: %{ + id: %Schema{type: :string}, + phrase: %Schema{type: :string, description: "The text to be filtered"}, + context: %Schema{ + type: :array, + items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]}, + description: "The contexts in which the filter should be applied." + }, + expires_at: %Schema{ + type: :string, + format: :"date-time", + description: + "When the filter should no longer be applied. String (ISO 8601 Datetime), or null if the filter does not expire.", + nullable: true + }, + irreversible: %Schema{ + type: :boolean, + description: + "Should matching entities in home and notifications be dropped by the server?" + }, + whole_word: %Schema{ + type: :boolean, + description: "Should the filter consider word boundaries?" + } + }, + example: %{ + "id" => "5580", + "phrase" => "@twitter.com", + "context" => [ + "home", + "notifications", + "public", + "thread" + ], + "whole_word" => false, + "expires_at" => nil, + "irreversible" => true + } + } + end + + defp array_of_filters do + %Schema{ + title: "ArrayOfFilters", + description: "Array of Filters", + type: :array, + items: filter(), + example: [ + %{ + "id" => "5580", + "phrase" => "@twitter.com", + "context" => [ + "home", + "notifications", + "public", + "thread" + ], + "whole_word" => false, + "expires_at" => nil, + "irreversible" => true + }, + %{ + "id" => "6191", + "phrase" => ":eurovision2019:", + "context" => [ + "home" + ], + "whole_word" => true, + "expires_at" => "2019-05-21T13:47:31.333Z", + "irreversible" => false + } + ] + } + end + + defp create_request do + %Schema{ + title: "FilterCreateRequest", + allOf: [ + update_request(), + %Schema{ + type: :object, + properties: %{ + irreversible: %Schema{ + allOf: [BooleanLike], + description: + "Should the server irreversibly drop matching entities from home and notifications?", + default: false + } + } + } + ], + example: %{ + "phrase" => "knights", + "context" => ["home"] + } + } + end + + defp update_request do + %Schema{ + title: "FilterUpdateRequest", + type: :object, + properties: %{ + phrase: %Schema{type: :string, description: "The text to be filtered"}, + context: %Schema{ + type: :array, + items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]}, + description: + "Array of enumerable strings `home`, `notifications`, `public`, `thread`. At least one context must be specified." + }, + irreversible: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: + "Should the server irreversibly drop matching entities from home and notifications?" + }, + whole_word: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Consider word boundaries?", + default: true + }, + expires_in: %Schema{ + nullable: true, + type: :integer, + description: + "Number of seconds from now the filter should expire. Otherwise, null for a filter that doesn't expire." + } + }, + required: [:phrase, :context], + example: %{ + "phrase" => "knights", + "context" => ["home"] + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/follow_request_operation.ex b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex new file mode 100644 index 0000000..72dc8b5 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex @@ -0,0 +1,65 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.FollowRequestOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Follow requests"], + summary: "Retrieve follow requests", + security: [%{"oAuth" => ["read:follows", "follow"]}], + operationId: "FollowRequestController.index", + responses: %{ + 200 => + Operation.response("Array of Account", "application/json", %Schema{ + type: :array, + items: Account, + example: [Account.schema().example] + }) + } + } + end + + def authorize_operation do + %Operation{ + tags: ["Follow requests"], + summary: "Accept follow request", + operationId: "FollowRequestController.authorize", + parameters: [id_param()], + security: [%{"oAuth" => ["follow", "write:follows"]}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def reject_operation do + %Operation{ + tags: ["Follow requests"], + summary: "Reject follow request", + operationId: "FollowRequestController.reject", + parameters: [id_param()], + security: [%{"oAuth" => ["follow", "write:follows"]}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex new file mode 100644 index 0000000..75d1871 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -0,0 +1,482 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.InstanceOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Instance misc"], + summary: "Retrieve instance information", + description: "Information about the server", + operationId: "InstanceController.show", + responses: %{ + 200 => Operation.response("Instance", "application/json", instance()) + } + } + end + + def show2_operation do + %Operation{ + tags: ["Instance misc"], + summary: "Retrieve instance information", + description: "Information about the server", + operationId: "InstanceController.show2", + responses: %{ + 200 => Operation.response("Instance", "application/json", instance2()) + } + } + end + + def peers_operation do + %Operation{ + tags: ["Instance misc"], + summary: "Retrieve list of known instances", + operationId: "InstanceController.peers", + responses: %{ + 200 => Operation.response("Array of domains", "application/json", array_of_domains()) + } + } + end + + def rules_operation do + %Operation{ + tags: ["Instance misc"], + summary: "Retrieve list of instance rules", + operationId: "InstanceController.rules", + responses: %{ + 200 => Operation.response("Array of rules", "application/json", array_of_rules()) + } + } + end + + def domain_blocks_operation do + %Operation{ + tags: ["Instance misc"], + summary: "Retrieve instance domain blocks", + operationId: "InstanceController.domain_blocks", + responses: %{ + 200 => + Operation.response( + "Array of domain blocks", + "application/json", + array_of_domain_blocks() + ) + } + } + end + + def translation_languages_operation do + %Operation{ + tags: ["Instance misc"], + summary: "Retrieve supported languages matrix", + operationId: "InstanceController.translation_languages", + responses: %{ + 200 => + Operation.response( + "Translation languages matrix", + "application/json", + %Schema{ + type: :object, + additionalProperties: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: "Supported target languages for a source language" + } + } + ) + } + } + end + + defp instance do + %Schema{ + type: :object, + properties: %{ + uri: %Schema{type: :string, description: "The domain name of the instance"}, + title: %Schema{type: :string, description: "The title of the website"}, + description: %Schema{ + type: :string, + description: "Admin-defined description of the Pleroma site" + }, + version: %Schema{ + type: :string, + description: "The version of Pleroma installed on the instance" + }, + email: %Schema{ + type: :string, + description: "An email that may be contacted for any inquiries", + format: :email + }, + urls: %Schema{ + type: :object, + description: "URLs of interest for clients apps", + properties: %{ + streaming_api: %Schema{ + type: :string, + description: "Websockets address for push streaming" + } + } + }, + stats: %Schema{ + type: :object, + description: "Statistics about how much information the instance contains", + properties: %{ + user_count: %Schema{ + type: :integer, + description: "Users registered on this instance" + }, + status_count: %Schema{ + type: :integer, + description: "Statuses authored by users on instance" + }, + domain_count: %Schema{ + type: :integer, + description: "Domains federated with this instance" + } + } + }, + thumbnail: %Schema{ + type: :string, + description: "Banner image for the website", + nullable: true + }, + languages: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: "Primary langauges of the website and its staff" + }, + registrations: %Schema{type: :boolean, description: "Whether registrations are enabled"}, + # Extra (not present in Mastodon): + max_toot_chars: %Schema{ + type: :integer, + description: ": Posts character limit (CW/Subject included in the counter)" + }, + poll_limits: %Schema{ + type: :object, + description: "A map with poll limits for local polls", + properties: %{ + max_options: %Schema{ + type: :integer, + description: "Maximum number of options." + }, + max_option_chars: %Schema{ + type: :integer, + description: "Maximum number of characters per option." + }, + min_expiration: %Schema{ + type: :integer, + description: "Minimum expiration time (in seconds)." + }, + max_expiration: %Schema{ + type: :integer, + description: "Maximum expiration time (in seconds)." + } + } + }, + upload_limit: %Schema{ + type: :integer, + description: "File size limit of uploads (except for avatar, background, banner)" + }, + avatar_upload_limit: %Schema{type: :integer, description: "The title of the website"}, + background_upload_limit: %Schema{type: :integer, description: "The title of the website"}, + banner_upload_limit: %Schema{type: :integer, description: "The title of the website"}, + background_image: %Schema{ + type: :string, + format: :uri, + description: "The background image for the website" + }, + configuration: %Schema{ + type: :object, + description: "Instance configuration", + properties: %{ + statuses: %Schema{ + type: :object, + description: "A map with poll limits for local statuses", + properties: %{ + max_characters: %Schema{ + type: :integer, + description: "Posts character limit (CW/Subject included in the counter)" + }, + max_media_attachments: %Schema{ + type: :integer, + description: "Media attachment limit" + } + } + }, + media_attachments: %Schema{ + type: :object, + description: "A map with poll limits for media attachments", + properties: %{ + image_size_limit: %Schema{ + type: :integer, + description: "File size limit of uploaded images" + }, + video_size_limit: %Schema{ + type: :integer, + description: "File size limit of uploaded videos" + } + } + }, + polls: %Schema{ + type: :object, + description: "A map with poll limits for local polls", + properties: %{ + max_options: %Schema{ + type: :integer, + description: "Maximum number of options." + }, + max_characters_per_option: %Schema{ + type: :integer, + description: "Maximum number of characters per option." + }, + min_expiration: %Schema{ + type: :integer, + description: "Minimum expiration time (in seconds)." + }, + max_expiration: %Schema{ + type: :integer, + description: "Maximum expiration time (in seconds)." + } + } + } + } + } + }, + example: %{ + "avatar_upload_limit" => 2_000_000, + "background_upload_limit" => 4_000_000, + "background_image" => "/static/image.png", + "banner_upload_limit" => 4_000_000, + "description" => "Pleroma: An efficient and flexible fediverse server", + "email" => "lain@lain.com", + "languages" => ["en"], + "max_toot_chars" => 5000, + "poll_limits" => %{ + "max_expiration" => 31_536_000, + "max_option_chars" => 200, + "max_options" => 20, + "min_expiration" => 0 + }, + "registrations" => false, + "stats" => %{ + "domain_count" => 2996, + "status_count" => 15_802, + "user_count" => 5 + }, + "thumbnail" => "https://lain.com/instance/thumbnail.jpeg", + "title" => "lain.com", + "upload_limit" => 16_000_000, + "uri" => "https://lain.com", + "urls" => %{ + "streaming_api" => "wss://lain.com" + }, + "version" => "2.7.2 (compatible; Pleroma 2.0.50-536-g25eec6d7-develop)", + "rules" => array_of_rules() + } + } + end + + defp instance2 do + %Schema{ + type: :object, + properties: %{ + domain: %Schema{type: :string, description: "The domain name of the instance"}, + title: %Schema{type: :string, description: "The title of the website"}, + version: %Schema{ + type: :string, + description: "The version of Pleroma installed on the instance" + }, + source_url: %Schema{ + type: :string, + description: "The version of Pleroma installed on the instance" + }, + description: %Schema{ + type: :string, + description: "Admin-defined description of the Pleroma site" + }, + usage: %Schema{ + type: :object, + description: "Instance usage statistics", + properties: %{ + users: %Schema{ + type: :object, + description: "User count statistics", + properties: %{ + active_month: %Schema{ + type: :integer, + description: "Monthly active users" + } + } + } + } + }, + email: %Schema{ + type: :string, + description: "An email that may be contacted for any inquiries", + format: :email + }, + urls: %Schema{ + type: :object, + description: "URLs of interest for clients apps", + properties: %{} + }, + stats: %Schema{ + type: :object, + description: "Statistics about how much information the instance contains", + properties: %{ + user_count: %Schema{ + type: :integer, + description: "Users registered on this instance" + }, + status_count: %Schema{ + type: :integer, + description: "Statuses authored by users on instance" + }, + domain_count: %Schema{ + type: :integer, + description: "Domains federated with this instance" + } + } + }, + thumbnail: %Schema{ + type: :object, + properties: %{ + url: %Schema{ + type: :string, + description: "Banner image for the website", + nullable: true + } + } + }, + languages: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: "Primary langauges of the website and its staff" + }, + registrations: %Schema{ + type: :object, + description: "Registrations-related configuration", + properties: %{ + enabled: %Schema{ + type: :boolean, + description: "Whether registrations are enabled" + }, + approval_required: %Schema{ + type: :boolean, + description: "Whether users need to be manually approved by admin" + } + } + }, + configuration: %Schema{ + type: :object, + description: "Instance configuration", + properties: %{ + urls: %Schema{ + type: :object, + properties: %{ + streaming: %Schema{ + type: :string, + description: "Websockets address for push streaming" + } + } + }, + statuses: %Schema{ + type: :object, + description: "A map with poll limits for local statuses", + properties: %{ + max_characters: %Schema{ + type: :integer, + description: "Posts character limit (CW/Subject included in the counter)" + }, + max_media_attachments: %Schema{ + type: :integer, + description: "Media attachment limit" + } + } + }, + media_attachments: %Schema{ + type: :object, + description: "A map with poll limits for media attachments", + properties: %{ + image_size_limit: %Schema{ + type: :integer, + description: "File size limit of uploaded images" + }, + video_size_limit: %Schema{ + type: :integer, + description: "File size limit of uploaded videos" + } + } + }, + polls: %Schema{ + type: :object, + description: "A map with poll limits for local polls", + properties: %{ + max_options: %Schema{ + type: :integer, + description: "Maximum number of options." + }, + max_characters_per_option: %Schema{ + type: :integer, + description: "Maximum number of characters per option." + }, + min_expiration: %Schema{ + type: :integer, + description: "Minimum expiration time (in seconds)." + }, + max_expiration: %Schema{ + type: :integer, + description: "Maximum expiration time (in seconds)." + } + } + } + } + } + } + } + end + + defp array_of_domains do + %Schema{ + type: :array, + items: %Schema{type: :string}, + example: ["pleroma.site", "lain.com", "bikeshed.party"] + } + end + + defp array_of_rules do + %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :integer}, + text: %Schema{type: :string} + } + } + } + end + + defp array_of_domain_blocks do + %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + domain: %Schema{type: :string}, + digest: %Schema{type: :string}, + severity: %Schema{type: :string}, + comment: %Schema{type: :string} + } + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/list_operation.ex b/lib/pleroma/web/api_spec/operations/list_operation.ex new file mode 100644 index 0000000..7d876ae --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/list_operation.ex @@ -0,0 +1,195 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ListOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.List + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Lists"], + summary: "Retrieve a list of lists", + description: "Fetch all lists that the user owns", + security: [%{"oAuth" => ["read:lists"]}], + operationId: "ListController.index", + responses: %{ + 200 => Operation.response("Array of List", "application/json", array_of_lists()) + } + } + end + + def create_operation do + %Operation{ + tags: ["Lists"], + summary: "Create a list", + description: "Fetch the list with the given ID. Used for verifying the title of a list.", + operationId: "ListController.create", + requestBody: create_update_request(), + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("List", "application/json", List), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Lists"], + summary: "Retrieve a list", + description: "Fetch the list with the given ID. Used for verifying the title of a list.", + operationId: "ListController.show", + parameters: [id_param()], + security: [%{"oAuth" => ["read:lists"]}], + responses: %{ + 200 => Operation.response("List", "application/json", List), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Lists"], + summary: "Update a list", + description: "Change the title of a list", + operationId: "ListController.update", + parameters: [id_param()], + requestBody: create_update_request(), + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("List", "application/json", List), + 422 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Lists"], + summary: "Delete a list", + operationId: "ListController.delete", + parameters: [id_param()], + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) + } + } + end + + def list_accounts_operation do + %Operation{ + tags: ["Lists"], + summary: "Retrieve accounts in list", + operationId: "ListController.list_accounts", + parameters: [id_param()], + security: [%{"oAuth" => ["read:lists"]}], + responses: %{ + 200 => + Operation.response("Array of Account", "application/json", %Schema{ + type: :array, + items: Account + }) + } + } + end + + def add_to_list_operation do + %Operation{ + tags: ["Lists"], + summary: "Add accounts to list", + description: "Add accounts to the given list.", + operationId: "ListController.add_to_list", + parameters: [id_param()], + requestBody: add_remove_accounts_request(true), + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) + } + } + end + + def remove_from_list_operation do + %Operation{ + tags: ["Lists"], + summary: "Remove accounts from list", + operationId: "ListController.remove_from_list", + parameters: [ + id_param(), + Operation.parameter( + :account_ids, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Array of account IDs" + ) + ], + requestBody: add_remove_accounts_request(false), + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) + } + } + end + + defp array_of_lists do + %Schema{ + title: "ArrayOfLists", + description: "Response schema for lists", + type: :array, + items: List, + example: [ + %{"id" => "123", "title" => "my list"}, + %{"id" => "1337", "title" => "another list"} + ] + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "List ID", + example: "123", + required: true + ) + end + + defp create_update_request do + request_body( + "Parameters", + %Schema{ + description: "POST body for creating or updating a List", + type: :object, + properties: %{ + title: %Schema{type: :string, description: "List title"} + }, + required: [:title] + }, + required: true + ) + end + + defp add_remove_accounts_request(required) when is_boolean(required) do + request_body( + "Parameters", + %Schema{ + description: "POST body for adding/removing accounts to/from a List", + type: :object, + properties: %{ + account_ids: %Schema{type: :array, description: "Array of account IDs", items: FlakeID} + } + }, + required: required + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/marker_operation.ex b/lib/pleroma/web/api_spec/operations/marker_operation.ex new file mode 100644 index 0000000..4dfdeb4 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/marker_operation.ex @@ -0,0 +1,142 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.MarkerOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Markers"], + summary: "Get saved timeline position", + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "MarkerController.index", + parameters: [ + Operation.parameter( + :timeline, + :query, + %Schema{ + type: :array, + items: %Schema{type: :string, enum: ["home", "notifications"]} + }, + "Array of markers to fetch. If not provided, an empty object will be returned." + ) + ], + responses: %{ + 200 => Operation.response("Marker", "application/json", response()), + 403 => Operation.response("Error", "application/json", api_error()) + } + } + end + + def upsert_operation do + %Operation{ + tags: ["Markers"], + summary: "Save position in timeline", + operationId: "MarkerController.upsert", + requestBody: Helpers.request_body("Parameters", upsert_request(), required: true), + security: [%{"oAuth" => ["follow", "write:blocks"]}], + responses: %{ + 200 => Operation.response("Marker", "application/json", response()), + 403 => Operation.response("Error", "application/json", api_error()) + } + } + end + + defp marker do + %Schema{ + title: "Marker", + description: "Schema for a marker", + type: :object, + properties: %{ + last_read_id: %Schema{type: :string}, + version: %Schema{type: :integer}, + updated_at: %Schema{type: :string}, + pleroma: %Schema{ + type: :object, + properties: %{ + unread_count: %Schema{type: :integer} + } + } + }, + example: %{ + "last_read_id" => "35098814", + "version" => 361, + "updated_at" => "2019-11-26T22:37:25.239Z", + "pleroma" => %{"unread_count" => 5} + } + } + end + + defp response do + %Schema{ + title: "MarkersResponse", + description: "Response schema for markers", + type: :object, + properties: %{ + notifications: %Schema{allOf: [marker()], nullable: true}, + home: %Schema{allOf: [marker()], nullable: true} + }, + items: %Schema{type: :string}, + example: %{ + "notifications" => %{ + "last_read_id" => "35098814", + "version" => 361, + "updated_at" => "2019-11-26T22:37:25.239Z", + "pleroma" => %{"unread_count" => 0} + }, + "home" => %{ + "last_read_id" => "103206604258487607", + "version" => 468, + "updated_at" => "2019-11-26T22:37:25.235Z", + "pleroma" => %{"unread_count" => 10} + } + } + } + end + + defp upsert_request do + %Schema{ + title: "MarkersUpsertRequest", + description: "Request schema for marker upsert", + type: :object, + properties: %{ + notifications: %Schema{ + type: :object, + nullable: true, + properties: %{ + last_read_id: %Schema{nullable: true, type: :string} + } + }, + home: %Schema{ + type: :object, + nullable: true, + properties: %{ + last_read_id: %Schema{nullable: true, type: :string} + } + } + }, + example: %{ + "home" => %{ + "last_read_id" => "103194548672408537", + "version" => 462, + "updated_at" => "2019-11-24T19:39:39.337Z" + } + } + } + end + + defp api_error do + %Schema{ + type: :object, + properties: %{error: %Schema{type: :string}} + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex b/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex new file mode 100644 index 0000000..2570599 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/account_operation.ex @@ -0,0 +1,247 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["User administration"], + summary: "View accounts by criteria", + operationId: "MastodonAdmin.AccountController.index", + description: "View accounts matching certain criteria for filtering, up to 40 at a time.", + security: [%{"oAuth" => ["admin:read:accounts"]}], + parameters: + [ + Operation.parameter(:local, :query, :boolean, "Filter for local accounts?"), + Operation.parameter(:remote, :query, :boolean, "Filter for remote accounts?"), + Operation.parameter( + :by_domain, + :query, + :string, + "Filter by the given domain (not implemented yet)" + ), + Operation.parameter( + :active, + :query, + :boolean, + "Filter for currently active accounts??" + ), + Operation.parameter( + :pending, + :query, + :boolean, + "Filter for currently pending accounts?" + ), + Operation.parameter( + :disabled, + :query, + :boolean, + "Filter for currently disabled accounts?" + ), + Operation.parameter( + :sensitized, + :query, + :boolean, + "Filter for currently sensitized accounts? (not implemented yet)" + ), + Operation.parameter( + :silenced, + :query, + :boolean, + "Filter for currently silenced accounts? (not implemented yet)" + ), + Operation.parameter( + :suspended, + :query, + :boolean, + "Filter for currently suspended accounts? (not implemented yet)" + ), + Operation.parameter(:username, :query, :string, "Username to search for"), + Operation.parameter(:display_name, :query, :string, "Display name to search for"), + Operation.parameter(:email, :query, :string, "Lookup a user with this email"), + Operation.parameter( + :ip, + :query, + :string, + "Lookup users by this IP address (not implemented yet)" + ), + Operation.parameter(:staff, :query, :boolean, "Filter for staff accounts?") + ] ++ + pagination_params(), + responses: %{ + 200 => + Operation.response("Account", "application/json", %Schema{ + title: "ArrayOfAccounts", + type: :array, + items: account() + }), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["User administration"], + summary: "View a specific account", + operationId: "MastodonAdmin.AccountController.show", + description: "View admin-level information about the given account.", + security: [%{"oAuth" => ["admin:read:accounts"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the account") + ], + responses: %{ + 200 => Operation.response("Account", "application/json", account()), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def account_action_operation do + %Operation{ + tags: ["User administration"], + summary: "Perform an action against an account", + operationId: "MastodonAdmin.AccountController.account_action", + description: + "Perform an action against an account and log this action in the moderation history.", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the account") + ], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + type: %Schema{ + type: :string, + enum: ["none", "disable", "sensitive", "silence", "suspend"] + }, + report_id: %Schema{ + type: :string, + nullable: true, + description: "ID of an associated report that caused this action to be taken" + } + } + }, + required: true + ), + responses: %{ + 204 => no_content_response(), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["User administration"], + summary: "Delete a specific account", + operationId: "MastodonAdmin.AccountController.delete", + description: "Delete the given account.", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the account") + ], + responses: %{ + 200 => Operation.response("Account", "application/json", account()), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def enable_operation do + %Operation{ + tags: ["User administration"], + summary: "Re-enable account", + operationId: "MastodonAdmin.AccountController.enable", + description: "Re-enable a local account whose login is currently disabled.", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the account") + ], + responses: %{ + 200 => Operation.response("Account", "application/json", account()), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def approve_operation do + %Operation{ + tags: ["User administration"], + summary: "Approve pending account", + operationId: "MastodonAdmin.AccountController.approve", + description: "Approve the given local account if it is currently pending approval.", + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the account") + ], + responses: %{ + 200 => Operation.response("Account", "application/json", account()), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def reject_operation do + %Operation{ + tags: ["User administration"], + summary: "Reject pending account", + operationId: "MastodonAdmin.AccountController.reject", + description: "Reject the given local account if it is currently pending approval.", + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the account") + ], + responses: %{ + 200 => Operation.response("Account", "application/json", account()), + 400 => Operation.response("Error", "application/json", ApiError), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def account do + %Schema{ + title: "AdminAccount", + description: "Admin-level information about a given account.", + type: :object, + properties: %{ + id: FlakeID, + username: %Schema{type: :string}, + domain: %Schema{type: :string, nullable: true}, + created_at: %Schema{type: :string, format: "date-time"}, + email: %Schema{type: :string, format: "email", nullable: true}, + ip: %Schema{type: :string, nullable: true}, + role: %Schema{type: :string, nullable: true}, + confirmed: %Schema{type: :boolean}, + sensitized: %Schema{type: :boolean, nullable: true}, + silenced: %Schema{type: :boolean, nullable: true}, + suspened: %Schema{type: :boolean, nullable: true}, + disabled: %Schema{type: :boolean}, + approved: %Schema{type: :boolean}, + locale: %Schema{type: :string, format: "date-time", nullable: true}, + invite_request: %Schema{type: :string, format: "date-time", nullable: true}, + account: Account + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex b/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex new file mode 100644 index 0000000..51704ec --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/mastodon_admin/report_operation.ex @@ -0,0 +1,172 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Report methods"], + summary: "View all reports", + operationId: "MastodonAdmin.ReportController.index", + description: + "View all reports. Pagination may be done with HTTP Link header in the response.", + security: [%{"oAuth" => ["admin:read:reports"]}], + parameters: + [ + Operation.parameter(:resolved, :query, :boolean, "Filter for resolved reports"), + Operation.parameter(:account_id, :query, :string, "Filter by author account id"), + Operation.parameter( + :target_account_id, + :query, + :string, + "Filter by report target account id (not implemented)" + ) + ] ++ + pagination_params(), + responses: %{ + 200 => + Operation.response("Report", "application/json", %Schema{ + title: "ArrayOfReports", + type: :array, + items: report() + }), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Report methods"], + summary: "View a single report", + operationId: "MastodonAdmin.ReportController.show", + description: "View information about the report with the given ID.", + security: [%{"oAuth" => ["admin:read:reports"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the report") + ], + responses: %{ + 200 => Operation.response("Report", "application/json", report()), + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def resolve_operation do + %Operation{ + tags: ["Report methods"], + summary: "Mark as resolved", + operationId: "MastodonAdmin.ReportController.resolve", + description: "Mark a report as resolved with no further action taken.", + security: [%{"oAuth" => ["admin:write:reports"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the report") + ], + responses: %{ + 200 => Operation.response("Report", "application/json", report()), + 400 => Operation.response("Error", "application/json", ApiError), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def reopen_operation do + %Operation{ + tags: ["Report methods"], + summary: "Re-open report", + operationId: "MastodonAdmin.ReportController.reopen", + description: "Reopen a currently closed report.", + security: [%{"oAuth" => ["admin:write:reports"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the report") + ], + responses: %{ + 200 => Operation.response("Report", "application/json", report()), + 400 => Operation.response("Error", "application/json", ApiError), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def assign_to_self_operation do + %Operation{ + tags: ["Report methods"], + summary: "Assign report to self", + operationId: "MastodonAdmin.ReportController.assign_to_self", + description: "Claim the handling of this report to yourself.", + security: [%{"oAuth" => ["admin:write:reports"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the report") + ], + responses: %{ + 200 => Operation.response("Report", "application/json", report()), + 400 => Operation.response("Error", "application/json", ApiError), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def unassign_operation do + %Operation{ + tags: ["Report methods"], + summary: "Unassign report", + operationId: "MastodonAdmin.ReportController.unassign", + description: "Unassign a report so that someone else can claim it.", + security: [%{"oAuth" => ["admin:write:reports"]}], + parameters: [ + Operation.parameter(:id, :path, :string, "ID of the report") + ], + responses: %{ + 200 => Operation.response("Report", "application/json", report()), + 400 => Operation.response("Error", "application/json", ApiError), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp report do + %Schema{ + title: "Report", + type: :object, + properties: %{ + id: FlakeID, + action_taken: %Schema{type: :boolean}, + category: %Schema{type: :string}, + comment: %Schema{type: :string, nullable: true}, + created_at: %Schema{type: :string, format: "date-time"}, + updated_at: %Schema{type: :string, format: "date-time"}, + account: AccountOperation.account(), + target_account: AccountOperation.account(), + statuses: %Schema{ + type: :array, + items: Status + }, + rules: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :integer}, + text: %Schema{type: :string} + } + } + } + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/media_operation.ex b/lib/pleroma/web/api_spec/operations/media_operation.ex new file mode 100644 index 0000000..e6df212 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/media_operation.ex @@ -0,0 +1,135 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.MediaOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.Attachment + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["Media attachments"], + summary: "Upload media as attachment", + description: "Creates an attachment to be used with a new status.", + operationId: "MediaController.create", + security: [%{"oAuth" => ["write:media"]}], + requestBody: Helpers.request_body("Parameters", create_request()), + responses: %{ + 200 => Operation.response("Media", "application/json", Attachment), + 400 => Operation.response("Media", "application/json", ApiError), + 401 => Operation.response("Media", "application/json", ApiError), + 422 => Operation.response("Media", "application/json", ApiError) + } + } + end + + defp create_request do + %Schema{ + title: "MediaCreateRequest", + description: "POST body for creating an attachment", + type: :object, + required: [:file], + properties: %{ + file: %Schema{ + type: :string, + format: :binary, + description: "The file to be attached, using multipart form data." + }, + description: %Schema{ + type: :string, + description: "A plain-text description of the media, for accessibility purposes." + }, + focus: %Schema{ + type: :string, + description: "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0." + } + } + } + end + + def update_operation do + %Operation{ + tags: ["Media attachments"], + summary: "Update attachment", + description: "Creates an attachment to be used with a new status.", + operationId: "MediaController.update", + security: [%{"oAuth" => ["write:media"]}], + parameters: [id_param()], + requestBody: Helpers.request_body("Parameters", update_request()), + responses: %{ + 200 => Operation.response("Media", "application/json", Attachment), + 400 => Operation.response("Media", "application/json", ApiError), + 401 => Operation.response("Media", "application/json", ApiError), + 422 => Operation.response("Media", "application/json", ApiError) + } + } + end + + defp update_request do + %Schema{ + title: "MediaUpdateRequest", + description: "POST body for updating an attachment", + type: :object, + properties: %{ + file: %Schema{ + type: :string, + format: :binary, + description: "The file to be attached, using multipart form data." + }, + description: %Schema{ + type: :string, + description: "A plain-text description of the media, for accessibility purposes." + }, + focus: %Schema{ + type: :string, + description: "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0." + } + } + } + end + + def show_operation do + %Operation{ + tags: ["Media attachments"], + summary: "Attachment", + operationId: "MediaController.show", + parameters: [id_param()], + security: [%{"oAuth" => ["read:media"]}], + responses: %{ + 200 => Operation.response("Media", "application/json", Attachment), + 401 => Operation.response("Media", "application/json", ApiError), + 403 => Operation.response("Media", "application/json", ApiError), + 422 => Operation.response("Media", "application/json", ApiError) + } + } + end + + def create2_operation do + %Operation{ + tags: ["Media attachments"], + summary: "Upload media as attachment (v2)", + description: "Creates an attachment to be used with a new status.", + operationId: "MediaController.create2", + security: [%{"oAuth" => ["write:media"]}], + requestBody: Helpers.request_body("Parameters", create_request()), + responses: %{ + 202 => Operation.response("Media", "application/json", Attachment), + 400 => Operation.response("Media", "application/json", ApiError), + 422 => Operation.response("Media", "application/json", ApiError), + 500 => Operation.response("Media", "application/json", ApiError) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "The ID of the Attachment entity") + end +end diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex new file mode 100644 index 0000000..ee1073a --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -0,0 +1,244 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.NotificationOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Notifications"], + summary: "Retrieve a list of notifications", + description: + "Notifications concerning the user. This API returns Link headers containing links to the next/previous page. However, the links can also be constructed dynamically using query params and `id` values.", + operationId: "NotificationController.index", + security: [%{"oAuth" => ["read:notifications"]}], + parameters: + [ + Operation.parameter( + :exclude_types, + :query, + %Schema{type: :array, items: notification_type()}, + "Array of types to exclude" + ), + Operation.parameter( + :account_id, + :query, + %Schema{type: :string}, + "Return only notifications received from this account" + ), + Operation.parameter( + :exclude_visibilities, + :query, + %Schema{type: :array, items: VisibilityScope}, + "Exclude the notifications for activities with the given visibilities" + ), + Operation.parameter( + :include_types, + :query, + %Schema{type: :array, items: notification_type()}, + "Deprecated, use `types` instead" + ), + Operation.parameter( + :types, + :query, + %Schema{type: :array, items: notification_type()}, + "Include the notifications for activities with the given types" + ), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include the notifications from muted users" + ) + ] ++ pagination_params(), + responses: %{ + 200 => + Operation.response("Array of notifications", "application/json", %Schema{ + type: :array, + items: notification() + }), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Notifications"], + summary: "Retrieve a notification", + description: "View information about a notification with a given ID.", + operationId: "NotificationController.show", + security: [%{"oAuth" => ["read:notifications"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Notification", "application/json", notification()) + } + } + end + + def clear_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss all notifications", + description: "Clear all notifications from the server.", + operationId: "NotificationController.clear", + security: [%{"oAuth" => ["write:notifications"]}], + responses: %{200 => empty_object_response()} + } + end + + def dismiss_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss a notification", + description: "Clear a single notification from the server.", + operationId: "NotificationController.dismiss", + parameters: [id_param()], + security: [%{"oAuth" => ["write:notifications"]}], + responses: %{200 => empty_object_response()} + } + end + + def dismiss_via_body_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss a single notification", + deprecated: true, + description: "Clear a single notification from the server.", + operationId: "NotificationController.dismiss_via_body", + requestBody: + request_body( + "Parameters", + %Schema{type: :object, properties: %{id: %Schema{type: :string}}}, + required: true + ), + security: [%{"oAuth" => ["write:notifications"]}], + responses: %{200 => empty_object_response()} + } + end + + def destroy_multiple_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss multiple notifications", + operationId: "NotificationController.destroy_multiple", + security: [%{"oAuth" => ["write:notifications"]}], + parameters: [ + Operation.parameter( + :ids, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Array of notification IDs to dismiss", + required: true + ) + ], + responses: %{200 => empty_object_response()} + } + end + + def notification do + %Schema{ + title: "Notification", + description: "Response schema for a notification", + type: :object, + properties: %{ + id: %Schema{type: :string}, + type: notification_type(), + created_at: %Schema{type: :string, format: :"date-time"}, + account: %Schema{ + allOf: [Account], + description: "The account that performed the action that generated the notification." + }, + status: %Schema{ + allOf: [Status], + description: + "Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls.", + nullable: true + }, + participation_message: %Schema{ + type: :string, + description: "Description of event participation request", + nullable: true + }, + pleroma: %Schema{ + type: :object, + properties: %{ + is_seen: %Schema{type: :boolean}, + is_muted: %Schema{type: :boolean} + } + } + }, + example: %{ + "id" => "34975861", + "type" => "mention", + "created_at" => "2019-11-23T07:49:02.064Z", + "account" => Account.schema().example, + "status" => Status.schema().example, + "pleroma" => %{"is_seen" => false, "is_muted" => false} + } + } + end + + defp notification_type do + %Schema{ + type: :string, + enum: [ + "follow", + "favourite", + "reblog", + "mention", + "pleroma:emoji_reaction", + "pleroma:chat_mention", + "pleroma:report", + "move", + "follow_request", + "poll", + "status", + "pleroma:participation_accepted", + "pleroma:participation_request", + "pleroma:event_reminder", + "pleroma:event_update" + ], + description: """ + The type of event that resulted in the notification. + + - `follow` - Someone followed you + - `follow_request` - Someone wants to follow you + - `mention` - Someone mentioned you in their status + - `reblog` - Someone boosted one of your statuses + - `favourite` - Someone favourited one of your statuses + - `poll` - A poll you have voted in or created has ended + - `move` - Someone moved their account + - `pleroma:emoji_reaction` - Someone reacted with emoji to your status + - `pleroma:chat_mention` - Someone mentioned you in a chat message + - `pleroma:report` - Someone was reported + - `status` - Someone you are subscribed to created a status + - `pleroma:event_reminder` – An event you are participating in or created is taking place soon + - `pleroma:event_update` – An event you are participating in was edited + - `pleroma:participation_request - Someone wants to participate in your event + - `pleroma:participation_accepted - Your event participation request was accepted + """ + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "Notification ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex new file mode 100644 index 0000000..5375c5b --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex @@ -0,0 +1,150 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaAccountOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.AccountOperation + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.StatusOperation + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def confirmation_resend_operation do + %Operation{ + tags: ["Account credentials"], + summary: "Resend confirmation email", + description: "Expects `email` or `nickname`.", + operationId: "PleromaAPI.AccountController.confirmation_resend", + parameters: [ + Operation.parameter(:email, :query, :string, "Email of that needs to be verified", + example: "cofe@cofe.io" + ), + Operation.parameter( + :nickname, + :query, + :string, + "Nickname of user that needs to be verified", + example: "cofefe" + ) + ], + responses: %{ + 204 => no_content_response() + } + } + end + + def favourites_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Favorites", + description: + "Only returns data if the user has opted into sharing it. See `hide_favorites` in [Update account credentials](#operation/AccountController.update_credentials).", + operationId: "PleromaAPI.AccountController.favourites", + parameters: [id_param() | pagination_params()], + security: [%{"oAuth" => ["read:favourites"]}], + responses: %{ + 200 => + Operation.response( + "Array of Statuses", + "application/json", + StatusOperation.array_of_statuses() + ), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def endorsements_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Endorsements", + description: "Returns endorsed accounts", + operationId: "PleromaAPI.AccountController.endorsements", + parameters: [with_relationships_param(), id_param()], + responses: %{ + 200 => + Operation.response( + "Array of Accounts", + "application/json", + AccountOperation.array_of_accounts() + ), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def subscribe_operation do + %Operation{ + tags: ["Account actions"], + summary: "Subscribe", + description: "Receive notifications for all statuses posted by the account.", + operationId: "PleromaAPI.AccountController.subscribe", + parameters: [id_param()], + security: [%{"oAuth" => ["follow", "write:follows"]}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def unsubscribe_operation do + %Operation{ + tags: ["Account actions"], + summary: "Unsubscribe", + description: "Stop receiving notifications for all statuses posted by the account.", + operationId: "PleromaAPI.AccountController.unsubscribe", + parameters: [id_param()], + security: [%{"oAuth" => ["follow", "write:follows"]}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def birthdays_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Birthday reminders", + description: "Birthday reminders about users you follow.", + operationId: "PleromaAPI.AccountController.birthdays", + parameters: [ + Operation.parameter( + :day, + :query, + %Schema{type: :integer}, + "Day of users' birthdays" + ), + Operation.parameter( + :month, + :query, + %Schema{type: :integer}, + "Month of users' birthdays" + ) + ], + security: [%{"oAuth" => ["read:accounts"]}], + responses: %{ + 200 => + Operation.response("Accounts", "application/json", AccountOperation.array_of_accounts()) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, FlakeID, "Account ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_app_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_app_operation.ex new file mode 100644 index 0000000..9f8df6c --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_app_operation.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaAppOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.App + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + @spec index_operation() :: Operation.t() + def index_operation do + %Operation{ + tags: ["Applications"], + summary: "List applications", + description: "List the OAuth applications for the current user", + operationId: "AppController.index", + responses: %{ + 200 => Operation.response("Array of App", "application/json", array_of_apps()) + } + } + end + + defp array_of_apps do + %Schema{type: :array, items: App, example: [App.schema().example]} + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex new file mode 100644 index 0000000..400f382 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex @@ -0,0 +1,87 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaBackupOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Backups"], + summary: "List backups", + security: [%{"oAuth" => ["read:backups"]}], + operationId: "PleromaAPI.BackupController.index", + responses: %{ + 200 => + Operation.response( + "An array of backups", + "application/json", + %Schema{ + type: :array, + items: backup() + } + ), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def create_operation do + %Operation{ + tags: ["Backups"], + summary: "Create a backup", + security: [%{"oAuth" => ["read:backups"]}], + operationId: "PleromaAPI.BackupController.create", + responses: %{ + 200 => + Operation.response( + "An array of backups", + "application/json", + %Schema{ + type: :array, + items: backup() + } + ), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + defp backup do + %Schema{ + title: "Backup", + description: "Response schema for a backup", + type: :object, + properties: %{ + inserted_at: %Schema{type: :string, format: :"date-time"}, + content_type: %Schema{type: :string}, + file_name: %Schema{type: :string}, + file_size: %Schema{type: :integer}, + processed: %Schema{type: :boolean, description: "whether this backup has succeeded"}, + state: %Schema{ + type: :string, + description: "the state of the backup", + enum: ["pending", "running", "complete", "failed"] + }, + processed_number: %Schema{type: :integer, description: "the number of records processed"} + }, + example: %{ + "content_type" => "application/zip", + "file_name" => + "https://cofe.fe:4000/media/backups/archive-foobar-20200908T164207-Yr7vuT5Wycv-sN3kSN2iJ0k-9pMo60j9qmvRCdDqIew.zip", + "file_size" => 4105, + "inserted_at" => "2020-09-08T16:42:07.000Z", + "processed" => true, + "state" => "complete", + "processed_number" => 20 + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_conversation_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_conversation_operation.ex new file mode 100644 index 0000000..89f0e13 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_conversation_operation.ex @@ -0,0 +1,107 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaConversationOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Conversation + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.StatusOperation + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Conversations"], + summary: "Conversation", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + ], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "PleromaAPI.ConversationController.show", + responses: %{ + 200 => Operation.response("Conversation", "application/json", Conversation) + } + } + end + + def statuses_operation do + %Operation{ + tags: ["Conversations"], + summary: "Timeline for conversation", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + | pagination_params() + ], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "PleromaAPI.ConversationController.statuses", + responses: %{ + 200 => + Operation.response( + "Array of Statuses", + "application/json", + StatusOperation.array_of_statuses() + ) + } + } + end + + def update_operation do + %Operation{ + tags: ["Conversations"], + summary: "Update conversation", + description: "Change set of recipients for the conversation.", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ), + Operation.parameter( + :recipients, + :query, + %Schema{type: :array, items: FlakeID}, + "A list of ids of users that should receive posts to this conversation. This will replace the current list of recipients, so submit the full list. The owner of owner of the conversation will always be part of the set of recipients, though.", + required: true + ) + ], + security: [%{"oAuth" => ["write:conversations"]}], + operationId: "PleromaAPI.ConversationController.update", + responses: %{ + 200 => Operation.response("Conversation", "application/json", Conversation) + } + } + end + + def mark_as_read_operation do + %Operation{ + tags: ["Conversations"], + summary: "Marks all conversations as read", + security: [%{"oAuth" => ["write:conversations"]}], + operationId: "PleromaAPI.ConversationController.mark_as_read", + responses: %{ + 200 => + Operation.response( + "Array of Conversations that were marked as read", + "application/json", + %Schema{ + type: :array, + items: Conversation, + example: [Conversation.schema().example] + } + ) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_file_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_file_operation.ex new file mode 100644 index 0000000..b05bad1 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_file_operation.ex @@ -0,0 +1,144 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaEmojiFileOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["Emoji pack administration"], + summary: "Add new file to the pack", + operationId: "PleromaAPI.EmojiPackController.add_file", + security: [%{"oAuth" => ["admin:write"]}], + requestBody: request_body("Parameters", create_request(), required: true), + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Files Object", "application/json", files_object()), + 422 => Operation.response("Unprocessable Entity", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 409 => Operation.response("Conflict", "application/json", ApiError), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp create_request do + %Schema{ + type: :object, + required: [:file], + properties: %{ + file: %Schema{ + description: + "File needs to be uploaded with the multipart request or link to remote file", + anyOf: [ + %Schema{type: :string, format: :binary}, + %Schema{type: :string, format: :uri} + ] + }, + shortcode: %Schema{ + type: :string, + description: + "Shortcode for new emoji, must be unique for all emoji. If not sended, shortcode will be taken from original filename." + }, + filename: %Schema{ + type: :string, + description: + "New emoji file name. If not specified will be taken from original filename." + } + } + } + end + + def update_operation do + %Operation{ + tags: ["Emoji pack administration"], + summary: "Add new file to the pack", + operationId: "PleromaAPI.EmojiPackController.update_file", + security: [%{"oAuth" => ["admin:write"]}], + requestBody: request_body("Parameters", update_request(), required: true), + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Files Object", "application/json", files_object()), + 404 => Operation.response("Not Found", "application/json", ApiError), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 409 => Operation.response("Conflict", "application/json", ApiError), + 422 => Operation.response("Unprocessable Entity", "application/json", ApiError) + } + } + end + + defp update_request do + %Schema{ + type: :object, + required: [:shortcode, :new_shortcode, :new_filename], + properties: %{ + shortcode: %Schema{ + type: :string, + description: "Emoji file shortcode" + }, + new_shortcode: %Schema{ + type: :string, + description: "New emoji file shortcode" + }, + new_filename: %Schema{ + type: :string, + description: "New filename for emoji file" + }, + force: %Schema{ + type: :boolean, + description: "With true value to overwrite existing emoji with new shortcode", + default: false + } + } + } + end + + def delete_operation do + %Operation{ + tags: ["Emoji pack administration"], + summary: "Delete emoji file from pack", + operationId: "PleromaAPI.EmojiPackController.delete_file", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [ + name_param(), + Operation.parameter(:shortcode, :query, :string, "File shortcode", + example: "cofe", + required: true + ) + ], + responses: %{ + 200 => Operation.response("Files Object", "application/json", files_object()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError), + 422 => Operation.response("Unprocessable Entity", "application/json", ApiError) + } + } + end + + defp name_param do + Operation.parameter(:name, :query, :string, "Pack Name", example: "cofe", required: true) + end + + defp files_object do + %Schema{ + type: :object, + additionalProperties: %Schema{ + type: :string, + description: "Filename of the emoji", + extensions: %{"x-additionalPropertiesName": "Emoji name"} + }, + description: "Object with emoji names as keys and filenames as values" + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex new file mode 100644 index 0000000..efa36ff --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -0,0 +1,349 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaEmojiPackOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def remote_operation do + %Operation{ + tags: ["Emoji pack administration"], + summary: "Make request to another instance for emoji packs list", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [ + url_param(), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 30}, + "Number of emoji to return" + ) + ], + operationId: "PleromaAPI.EmojiPackController.remote", + responses: %{ + 200 => emoji_packs_response(), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def index_operation do + %Operation{ + tags: ["Emoji packs"], + summary: "Lists local custom emoji packs", + operationId: "PleromaAPI.EmojiPackController.index", + parameters: [ + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of emoji packs to return" + ) + ], + responses: %{ + 200 => emoji_packs_response() + } + } + end + + def show_operation do + %Operation{ + tags: ["Emoji packs"], + summary: "Show emoji pack", + operationId: "PleromaAPI.EmojiPackController.show", + parameters: [ + name_param(), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 30}, + "Number of emoji to return" + ) + ], + responses: %{ + 200 => Operation.response("Emoji Pack", "application/json", emoji_pack()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def archive_operation do + %Operation{ + tags: ["Emoji packs"], + summary: "Requests a local pack archive from the instance", + operationId: "PleromaAPI.EmojiPackController.archive", + parameters: [name_param()], + responses: %{ + 200 => + Operation.response("Archive file", "application/octet-stream", %Schema{ + type: :string, + format: :binary + }), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def download_operation do + %Operation{ + tags: ["Emoji pack administration"], + summary: "Download pack from another instance", + operationId: "PleromaAPI.EmojiPackController.download", + security: [%{"oAuth" => ["admin:write"]}], + requestBody: request_body("Parameters", download_request(), required: true), + responses: %{ + 200 => ok_response(), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp download_request do + %Schema{ + type: :object, + required: [:url, :name], + properties: %{ + url: %Schema{ + type: :string, + format: :uri, + description: "URL of the instance to download from" + }, + name: %Schema{type: :string, format: :uri, description: "Pack Name"}, + as: %Schema{type: :string, format: :uri, description: "Save as"} + } + } + end + + def create_operation do + %Operation{ + tags: ["Emoji pack administration"], + summary: "Create an empty pack", + operationId: "PleromaAPI.EmojiPackController.create", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [name_param()], + responses: %{ + 200 => ok_response(), + 400 => Operation.response("Not Found", "application/json", ApiError), + 409 => Operation.response("Conflict", "application/json", ApiError), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Emoji pack administration"], + summary: "Delete a custom emoji pack", + operationId: "PleromaAPI.EmojiPackController.delete", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [name_param()], + responses: %{ + 200 => ok_response(), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Emoji pack administration"], + summary: "Updates (replaces) pack metadata", + operationId: "PleromaAPI.EmojiPackController.update", + security: [%{"oAuth" => ["admin:write"]}], + requestBody: request_body("Parameters", update_request(), required: true), + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Metadata", "application/json", metadata()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def import_from_filesystem_operation do + %Operation{ + tags: ["Emoji pack administration"], + summary: "Imports packs from filesystem", + operationId: "PleromaAPI.EmojiPackController.import", + security: [%{"oAuth" => ["admin:write"]}], + responses: %{ + 200 => + Operation.response("Array of imported pack names", "application/json", %Schema{ + type: :array, + items: %Schema{type: :string} + }) + } + } + end + + defp name_param do + Operation.parameter(:name, :query, :string, "Pack Name", example: "cofe", required: true) + end + + defp url_param do + Operation.parameter( + :url, + :query, + %Schema{type: :string, format: :uri}, + "URL of the instance", + required: true + ) + end + + defp ok_response do + Operation.response("Ok", "application/json", %Schema{type: :string, example: "ok"}) + end + + defp emoji_packs_response do + Operation.response( + "Emoji packs and the count", + "application/json", + %Schema{ + type: :object, + properties: %{ + packs: %Schema{ + type: :object, + description: "Object with pack names as keys and pack contents as values", + additionalProperties: %Schema{ + emoji_pack() + | extensions: %{"x-additionalPropertiesName": "Pack name"} + } + }, + count: %Schema{ + type: :integer, + description: "Number of emoji packs" + } + }, + example: %{ + "packs" => %{ + "emojos" => emoji_pack().example + }, + "count" => 1 + } + } + ) + end + + defp emoji_pack do + %Schema{ + title: "EmojiPack", + type: :object, + properties: %{ + files: files_object(), + pack: %Schema{ + type: :object, + properties: %{ + license: %Schema{type: :string}, + homepage: %Schema{type: :string, format: :uri}, + description: %Schema{type: :string}, + "can-download": %Schema{type: :boolean}, + "share-files": %Schema{type: :boolean}, + "download-sha256": %Schema{type: :string} + } + } + }, + example: %{ + "files" => %{"emacs" => "emacs.png", "guix" => "guix.png"}, + "pack" => %{ + "license" => "Test license", + "homepage" => "https://pleroma.social", + "description" => "Test description", + "can-download" => true, + "share-files" => true, + "download-sha256" => "57482F30674FD3DE821FF48C81C00DA4D4AF1F300209253684ABA7075E5FC238" + } + } + } + end + + defp files_object do + %Schema{ + type: :object, + additionalProperties: %Schema{ + type: :string, + description: "Filename", + extensions: %{"x-additionalPropertiesName": "Emoji name"} + }, + description: "Object with emoji names as keys and filenames as values" + } + end + + defp update_request do + %Schema{ + type: :object, + properties: %{ + metadata: %Schema{ + type: :object, + description: "Metadata to replace the old one", + properties: %{ + license: %Schema{type: :string}, + homepage: %Schema{type: :string, format: :uri}, + description: %Schema{type: :string}, + "fallback-src": %Schema{ + type: :string, + format: :uri, + description: "Fallback url to download pack from" + }, + "fallback-src-sha256": %Schema{ + type: :string, + description: "SHA256 encoded for fallback pack archive" + }, + "share-files": %Schema{type: :boolean, description: "Is pack allowed for sharing?"} + } + } + } + } + end + + defp metadata do + %Schema{ + type: :object, + properties: %{ + license: %Schema{type: :string}, + homepage: %Schema{type: :string, format: :uri}, + description: %Schema{type: :string}, + "fallback-src": %Schema{ + type: :string, + format: :uri, + description: "Fallback url to download pack from" + }, + "fallback-src-sha256": %Schema{ + type: :string, + description: "SHA256 encoded for fallback pack archive" + }, + "share-files": %Schema{type: :boolean, description: "Is pack allowed for sharing?"} + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_event_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_event_operation.ex new file mode 100644 index 0000000..9e18372 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_event_operation.ex @@ -0,0 +1,346 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaEventOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.AccountOperation + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.ParticipationRequest + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.StatusOperation + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["Event actions"], + summary: "Publish new status", + security: [%{"oAuth" => ["write"]}], + description: "Create a new event", + operationId: "PleromaAPI.EventController.create", + requestBody: request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => event_response(), + 422 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Event actions"], + summary: "Update event", + description: "Change the content of an event", + operationId: "PleromaAPI.EventController.update", + security: [%{"oAuth" => ["write"]}], + parameters: [id_param()], + requestBody: request_body("Parameters", update_request(), required: true), + responses: %{ + 200 => event_response(), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def participations_operation do + %Operation{ + tags: ["Event actions"], + summary: "Participants list", + description: "View who joined a given event", + operationId: "EventController.participations", + security: [%{"oAuth" => ["read"]}], + parameters: [id_param()], + responses: %{ + 200 => + Operation.response( + "Array of Accounts", + "application/json", + AccountOperation.array_of_accounts() + ), + 403 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def participation_requests_operation do + %Operation{ + tags: ["Event actions"], + summary: "Participation requests list", + description: "View who wants to join the event", + operationId: "EventController.participations", + security: [%{"oAuth" => ["read"]}], + parameters: [id_param()], + responses: %{ + 200 => + Operation.response( + "Array of participation requests", + "application/json", + array_of_participation_requests() + ), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def join_operation do + %Operation{ + tags: ["Event actions"], + summary: "Participate", + security: [%{"oAuth" => ["write"]}], + description: "Participate in an event", + operationId: "PleromaAPI.EventController.join", + parameters: [id_param()], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + account: Account, + participation_message: %Schema{ + type: :string, + description: "Why the user wants to participate" + } + } + }, + required: false + ), + responses: %{ + 200 => event_response(), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def leave_operation do + %Operation{ + tags: ["Event actions"], + summary: "Unparticipate", + security: [%{"oAuth" => ["write"]}], + description: "Delete event participation", + operationId: "PleromaAPI.EventController.leave", + parameters: [id_param()], + responses: %{ + 200 => event_response(), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def authorize_participation_request_operation do + %Operation{ + tags: ["Event actions"], + summary: "Accept participation", + security: [%{"oAuth" => ["write"]}], + description: "Accept event participation request", + operationId: "PleromaAPI.EventController.authorize_participation_request", + parameters: [id_param(), participant_id_param()], + responses: %{ + 200 => event_response(), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def reject_participation_request_operation do + %Operation{ + tags: ["Event actions"], + summary: "Reject participation", + security: [%{"oAuth" => ["write"]}], + description: "Reject event participation request", + operationId: "PleromaAPI.EventController.reject_participation_request", + parameters: [id_param(), participant_id_param()], + responses: %{ + 200 => event_response(), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def export_ics_operation do + %Operation{ + tags: ["Event actions"], + summary: "Export status", + description: "Export event to .ics", + operationId: "PleromaAPI.EventController.export_ics", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [id_param()], + responses: %{ + 200 => + Operation.response("Event", "text/calendar; charset=utf-8", %Schema{type: :string}), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def joined_events_operation do + %Operation{ + tags: ["Event actions"], + summary: "Joined events", + description: "Get your joined events", + operationId: "PleromaAPI.EventController.joined_events", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + Operation.parameter( + :state, + :query, + %Schema{type: :string, enum: ["pending", "reject", "accept"]}, + "Filter by join state" + ) + | pagination_params() + ], + responses: %{ + 200 => + Operation.response( + "Array of Statuses", + "application/json", + StatusOperation.array_of_statuses() + ) + } + } + end + + defp create_request do + %Schema{ + title: "EventCreateRequest", + type: :object, + properties: %{ + name: %Schema{ + type: :string, + description: "Name of the event." + }, + status: %Schema{ + type: :string, + nullable: true, + description: "Text description of the event." + }, + banner_id: %Schema{ + nullable: true, + type: :string, + description: "Attachment id to be attached as banner." + }, + start_time: %Schema{ + type: :string, + format: :"date-time", + description: "Start time." + }, + end_time: %Schema{ + type: :string, + format: :"date-time", + description: "End time." + }, + join_mode: %Schema{ + type: :string, + enum: ["free", "restricted"] + }, + location_id: %Schema{ + type: :string, + description: "Location ID from geospatial provider", + nullable: true + }, + language: %Schema{ + type: :string, + nullable: true, + description: "ISO 639 language code for this status." + } + }, + example: %{ + "name" => "Example event", + "status" => "No information for now.", + "start_time" => "2022-02-21T22:00:00.000Z", + "end_time" => "2022-02-21T23:00:00.000Z" + } + } + end + + defp update_request do + %Schema{ + title: "EventUpdateRequest", + type: :object, + properties: %{ + name: %Schema{ + type: :string, + description: "Name of the event." + }, + status: %Schema{ + type: :string, + nullable: true, + description: "Text description of the event." + }, + banner_id: %Schema{ + nullable: true, + type: :string, + description: "Attachment id to be attached as banner." + }, + start_time: %Schema{ + type: :string, + format: :"date-time", + description: "Start time." + }, + end_time: %Schema{ + type: :string, + format: :"date-time", + description: "End time." + }, + location_id: %Schema{ + type: :string, + description: "Location ID from geospatial provider", + nullable: true + } + }, + example: %{ + "name" => "Updated event", + "status" => "We had to reschedule the event.", + "start_time" => "2022-02-22T22:00:00.000Z", + "end_time" => "2022-02-22T23:00:00.000Z" + } + } + end + + defp event_response do + Operation.response( + "Status", + "application/json", + Status + ) + end + + defp id_param do + Operation.parameter(:id, :path, FlakeID, "Event ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end + + defp participant_id_param do + Operation.parameter(:participant_id, :path, FlakeID, "Event participant ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end + + def array_of_participation_requests do + %Schema{ + title: "ArrayOfParticipationRequests", + type: :array, + items: ParticipationRequest, + example: [ParticipationRequest.schema().example] + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_instances_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_instances_operation.ex new file mode 100644 index 0000000..e9319f3 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_instances_operation.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaInstancesOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Instance misc"], + summary: "Retrieve federation status", + description: "Information about instances deemed unreachable by the server", + operationId: "PleromaInstances.show", + responses: %{ + 200 => Operation.response("PleromaInstances", "application/json", pleroma_instances()) + } + } + end + + def pleroma_instances do + %Schema{ + type: :object, + properties: %{ + unreachable: %Schema{ + type: :object, + properties: %{hostname: %Schema{type: :string, format: :"date-time"}} + } + }, + example: %{ + "unreachable" => %{"consistently-unreachable.name" => "2020-10-14 22:07:58.216473"} + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_mascot_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_mascot_operation.ex new file mode 100644 index 0000000..775e272 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_mascot_operation.ex @@ -0,0 +1,79 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaMascotOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Mascot"], + summary: "Retrieve mascot", + security: [%{"oAuth" => ["read:accounts"]}], + operationId: "PleromaAPI.MascotController.show", + responses: %{ + 200 => Operation.response("Mascot", "application/json", mascot()) + } + } + end + + def update_operation do + %Operation{ + tags: ["Mascot"], + summary: "Set or clear mascot", + description: + "Behaves exactly the same as `POST /api/v1/upload`. Can only accept images - any attempt to upload non-image files will be met with `HTTP 415 Unsupported Media Type`.", + operationId: "PleromaAPI.MascotController.update", + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + file: %Schema{type: :string, format: :binary} + } + }, + required: true + ), + security: [%{"oAuth" => ["write:accounts"]}], + responses: %{ + 200 => Operation.response("Mascot", "application/json", mascot()), + 415 => Operation.response("Unsupported Media Type", "application/json", ApiError) + } + } + end + + defp mascot do + %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + url: %Schema{type: :string, format: :uri}, + type: %Schema{type: :string}, + pleroma: %Schema{ + type: :object, + properties: %{ + mime_type: %Schema{type: :string} + } + } + }, + example: %{ + "id" => "abcdefg", + "url" => "https://pleroma.example.org/media/abcdefg.png", + "type" => "image", + "pleroma" => %{ + "mime_type" => "image/png" + } + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex new file mode 100644 index 0000000..a994345 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaNotificationOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.NotificationOperation + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def mark_as_read_operation do + %Operation{ + tags: ["Notifications"], + summary: "Mark notifications as read", + description: "Query parameters are mutually exclusive.", + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :integer, description: "A single notification ID to read"}, + max_id: %Schema{type: :integer, description: "Read all notifications up to this ID"} + } + }), + security: [%{"oAuth" => ["write:notifications"]}], + operationId: "PleromaAPI.NotificationController.mark_as_read", + responses: %{ + 200 => + Operation.response( + "A Notification or array of Notifications", + "application/json", + %Schema{ + anyOf: [ + %Schema{type: :array, items: NotificationOperation.notification()}, + NotificationOperation.notification() + ] + } + ), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_report_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_report_operation.ex new file mode 100644 index 0000000..9bc1877 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_report_operation.ex @@ -0,0 +1,97 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaReportOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Admin.ReportOperation + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Reports"], + summary: "Get a list of your own reports", + operationId: "PleromaAPI.ReportController.index", + security: [%{"oAuth" => ["read:reports"]}], + parameters: [ + Operation.parameter( + :state, + :query, + ReportOperation.report_state(), + "Filter by report state" + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer}, + "The number of records to retrieve" + ), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page number" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number number of log entries per page" + ) + ], + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{ + total: %Schema{type: :integer}, + reports: %Schema{ + type: :array, + items: report() + } + } + }), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Reports"], + summary: "Get an individual report", + operationId: "PleromaAPI.ReportController.show", + parameters: [ReportOperation.id_param()], + security: [%{"oAuth" => ["read:reports"]}], + responses: %{ + 200 => Operation.response("Report", "application/json", report()), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + # Copied from ReportOperation.report with removing notes + defp report do + %Schema{ + type: :object, + properties: %{ + id: FlakeID, + state: ReportOperation.report_state(), + account: Account, + actor: Account, + content: %Schema{type: :string}, + created_at: %Schema{type: :string, format: :"date-time"}, + statuses: %Schema{type: :array, items: Status} + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex new file mode 100644 index 0000000..1facc96 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex @@ -0,0 +1,106 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaScrobbleOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Reference + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["Scrobbles"], + summary: "Creates a new Listen activity for an account", + security: [%{"oAuth" => ["write"]}], + operationId: "PleromaAPI.ScrobbleController.create", + requestBody: request_body("Parameters", create_request(), requried: true), + responses: %{ + 200 => Operation.response("Scrobble", "application/json", scrobble()) + } + } + end + + def index_operation do + %Operation{ + tags: ["Scrobbles"], + summary: "Requests a list of current and recent Listen activities for an account", + operationId: "PleromaAPI.ScrobbleController.index", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"} | pagination_params() + ], + security: [%{"oAuth" => ["read"]}], + responses: %{ + 200 => + Operation.response("Array of Scrobble", "application/json", %Schema{ + type: :array, + items: scrobble() + }) + } + } + end + + defp create_request do + %Schema{ + type: :object, + required: [:title], + properties: %{ + title: %Schema{type: :string, description: "The title of the media playing"}, + album: %Schema{type: :string, description: "The album of the media playing"}, + artist: %Schema{type: :string, description: "The artist of the media playing"}, + length: %Schema{type: :integer, description: "The length of the media playing"}, + url: %Schema{type: :string, description: "A URL referencing the media playing"}, + visibility: %Schema{ + allOf: [VisibilityScope], + default: "public", + description: "Scrobble visibility" + } + }, + example: %{ + "title" => "Some Title", + "artist" => "Some Artist", + "album" => "Some Album", + "length" => 180_000, + "url" => "https://www.last.fm/music/Some+Artist/_/Some+Title" + } + } + end + + defp scrobble do + %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + account: Account, + title: %Schema{type: :string, description: "The title of the media playing"}, + album: %Schema{type: :string, description: "The album of the media playing"}, + artist: %Schema{type: :string, description: "The artist of the media playing"}, + url: %Schema{type: :string, description: "A URL referencing the media playing"}, + length: %Schema{ + type: :integer, + description: "The length of the media playing", + nullable: true + }, + created_at: %Schema{type: :string, format: :"date-time"} + }, + example: %{ + "id" => "1234", + "account" => Account.schema().example, + "title" => "Some Title", + "artist" => "Some Artist", + "album" => "Some Album", + "length" => 180_000, + "url" => "https://www.last.fm/music/Some+Artist/_/Some+Title", + "created_at" => "2019-09-28T12:40:45.000Z" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_search_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_search_operation.ex new file mode 100644 index 0000000..ff54fea --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_search_operation.ex @@ -0,0 +1,56 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaSearchOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.LocationResult + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def location_operation do + %Operation{ + tags: ["Search"], + summary: "Search locations", + security: [%{"oAuth" => []}], + operationId: "PleromaAPI.SearchController.location", + parameters: [ + Operation.parameter( + :q, + :query, + %Schema{type: :string}, + "What to search for", + required: true + ), + Operation.parameter( + :locale, + :query, + %Schema{type: :string}, + "The user's locale. Geocoding backends will make use of this value" + ), + Operation.parameter( + :type, + :query, + %Schema{type: :string, enum: ["ADMINISTRATIVE"]}, + "Filter by type of results" + ) + ], + responses: %{ + 200 => Operation.response("Results", "application/json", location_results()) + } + } + end + + def location_results do + %Schema{ + type: :array, + items: LocationResult, + description: "Locations which match the given query", + example: [LocationResult.schema().example] + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex new file mode 100644 index 0000000..6e69c52 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaStatusOperation do + alias OpenApiSpex.Operation + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.StatusOperation + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def quotes_operation do + %Operation{ + tags: ["Retrieve status information"], + summary: "Quoted by", + description: "View quotes for a given status", + operationId: "PleromaAPI.StatusController.quotes", + parameters: [id_param() | pagination_params()], + security: [%{"oAuth" => ["read:statuses"]}], + responses: %{ + 200 => + Operation.response( + "Array of Status", + "application/json", + StatusOperation.array_of_statuses() + ), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def id_param do + Operation.parameter(:id, :path, FlakeID, "Status ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/poll_operation.ex b/lib/pleroma/web/api_spec/operations/poll_operation.ex new file mode 100644 index 0000000..efd784f --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/poll_operation.ex @@ -0,0 +1,76 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PollOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Poll + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Polls"], + summary: "View a poll", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [id_param()], + operationId: "PollController.show", + responses: %{ + 200 => Operation.response("Poll", "application/json", Poll), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def vote_operation do + %Operation{ + tags: ["Polls"], + summary: "Vote on a poll", + parameters: [id_param()], + operationId: "PollController.vote", + requestBody: vote_request(), + security: [%{"oAuth" => ["write:statuses"]}], + responses: %{ + 200 => Operation.response("Poll", "application/json", Poll), + 422 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, FlakeID, "Poll ID", + example: "123", + required: true + ) + end + + defp vote_request do + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + choices: %Schema{ + type: :array, + items: %Schema{type: :integer}, + description: "Array of own votes containing index for each option (starting from 0)" + } + }, + required: [:choices] + }, + required: true, + example: %{ + "choices" => [0, 1, 2] + } + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/report_operation.ex b/lib/pleroma/web/api_spec/operations/report_operation.ex new file mode 100644 index 0000000..fd68f67 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/report_operation.ex @@ -0,0 +1,89 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ReportOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["Reports"], + summary: "File a report", + description: "Report problematic users to your moderators", + operationId: "ReportController.create", + security: [%{"oAuth" => ["follow", "write:reports"]}], + requestBody: Helpers.request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Report", "application/json", create_response()), + 400 => Operation.response("Report", "application/json", ApiError) + } + } + end + + defp create_request do + %Schema{ + title: "ReportCreateRequest", + description: "POST body for creating a report", + type: :object, + properties: %{ + account_id: %Schema{type: :string, description: "ID of the account to report"}, + status_ids: %Schema{ + type: :array, + nullable: true, + items: %Schema{type: :string}, + description: "Array of Statuses to attach to the report, for context" + }, + comment: %Schema{ + type: :string, + nullable: true, + description: "Reason for the report" + }, + forward: %Schema{ + allOf: [BooleanLike], + nullable: true, + default: false, + description: + "If the account is remote, should the report be forwarded to the remote admin?" + }, + rule_ids: %Schema{ + type: :array, + nullable: true, + items: %Schema{type: :number}, + description: "Array of rules" + } + }, + required: [:account_id], + example: %{ + "account_id" => "123", + "status_ids" => ["1337"], + "comment" => "bad status!", + "forward" => "false", + "rule_ids" => [3] + } + } + end + + defp create_response do + %Schema{ + title: "ReportResponse", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Report ID"}, + action_taken: %Schema{type: :boolean, description: "Is action taken?"} + }, + example: %{ + "id" => "123", + "action_taken" => false + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex b/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex new file mode 100644 index 0000000..802d3b6 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex @@ -0,0 +1,96 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ScheduledActivityOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Scheduled statuses"], + summary: "View scheduled statuses", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: pagination_params(), + operationId: "ScheduledActivity.index", + responses: %{ + 200 => + Operation.response("Array of ScheduledStatus", "application/json", %Schema{ + type: :array, + items: ScheduledStatus + }) + } + } + end + + def show_operation do + %Operation{ + tags: ["Scheduled statuses"], + summary: "View a single scheduled status", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [id_param()], + operationId: "ScheduledActivity.show", + responses: %{ + 200 => Operation.response("Scheduled Status", "application/json", ScheduledStatus), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Scheduled statuses"], + summary: "Schedule a status", + operationId: "ScheduledActivity.update", + security: [%{"oAuth" => ["write:statuses"]}], + parameters: [id_param()], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + scheduled_at: %Schema{ + type: :string, + format: :"date-time", + description: + "ISO 8601 Datetime at which the status will be published. Must be at least 5 minutes into the future." + } + } + }), + responses: %{ + 200 => Operation.response("Scheduled Status", "application/json", ScheduledStatus), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Scheduled statuses"], + summary: "Cancel a scheduled status", + security: [%{"oAuth" => ["write:statuses"]}], + parameters: [id_param()], + operationId: "ScheduledActivity.delete", + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, FlakeID, "Poll ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/search_operation.ex b/lib/pleroma/web/api_spec/operations/search_operation.ex new file mode 100644 index 0000000..1a7e49b --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/search_operation.ex @@ -0,0 +1,208 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.SearchOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.AccountOperation + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.Tag + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + # Note: `with_relationships` param is not supported (PleromaFE uses this op for autocomplete) + def account_search_operation do + %Operation{ + tags: ["Search"], + summary: "Search for matching accounts by username or display name", + operationId: "SearchController.account_search", + parameters: [ + Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for", + required: true + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 40}, + "Maximum number of results" + ), + Operation.parameter( + :resolve, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Attempt WebFinger lookup. Use this when `q` is an exact address." + ), + Operation.parameter( + :following, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Only include accounts that the user is following" + ) + ], + responses: %{ + 200 => + Operation.response( + "Array of Account", + "application/json", + AccountOperation.array_of_accounts() + ) + } + } + end + + def search_operation do + %Operation{ + tags: ["Search"], + summary: "Search results", + security: [%{"oAuth" => ["read:search"]}], + operationId: "SearchController.search", + deprecated: true, + parameters: [ + Operation.parameter( + :account_id, + :query, + FlakeID, + "If provided, statuses returned will be authored only by this account" + ), + Operation.parameter( + :type, + :query, + %Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]}, + "Search type" + ), + Operation.parameter(:q, :query, %Schema{type: :string}, "The search query", required: true), + Operation.parameter( + :resolve, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Attempt WebFinger lookup" + ), + Operation.parameter( + :following, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Only include accounts that the user is following" + ), + Operation.parameter( + :offset, + :query, + %Schema{type: :integer}, + "Offset" + ), + with_relationships_param() | pagination_params() + ], + responses: %{ + 200 => Operation.response("Results", "application/json", results()) + } + } + end + + def search2_operation do + %Operation{ + tags: ["Search"], + summary: "Search results", + security: [%{"oAuth" => ["read:search"]}], + operationId: "SearchController.search2", + parameters: [ + Operation.parameter( + :account_id, + :query, + FlakeID, + "If provided, statuses returned will be authored only by this account" + ), + Operation.parameter( + :type, + :query, + %Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]}, + "Search type" + ), + Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for", + required: true + ), + Operation.parameter( + :resolve, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Attempt WebFinger lookup" + ), + Operation.parameter( + :following, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Only include accounts that the user is following" + ), + with_relationships_param() | pagination_params() + ], + responses: %{ + 200 => Operation.response("Results", "application/json", results2()) + } + } + end + + defp results2 do + %Schema{ + title: "SearchResults", + type: :object, + properties: %{ + accounts: %Schema{ + type: :array, + items: Account, + description: "Accounts which match the given query" + }, + statuses: %Schema{ + type: :array, + items: Status, + description: "Statuses which match the given query" + }, + hashtags: %Schema{ + type: :array, + items: Tag, + description: "Hashtags which match the given query" + } + }, + example: %{ + "accounts" => [Account.schema().example], + "statuses" => [Status.schema().example], + "hashtags" => [Tag.schema().example] + } + } + end + + defp results do + %Schema{ + title: "SearchResults", + type: :object, + properties: %{ + accounts: %Schema{ + type: :array, + items: Account, + description: "Accounts which match the given query" + }, + statuses: %Schema{ + type: :array, + items: Status, + description: "Statuses which match the given query" + }, + hashtags: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: "Hashtags which match the given query" + } + }, + example: %{ + "accounts" => [Account.schema().example], + "statuses" => [Status.schema().example], + "hashtags" => ["cofe"] + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex new file mode 100644 index 0000000..b128ab2 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -0,0 +1,863 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.StatusOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.AccountOperation + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.Attachment + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.Emoji + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.LocationResult + alias Pleroma.Web.ApiSpec.Schemas.Poll + alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Retrieve status information"], + summary: "Multiple statuses", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + Operation.parameter( + :ids, + :query, + %Schema{type: :array, items: FlakeID}, + "Array of status IDs" + ), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include reactions from muted acccounts." + ) + ], + operationId: "StatusController.index", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + def create_operation do + %Operation{ + tags: ["Status actions"], + summary: "Publish new status", + security: [%{"oAuth" => ["write:statuses"]}], + description: "Post a new status", + operationId: "StatusController.create", + requestBody: request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => + Operation.response( + "Status. When `scheduled_at` is present, ScheduledStatus is returned instead", + "application/json", + %Schema{anyOf: [Status, ScheduledStatus]} + ), + 422 => Operation.response("Bad Request / MRF Rejection", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Retrieve status information"], + summary: "Status", + description: "View information about a status", + operationId: "StatusController.show", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + id_param(), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include reactions from muted acccounts." + ) + ], + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Status actions"], + summary: "Delete", + security: [%{"oAuth" => ["write:statuses"]}], + description: "Delete one of your own statuses", + operationId: "StatusController.delete", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def reblog_operation do + %Operation{ + tags: ["Status actions"], + summary: "Reblog", + security: [%{"oAuth" => ["write:statuses"]}], + description: "Share a status", + operationId: "StatusController.reblog", + parameters: [id_param()], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + visibility: %Schema{allOf: [VisibilityScope]} + } + }), + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def unreblog_operation do + %Operation{ + tags: ["Status actions"], + summary: "Undo reblog", + security: [%{"oAuth" => ["write:statuses"]}], + description: "Undo a reshare of a status", + operationId: "StatusController.unreblog", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def favourite_operation do + %Operation{ + tags: ["Status actions"], + summary: "Favourite", + security: [%{"oAuth" => ["write:favourites"]}], + description: "Add a status to your favourites list", + operationId: "StatusController.favourite", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def unfavourite_operation do + %Operation{ + tags: ["Status actions"], + summary: "Undo favourite", + security: [%{"oAuth" => ["write:favourites"]}], + description: "Remove a status from your favourites list", + operationId: "StatusController.unfavourite", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def pin_operation do + %Operation{ + tags: ["Status actions"], + summary: "Pin to profile", + security: [%{"oAuth" => ["write:accounts"]}], + description: "Feature one of your own public statuses at the top of your profile", + operationId: "StatusController.pin", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 400 => + Operation.response("Bad Request", "application/json", %Schema{ + allOf: [ApiError], + title: "Unprocessable Entity", + example: %{ + "error" => "You have already pinned the maximum number of statuses" + } + }), + 404 => + Operation.response("Not found", "application/json", %Schema{ + allOf: [ApiError], + title: "Unprocessable Entity", + example: %{ + "error" => "Record not found" + } + }), + 422 => + Operation.response( + "Unprocessable Entity", + "application/json", + %Schema{ + allOf: [ApiError], + title: "Unprocessable Entity", + example: %{ + "error" => "Someone else's status cannot be pinned" + } + } + ) + } + } + end + + def unpin_operation do + %Operation{ + tags: ["Status actions"], + summary: "Unpin from profile", + security: [%{"oAuth" => ["write:accounts"]}], + description: "Unfeature a status from the top of your profile", + operationId: "StatusController.unpin", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 400 => + Operation.response("Bad Request", "application/json", %Schema{ + allOf: [ApiError], + title: "Unprocessable Entity", + example: %{ + "error" => "You have already pinned the maximum number of statuses" + } + }), + 404 => + Operation.response("Not found", "application/json", %Schema{ + allOf: [ApiError], + title: "Unprocessable Entity", + example: %{ + "error" => "Record not found" + } + }) + } + } + end + + def bookmark_operation do + %Operation{ + tags: ["Status actions"], + summary: "Bookmark", + security: [%{"oAuth" => ["write:bookmarks"]}], + description: "Privately bookmark a status", + operationId: "StatusController.bookmark", + parameters: [id_param()], + responses: %{ + 200 => status_response() + } + } + end + + def unbookmark_operation do + %Operation{ + tags: ["Status actions"], + summary: "Undo bookmark", + security: [%{"oAuth" => ["write:bookmarks"]}], + description: "Remove a status from your private bookmarks", + operationId: "StatusController.unbookmark", + parameters: [id_param()], + responses: %{ + 200 => status_response() + } + } + end + + def mute_conversation_operation do + %Operation{ + tags: ["Status actions"], + summary: "Mute conversation", + security: [%{"oAuth" => ["write:mutes"]}], + description: "Do not receive notifications for the thread that this status is part of.", + operationId: "StatusController.mute_conversation", + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + expires_in: %Schema{ + type: :integer, + nullable: true, + description: "Expire the mute in `expires_in` seconds. Default 0 for infinity", + default: 0 + } + } + }), + parameters: [ + id_param(), + Operation.parameter( + :expires_in, + :query, + %Schema{type: :integer, default: 0}, + "Expire the mute in `expires_in` seconds. Default 0 for infinity" + ) + ], + responses: %{ + 200 => status_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def unmute_conversation_operation do + %Operation{ + tags: ["Status actions"], + summary: "Unmute conversation", + security: [%{"oAuth" => ["write:mutes"]}], + description: + "Start receiving notifications again for the thread that this status is part of", + operationId: "StatusController.unmute_conversation", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def card_operation do + %Operation{ + tags: ["Retrieve status information"], + deprecated: true, + summary: "Preview card", + description: "Deprecated in favor of card property inlined on Status entity", + operationId: "StatusController.card", + parameters: [id_param()], + security: [%{"oAuth" => ["read:statuses"]}], + responses: %{ + 200 => + Operation.response("Card", "application/json", %Schema{ + type: :object, + nullable: true, + properties: %{ + type: %Schema{type: :string, enum: ["link", "photo", "video", "rich"]}, + provider_name: %Schema{type: :string, nullable: true}, + provider_url: %Schema{type: :string, format: :uri}, + url: %Schema{type: :string, format: :uri}, + image: %Schema{type: :string, nullable: true, format: :uri}, + title: %Schema{type: :string}, + description: %Schema{type: :string} + } + }) + } + } + end + + def favourited_by_operation do + %Operation{ + tags: ["Retrieve status information"], + summary: "Favourited by", + description: "View who favourited a given status", + operationId: "StatusController.favourited_by", + security: [%{"oAuth" => ["read:accounts"]}], + parameters: [id_param()], + responses: %{ + 200 => + Operation.response( + "Array of Accounts", + "application/json", + AccountOperation.array_of_accounts() + ), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def reblogged_by_operation do + %Operation{ + tags: ["Retrieve status information"], + summary: "Reblogged by", + description: "View who reblogged a given status", + operationId: "StatusController.reblogged_by", + security: [%{"oAuth" => ["read:accounts"]}], + parameters: [id_param()], + responses: %{ + 200 => + Operation.response( + "Array of Accounts", + "application/json", + AccountOperation.array_of_accounts() + ), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def context_operation do + %Operation{ + tags: ["Retrieve status information"], + summary: "Parent and child statuses", + description: "View statuses above and below this status in the thread", + operationId: "StatusController.context", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Context", "application/json", context()) + } + } + end + + def translate_operation do + %Operation{ + tags: ["Retrieve status information"], + summary: "Translate status", + description: "Translate status with an external API", + operationId: "StatusController.translate", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [id_param()], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + target_language: %Schema{ + type: :string, + nullable: true, + description: "Translation target language." + } + } + }, + required: false + ), + responses: %{ + 200 => Operation.response("Translation", "application/json", translation()), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError), + 503 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def favourites_operation do + %Operation{ + tags: ["Timelines"], + summary: "Favourited statuses", + description: + "Statuses the user has favourited. Please note that you have to use the link headers to paginate this. You can not build the query parameters yourself.", + operationId: "StatusController.favourites", + parameters: pagination_params(), + security: [%{"oAuth" => ["read:favourites"]}], + responses: %{ + 200 => Operation.response("Array of Statuses", "application/json", array_of_statuses()) + } + } + end + + def bookmarks_operation do + %Operation{ + tags: ["Timelines"], + summary: "Bookmarked statuses", + description: "Statuses the user has bookmarked", + operationId: "StatusController.bookmarks", + parameters: pagination_params(), + security: [%{"oAuth" => ["read:bookmarks"]}], + responses: %{ + 200 => Operation.response("Array of Statuses", "application/json", array_of_statuses()) + } + } + end + + def show_history_operation do + %Operation{ + tags: ["Retrieve status information"], + summary: "Status history", + description: "View history of a status", + operationId: "StatusController.show_history", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + id_param() + ], + responses: %{ + 200 => status_history_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def show_source_operation do + %Operation{ + tags: ["Retrieve status information"], + summary: "Status source", + description: "View source of a status", + operationId: "StatusController.show_source", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + id_param() + ], + responses: %{ + 200 => status_source_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Status actions"], + summary: "Update status", + description: "Change the content of a status", + operationId: "StatusController.update", + security: [%{"oAuth" => ["write:statuses"]}], + parameters: [ + id_param() + ], + requestBody: request_body("Parameters", update_request(), required: true), + responses: %{ + 200 => status_response(), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError), + 422 => Operation.response("Unprocessable Entity", "application/json", ApiError) + } + } + end + + def array_of_statuses do + %Schema{type: :array, items: Status, example: [Status.schema().example]} + end + + defp create_request do + %Schema{ + title: "StatusCreateRequest", + type: :object, + properties: %{ + status: %Schema{ + type: :string, + nullable: true, + description: + "Text content of the status. If `media_ids` is provided, this becomes optional. Attaching a `poll` is optional while `status` is provided." + }, + media_ids: %Schema{ + nullable: true, + type: :array, + items: %Schema{type: :string}, + description: "Array of Attachment ids to be attached as media." + }, + poll: poll_params(), + in_reply_to_id: %Schema{ + nullable: true, + allOf: [FlakeID], + description: "ID of the status being replied to, if status is a reply" + }, + sensitive: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Mark status and attached media as sensitive?" + }, + spoiler_text: %Schema{ + type: :string, + nullable: true, + description: + "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field." + }, + scheduled_at: %Schema{ + type: :string, + format: :"date-time", + nullable: true, + description: + "ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future." + }, + language: %Schema{ + type: :string, + nullable: true, + description: "ISO 639 language code for this status." + }, + # Pleroma-specific properties: + preview: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: + "If set to `true` the post won't be actually posted, but the status entitiy would still be rendered back. This could be useful for previewing rich text/custom emoji, for example" + }, + content_type: %Schema{ + type: :string, + nullable: true, + description: + "The MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint." + }, + to: %Schema{ + type: :array, + nullable: true, + items: %Schema{type: :string}, + description: + "A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply" + }, + visibility: %Schema{ + nullable: true, + anyOf: [ + VisibilityScope, + %Schema{type: :string, description: "`list:LIST_ID`", example: "LIST:123"} + ], + description: + "Visibility of the posted status. Besides standard MastoAPI values (`direct`, `private`, `unlisted` or `public`) it can be used to address a List by setting it to `list:LIST_ID`" + }, + expires_in: %Schema{ + nullable: true, + type: :integer, + description: + "The number of seconds the posted activity should expire in. When a posted activity expires it will be deleted from the server, and a delete request for it will be federated. This needs to be longer than an hour." + }, + in_reply_to_conversation_id: %Schema{ + nullable: true, + type: :string, + description: + "Will reply to a given conversation, addressing only the people who are part of the recipient set of that conversation. Sets the visibility to `direct`." + }, + quote_id: %Schema{ + nullable: true, + allOf: [FlakeID], + description: "ID of the status being quoted, if any" + } + }, + example: %{ + "status" => "What time is it?", + "sensitive" => "false", + "poll" => %{ + "options" => ["Cofe", "Adventure"], + "expires_in" => 420 + } + } + } + end + + defp update_request do + %Schema{ + title: "StatusUpdateRequest", + type: :object, + properties: %{ + status: %Schema{ + type: :string, + nullable: true, + description: + "Text content of the status. If `media_ids` is provided, this becomes optional. Attaching a `poll` is optional while `status` is provided." + }, + media_ids: %Schema{ + nullable: true, + type: :array, + items: %Schema{type: :string}, + description: "Array of Attachment ids to be attached as media." + }, + poll: poll_params(), + sensitive: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Mark status and attached media as sensitive?" + }, + spoiler_text: %Schema{ + type: :string, + nullable: true, + description: + "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field." + }, + content_type: %Schema{ + type: :string, + nullable: true, + description: + "The MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint." + }, + to: %Schema{ + type: :array, + nullable: true, + items: %Schema{type: :string}, + description: + "A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply" + } + }, + example: %{ + "status" => "What time is it?", + "sensitive" => "false", + "poll" => %{ + "options" => ["Cofe", "Adventure"], + "expires_in" => 420 + } + } + } + end + + def poll_params do + %Schema{ + nullable: true, + type: :object, + required: [:options, :expires_in], + properties: %{ + options: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: "Array of possible answers. Must be provided with `poll[expires_in]`." + }, + expires_in: %Schema{ + type: :integer, + nullable: true, + description: + "Duration the poll should be open, in seconds. Must be provided with `poll[options]`" + }, + multiple: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Allow multiple choices?" + }, + hide_totals: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Hide vote counts until the poll ends?" + } + } + } + end + + def id_param do + Operation.parameter(:id, :path, FlakeID, "Status ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end + + defp status_response do + Operation.response("Status", "application/json", Status) + end + + defp status_history_response do + Operation.response( + "Status History", + "application/json", + %Schema{ + title: "Status history", + description: "Response schema for history of a status", + type: :array, + items: %Schema{ + type: :object, + properties: %{ + account: %Schema{ + allOf: [Account], + description: "The account that authored this status" + }, + content: %Schema{ + type: :string, + format: :html, + description: "HTML-encoded status content" + }, + sensitive: %Schema{ + type: :boolean, + description: "Is this status marked as sensitive content?" + }, + spoiler_text: %Schema{ + type: :string, + description: + "Subject or summary line, below which status content is collapsed until expanded" + }, + created_at: %Schema{ + type: :string, + format: "date-time", + description: "The date when this status was created" + }, + media_attachments: %Schema{ + type: :array, + items: Attachment, + description: "Media that is attached to this status" + }, + emojis: %Schema{ + type: :array, + items: Emoji, + description: "Custom emoji to be used when rendering status content" + }, + poll: %Schema{ + allOf: [Poll], + nullable: true, + description: "The poll attached to the status" + } + } + } + } + ) + end + + defp status_source_response do + Operation.response( + "Status Source", + "application/json", + %Schema{ + type: :object, + properties: %{ + id: FlakeID, + text: %Schema{ + type: :string, + description: "Raw source of status content" + }, + spoiler_text: %Schema{ + type: :string, + description: + "Subject or summary line, below which status content is collapsed until expanded" + }, + content_type: %Schema{ + type: :string, + description: "The content type of the source" + }, + location: %Schema{ + allOf: [LocationResult], + description: "Location result for an event", + nullable: true + } + } + } + ) + end + + defp context do + %Schema{ + title: "StatusContext", + description: + "Represents the tree around a given status. Used for reconstructing threads of statuses.", + type: :object, + required: [:ancestors, :descendants], + properties: %{ + ancestors: array_of_statuses(), + descendants: array_of_statuses() + }, + example: %{ + "ancestors" => [Status.schema().example], + "descendants" => [Status.schema().example] + } + } + end + + defp translation do + %Schema{ + title: "StatusTranslation", + description: "Represents status translation with related information.", + type: :object, + required: [:content, :detected_source_language, :provider], + properties: %{ + content: %Schema{ + type: :string, + description: "Translated status content" + }, + detected_source_language: %Schema{ + type: :string, + description: "Detected source language" + }, + provider: %Schema{ + type: :string, + description: "Translation provider service name" + } + }, + example: %{ + "content" => "Software für die nächste Generation der sozialen Medien.", + "detected_source_language" => "en", + "provider" => "Deepl" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/streaming_operation.ex b/lib/pleroma/web/api_spec/operations/streaming_operation.ex new file mode 100644 index 0000000..b580bc2 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/streaming_operation.ex @@ -0,0 +1,464 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.StreamingOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Response + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.NotificationOperation + alias Pleroma.Web.ApiSpec.Schemas.Chat + alias Pleroma.Web.ApiSpec.Schemas.Conversation + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + + require Pleroma.Constants + + @spec open_api_operation(atom) :: Operation.t() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + @spec streaming_operation() :: Operation.t() + def streaming_operation do + %Operation{ + tags: ["Timelines"], + summary: "Establish streaming connection", + description: """ + Receive statuses in real-time via WebSocket. + + You can specify the access token on the query string or through the `sec-websocket-protocol` header. Using + the query string to authenticate is considered unsafe and should not be used unless you have to (e.g. to maintain + your client's compatibility with Mastodon). + + You may specify a stream on the query string. If you do so and you are connecting to a stream that requires logged-in users, + you must specify the access token at the time of the connection (i.e. via query string or header). + + Otherwise, you have the option to authenticate after you have established the connection through client-sent events. + + The "Request body" section below describes what events clients can send through WebSocket, and the "Responses" section + describes what events server will send through WebSocket. + """, + security: [%{"oAuth" => ["read:statuses", "read:notifications"]}], + operationId: "WebsocketHandler.streaming", + parameters: + [ + Operation.parameter(:connection, :header, %Schema{type: :string}, "connection header", + required: true + ), + Operation.parameter(:upgrade, :header, %Schema{type: :string}, "upgrade header", + required: true + ), + Operation.parameter( + :"sec-websocket-key", + :header, + %Schema{type: :string}, + "sec-websocket-key header", + required: true + ), + Operation.parameter( + :"sec-websocket-version", + :header, + %Schema{type: :string}, + "sec-websocket-version header", + required: true + ) + ] ++ stream_params() ++ access_token_params(), + requestBody: request_body("Client-sent events", client_sent_events()), + responses: %{ + 101 => switching_protocols_response(), + 200 => + Operation.response( + "Server-sent events", + "application/json", + server_sent_events() + ) + } + } + end + + defp stream_params do + stream_specifier() + |> Enum.map(fn {name, schema} -> + Operation.parameter(name, :query, schema, get_schema(schema).description) + end) + end + + defp access_token_params do + [ + Operation.parameter(:access_token, :query, token(), token().description), + Operation.parameter(:"sec-websocket-protocol", :header, token(), token().description) + ] + end + + defp switching_protocols_response do + %Response{ + description: "Switching protocols", + headers: %{ + "connection" => %OpenApiSpex.Header{required: true}, + "upgrade" => %OpenApiSpex.Header{required: true}, + "sec-websocket-accept" => %OpenApiSpex.Header{required: true} + } + } + end + + defp server_sent_events do + %Schema{ + oneOf: [ + update_event(), + status_update_event(), + notification_event(), + chat_update_event(), + follow_relationships_update_event(), + conversation_event(), + delete_event(), + pleroma_respond_event() + ] + } + end + + defp stream do + %Schema{ + type: :array, + title: "Stream", + description: """ + The stream identifier. + The first item is the name of the stream. If the stream needs a differentiator, the second item will be the corresponding identifier. + Currently, for the following stream types, there is a second element in the array: + + - `list`: The second element is the id of the list, as a string. + - `hashtag`: The second element is the name of the hashtag. + - `public:remote:media` and `public:remote`: The second element is the domain of the corresponding instance. + """, + maxItems: 2, + minItems: 1, + items: %Schema{type: :string}, + example: ["hashtag", "mew"] + } + end + + defp get_schema(%Schema{} = schema), do: schema + defp get_schema(schema), do: schema.schema + + defp server_sent_event_helper(name, description, type, payload, opts \\ []) do + payload_type = Keyword.get(opts, :payload_type, :json) + has_stream = Keyword.get(opts, :has_stream, true) + + stream_properties = + if has_stream do + %{stream: stream()} + else + %{} + end + + stream_example = if has_stream, do: %{"stream" => get_schema(stream()).example}, else: %{} + + stream_required = if has_stream, do: [:stream], else: [] + + payload_schema = + if payload_type == :json do + %Schema{ + title: "Event payload", + description: "JSON-encoded string of #{get_schema(payload).title}", + allOf: [payload] + } + else + payload + end + + payload_example = + if payload_type == :json do + get_schema(payload).example |> Jason.encode!() + else + get_schema(payload).example + end + + %Schema{ + type: :object, + title: name, + description: description, + required: [:event, :payload] ++ stream_required, + properties: + %{ + event: %Schema{ + title: "Event type", + description: "Type of the event.", + type: :string, + required: true, + enum: [type] + }, + payload: payload_schema + } + |> Map.merge(stream_properties), + example: + %{ + "event" => type, + "payload" => payload_example + } + |> Map.merge(stream_example) + } + end + + defp update_event do + server_sent_event_helper("New status", "A newly-posted status.", "update", Status) + end + + defp status_update_event do + server_sent_event_helper("Edit", "A status that was just edited", "status.update", Status) + end + + defp notification_event do + server_sent_event_helper( + "Notification", + "A new notification.", + "notification", + NotificationOperation.notification() + ) + end + + defp follow_relationships_update_event do + server_sent_event_helper( + "Follow relationships update", + "An update to follow relationships.", + "pleroma:follow_relationships_update", + %Schema{ + type: :object, + title: "Follow relationships update", + required: [:state, :follower, :following], + properties: %{ + state: %Schema{ + type: :string, + description: "Follow state of the relationship.", + enum: ["follow_pending", "follow_accept", "follow_reject", "unfollow"] + }, + follower: %Schema{ + type: :object, + description: "Information about the follower.", + required: [:id, :follower_count, :following_count], + properties: %{ + id: FlakeID, + follower_count: %Schema{type: :integer}, + following_count: %Schema{type: :integer} + } + }, + following: %Schema{ + type: :object, + description: "Information about the following person.", + required: [:id, :follower_count, :following_count], + properties: %{ + id: FlakeID, + follower_count: %Schema{type: :integer}, + following_count: %Schema{type: :integer} + } + } + }, + example: %{ + "state" => "follow_pending", + "follower" => %{ + "id" => "someUser1", + "follower_count" => 1, + "following_count" => 1 + }, + "following" => %{ + "id" => "someUser2", + "follower_count" => 1, + "following_count" => 1 + } + } + } + ) + end + + defp chat_update_event do + server_sent_event_helper( + "Chat update", + "A new chat message.", + "pleroma:chat_update", + Chat + ) + end + + defp conversation_event do + server_sent_event_helper( + "Conversation update", + "An update about a conversation", + "conversation", + Conversation + ) + end + + defp delete_event do + server_sent_event_helper( + "Delete", + "A status that was just deleted.", + "delete", + %Schema{ + type: :string, + title: "Status id", + description: "Id of the deleted status", + allOf: [FlakeID], + example: "some-opaque-id" + }, + payload_type: :string, + has_stream: false + ) + end + + defp pleroma_respond_event do + server_sent_event_helper( + "Server response", + "A response to a client-sent event.", + "pleroma:respond", + %Schema{ + type: :object, + title: "Results", + required: [:result, :type], + properties: %{ + result: %Schema{ + type: :string, + title: "Result of the request", + enum: ["success", "error", "ignored"] + }, + error: %Schema{ + type: :string, + title: "Error code", + description: "An error identifier. Only appears if `result` is `error`." + }, + type: %Schema{ + type: :string, + description: "Type of the request." + } + }, + example: %{"result" => "success", "type" => "pleroma:authenticate"} + }, + has_stream: false + ) + end + + defp client_sent_events do + %Schema{ + oneOf: [ + subscribe_event(), + unsubscribe_event(), + authenticate_event() + ] + } + end + + defp request_body(description, schema, opts \\ []) do + %OpenApiSpex.RequestBody{ + description: description, + content: %{ + "application/json" => %OpenApiSpex.MediaType{ + schema: schema, + example: opts[:example], + examples: opts[:examples] + } + } + } + end + + defp client_sent_event_helper(name, description, type, properties, opts) do + required = opts[:required] || [] + + %Schema{ + type: :object, + title: name, + required: [:type] ++ required, + description: description, + properties: + %{ + type: %Schema{type: :string, enum: [type], description: "Type of the event."} + } + |> Map.merge(properties), + example: opts[:example] + } + end + + defp subscribe_event do + client_sent_event_helper( + "Subscribe", + "Subscribe to a stream.", + "subscribe", + stream_specifier(), + required: [:stream], + example: %{"type" => "subscribe", "stream" => "list", "list" => "1"} + ) + end + + defp unsubscribe_event do + client_sent_event_helper( + "Unsubscribe", + "Unsubscribe from a stream.", + "unsubscribe", + stream_specifier(), + required: [:stream], + example: %{ + "type" => "unsubscribe", + "stream" => "public:remote:media", + "instance" => "example.org" + } + ) + end + + defp authenticate_event do + client_sent_event_helper( + "Authenticate", + "Authenticate via an access token.", + "pleroma:authenticate", + %{ + token: token() + }, + required: [:token] + ) + end + + defp token do + %Schema{ + type: :string, + description: "An OAuth access token with corresponding permissions.", + example: "some token" + } + end + + defp stream_specifier do + %{ + stream: %Schema{ + type: :string, + description: "The name of the stream.", + enum: + Pleroma.Constants.public_streams() ++ + [ + "public:remote", + "public:remote:media", + "user", + "user:pleroma_chat", + "user:notification", + "direct", + "list", + "hashtag" + ] + }, + list: %Schema{ + type: :string, + title: "List id", + description: "The id of the list. Required when `stream` is `list`.", + example: "some-id" + }, + tag: %Schema{ + type: :string, + title: "Hashtag name", + description: "The name of the hashtag. Required when `stream` is `hashtag`.", + example: "mew" + }, + instance: %Schema{ + type: :string, + title: "Domain name", + description: + "Domain name of the instance. Required when `stream` is `public:remote` or `public:remote:media`.", + example: "example.org" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/subscription_operation.ex b/lib/pleroma/web/api_spec/operations/subscription_operation.ex new file mode 100644 index 0000000..c53ec29 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/subscription_operation.ex @@ -0,0 +1,247 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.SubscriptionOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.PushSubscription + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["Push subscriptions"], + summary: "Subscribe to push notifications", + description: + "Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted.", + operationId: "SubscriptionController.create", + security: [%{"oAuth" => ["push"]}], + requestBody: Helpers.request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Push subscription", "application/json", PushSubscription), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Push subscriptions"], + summary: "Get current subscription", + description: "View the PushSubscription currently associated with this access token.", + operationId: "SubscriptionController.show", + security: [%{"oAuth" => ["push"]}], + responses: %{ + 200 => Operation.response("Push subscription", "application/json", PushSubscription), + 403 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Push subscriptions"], + summary: "Change types of notifications", + description: + "Updates the current push subscription. Only the data part can be updated. To change fundamentals, a new subscription must be created instead.", + operationId: "SubscriptionController.update", + security: [%{"oAuth" => ["push"]}], + requestBody: Helpers.request_body("Parameters", update_request(), required: true), + responses: %{ + 200 => Operation.response("Push subscription", "application/json", PushSubscription), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Push subscriptions"], + summary: "Remove current subscription", + description: "Removes the current Web Push API subscription.", + operationId: "SubscriptionController.delete", + security: [%{"oAuth" => ["push"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}), + 403 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp create_request do + %Schema{ + title: "SubscriptionCreateRequest", + description: "POST body for creating a push subscription", + type: :object, + properties: %{ + subscription: %Schema{ + type: :object, + properties: %{ + endpoint: %Schema{ + type: :string, + description: "Endpoint URL that is called when a notification event occurs." + }, + keys: %Schema{ + type: :object, + properties: %{ + p256dh: %Schema{ + type: :string, + description: + "User agent public key. Base64 encoded string of public key of ECDH key using `prime256v1` curve." + }, + auth: %Schema{ + type: :string, + description: "Auth secret. Base64 encoded string of 16 bytes of random data." + } + }, + required: [:p256dh, :auth] + } + }, + required: [:endpoint, :keys] + }, + data: %Schema{ + nullable: true, + type: :object, + properties: %{ + alerts: %Schema{ + nullable: true, + type: :object, + properties: %{ + follow: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive follow notifications?" + }, + favourite: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive favourite notifications?" + }, + reblog: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive reblog notifications?" + }, + mention: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive mention notifications?" + }, + poll: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive poll notifications?" + }, + "pleroma:chat_mention": %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive chat notifications?" + }, + "pleroma:emoji_reaction": %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive emoji reaction notifications?" + } + } + } + } + } + }, + required: [:subscription], + example: %{ + "subscription" => %{ + "endpoint" => "https://example.com/example/1234", + "keys" => %{ + "auth" => "8eDyX_uCN0XRhSbY5hs7Hg==", + "p256dh" => + "BCIWgsnyXDv1VkhqL2P7YRBvdeuDnlwAPT2guNhdIoW3IP7GmHh1SMKPLxRf7x8vJy6ZFK3ol2ohgn_-0yP7QQA=" + } + }, + "data" => %{ + "alerts" => %{ + "follow" => true, + "mention" => true, + "poll" => false + } + } + } + } + end + + defp update_request do + %Schema{ + title: "SubscriptionUpdateRequest", + type: :object, + properties: %{ + data: %Schema{ + nullable: true, + type: :object, + properties: %{ + alerts: %Schema{ + nullable: true, + type: :object, + properties: %{ + follow: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive follow notifications?" + }, + favourite: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive favourite notifications?" + }, + reblog: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive reblog notifications?" + }, + mention: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive mention notifications?" + }, + poll: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive poll notifications?" + }, + "pleroma:chat_mention": %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive chat notifications?" + }, + "pleroma:emoji_reaction": %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive emoji reaction notifications?" + } + } + } + } + } + }, + example: %{ + "data" => %{ + "alerts" => %{ + "follow" => true, + "favourite" => true, + "reblog" => true, + "mention" => true, + "poll" => true + } + } + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex new file mode 100644 index 0000000..3151bdb --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -0,0 +1,230 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.TimelineOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def home_operation do + %Operation{ + tags: ["Timelines"], + summary: "Home timeline", + description: "View statuses from followed users", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + local_param(), + remote_param(), + only_media_param(), + only_events_param(), + with_muted_param(), + exclude_visibilities_param(), + reply_visibility_param() | pagination_params() + ], + operationId: "TimelineController.home", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + def direct_operation do + %Operation{ + tags: ["Timelines"], + summary: "Direct timeline", + description: + "View statuses with a “direct” scope addressed to the account. Using this endpoint is discouraged, please use [conversations](#tag/Conversations) or [chats](#tag/Chats).", + parameters: [with_muted_param() | pagination_params()], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "TimelineController.direct", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + def public_operation do + %Operation{ + tags: ["Timelines"], + summary: "Public timeline", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + local_param(), + instance_param(), + only_media_param(), + only_events_param(), + remote_param(), + with_muted_param(), + exclude_visibilities_param(), + reply_visibility_param() | pagination_params() + ], + operationId: "TimelineController.public", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def hashtag_operation do + %Operation{ + tags: ["Timelines"], + summary: "Hashtag timeline", + description: "View public statuses containing the given hashtag", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + Operation.parameter( + :tag, + :path, + %Schema{type: :string}, + "Content of a #hashtag, not including # symbol.", + required: true + ), + Operation.parameter( + :any, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Statuses that also includes any of these tags" + ), + Operation.parameter( + :all, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Statuses that also includes all of these tags" + ), + Operation.parameter( + :none, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Statuses that do not include these tags" + ), + local_param(), + only_media_param(), + only_events_param(), + remote_param(), + with_muted_param(), + exclude_visibilities_param() | pagination_params() + ], + operationId: "TimelineController.hashtag", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def list_operation do + %Operation{ + tags: ["Timelines"], + summary: "List timeline", + description: "View statuses in the given list timeline", + security: [%{"oAuth" => ["read:lists"]}], + parameters: [ + Operation.parameter( + :list_id, + :path, + %Schema{type: :string}, + "Local ID of the list in the database", + required: true + ), + with_muted_param(), + local_param(), + remote_param(), + only_media_param(), + only_events_param(), + exclude_visibilities_param() | pagination_params() + ], + operationId: "TimelineController.list", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + defp array_of_statuses do + %Schema{ + title: "ArrayOfStatuses", + type: :array, + items: Status, + example: [Status.schema().example] + } + end + + defp local_param do + Operation.parameter( + :local, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Show only local statuses?" + ) + end + + defp instance_param do + Operation.parameter( + :instance, + :query, + %Schema{type: :string}, + "Show only statuses from the given domain" + ) + end + + defp with_muted_param do + Operation.parameter(:with_muted, :query, BooleanLike, "Include activities by muted users") + end + + defp exclude_visibilities_param do + Operation.parameter( + :exclude_visibilities, + :query, + %Schema{type: :array, items: VisibilityScope}, + "Exclude the statuses with the given visibilities" + ) + end + + defp reply_visibility_param do + Operation.parameter( + :reply_visibility, + :query, + %Schema{type: :string, enum: ["following", "self"]}, + "Filter replies. Possible values: without parameter (default) shows all replies, `following` - replies directed to you or users you follow, `self` - replies directed to you." + ) + end + + defp only_media_param do + Operation.parameter( + :only_media, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Show only statuses with media attached?" + ) + end + + defp only_events_param do + Operation.parameter( + :only_events, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Include only objects with Event type" + ) + end + + defp remote_param do + Operation.parameter( + :remote, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Show only remote statuses?" + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex new file mode 100644 index 0000000..e63484d --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex @@ -0,0 +1,443 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.TwitterUtilOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def emoji_operation do + %Operation{ + tags: ["Custom emojis"], + summary: "List all custom emojis", + operationId: "UtilController.emoji", + parameters: [], + responses: %{ + 200 => + Operation.response("List", "application/json", %Schema{ + type: :object, + additionalProperties: %Schema{ + type: :object, + properties: %{ + image_url: %Schema{type: :string}, + tags: %Schema{type: :array, items: %Schema{type: :string}} + }, + extensions: %{"x-additionalPropertiesName": "Emoji name"} + }, + example: %{ + "firefox" => %{ + "image_url" => "/emoji/firefox.png", + "tag" => ["Fun"] + } + } + }) + } + } + end + + def frontend_configurations_operation do + %Operation{ + tags: ["Others"], + summary: "Dump frontend configurations", + operationId: "UtilController.frontend_configurations", + parameters: [], + responses: %{ + 200 => + Operation.response("List", "application/json", %Schema{ + type: :object, + additionalProperties: %Schema{ + type: :object, + description: + "Opaque object representing the instance-wide configuration for the frontend", + extensions: %{"x-additionalPropertiesName": "Frontend name"} + } + }) + } + } + end + + def change_password_operation do + %Operation{ + tags: ["Account credentials"], + summary: "Change account password", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.change_password", + requestBody: request_body("Parameters", change_password_request(), required: true), + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{status: %Schema{type: :string, example: "success"}} + }), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp change_password_request do + %Schema{ + title: "ChangePasswordRequest", + description: "POST body for changing the account's passowrd", + type: :object, + required: [:password, :new_password, :new_password_confirmation], + properties: %{ + password: %Schema{type: :string, description: "Current password"}, + new_password: %Schema{type: :string, description: "New password"}, + new_password_confirmation: %Schema{ + type: :string, + description: "New password, confirmation" + } + } + } + end + + def change_email_operation do + %Operation{ + tags: ["Account credentials"], + summary: "Change account email", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.change_email", + requestBody: request_body("Parameters", change_email_request(), required: true), + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{status: %Schema{type: :string, example: "success"}} + }), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp change_email_request do + %Schema{ + title: "ChangeEmailRequest", + description: "POST body for changing the account's email", + type: :object, + required: [:email, :password], + properties: %{ + email: %Schema{ + type: :string, + description: "New email. Set to blank to remove the user's email." + }, + password: %Schema{type: :string, description: "Current password"} + } + } + end + + def update_notificaton_settings_operation do + %Operation{ + tags: ["Settings"], + summary: "Update Notification Settings", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.update_notificaton_settings", + parameters: [ + Operation.parameter( + :block_from_strangers, + :query, + BooleanLike, + "blocks notifications from accounts you do not follow" + ), + Operation.parameter( + :hide_notification_contents, + :query, + BooleanLike, + "removes the contents of a message from the push notification" + ) + ], + requestBody: nil, + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{status: %Schema{type: :string, example: "success"}} + }), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def disable_account_operation do + %Operation{ + tags: ["Account credentials"], + summary: "Disable Account", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.disable_account", + parameters: [ + Operation.parameter(:password, :query, :string, "Password") + ], + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{status: %Schema{type: :string, example: "success"}} + }), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_account_operation do + %Operation{ + tags: ["Account credentials"], + summary: "Delete Account", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.delete_account", + parameters: [ + Operation.parameter(:password, :query, :string, "Password") + ], + requestBody: request_body("Parameters", delete_account_request(), required: false), + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{status: %Schema{type: :string, example: "success"}} + }), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def captcha_operation do + %Operation{ + summary: "Get a captcha", + operationId: "UtilController.captcha", + tags: ["Others"], + parameters: [], + responses: %{ + 200 => Operation.response("Success", "application/json", %Schema{type: :object}) + } + } + end + + def move_account_operation do + %Operation{ + tags: ["Account credentials"], + summary: "Move account", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.move_account", + requestBody: request_body("Parameters", move_account_request(), required: true), + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{status: %Schema{type: :string, example: "success"}} + }), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError), + 429 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp move_account_request do + %Schema{ + title: "MoveAccountRequest", + description: "POST body for moving the account", + type: :object, + required: [:password, :target_account], + properties: %{ + password: %Schema{type: :string, description: "Current password"}, + target_account: %Schema{ + type: :string, + description: "The nickname of the target account to move to" + } + } + } + end + + def list_aliases_operation do + %Operation{ + tags: ["Account credentials"], + summary: "List account aliases", + security: [%{"oAuth" => ["read:accounts"]}], + operationId: "UtilController.list_aliases", + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{ + aliases: %Schema{ + type: :array, + items: %Schema{type: :string}, + example: ["foo@example.org"] + } + } + }), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def add_alias_operation do + %Operation{ + tags: ["Account credentials"], + summary: "Add an alias to this account", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.add_alias", + requestBody: request_body("Parameters", add_alias_request(), required: true), + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{ + status: %Schema{ + type: :string, + example: "success" + } + } + }), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp add_alias_request do + %Schema{ + title: "AddAliasRequest", + description: "PUT body for adding aliases", + type: :object, + required: [:alias], + properties: %{ + alias: %Schema{ + type: :string, + description: "The nickname of the account to add to aliases" + } + } + } + end + + def delete_alias_operation do + %Operation{ + tags: ["Account credentials"], + summary: "Delete an alias from this account", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.delete_alias", + requestBody: request_body("Parameters", delete_alias_request(), required: true), + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{ + status: %Schema{ + type: :string, + example: "success" + } + } + }), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp delete_alias_request do + %Schema{ + title: "DeleteAliasRequest", + description: "PUT body for deleting aliases", + type: :object, + required: [:alias], + properties: %{ + alias: %Schema{ + type: :string, + description: "The nickname of the account to delete from aliases" + } + } + } + end + + def healthcheck_operation do + %Operation{ + tags: ["Others"], + summary: "Quick status check on the instance", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.healthcheck", + parameters: [], + responses: %{ + 200 => Operation.response("Healthy", "application/json", %Schema{type: :object}), + 503 => + Operation.response("Disabled or Unhealthy", "application/json", %Schema{type: :object}) + } + } + end + + def remote_subscribe_operation do + %Operation{ + tags: ["Remote interaction"], + summary: "Remote Subscribe", + operationId: "UtilController.remote_subscribe", + parameters: [], + responses: %{200 => Operation.response("Web Page", "test/html", %Schema{type: :string})} + } + end + + def remote_interaction_operation do + %Operation{ + tags: ["Remote interaction"], + summary: "Remote interaction", + operationId: "UtilController.remote_interaction", + requestBody: request_body("Parameters", remote_interaction_request(), required: true), + responses: %{ + 200 => + Operation.response("Remote interaction URL", "application/json", %Schema{type: :object}) + } + } + end + + defp remote_interaction_request do + %Schema{ + title: "RemoteInteractionRequest", + description: "POST body for remote interaction", + type: :object, + required: [:ap_id, :profile], + properties: %{ + ap_id: %Schema{type: :string, description: "Profile or status ActivityPub ID"}, + profile: %Schema{type: :string, description: "Remote profile webfinger"} + } + } + end + + def show_subscribe_form_operation do + %Operation{ + tags: ["Remote interaction"], + summary: "Show remote subscribe form", + operationId: "UtilController.show_subscribe_form", + parameters: [], + responses: %{200 => Operation.response("Web Page", "test/html", %Schema{type: :string})} + } + end + + defp delete_account_request do + %Schema{ + title: "AccountDeleteRequest", + description: "POST body for deleting one's own account", + type: :object, + properties: %{ + password: %Schema{ + type: :string, + description: "The user's own password for confirmation.", + format: :password + } + }, + example: %{ + "password" => "prettyp0ony1313" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/user_import_operation.ex b/lib/pleroma/web/api_spec/operations/user_import_operation.ex new file mode 100644 index 0000000..e99e6e6 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/user_import_operation.ex @@ -0,0 +1,81 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.UserImportOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + @spec open_api_operation(atom) :: Operation.t() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def follow_operation do + %Operation{ + tags: ["Data import"], + summary: "Import follows", + operationId: "UserImportController.follow", + requestBody: request_body("Parameters", import_request(), required: true), + responses: %{ + 200 => ok_response(), + 403 => Operation.response("Error", "application/json", ApiError), + 500 => Operation.response("Error", "application/json", ApiError) + }, + security: [%{"oAuth" => ["write:follow"]}] + } + end + + def blocks_operation do + %Operation{ + tags: ["Data import"], + summary: "Import blocks", + operationId: "UserImportController.blocks", + requestBody: request_body("Parameters", import_request(), required: true), + responses: %{ + 200 => ok_response(), + 500 => Operation.response("Error", "application/json", ApiError) + }, + security: [%{"oAuth" => ["write:blocks"]}] + } + end + + def mutes_operation do + %Operation{ + tags: ["Data import"], + summary: "Import mutes", + operationId: "UserImportController.mutes", + requestBody: request_body("Parameters", import_request(), required: true), + responses: %{ + 200 => ok_response(), + 500 => Operation.response("Error", "application/json", ApiError) + }, + security: [%{"oAuth" => ["write:mutes"]}] + } + end + + defp import_request do + %Schema{ + type: :object, + required: [:list], + properties: %{ + list: %Schema{ + description: + "STRING or FILE containing a whitespace-separated list of accounts to import.", + anyOf: [ + %Schema{type: :string, format: :binary}, + %Schema{type: :string} + ] + } + } + } + end + + defp ok_response do + Operation.response("Ok", "application/json", %Schema{type: :string, example: "ok"}) + end +end diff --git a/lib/pleroma/web/api_spec/render_error.ex b/lib/pleroma/web/api_spec/render_error.ex new file mode 100644 index 0000000..3539af6 --- /dev/null +++ b/lib/pleroma/web/api_spec/render_error.ex @@ -0,0 +1,234 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.RenderError do + @behaviour Plug + + import Plug.Conn, only: [put_status: 2] + import Phoenix.Controller, only: [json: 2] + import Pleroma.Web.Gettext + + @impl Plug + def init(opts), do: opts + + @impl Plug + + def call(conn, errors) do + errors = + Enum.map(errors, fn + %{name: nil, reason: :invalid_enum} = err -> + %OpenApiSpex.Cast.Error{err | name: err.value} + + %{name: nil} = err -> + %OpenApiSpex.Cast.Error{err | name: List.last(err.path)} + + err -> + err + end) + + conn + |> put_status(:bad_request) + |> json(%{ + error: errors |> Enum.map(&message/1) |> Enum.join(" "), + errors: errors |> Enum.map(&render_error/1) + }) + end + + defp render_error(error) do + pointer = OpenApiSpex.path_to_string(error) + + %{ + title: "Invalid value", + source: %{ + pointer: pointer + }, + message: OpenApiSpex.Cast.Error.message(error) + } + end + + defp message(%{reason: :invalid_schema_type, type: type, name: name}) do + gettext("%{name} - Invalid schema.type. Got: %{type}.", + name: name, + type: inspect(type) + ) + end + + defp message(%{reason: :null_value, name: name} = error) do + case error.type do + nil -> + gettext("%{name} - null value.", name: name) + + type -> + gettext("%{name} - null value where %{type} expected.", + name: name, + type: type + ) + end + end + + defp message(%{reason: :all_of, meta: %{invalid_schema: invalid_schema}}) do + gettext( + "Failed to cast value as %{invalid_schema}. Value must be castable using `allOf` schemas listed.", + invalid_schema: invalid_schema + ) + end + + defp message(%{reason: :any_of, meta: %{failed_schemas: failed_schemas}}) do + gettext("Failed to cast value using any of: %{failed_schemas}.", + failed_schemas: failed_schemas + ) + end + + defp message(%{reason: :one_of, meta: %{failed_schemas: failed_schemas}}) do + gettext("Failed to cast value to one of: %{failed_schemas}.", failed_schemas: failed_schemas) + end + + defp message(%{reason: :min_length, length: length, name: name}) do + gettext("%{name} - String length is smaller than minLength: %{length}.", + name: name, + length: length + ) + end + + defp message(%{reason: :max_length, length: length, name: name}) do + gettext("%{name} - String length is larger than maxLength: %{length}.", + name: name, + length: length + ) + end + + defp message(%{reason: :unique_items, name: name}) do + gettext("%{name} - Array items must be unique.", name: name) + end + + defp message(%{reason: :min_items, length: min, value: array, name: name}) do + gettext("%{name} - Array length %{length} is smaller than minItems: %{min}.", + name: name, + length: length(array), + min: min + ) + end + + defp message(%{reason: :max_items, length: max, value: array, name: name}) do + gettext("%{name} - Array length %{length} is larger than maxItems: %{}.", + name: name, + length: length(array), + max: max + ) + end + + defp message(%{reason: :multiple_of, length: multiple, value: count, name: name}) do + gettext("%{name} - %{count} is not a multiple of %{multiple}.", + name: name, + count: count, + multiple: multiple + ) + end + + defp message(%{reason: :exclusive_max, length: max, value: value, name: name}) + when value >= max do + gettext("%{name} - %{value} is larger than exclusive maximum %{max}.", + name: name, + value: value, + max: max + ) + end + + defp message(%{reason: :maximum, length: max, value: value, name: name}) + when value > max do + gettext("%{name} - %{value} is larger than inclusive maximum %{max}.", + name: name, + value: value, + max: max + ) + end + + defp message(%{reason: :exclusive_multiple, length: min, value: value, name: name}) + when value <= min do + gettext("%{name} - %{value} is smaller than exclusive minimum %{min}.", + name: name, + value: value, + min: min + ) + end + + defp message(%{reason: :minimum, length: min, value: value, name: name}) + when value < min do + gettext("%{name} - %{value} is smaller than inclusive minimum %{min}.", + name: name, + value: value, + min: min + ) + end + + defp message(%{reason: :invalid_type, type: type, value: value, name: name}) do + gettext("%{name} - Invalid %{type}. Got: %{value}.", + name: name, + value: OpenApiSpex.TermType.type(value), + type: type + ) + end + + defp message(%{reason: :invalid_format, format: format, name: name}) do + gettext("%{name} - Invalid format. Expected %{format}.", name: name, format: inspect(format)) + end + + defp message(%{reason: :invalid_enum, name: name}) do + gettext("%{name} - Invalid value for enum.", name: name) + end + + defp message(%{reason: :polymorphic_failed, type: polymorphic_type}) do + gettext("Failed to cast to any schema in %{polymorphic_type}", + polymorphic_type: polymorphic_type + ) + end + + defp message(%{reason: :unexpected_field, name: name}) do + gettext("Unexpected field: %{name}.", name: safe_string(name)) + end + + defp message(%{reason: :no_value_for_discriminator, name: field}) do + gettext("Value used as discriminator for `%{field}` matches no schemas.", name: field) + end + + defp message(%{reason: :invalid_discriminator_value, name: field}) do + gettext("No value provided for required discriminator `%{field}`.", name: field) + end + + defp message(%{reason: :unknown_schema, name: name}) do + gettext("Unknown schema: %{name}.", name: name) + end + + defp message(%{reason: :missing_field, name: name}) do + gettext("Missing field: %{name}.", name: name) + end + + defp message(%{reason: :missing_header, name: name}) do + gettext("Missing header: %{name}.", name: name) + end + + defp message(%{reason: :invalid_header, name: name}) do + gettext("Invalid value for header: %{name}.", name: name) + end + + defp message(%{reason: :max_properties, meta: meta}) do + gettext( + "Object property count %{property_count} is greater than maxProperties: %{max_properties}.", + property_count: meta.property_count, + max_properties: meta.max_properties + ) + end + + defp message(%{reason: :min_properties, meta: meta}) do + gettext( + "Object property count %{property_count} is less than minProperties: %{min_properties}", + property_count: meta.property_count, + min_properties: meta.min_properties + ) + end + + defp safe_string(string) do + to_string(string) |> String.slice(0..39) + end +end diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex new file mode 100644 index 0000000..83731e1 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -0,0 +1,226 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Account do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.AccountField + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship + alias Pleroma.Web.ApiSpec.Schemas.ActorType + alias Pleroma.Web.ApiSpec.Schemas.Emoji + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Account", + description: "Response schema for an account", + type: :object, + properties: %{ + acct: %Schema{type: :string}, + avatar_static: %Schema{type: :string, format: :uri}, + avatar: %Schema{type: :string, format: :uri}, + bot: %Schema{type: :boolean}, + created_at: %Schema{type: :string, format: "date-time"}, + display_name: %Schema{type: :string}, + emojis: %Schema{type: :array, items: Emoji}, + fields: %Schema{type: :array, items: AccountField}, + follow_requests_count: %Schema{type: :integer}, + followers_count: %Schema{type: :integer}, + following_count: %Schema{type: :integer}, + header_static: %Schema{type: :string, format: :uri}, + header: %Schema{type: :string, format: :uri}, + id: FlakeID, + locked: %Schema{type: :boolean}, + mute_expires_at: %Schema{type: :string, format: "date-time", nullable: true}, + note: %Schema{type: :string, format: :html}, + statuses_count: %Schema{type: :integer}, + url: %Schema{type: :string, format: :uri}, + username: %Schema{type: :string}, + pleroma: %Schema{ + type: :object, + properties: %{ + ap_id: %Schema{type: :string}, + also_known_as: %Schema{type: :array, items: %Schema{type: :string}}, + allow_following_move: %Schema{ + type: :boolean, + description: "whether the user allows automatically follow moved following accounts" + }, + background_image: %Schema{type: :string, nullable: true, format: :uri}, + birthday: %Schema{type: :string, nullable: true, format: :date}, + is_confirmed: %Schema{ + type: :boolean, + description: + "whether the user account is waiting on email confirmation to be activated" + }, + show_birthday: %Schema{type: :boolean, nullable: true}, + location: %Schema{type: :string, nullable: true}, + hide_favorites: %Schema{type: :boolean}, + hide_followers_count: %Schema{ + type: :boolean, + description: "whether the user has follower stat hiding enabled" + }, + hide_followers: %Schema{ + type: :boolean, + description: "whether the user has follower hiding enabled" + }, + hide_follows_count: %Schema{ + type: :boolean, + description: "whether the user has follow stat hiding enabled" + }, + hide_follows: %Schema{ + type: :boolean, + description: "whether the user has follow hiding enabled" + }, + is_admin: %Schema{ + type: :boolean, + description: "whether the user is an admin of the local instance" + }, + is_moderator: %Schema{ + type: :boolean, + description: "whether the user is a moderator of the local instance" + }, + skip_thread_containment: %Schema{type: :boolean}, + tags: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: + "List of tags being used for things like extra roles or moderation(ie. marking all media as nsfw all)." + }, + unread_conversation_count: %Schema{ + type: :integer, + description: "The count of unread conversations. Only returned to the account owner." + }, + notification_settings: %Schema{ + type: :object, + properties: %{ + block_from_strangers: %Schema{type: :boolean}, + hide_notification_contents: %Schema{type: :boolean} + } + }, + relationship: %Schema{allOf: [AccountRelationship], nullable: true}, + settings_store: %Schema{ + type: :object, + description: + "A generic map of settings for frontends. Opaque to the backend. Only returned in `verify_credentials` and `update_credentials`" + }, + accepts_chat_messages: %Schema{type: :boolean, nullable: true}, + favicon: %Schema{ + type: :string, + format: :uri, + nullable: true, + description: "Favicon image of the user's instance" + } + } + }, + source: %Schema{ + type: :object, + properties: %{ + fields: %Schema{type: :array, items: AccountField}, + note: %Schema{ + type: :string, + description: + "Plaintext version of the bio without formatting applied by the backend, used for editing the bio." + }, + privacy: VisibilityScope, + sensitive: %Schema{type: :boolean}, + pleroma: %Schema{ + type: :object, + properties: %{ + actor_type: ActorType, + discoverable: %Schema{ + type: :boolean, + description: + "whether the user allows indexing / listing of the account by external services (search engines etc.)." + }, + no_rich_text: %Schema{ + type: :boolean, + description: + "whether the HTML tags for rich-text formatting are stripped from all statuses requested from the API." + }, + show_role: %Schema{ + type: :boolean, + description: + "whether the user wants their role (e.g admin, moderator) to be shown" + } + } + } + } + } + }, + example: %{ + "acct" => "foobar", + "avatar" => "https://mypleroma.com/images/avi.png", + "avatar_static" => "https://mypleroma.com/images/avi.png", + "bot" => false, + "created_at" => "2020-03-24T13:05:58.000Z", + "display_name" => "foobar", + "emojis" => [], + "fields" => [], + "follow_requests_count" => 0, + "followers_count" => 0, + "following_count" => 1, + "header" => "https://mypleroma.com/images/banner.png", + "header_static" => "https://mypleroma.com/images/banner.png", + "id" => "9tKi3esbG7OQgZ2920", + "locked" => false, + "note" => "cofe", + "pleroma" => %{ + "allow_following_move" => true, + "background_image" => nil, + "is_confirmed" => false, + "hide_favorites" => true, + "hide_followers" => false, + "hide_followers_count" => false, + "hide_follows" => false, + "hide_follows_count" => false, + "is_admin" => false, + "is_moderator" => false, + "skip_thread_containment" => false, + "accepts_chat_messages" => true, + "unread_conversation_count" => 0, + "tags" => [], + "notification_settings" => %{ + "block_from_strangers" => false, + "hide_notification_contents" => false + }, + "relationship" => %{ + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "endorsed" => false, + "followed_by" => false, + "following" => false, + "id" => "9tKi3esbG7OQgZ2920", + "muting" => false, + "muting_notifications" => false, + "note" => "", + "requested" => false, + "showing_reblogs" => true, + "subscribing" => false, + "notifying" => false + }, + "settings_store" => %{ + "pleroma-fe" => %{} + }, + "birthday" => "2001-02-12" + }, + "source" => %{ + "fields" => [], + "note" => "foobar", + "pleroma" => %{ + "actor_type" => "Person", + "discoverable" => false, + "no_rich_text" => false, + "show_role" => true + }, + "privacy" => "public", + "sensitive" => false + }, + "statuses_count" => 0, + "url" => "https://mypleroma.com/users/foobar", + "username" => "foobar" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/account_field.ex b/lib/pleroma/web/api_spec/schemas/account_field.ex new file mode 100644 index 0000000..93ba1b5 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_field.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountField do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountField", + description: "Response schema for account custom fields", + type: :object, + properties: %{ + name: %Schema{type: :string}, + value: %Schema{type: :string, format: :html}, + verified_at: %Schema{type: :string, format: :"date-time", nullable: true} + }, + example: %{ + "name" => "Website", + "value" => + "https://pleroma.com", + "verified_at" => "2019-08-29T04:14:55.571+00:00" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/account_relationship.ex b/lib/pleroma/web/api_spec/schemas/account_relationship.ex new file mode 100644 index 0000000..68219a0 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_relationship.ex @@ -0,0 +1,48 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationship do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountRelationship", + description: "Relationship between current account and requested account", + type: :object, + properties: %{ + blocked_by: %Schema{type: :boolean}, + blocking: %Schema{type: :boolean}, + domain_blocking: %Schema{type: :boolean}, + endorsed: %Schema{type: :boolean}, + followed_by: %Schema{type: :boolean}, + following: %Schema{type: :boolean}, + id: FlakeID, + muting: %Schema{type: :boolean}, + muting_notifications: %Schema{type: :boolean}, + note: %Schema{type: :string}, + requested: %Schema{type: :boolean}, + showing_reblogs: %Schema{type: :boolean}, + subscribing: %Schema{type: :boolean}, + notifying: %Schema{type: :boolean} + }, + example: %{ + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "endorsed" => false, + "followed_by" => false, + "following" => false, + "id" => "9tKi3esbG7OQgZ2920", + "muting" => false, + "muting_notifications" => false, + "note" => "", + "requested" => false, + "showing_reblogs" => true, + "subscribing" => false, + "notifying" => false + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/actor_type.ex b/lib/pleroma/web/api_spec/schemas/actor_type.ex new file mode 100644 index 0000000..13b6b47 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/actor_type.ex @@ -0,0 +1,13 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ActorType do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ActorType", + type: :string, + enum: ["Application", "Group", "Organization", "Person", "Service"] + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/announcement.ex b/lib/pleroma/web/api_spec/schemas/announcement.ex new file mode 100644 index 0000000..67d129e --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/announcement.ex @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Announcement do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Announcement", + description: "Response schema for an announcement", + type: :object, + properties: %{ + id: FlakeID, + content: %Schema{type: :string}, + starts_at: %Schema{ + type: :string, + format: "date-time", + nullable: true + }, + ends_at: %Schema{ + type: :string, + format: "date-time", + nullable: true + }, + all_day: %Schema{type: :boolean}, + published_at: %Schema{type: :string, format: "date-time"}, + updated_at: %Schema{type: :string, format: "date-time"}, + read: %Schema{type: :boolean}, + mentions: %Schema{type: :array}, + statuses: %Schema{type: :array}, + tags: %Schema{type: :array}, + emojis: %Schema{type: :array}, + reactions: %Schema{type: :array}, + pleroma: %Schema{ + type: :object, + properties: %{ + raw_content: %Schema{type: :string} + } + } + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/api_error.ex b/lib/pleroma/web/api_spec/schemas/api_error.ex new file mode 100644 index 0000000..58a7107 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/api_error.ex @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ApiError do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ApiError", + description: "Response schema for API error", + type: :object, + properties: %{error: %Schema{type: :string}}, + example: %{ + "error" => "Something went wrong" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/app.ex b/lib/pleroma/web/api_spec/schemas/app.ex new file mode 100644 index 0000000..742413b --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/app.ex @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.App do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "App", + description: "Response schema for an app", + type: :object, + properties: %{ + id: %Schema{type: :string}, + name: %Schema{type: :string}, + client_id: %Schema{type: :string}, + client_secret: %Schema{type: :string}, + redirect_uri: %Schema{type: :string}, + vapid_key: %Schema{type: :string}, + website: %Schema{type: :string, nullable: true} + }, + example: %{ + "id" => "123", + "name" => "My App", + "client_id" => "TWhM-tNSuncnqN7DBJmoyeLnk6K3iJJ71KKXxgL1hPM", + "client_secret" => "ZEaFUFmF0umgBX1qKJDjaU99Q31lDkOU8NutzTOoliw", + "vapid_key" => + "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=", + "website" => "https://myapp.com/" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/attachment.ex b/lib/pleroma/web/api_spec/schemas/attachment.ex new file mode 100644 index 0000000..48634a1 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/attachment.ex @@ -0,0 +1,68 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Attachment do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Attachment", + description: "Represents a file or media attachment that can be added to a status.", + type: :object, + requried: [:id, :url, :preview_url], + properties: %{ + id: %Schema{type: :string, description: "The ID of the attachment in the database."}, + url: %Schema{ + type: :string, + format: :uri, + description: "The location of the original full-size attachment" + }, + remote_url: %Schema{ + type: :string, + format: :uri, + description: + "The location of the full-size original attachment on the remote website. String (URL), or null if the attachment is local", + nullable: true + }, + preview_url: %Schema{ + type: :string, + format: :uri, + description: "The location of a scaled-down preview of the attachment" + }, + text_url: %Schema{ + type: :string, + format: :uri, + description: "A shorter URL for the attachment" + }, + description: %Schema{ + type: :string, + nullable: true, + description: + "Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load" + }, + type: %Schema{ + type: :string, + enum: ["image", "video", "audio", "unknown"], + description: "The type of the attachment" + }, + pleroma: %Schema{ + type: :object, + properties: %{ + mime_type: %Schema{type: :string, description: "mime type of the attachment"} + } + } + }, + example: %{ + id: "1638338801", + type: "image", + url: "someurl", + remote_url: "someurl", + preview_url: "someurl", + text_url: "someurl", + description: nil, + pleroma: %{mime_type: "image/png"} + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/boolean_like.ex b/lib/pleroma/web/api_spec/schemas/boolean_like.ex new file mode 100644 index 0000000..14f728e --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/boolean_like.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.BooleanLike do + alias OpenApiSpex.Cast + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "BooleanLike", + description: """ + The following values will be treated as `false`: + - false + - 0 + - "0", + - "f", + - "F", + - "false", + - "FALSE", + - "off", + - "OFF" + + All other non-null values will be treated as `true` + """, + anyOf: [ + %Schema{type: :boolean}, + %Schema{type: :string}, + %Schema{type: :integer} + ], + "x-validate": __MODULE__ + }) + + def cast(%Cast{value: value} = context) do + context + |> Map.put(:value, Pleroma.Web.Utils.Params.truthy_param?(value)) + |> Cast.ok() + end +end diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex new file mode 100644 index 0000000..a07d128 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -0,0 +1,75 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Chat do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ChatMessage + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Chat", + description: "Response schema for a Chat", + type: :object, + properties: %{ + id: %Schema{type: :string}, + account: %Schema{type: :object}, + unread: %Schema{type: :integer}, + last_message: ChatMessage, + updated_at: %Schema{type: :string, format: :"date-time"} + }, + example: %{ + "account" => %{ + "pleroma" => %{ + "is_admin" => false, + "is_confirmed" => true, + "hide_followers_count" => false, + "is_moderator" => false, + "hide_favorites" => true, + "ap_id" => "https://dontbulling.me/users/lain", + "hide_follows_count" => false, + "hide_follows" => false, + "background_image" => nil, + "skip_thread_containment" => false, + "hide_followers" => false, + "relationship" => %{}, + "tags" => [] + }, + "avatar" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "following_count" => 0, + "header_static" => "https://originalpatchou.li/images/banner.png", + "source" => %{ + "sensitive" => false, + "note" => "lain", + "pleroma" => %{ + "discoverable" => false, + "actor_type" => "Person" + }, + "fields" => [] + }, + "statuses_count" => 1, + "is_locked" => false, + "created_at" => "2020-04-16T13:40:15.000Z", + "display_name" => "lain", + "fields" => [], + "acct" => "lain@dontbulling.me", + "id" => "9u6Qw6TAZANpqokMkK", + "emojis" => [], + "avatar_static" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "username" => "lain", + "followers_count" => 0, + "header" => "https://originalpatchou.li/images/banner.png", + "bot" => false, + "note" => "lain", + "url" => "https://dontbulling.me/users/lain" + }, + "id" => "1", + "unread" => 2, + "last_message" => ChatMessage.schema().example(), + "updated_at" => "2020-04-21T15:06:45.000Z" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/chat_message.ex b/lib/pleroma/web/api_spec/schemas/chat_message.ex new file mode 100644 index 0000000..57f7890 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat_message.ex @@ -0,0 +1,77 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Emoji + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ChatMessage", + description: "Response schema for a ChatMessage", + nullable: true, + type: :object, + properties: %{ + id: %Schema{type: :string}, + account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, + chat_id: %Schema{type: :string}, + content: %Schema{type: :string, nullable: true}, + created_at: %Schema{type: :string, format: :"date-time"}, + emojis: %Schema{type: :array, items: Emoji}, + attachment: %Schema{type: :object, nullable: true}, + card: %Schema{ + type: :object, + nullable: true, + description: "Preview card for links included within status content", + required: [:url, :title, :description, :type], + properties: %{ + type: %Schema{ + type: :string, + enum: ["link", "photo", "video", "rich"], + description: "The type of the preview card" + }, + provider_name: %Schema{ + type: :string, + nullable: true, + description: "The provider of the original resource" + }, + provider_url: %Schema{ + type: :string, + format: :uri, + description: "A link to the provider of the original resource" + }, + url: %Schema{type: :string, format: :uri, description: "Location of linked resource"}, + image: %Schema{ + type: :string, + nullable: true, + format: :uri, + description: "Preview thumbnail" + }, + title: %Schema{type: :string, description: "Title of linked resource"}, + description: %Schema{type: :string, description: "Description of preview"} + } + }, + unread: %Schema{type: :boolean, description: "Whether a message has been marked as read."} + }, + example: %{ + "account_id" => "someflakeid", + "chat_id" => "1", + "content" => "hey you again", + "created_at" => "2020-04-21T15:06:45.000Z", + "card" => nil, + "emojis" => [ + %{ + "static_url" => "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker" => false, + "shortcode" => "firefox", + "url" => "https://dontbulling.me/emoji/Firefox.gif" + } + ], + "id" => "14", + "attachment" => nil, + "unread" => false + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/conversation.ex b/lib/pleroma/web/api_spec/schemas/conversation.ex new file mode 100644 index 0000000..f00a973 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/conversation.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Conversation do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.Status + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Conversation", + description: "Represents a conversation with \"direct message\" visibility.", + type: :object, + required: [:id, :accounts, :unread], + properties: %{ + id: %Schema{type: :string}, + accounts: %Schema{ + type: :array, + items: Account, + description: "Participants in the conversation" + }, + unread: %Schema{ + type: :boolean, + description: "Is the conversation currently marked as unread?" + }, + # last_status: Status + last_status: %Schema{ + allOf: [Status], + description: "The last status in the conversation, to be used for optional display" + } + }, + example: %{ + "id" => "418450", + "unread" => true, + "accounts" => [Account.schema().example], + "last_status" => Status.schema().example + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/emoji.ex b/lib/pleroma/web/api_spec/schemas/emoji.ex new file mode 100644 index 0000000..936bbe1 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/emoji.ex @@ -0,0 +1,29 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Emoji do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Emoji", + description: "Response schema for an emoji", + type: :object, + properties: %{ + shortcode: %Schema{type: :string}, + url: %Schema{type: :string, format: :uri}, + static_url: %Schema{type: :string, format: :uri}, + visible_in_picker: %Schema{type: :boolean} + }, + example: %{ + "shortcode" => "fatyoshi", + "url" => + "https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png", + "static_url" => + "https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png", + "visible_in_picker" => true + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/event.ex b/lib/pleroma/web/api_spec/schemas/event.ex new file mode 100644 index 0000000..77e58ed --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/event.ex @@ -0,0 +1,112 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Event do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Event", + description: "Represents an event attached to a status", + type: :object, + properties: %{ + name: %Schema{ + type: :string, + description: "Name of the event" + }, + start_time: %Schema{ + type: :string, + format: :"date-time", + description: "Start time", + nullable: true + }, + end_time: %Schema{ + type: :string, + format: :"date-time", + description: "End time", + nullable: true + }, + join_mode: %Schema{ + type: :string, + description: "Who can join the event" + }, + participants_count: %Schema{ + type: :integer, + description: "Event participants count", + nullable: true + }, + participation_request_count: %Schema{ + type: :integer, + description: "Event participation requests count", + nullable: true + }, + location: %Schema{ + type: :object, + description: "Location where the event takes part", + properties: %{ + name: %Schema{ + type: :string, + description: "Object name", + nullable: true + }, + url: %Schema{ + type: :string, + description: "Object URL", + nullable: true + }, + longitude: %Schema{ + type: :number, + description: "Object vertical coordinate", + nullable: true + }, + latitude: %Schema{ + type: :number, + description: "Object horizontal coordinate", + nullable: true + }, + street: %Schema{ + type: :string, + description: "Object street", + nullable: true + }, + postal_code: %Schema{ + type: :string, + description: "Object postal code", + nullable: true + }, + locality: %Schema{ + type: :string, + description: "Object locality", + nullable: true + }, + region: %Schema{ + type: :string, + description: "Object region", + nullable: true + }, + country: %Schema{ + type: :string, + description: "Object country", + nullable: true + } + }, + nullable: true + }, + join_state: %Schema{ + type: :string, + description: "Have you joined the event?", + enum: ["pending", "reject", "accept"], + nullable: true + } + }, + example: %{ + name: "Example event", + start_time: "2022-02-21T22:00:00.000Z", + end_time: "2022-02-21T23:00:00.000Z", + join_mode: "free", + participants_count: 0 + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/flake_id.ex b/lib/pleroma/web/api_spec/schemas/flake_id.ex new file mode 100644 index 0000000..4c3ec01 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/flake_id.ex @@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.FlakeID do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "FlakeID", + description: + "Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings", + type: :string + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/list.ex b/lib/pleroma/web/api_spec/schemas/list.ex new file mode 100644 index 0000000..e57de79 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/list.ex @@ -0,0 +1,23 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.List do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "List", + description: "Represents a list of users", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "The internal database ID of the list"}, + title: %Schema{type: :string, description: "The user-defined title of the list"} + }, + example: %{ + "id" => "12249", + "title" => "Friends" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/location_result.ex b/lib/pleroma/web/api_spec/schemas/location_result.ex new file mode 100644 index 0000000..c5d1075 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/location_result.ex @@ -0,0 +1,80 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.LocationResult do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "LocationResult", + description: "Represents a location lookup result", + type: :object, + properties: %{ + country: %Schema{type: :string, description: "The address's country", nullable: true}, + description: %Schema{ + type: :string, + description: "The address's description", + nullable: true + }, + locality: %Schema{type: :string, description: "The address's locality", nullable: true}, + origin_id: %Schema{ + type: :string, + description: "The address's original ID from the provider", + nullable: true + }, + origin_provider: %Schema{ + type: :string, + description: "The provider used by instance", + nullable: true + }, + postal_code: %Schema{ + type: :string, + description: "The address's postal code", + nullable: true + }, + region: %Schema{type: :string, description: "The address's region", nullable: true}, + street: %Schema{ + type: :string, + description: "The address's street name (with number)", + nullable: true + }, + timezone: %Schema{ + type: :string, + description: "The (estimated) timezone of the location", + nullable: true + }, + type: %Schema{type: :string, description: "The address's type", nullable: true}, + url: %Schema{type: :string, description: "The address's URL", nullable: true}, + geom: %Schema{ + type: :object, + properties: %{ + coordinates: %Schema{ + type: :array, + items: %Schema{type: :number} + }, + srid: %Schema{type: :integer} + }, + nullable: true + } + }, + example: %{ + "country" => "Poland", + "description" => "Dworek Modrzewiowy", + "geom" => %{ + "coordinates" => [19.35267765039501, 52.233616299999994], + "srid" => 4326 + }, + "locality" => "Kutno", + "origin_id" => "251399743", + "origin_provider" => "nominatim", + "postal_code" => "80-549", + "region" => "Łódź Voivodeship", + "street" => "20 Gabriela Narutowicza", + "timezone" => nil, + "type" => "house", + "url" => nil + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/participation_request.ex b/lib/pleroma/web/api_spec/schemas/participation_request.ex new file mode 100644 index 0000000..29d86d9 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/participation_request.ex @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ParticipationRequest do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ParticipationRequest", + description: "Represents an event participation request", + type: :object, + properties: %{ + account: %Schema{ + allOf: [Account], + description: "The account that wants to participate in the event." + }, + participation_message: %Schema{ + type: :string, + description: "Why the user wants to participate" + } + }, + example: %{ + "account" => Account.schema().example, + "participation_message" => "I'm interested in this event" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/poll.ex b/lib/pleroma/web/api_spec/schemas/poll.ex new file mode 100644 index 0000000..cb2ffdc --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/poll.ex @@ -0,0 +1,94 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Poll do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Emoji + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Poll", + description: "Represents a poll attached to a status", + type: :object, + properties: %{ + id: FlakeID, + expires_at: %Schema{ + type: :string, + format: :"date-time", + nullable: true, + description: "When the poll ends" + }, + expired: %Schema{type: :boolean, description: "Is the poll currently expired?"}, + multiple: %Schema{ + type: :boolean, + description: "Does the poll allow multiple-choice answers?" + }, + votes_count: %Schema{ + type: :integer, + description: "How many votes have been received. Number." + }, + voters_count: %Schema{ + type: :integer, + description: "How many unique accounts have voted. Number." + }, + voted: %Schema{ + type: :boolean, + nullable: true, + description: + "When called with a user token, has the authorized user voted? Boolean, or null if no current user." + }, + emojis: %Schema{ + type: :array, + items: Emoji, + description: "Custom emoji to be used for rendering poll options." + }, + options: %Schema{ + type: :array, + items: %Schema{ + title: "PollOption", + type: :object, + properties: %{ + title: %Schema{type: :string}, + votes_count: %Schema{type: :integer} + } + }, + description: "Possible answers for the poll." + }, + pleroma: %Schema{ + type: :object, + properties: %{ + non_anonymous: %Schema{type: :boolean, description: "Is the voters collection public?"} + } + } + }, + example: %{ + id: "34830", + expires_at: "2019-12-05T04:05:08.302Z", + expired: true, + multiple: false, + votes_count: 10, + voters_count: 10, + voted: true, + own_votes: [ + 1 + ], + options: [ + %{ + title: "accept", + votes_count: 6 + }, + %{ + title: "deny", + votes_count: 4 + } + ], + emojis: [], + pleroma: %{ + non_anonymous: false + } + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/push_subscription.ex b/lib/pleroma/web/api_spec/schemas/push_subscription.ex new file mode 100644 index 0000000..a546663 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/push_subscription.ex @@ -0,0 +1,66 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.PushSubscription do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "PushSubscription", + description: "Response schema for a push subscription", + type: :object, + properties: %{ + id: %Schema{ + anyOf: [%Schema{type: :string}, %Schema{type: :integer}], + description: "The id of the push subscription in the database." + }, + endpoint: %Schema{type: :string, description: "Where push alerts will be sent to."}, + server_key: %Schema{type: :string, description: "The streaming server's VAPID key."}, + alerts: %Schema{ + type: :object, + description: "Which alerts should be delivered to the endpoint.", + properties: %{ + follow: %Schema{ + type: :boolean, + description: "Receive a push notification when someone has followed you?" + }, + favourite: %Schema{ + type: :boolean, + description: + "Receive a push notification when a status you created has been favourited by someone else?" + }, + reblog: %Schema{ + type: :boolean, + description: + "Receive a push notification when a status you created has been boosted by someone else?" + }, + mention: %Schema{ + type: :boolean, + description: + "Receive a push notification when someone else has mentioned you in a status?" + }, + poll: %Schema{ + type: :boolean, + description: + "Receive a push notification when a poll you voted in or created has ended? " + } + } + } + }, + example: %{ + "id" => "328_183", + "endpoint" => "https://yourdomain.example/listener", + "alerts" => %{ + "follow" => true, + "favourite" => true, + "reblog" => true, + "mention" => true, + "poll" => true + }, + "server_key" => + "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/scheduled_status.ex b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex new file mode 100644 index 0000000..a1acda1 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex @@ -0,0 +1,56 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Attachment + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + alias Pleroma.Web.ApiSpec.StatusOperation + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ScheduledStatus", + description: "Represents a status that will be published at a future scheduled date.", + type: :object, + required: [:id, :scheduled_at, :params], + properties: %{ + id: %Schema{type: :string}, + scheduled_at: %Schema{type: :string, format: :"date-time"}, + media_attachments: %Schema{type: :array, items: Attachment}, + params: %Schema{ + type: :object, + required: [:text, :visibility], + properties: %{ + text: %Schema{type: :string, nullable: true}, + media_ids: %Schema{type: :array, nullable: true, items: %Schema{type: :string}}, + sensitive: %Schema{type: :boolean, nullable: true}, + spoiler_text: %Schema{type: :string, nullable: true}, + visibility: %Schema{allOf: [VisibilityScope], nullable: true}, + scheduled_at: %Schema{type: :string, format: :"date-time", nullable: true}, + poll: StatusOperation.poll_params(), + in_reply_to_id: %Schema{type: :string, nullable: true}, + expires_in: %Schema{type: :integer, nullable: true} + } + } + }, + example: %{ + id: "3221", + scheduled_at: "2019-12-05T12:33:01.000Z", + params: %{ + text: "test content", + media_ids: nil, + sensitive: nil, + spoiler_text: nil, + visibility: nil, + scheduled_at: nil, + poll: nil, + idempotency: nil, + in_reply_to_id: nil, + expires_in: nil + }, + media_attachments: [Attachment.schema().example] + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex new file mode 100644 index 0000000..de25770 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -0,0 +1,401 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Status do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.Attachment + alias Pleroma.Web.ApiSpec.Schemas.Emoji + alias Pleroma.Web.ApiSpec.Schemas.Event + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Poll + alias Pleroma.Web.ApiSpec.Schemas.Tag + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Status", + description: "Response schema for a status", + type: :object, + properties: %{ + account: %Schema{allOf: [Account], description: "The account that authored this status"}, + application: %Schema{ + description: "The application used to post this status", + type: :object, + nullable: true, + properties: %{ + name: %Schema{type: :string}, + website: %Schema{type: :string, format: :uri} + } + }, + bookmarked: %Schema{type: :boolean, description: "Have you bookmarked this status?"}, + card: %Schema{ + type: :object, + nullable: true, + description: "Preview card for links included within status content", + required: [:url, :title, :description, :type], + properties: %{ + type: %Schema{ + type: :string, + enum: ["link", "photo", "video", "rich"], + description: "The type of the preview card" + }, + provider_name: %Schema{ + type: :string, + nullable: true, + description: "The provider of the original resource" + }, + provider_url: %Schema{ + type: :string, + format: :uri, + description: "A link to the provider of the original resource" + }, + url: %Schema{type: :string, format: :uri, description: "Location of linked resource"}, + image: %Schema{ + type: :string, + nullable: true, + format: :uri, + description: "Preview thumbnail" + }, + title: %Schema{type: :string, description: "Title of linked resource"}, + description: %Schema{type: :string, description: "Description of preview"} + } + }, + content: %Schema{type: :string, format: :html, description: "HTML-encoded status content"}, + text: %Schema{ + type: :string, + description: "Original unformatted content in plain text", + nullable: true + }, + created_at: %Schema{ + type: :string, + format: "date-time", + description: "The date when this status was created" + }, + edited_at: %Schema{ + type: :string, + format: "date-time", + nullable: true, + description: "The date when this status was last edited" + }, + emojis: %Schema{ + type: :array, + items: Emoji, + description: "Custom emoji to be used when rendering status content" + }, + favourited: %Schema{type: :boolean, description: "Have you favourited this status?"}, + favourites_count: %Schema{ + type: :integer, + description: "How many favourites this status has received" + }, + id: FlakeID, + in_reply_to_account_id: %Schema{ + allOf: [FlakeID], + nullable: true, + description: "ID of the account being replied to" + }, + in_reply_to_id: %Schema{ + allOf: [FlakeID], + nullable: true, + description: "ID of the status being replied" + }, + language: %Schema{ + type: :string, + nullable: true, + description: "Primary language of this status" + }, + media_attachments: %Schema{ + type: :array, + items: Attachment, + description: "Media that is attached to this status" + }, + mentions: %Schema{ + type: :array, + description: "Mentions of users within the status content", + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{allOf: [FlakeID], description: "The account id of the mentioned user"}, + acct: %Schema{ + type: :string, + description: + "The webfinger acct: URI of the mentioned user. Equivalent to `username` for local users, or `username@domain` for remote users." + }, + username: %Schema{type: :string, description: "The username of the mentioned user"}, + url: %Schema{ + type: :string, + format: :uri, + description: "The location of the mentioned user's profile" + } + } + } + }, + muted: %Schema{ + type: :boolean, + description: "Have you muted notifications for this status's conversation?" + }, + pinned: %Schema{ + type: :boolean, + description: "Have you pinned this status? Only appears if the status is pinnable." + }, + pleroma: %Schema{ + type: :object, + properties: %{ + content: %Schema{ + type: :object, + additionalProperties: %Schema{ + type: :string, + description: "Alternate representation in the MIME type specified", + extensions: %{"x-additionalPropertiesName": "MIME type"} + }, + description: + "A map consisting of alternate representations of the `content` property with the key being it's mimetype. Currently the only alternate representation supported is `text/plain`" + }, + content_type: %Schema{ + type: :string, + nullable: true, + description: "A MIME type of the status" + }, + context: %Schema{ + type: :string, + description: "The thread identifier the status is associated with" + }, + conversation_id: %Schema{ + type: :integer, + deprecated: true, + description: + "The ID of the AP context the status is associated with (if any); deprecated, please use `context` instead" + }, + direct_conversation_id: %Schema{ + type: :integer, + nullable: true, + description: + "The ID of the Mastodon direct message conversation the status is associated with (if any)" + }, + emoji_reactions: %Schema{ + type: :array, + description: + "A list with emoji / reaction maps. Contains no information about the reacting users, for that use the /statuses/:id/reactions endpoint.", + items: %Schema{ + type: :object, + properties: %{ + name: %Schema{type: :string}, + count: %Schema{type: :integer}, + me: %Schema{type: :boolean} + } + } + }, + event: %Schema{ + allOf: [Event], + nullable: true, + description: "The event attached to the status" + }, + expires_at: %Schema{ + type: :string, + format: "date-time", + nullable: true, + description: + "A datetime (ISO 8601) that states when the post will expire (be deleted automatically), or empty if the post won't expire" + }, + in_reply_to_account_acct: %Schema{ + type: :string, + nullable: true, + description: "The `acct` property of User entity for replied user (if any)" + }, + quote: %Schema{ + allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], + nullable: true, + description: "Quoted status (if any)" + }, + quote_id: %Schema{ + nullable: true, + allOf: [FlakeID], + description: "ID of the status being quoted, if any" + }, + quote_url: %Schema{ + type: :string, + format: :uri, + nullable: true, + description: "URL of the quoted status" + }, + quote_visible: %Schema{ + type: :boolean, + description: "`true` if the quoted post is visible to the user" + }, + quotes_count: %Schema{ + type: :integer, + description: "How many statuses quoted this status" + }, + local: %Schema{ + type: :boolean, + description: "`true` if the post was made on the local instance" + }, + spoiler_text: %Schema{ + type: :object, + additionalProperties: %Schema{ + type: :string, + description: "Alternate representation in the MIME type specified", + extensions: %{"x-additionalPropertiesName": "MIME type"} + }, + description: + "A map consisting of alternate representations of the `spoiler_text` property with the key being it's mimetype. Currently the only alternate representation supported is `text/plain`." + }, + thread_muted: %Schema{ + type: :boolean, + description: "`true` if the thread the post belongs to is muted" + }, + parent_visible: %Schema{ + type: :boolean, + description: "`true` if the parent post is visible to the user" + }, + pinned_at: %Schema{ + type: :string, + format: "date-time", + nullable: true, + description: + "A datetime (ISO 8601) that states when the post was pinned or `null` if the post is not pinned" + } + } + }, + poll: %Schema{allOf: [Poll], nullable: true, description: "The poll attached to the status"}, + reblog: %Schema{ + allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], + nullable: true, + description: "The status being reblogged" + }, + reblogged: %Schema{type: :boolean, description: "Have you boosted this status?"}, + reblogs_count: %Schema{ + type: :integer, + description: "How many boosts this status has received" + }, + replies_count: %Schema{ + type: :integer, + description: "How many replies this status has received" + }, + sensitive: %Schema{ + type: :boolean, + description: "Is this status marked as sensitive content?" + }, + spoiler_text: %Schema{ + type: :string, + description: + "Subject or summary line, below which status content is collapsed until expanded" + }, + tags: %Schema{type: :array, items: Tag}, + uri: %Schema{ + type: :string, + format: :uri, + description: "URI of the status used for federation" + }, + url: %Schema{ + type: :string, + nullable: true, + format: :uri, + description: "A link to the status's HTML representation" + }, + visibility: %Schema{ + allOf: [VisibilityScope], + description: "Visibility of this status" + } + }, + example: %{ + "account" => %{ + "acct" => "nick6", + "avatar" => "http://localhost:4001/images/avi.png", + "avatar_static" => "http://localhost:4001/images/avi.png", + "bot" => false, + "created_at" => "2020-04-07T19:48:51.000Z", + "display_name" => "Test テスト User 6", + "emojis" => [], + "fields" => [], + "followers_count" => 1, + "following_count" => 0, + "header" => "http://localhost:4001/images/banner.png", + "header_static" => "http://localhost:4001/images/banner.png", + "id" => "9toJCsKN7SmSf3aj5c", + "is_locked" => false, + "note" => "Tester Number 6", + "pleroma" => %{ + "background_image" => nil, + "is_confirmed" => true, + "hide_favorites" => true, + "hide_followers" => false, + "hide_followers_count" => false, + "hide_follows" => false, + "hide_follows_count" => false, + "is_admin" => false, + "is_moderator" => false, + "relationship" => %{ + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "endorsed" => false, + "followed_by" => false, + "following" => true, + "id" => "9toJCsKN7SmSf3aj5c", + "muting" => false, + "muting_notifications" => false, + "note" => "", + "requested" => false, + "showing_reblogs" => true, + "subscribing" => false, + "notifying" => false + }, + "skip_thread_containment" => false, + "tags" => [] + }, + "source" => %{ + "fields" => [], + "note" => "Tester Number 6", + "pleroma" => %{"actor_type" => "Person", "discoverable" => false}, + "sensitive" => false + }, + "statuses_count" => 1, + "url" => "http://localhost:4001/users/nick6", + "username" => "nick6" + }, + "application" => nil, + "bookmarked" => false, + "card" => nil, + "content" => "foobar", + "created_at" => "2020-04-07T19:48:51.000Z", + "emojis" => [], + "favourited" => false, + "favourites_count" => 0, + "id" => "9toJCu5YZW7O7gfvH6", + "in_reply_to_account_id" => nil, + "in_reply_to_id" => nil, + "language" => nil, + "media_attachments" => [], + "mentions" => [], + "muted" => false, + "pinned" => false, + "pleroma" => %{ + "content" => %{"text/plain" => "foobar"}, + "context" => "http://localhost:4001/objects/8b4c0c80-6a37-4d2a-b1b9-05a19e3875aa", + "conversation_id" => 345_972, + "direct_conversation_id" => nil, + "emoji_reactions" => [], + "expires_at" => nil, + "in_reply_to_account_acct" => nil, + "local" => true, + "spoiler_text" => %{"text/plain" => ""}, + "thread_muted" => false, + "quotes_count" => 0 + }, + "poll" => nil, + "reblog" => nil, + "reblogged" => false, + "reblogs_count" => 0, + "replies_count" => 0, + "sensitive" => false, + "spoiler_text" => "", + "tags" => [], + "uri" => "http://localhost:4001/objects/0f5dad44-0e9e-4610-b377-a2631e499190", + "url" => "http://localhost:4001/notice/9toJCu5YZW7O7gfvH6", + "visibility" => "private" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/tag.ex b/lib/pleroma/web/api_spec/schemas/tag.ex new file mode 100644 index 0000000..66bf0ca --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/tag.ex @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Tag do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Tag", + description: "Represents a hashtag used within the content of a status", + type: :object, + properties: %{ + name: %Schema{type: :string, description: "The value of the hashtag after the # sign"}, + url: %Schema{ + type: :string, + format: :uri, + description: "A link to the hashtag on the instance" + } + }, + example: %{ + name: "cofe", + url: "https://lain.com/tag/cofe" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/visibility_scope.ex b/lib/pleroma/web/api_spec/schemas/visibility_scope.ex new file mode 100644 index 0000000..ecd247b --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/visibility_scope.ex @@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.VisibilityScope do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "VisibilityScope", + description: "Status visibility", + type: :string, + enum: ["public", "unlisted", "local", "private", "direct", "list"] + }) +end diff --git a/lib/pleroma/web/api_spec/scopes/compiler.ex b/lib/pleroma/web/api_spec/scopes/compiler.ex new file mode 100644 index 0000000..162edc9 --- /dev/null +++ b/lib/pleroma/web/api_spec/scopes/compiler.ex @@ -0,0 +1,82 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Scopes.Compiler do + defmacro __before_compile__(_env) do + strings = __MODULE__.extract_all_scopes() + + quote do + def placeholder do + unquote do + Enum.map( + strings, + fn string -> + quote do + Pleroma.Web.Gettext.dgettext_noop( + "oauth_scopes", + unquote(string) + ) + end + end + ) + end + end + end + end + + def extract_all_scopes do + extract_all_scopes_from(Pleroma.Web.ApiSpec.spec()) + end + + def extract_all_scopes_from(specs) do + specs.paths + |> Enum.reduce([], fn + {_path, %{} = path_item}, acc -> + extract_routes(path_item) + |> Enum.flat_map(fn operation -> process_operation(operation) end) + |> Kernel.++(acc) + + {_, _}, acc -> + acc + end) + |> Enum.uniq() + end + + defp extract_routes(path_item) do + path_item + |> Map.from_struct() + |> Enum.map(fn {_method, path_item} -> path_item end) + |> Enum.filter(fn + %OpenApiSpex.Operation{} = _operation -> true + _ -> false + end) + end + + defp process_operation(operation) do + operation.security + |> Kernel.||([]) + |> Enum.flat_map(fn + %{"oAuth" => scopes} -> process_scopes(scopes) + _ -> [] + end) + end + + defp process_scopes(scopes) do + scopes + |> Enum.flat_map(fn scope -> + process_scope(scope) + end) + end + + def process_scope(scope) do + hierarchy = String.split(scope, ":") + + {_, list} = + Enum.reduce(hierarchy, {"", []}, fn comp, {cur, list} -> + {cur <> comp <> ":", [cur <> comp | list]} + end) + + list + end +end diff --git a/lib/pleroma/web/api_spec/scopes/translator.ex b/lib/pleroma/web/api_spec/scopes/translator.ex new file mode 100644 index 0000000..54eea35 --- /dev/null +++ b/lib/pleroma/web/api_spec/scopes/translator.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Scopes.Translator do + require Pleroma.Web.ApiSpec.Scopes.Compiler + require Pleroma.Web.Gettext + + @before_compile Pleroma.Web.ApiSpec.Scopes.Compiler +end diff --git a/lib/pleroma/web/auth/authenticator.ex b/lib/pleroma/web/auth/authenticator.ex new file mode 100644 index 0000000..a0bd154 --- /dev/null +++ b/lib/pleroma/web/auth/authenticator.ex @@ -0,0 +1,13 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.Authenticator do + @callback get_user(Plug.Conn.t()) :: {:ok, user :: struct()} | {:error, any()} + @callback create_from_registration(Plug.Conn.t(), registration :: struct()) :: + {:ok, User.t()} | {:error, any()} + @callback get_registration(Plug.Conn.t()) :: {:ok, registration :: struct()} | {:error, any()} + @callback handle_error(Plug.Conn.t(), any()) :: any() + @callback auth_template() :: String.t() | nil + @callback oauth_consumer_template() :: String.t() | nil +end diff --git a/lib/pleroma/web/auth/helpers.ex b/lib/pleroma/web/auth/helpers.ex new file mode 100644 index 0000000..02e1f39 --- /dev/null +++ b/lib/pleroma/web/auth/helpers.ex @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.Helpers do + alias Pleroma.User + + @doc "Gets user by nickname or email for auth." + @spec fetch_user(String.t()) :: User.t() | nil + def fetch_user(name) do + User.get_by_nickname_or_email(name) + end + + # Gets name and password from conn + # + @spec fetch_credentials(Plug.Conn.t() | map()) :: + {:ok, {name :: any, password :: any}} | {:error, :invalid_credentials} + def fetch_credentials(%Plug.Conn{params: params} = _), + do: fetch_credentials(params) + + def fetch_credentials(params) do + case params do + %{"authorization" => %{"name" => name, "password" => password}} -> + {:ok, {name, password}} + + %{"grant_type" => "password", "username" => name, "password" => password} -> + {:ok, {name, password}} + + _ -> + {:error, :invalid_credentials} + end + end +end diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex new file mode 100644 index 0000000..e8cd449 --- /dev/null +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -0,0 +1,129 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.LDAPAuthenticator do + alias Pleroma.User + + require Logger + + import Pleroma.Web.Auth.Helpers, only: [fetch_credentials: 1, fetch_user: 1] + + @behaviour Pleroma.Web.Auth.Authenticator + @base Pleroma.Web.Auth.PleromaAuthenticator + + @connection_timeout 10_000 + @search_timeout 10_000 + + defdelegate get_registration(conn), to: @base + defdelegate create_from_registration(conn, registration), to: @base + defdelegate handle_error(conn, error), to: @base + defdelegate auth_template, to: @base + defdelegate oauth_consumer_template, to: @base + + def get_user(%Plug.Conn{} = conn) do + with {:ldap, true} <- {:ldap, Pleroma.Config.get([:ldap, :enabled])}, + {:ok, {name, password}} <- fetch_credentials(conn), + %User{} = user <- ldap_user(name, password) do + {:ok, user} + else + {:ldap, _} -> + @base.get_user(conn) + + error -> + error + end + end + + defp ldap_user(name, password) do + ldap = Pleroma.Config.get(:ldap, []) + host = Keyword.get(ldap, :host, "localhost") + port = Keyword.get(ldap, :port, 389) + ssl = Keyword.get(ldap, :ssl, false) + sslopts = Keyword.get(ldap, :sslopts, []) + + options = + [{:port, port}, {:ssl, ssl}, {:timeout, @connection_timeout}] ++ + if sslopts != [], do: [{:sslopts, sslopts}], else: [] + + case :eldap.open([to_charlist(host)], options) do + {:ok, connection} -> + try do + if Keyword.get(ldap, :tls, false) do + :application.ensure_all_started(:ssl) + + case :eldap.start_tls( + connection, + Keyword.get(ldap, :tlsopts, []), + @connection_timeout + ) do + :ok -> + :ok + + error -> + Logger.error("Could not start TLS: #{inspect(error)}") + end + end + + bind_user(connection, ldap, name, password) + after + :eldap.close(connection) + end + + {:error, error} -> + Logger.error("Could not open LDAP connection: #{inspect(error)}") + {:error, {:ldap_connection_error, error}} + end + end + + defp bind_user(connection, ldap, name, password) do + uid = Keyword.get(ldap, :uid, "cn") + base = Keyword.get(ldap, :base) + + case :eldap.simple_bind(connection, "#{uid}=#{name},#{base}", password) do + :ok -> + case fetch_user(name) do + %User{} = user -> + user + + _ -> + register_user(connection, base, uid, name) + end + + error -> + error + end + end + + defp register_user(connection, base, uid, name) do + case :eldap.search(connection, [ + {:base, to_charlist(base)}, + {:filter, :eldap.equalityMatch(to_charlist(uid), to_charlist(name))}, + {:scope, :eldap.wholeSubtree()}, + {:timeout, @search_timeout} + ]) do + {:ok, {:eldap_search_result, [{:eldap_entry, _, attributes}], _}} -> + params = %{ + name: name, + nickname: name, + password: nil + } + + params = + case List.keyfind(attributes, 'mail', 0) do + {_, [mail]} -> Map.put_new(params, :email, :erlang.list_to_binary(mail)) + _ -> params + end + + changeset = User.register_changeset_ldap(%User{}, params) + + case User.register(changeset) do + {:ok, user} -> user + error -> error + end + + error -> + error + end + end +end diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex new file mode 100644 index 0000000..09a58eb --- /dev/null +++ b/lib/pleroma/web/auth/pleroma_authenticator.ex @@ -0,0 +1,104 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.PleromaAuthenticator do + alias Pleroma.Registration + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.Plugs.AuthenticationPlug + + import Pleroma.Web.Auth.Helpers, only: [fetch_credentials: 1, fetch_user: 1] + + @behaviour Pleroma.Web.Auth.Authenticator + + def get_user(%Plug.Conn{} = conn) do + with {:ok, {name, password}} <- fetch_credentials(conn), + {_, %User{} = user} <- {:user, fetch_user(name)}, + {_, true} <- {:checkpw, AuthenticationPlug.checkpw(password, user.password_hash)}, + {:ok, user} <- AuthenticationPlug.maybe_update_password(user, password) do + {:ok, user} + else + {:error, _reason} = error -> error + error -> {:error, error} + end + end + + @doc """ + Gets or creates Pleroma.Registration record from Ueberauth assigns. + Note: some strategies (like `keycloak`) might need extra configuration to fill `uid` from callback response — + see [`docs/config.md`](docs/config.md). + """ + def get_registration(%Plug.Conn{assigns: %{ueberauth_auth: %{uid: nil}}}), + do: {:error, :missing_uid} + + def get_registration(%Plug.Conn{ + assigns: %{ueberauth_auth: %{provider: provider, uid: uid} = auth} + }) do + registration = Registration.get_by_provider_uid(provider, uid) + + if registration do + {:ok, registration} + else + info = auth.info + + %Registration{} + |> Registration.changeset(%{ + provider: to_string(provider), + uid: to_string(uid), + info: %{ + "nickname" => info.nickname, + "email" => info.email, + "name" => info.name, + "description" => info.description + } + }) + |> Repo.insert() + end + end + + def get_registration(%Plug.Conn{} = _conn), do: {:error, :missing_credentials} + + @doc "Creates Pleroma.User record basing on params and Pleroma.Registration record." + def create_from_registration( + %Plug.Conn{params: %{"authorization" => registration_attrs}}, + %Registration{} = registration + ) do + nickname = value([registration_attrs["nickname"], Registration.nickname(registration)]) + email = value([registration_attrs["email"], Registration.email(registration)]) + name = value([registration_attrs["name"], Registration.name(registration)]) || nickname + bio = value([registration_attrs["bio"], Registration.description(registration)]) || "" + + random_password = :crypto.strong_rand_bytes(64) |> Base.encode64() + + with {:ok, new_user} <- + User.register_changeset( + %User{}, + %{ + email: email, + nickname: nickname, + name: name, + bio: bio, + password: random_password, + password_confirmation: random_password + }, + external: true, + confirmed: true + ) + |> Repo.insert(), + {:ok, _} <- + Registration.changeset(registration, %{user_id: new_user.id}) |> Repo.update() do + {:ok, new_user} + end + end + + defp value(list), do: Enum.find(list, &(to_string(&1) != "")) + + def handle_error(%Plug.Conn{} = _conn, error) do + error + end + + def auth_template, do: nil + + def oauth_consumer_template, do: nil +end diff --git a/lib/pleroma/web/auth/totp_authenticator.ex b/lib/pleroma/web/auth/totp_authenticator.ex new file mode 100644 index 0000000..4be3641 --- /dev/null +++ b/lib/pleroma/web/auth/totp_authenticator.ex @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.TOTPAuthenticator do + alias Pleroma.MFA + alias Pleroma.MFA.TOTP + alias Pleroma.User + alias Pleroma.Web.Plugs.AuthenticationPlug + + @doc "Verify code or check backup code." + @spec verify(String.t(), User.t()) :: + {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token} + def verify( + token, + %User{ + multi_factor_authentication_settings: + %{enabled: true, totp: %{secret: secret, confirmed: true}} = _ + } = _user + ) + when is_binary(token) and byte_size(token) > 0 do + TOTP.validate_token(secret, token) + end + + def verify(_, _), do: {:error, :invalid_token} + + @spec verify_recovery_code(User.t(), String.t()) :: + {:ok, :pass} | {:error, :invalid_token} + def verify_recovery_code( + %User{multi_factor_authentication_settings: %{enabled: true, backup_codes: codes}} = user, + code + ) + when is_list(codes) and is_binary(code) do + hash_code = Enum.find(codes, fn hash -> AuthenticationPlug.checkpw(code, hash) end) + + if hash_code do + MFA.invalidate_backup_code(user, hash_code) + {:ok, :pass} + else + {:error, :invalid_token} + end + end + + def verify_recovery_code(_, _), do: {:error, :invalid_token} +end diff --git a/lib/pleroma/web/auth/wrapper_authenticator.ex b/lib/pleroma/web/auth/wrapper_authenticator.ex new file mode 100644 index 0000000..a077cfa --- /dev/null +++ b/lib/pleroma/web/auth/wrapper_authenticator.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.WrapperAuthenticator do + @behaviour Pleroma.Web.Auth.Authenticator + + defp implementation do + Pleroma.Config.get( + Pleroma.Web.Auth.Authenticator, + Pleroma.Web.Auth.PleromaAuthenticator + ) + end + + @impl true + def get_user(plug), do: implementation().get_user(plug) + + @impl true + def create_from_registration(plug, registration), + do: implementation().create_from_registration(plug, registration) + + @impl true + def get_registration(plug), do: implementation().get_registration(plug) + + @impl true + def handle_error(plug, error), + do: implementation().handle_error(plug, error) + + @impl true + def auth_template do + # Note: `config :pleroma, :auth_template, "..."` support is deprecated + implementation().auth_template() || + Pleroma.Config.get([:auth, :auth_template], Pleroma.Config.get(:auth_template)) || + "show.html" + end + + @impl true + def oauth_consumer_template do + implementation().oauth_consumer_template() || + Pleroma.Config.get([:auth, :oauth_consumer_template], "consumer.html") + end +end diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex new file mode 100644 index 0000000..cacd952 --- /dev/null +++ b/lib/pleroma/web/common_api.ex @@ -0,0 +1,813 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.CommonAPI do + alias Pleroma.Activity + alias Pleroma.Conversation.Participation + alias Pleroma.Formatter + alias Pleroma.ModerationLog + alias Pleroma.Object + alias Pleroma.Rule + alias Pleroma.ThreadMute + alias Pleroma.User + alias Pleroma.UserRelationship + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI.ActivityDraft + + import Pleroma.Web.Gettext + import Pleroma.Web.CommonAPI.Utils + + require Pleroma.Constants + require Logger + + def block(blocker, blocked) do + with {:ok, block_data, _} <- Builder.block(blocker, blocked), + {:ok, block, _} <- Pipeline.common_pipeline(block_data, local: true) do + {:ok, block} + end + end + + def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) do + with maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]), + :ok <- validate_chat_attachment_attribution(maybe_attachment, user), + :ok <- validate_chat_content_length(content, !!maybe_attachment), + {_, {:ok, chat_message_data, _meta}} <- + {:build_object, + Builder.chat_message( + user, + recipient.ap_id, + content |> format_chat_content, + attachment: maybe_attachment + )}, + {_, {:ok, create_activity_data, _meta}} <- + {:build_create_activity, Builder.create(user, chat_message_data, [recipient.ap_id])}, + {_, {:ok, %Activity{} = activity, _meta}} <- + {:common_pipeline, + Pipeline.common_pipeline(create_activity_data, + local: true, + idempotency_key: opts[:idempotency_key] + )} do + {:ok, activity} + else + {:common_pipeline, {:reject, _} = e} -> e + e -> e + end + end + + defp format_chat_content(nil), do: nil + + defp format_chat_content(content) do + {text, _, _} = + content + |> Formatter.html_escape("text/plain") + |> Formatter.linkify() + |> (fn {text, mentions, tags} -> + {String.replace(text, ~r/\r?\n/, "
    "), mentions, tags} + end).() + + text + end + + defp validate_chat_attachment_attribution(nil, _), do: :ok + + defp validate_chat_attachment_attribution(attachment, user) do + with :ok <- Object.authorize_access(attachment, user) do + :ok + else + e -> + e + end + end + + defp validate_chat_content_length(_, true), do: :ok + defp validate_chat_content_length(nil, false), do: {:error, :no_content} + + defp validate_chat_content_length(content, _) do + if String.length(content) <= Pleroma.Config.get([:instance, :chat_limit]) do + :ok + else + {:error, :content_too_long} + end + end + + def unblock(blocker, blocked) do + with {_, %Activity{} = block} <- {:fetch_block, Utils.fetch_latest_block(blocker, blocked)}, + {:ok, unblock_data, _} <- Builder.undo(blocker, block), + {:ok, unblock, _} <- Pipeline.common_pipeline(unblock_data, local: true) do + {:ok, unblock} + else + {:fetch_block, nil} -> + if User.blocks?(blocker, blocked) do + User.unblock(blocker, blocked) + {:ok, :no_activity} + else + {:error, :not_blocking} + end + + e -> + e + end + end + + def follow(follower, followed) do + timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout]) + + with {:ok, follow_data, _} <- Builder.follow(follower, followed), + {:ok, activity, _} <- Pipeline.common_pipeline(follow_data, local: true), + {:ok, follower, followed} <- User.wait_and_refresh(timeout, follower, followed) do + if activity.data["state"] == "reject" do + {:error, :rejected} + else + {:ok, follower, followed, activity} + end + end + end + + def unfollow(follower, unfollowed) do + with {:ok, follower, _follow_activity} <- User.unfollow(follower, unfollowed), + {:ok, _activity} <- ActivityPub.unfollow(follower, unfollowed), + {:ok, _subscription} <- User.unsubscribe(follower, unfollowed), + {:ok, _endorsement} <- User.unendorse(follower, unfollowed) do + {:ok, follower} + end + end + + def accept_follow_request(follower, followed) do + with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), + {:ok, accept_data, _} <- Builder.accept(followed, follow_activity), + {:ok, _activity, _} <- Pipeline.common_pipeline(accept_data, local: true) do + {:ok, follower} + end + end + + def reject_follow_request(follower, followed) do + with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), + {:ok, reject_data, _} <- Builder.reject(followed, follow_activity), + {:ok, _activity, _} <- Pipeline.common_pipeline(reject_data, local: true) do + {:ok, follower} + end + end + + def delete(activity_id, user) do + with {_, %Activity{data: %{"object" => _, "type" => "Create"}} = activity} <- + {:find_activity, Activity.get_by_id(activity_id, filter: [])}, + {_, %Object{} = object, _} <- + {:find_object, Object.normalize(activity, fetch: false), activity}, + true <- User.privileged?(user, :messages_delete) || user.ap_id == object.data["actor"], + {:ok, delete_data, _} <- Builder.delete(user, object.data["id"]), + {:ok, delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do + if User.privileged?(user, :messages_delete) and user.ap_id != object.data["actor"] do + action = + if object.data["type"] == "ChatMessage" do + "chat_message_delete" + else + "status_delete" + end + + ModerationLog.insert_log(%{ + action: action, + actor: user, + subject_id: activity_id + }) + end + + {:ok, delete} + else + {:find_activity, _} -> + {:error, :not_found} + + {:find_object, nil, %Activity{data: %{"actor" => actor, "object" => object}}} -> + # We have the create activity, but not the object, it was probably pruned. + # Insert a tombstone and try again + with {:ok, tombstone_data, _} <- Builder.tombstone(actor, object), + {:ok, _tombstone} <- Object.create(tombstone_data) do + delete(activity_id, user) + else + _ -> + Logger.error( + "Could not insert tombstone for missing object on deletion. Object is #{object}." + ) + + {:error, dgettext("errors", "Could not delete")} + end + + _ -> + {:error, dgettext("errors", "Could not delete")} + end + end + + def repeat(id, user, params \\ %{}) do + with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id), + object = %Object{} <- Object.normalize(activity, fetch: false), + {_, nil} <- {:existing_announce, Utils.get_existing_announce(user.ap_id, object)}, + public = public_announce?(object, params), + {:ok, announce, _} <- Builder.announce(user, object, public: public), + {:ok, activity, _} <- Pipeline.common_pipeline(announce, local: true) do + {:ok, activity} + else + {:existing_announce, %Activity{} = announce} -> + {:ok, announce} + + _ -> + {:error, :not_found} + end + end + + def unrepeat(id, user) do + with {_, %Activity{data: %{"type" => "Create"}} = activity} <- + {:find_activity, Activity.get_by_id(id)}, + %Object{} = note <- Object.normalize(activity, fetch: false), + %Activity{} = announce <- Utils.get_existing_announce(user.ap_id, note), + {:ok, undo, _} <- Builder.undo(user, announce), + {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do + {:ok, activity} + else + {:find_activity, _} -> {:error, :not_found} + _ -> {:error, dgettext("errors", "Could not unrepeat")} + end + end + + @spec favorite(User.t(), binary()) :: {:ok, Activity.t() | :already_liked} | {:error, any()} + def favorite(%User{} = user, id) do + case favorite_helper(user, id) do + {:ok, _} = res -> + res + + {:error, :not_found} = res -> + res + + {:error, e} -> + Logger.error("Could not favorite #{id}. Error: #{inspect(e, pretty: true)}") + {:error, dgettext("errors", "Could not favorite")} + end + end + + def favorite_helper(user, id) do + with {_, %Activity{object: object}} <- {:find_object, Activity.get_by_id_with_object(id)}, + {_, {:ok, like_object, meta}} <- {:build_object, Builder.like(user, object)}, + {_, {:ok, %Activity{} = activity, _meta}} <- + {:common_pipeline, + Pipeline.common_pipeline(like_object, Keyword.put(meta, :local, true))} do + {:ok, activity} + else + {:find_object, _} -> + {:error, :not_found} + + {:common_pipeline, {:error, {:validate, {:error, changeset}}}} = e -> + if {:object, {"already liked by this actor", []}} in changeset.errors do + {:ok, :already_liked} + else + {:error, e} + end + + e -> + {:error, e} + end + end + + def unfavorite(id, user) do + with {_, %Activity{data: %{"type" => "Create"}} = activity} <- + {:find_activity, Activity.get_by_id(id)}, + %Object{} = note <- Object.normalize(activity, fetch: false), + %Activity{} = like <- Utils.get_existing_like(user.ap_id, note), + {:ok, undo, _} <- Builder.undo(user, like), + {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do + {:ok, activity} + else + {:find_activity, _} -> {:error, :not_found} + _ -> {:error, dgettext("errors", "Could not unfavorite")} + end + end + + def react_with_emoji(id, user, emoji) do + with %Activity{} = activity <- Activity.get_by_id(id), + object <- Object.normalize(activity, fetch: false), + {:ok, emoji_react, _} <- Builder.emoji_react(user, object, emoji), + {:ok, activity, _} <- Pipeline.common_pipeline(emoji_react, local: true) do + {:ok, activity} + else + _ -> + {:error, dgettext("errors", "Could not add reaction emoji")} + end + end + + def unreact_with_emoji(id, user, emoji) do + with %Activity{} = reaction_activity <- Utils.get_latest_reaction(id, user, emoji), + {:ok, undo, _} <- Builder.undo(user, reaction_activity), + {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do + {:ok, activity} + else + _ -> + {:error, dgettext("errors", "Could not remove reaction emoji")} + end + end + + def vote(user, %{data: %{"type" => "Question"}} = object, choices) do + with :ok <- validate_not_author(object, user), + :ok <- validate_existing_votes(user, object), + {:ok, options, choices} <- normalize_and_validate_choices(choices, object) do + answer_activities = + Enum.map(choices, fn index -> + {:ok, answer_object, _meta} = + Builder.answer(user, object, Enum.at(options, index)["name"]) + + {:ok, activity_data, _meta} = Builder.create(user, answer_object, []) + + {:ok, activity, _meta} = + activity_data + |> Map.put("cc", answer_object["cc"]) + |> Map.put("context", answer_object["context"]) + |> Pipeline.common_pipeline(local: true) + + # TODO: Do preload of Pleroma.Object in Pipeline + Activity.normalize(activity.data) + end) + + object = Object.get_cached_by_ap_id(object.data["id"]) + {:ok, answer_activities, object} + end + end + + def join(%User{} = user, event_id, params \\ %{}) do + participation_message = Map.get(params, :participation_message) + + case join_helper(user, event_id, participation_message) do + {:ok, _} = res -> + res + + {:error, :not_found} = res -> + res + + {:error, e} -> + Logger.error("Could not join #{event_id}. Error: #{inspect(e, pretty: true)}") + {:error, dgettext("errors", "Could not join")} + end + end + + defp join_helper(user, id, participation_message) do + with {_, %Activity{object: object}} <- {:find_object, Activity.get_by_id_with_object(id)}, + {_, {:ok, join_object, meta}} <- + {:build_object, Builder.join(user, object, participation_message)}, + {_, {:ok, %Activity{} = activity, _meta}} <- + {:common_pipeline, + Pipeline.common_pipeline(join_object, Keyword.put(meta, :local, true))} do + {:ok, activity} + else + {:find_object, _} -> + {:error, :not_found} + + {:common_pipeline, {:error, {:validate, {:error, changeset}}}} = e -> + if {:object, {"already joined by this actor", []}} in changeset.errors do + {:ok, :already_joined} + else + {:error, e} + end + + e -> + {:error, e} + end + end + + def leave(%User{ap_id: participant_ap_id} = user, event_id) do + with %Activity{data: %{"object" => event_ap_id}} <- Activity.get_by_id(event_id), + %Activity{} = join_activity <- Utils.get_existing_join(participant_ap_id, event_ap_id), + {:ok, undo, _} <- Builder.undo(user, join_activity), + {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do + {:ok, activity} + else + nil -> + {:error, dgettext("errors", "Not participating in the event")} + + _ -> + {:error, dgettext("errors", "Could not remove join activity")} + end + end + + def accept_join_request(%User{} = user, %User{ap_id: participant_ap_id} = participant, event_id) do + with %Activity{} = join_activity <- Utils.get_existing_join(participant_ap_id, event_id), + {:ok, accept_data, _} <- Builder.accept(user, join_activity), + {:ok, _activity, _} <- Pipeline.common_pipeline(accept_data, local: true), + event <- Object.get_by_ap_id(event_id) do + if Object.local?(event) and event.data["joinMode"] != "free" and + join_activity.data["actor"] == event.data["actor"] do + Utils.update_participation_request_count_in_object(event) + end + + {:ok, participant} + end + end + + def reject_join_request(%User{} = user, %User{ap_id: participant_ap_id} = participant, event_id) do + with %Activity{} = join_activity <- Utils.get_existing_join(participant_ap_id, event_id), + {:ok, reject_data, _} <- Builder.reject(user, join_activity), + {:ok, _activity, _} <- Pipeline.common_pipeline(reject_data, local: true), + event <- Object.get_by_ap_id(event_id), + {:ok, _} <- Utils.update_participation_request_count_in_object(event) do + {:ok, participant} + end + end + + defp validate_not_author(%{data: %{"actor" => ap_id}}, %{ap_id: ap_id}), + do: {:error, dgettext("errors", "Poll's author can't vote")} + + defp validate_not_author(_, _), do: :ok + + defp validate_existing_votes(%{ap_id: ap_id}, object) do + if Utils.get_existing_votes(ap_id, object) == [] do + :ok + else + {:error, dgettext("errors", "Already voted")} + end + end + + defp get_options_and_max_count(%{data: %{"anyOf" => any_of}}) + when is_list(any_of) and any_of != [], + do: {any_of, Enum.count(any_of)} + + defp get_options_and_max_count(%{data: %{"oneOf" => one_of}}) + when is_list(one_of) and one_of != [], + do: {one_of, 1} + + defp normalize_and_validate_choices(choices, object) do + choices = Enum.map(choices, fn i -> if is_binary(i), do: String.to_integer(i), else: i end) + {options, max_count} = get_options_and_max_count(object) + count = Enum.count(options) + + with {_, true} <- {:valid_choice, Enum.all?(choices, &(&1 < count))}, + {_, true} <- {:count_check, Enum.count(choices) <= max_count} do + {:ok, options, choices} + else + {:valid_choice, _} -> {:error, dgettext("errors", "Invalid indices")} + {:count_check, _} -> {:error, dgettext("errors", "Too many choices")} + end + end + + def public_announce?(_, %{visibility: visibility}) + when visibility in ~w{public unlisted private direct}, + do: visibility in ~w(public unlisted) + + def public_announce?(object, _) do + Visibility.is_public?(object) + end + + def get_visibility(_, _, %Participation{}), do: {"direct", "direct"} + + def get_visibility(%{visibility: visibility}, in_reply_to, _) + when visibility in ~w{public local unlisted private direct}, + do: {visibility, get_replied_to_visibility(in_reply_to)} + + def get_visibility(%{visibility: "list:" <> list_id}, in_reply_to, _) do + visibility = {:list, String.to_integer(list_id)} + {visibility, get_replied_to_visibility(in_reply_to)} + end + + def get_visibility(_, in_reply_to, _) when not is_nil(in_reply_to) do + visibility = get_replied_to_visibility(in_reply_to) + {visibility, visibility} + end + + def get_visibility(_, in_reply_to, _), do: {"public", get_replied_to_visibility(in_reply_to)} + + def get_replied_to_visibility(nil), do: nil + + def get_replied_to_visibility(activity) do + with %Object{} = object <- Object.normalize(activity, fetch: false) do + Visibility.get_visibility(object) + end + end + + def check_expiry_date({:ok, nil} = res), do: res + + def check_expiry_date({:ok, in_seconds}) do + expiry = DateTime.add(DateTime.utc_now(), in_seconds) + + if Pleroma.Workers.PurgeExpiredActivity.expires_late_enough?(expiry) do + {:ok, expiry} + else + {:error, "Expiry date is too soon"} + end + end + + def check_expiry_date(expiry_str) do + Ecto.Type.cast(:integer, expiry_str) + |> check_expiry_date() + end + + def listen(user, data) do + with {:ok, draft} <- ActivityDraft.listen(user, data) do + ActivityPub.listen(draft.changes) + end + end + + def post(user, %{status: _} = data) do + with {:ok, draft} <- ActivityDraft.create(user, data) do + ActivityPub.create(draft.changes, draft.preview?) + end + end + + def update(user, orig_activity, changes) do + with orig_object <- Object.normalize(orig_activity), + {:ok, new_object} <- make_update_data(user, orig_object, changes), + {:ok, update_data, _} <- Builder.update(user, new_object), + {:ok, update, _} <- Pipeline.common_pipeline(update_data, local: true) do + {:ok, update} + else + _ -> {:error, nil} + end + end + + defp make_update_data(user, orig_object, changes) do + kept_params = %{ + visibility: Visibility.get_visibility(orig_object), + in_reply_to_id: + with replied_id when is_binary(replied_id) <- orig_object.data["inReplyTo"], + %Activity{id: activity_id} <- Activity.get_create_by_object_ap_id(replied_id) do + activity_id + else + _ -> nil + end + } + + params = Map.merge(changes, kept_params) + + with {:ok, draft} <- ActivityDraft.create(user, params) do + change = + Object.Updater.make_update_object_data(orig_object.data, draft.object, Utils.make_date()) + + {:ok, change} + else + _ -> {:error, nil} + end + end + + @spec pin(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, term()} + def pin(id, %User{} = user) do + with %Activity{} = activity <- create_activity_by_id(id), + true <- activity_belongs_to_actor(activity, user.ap_id), + true <- object_type_is_allowed_for_pin(activity.object), + true <- activity_is_public(activity), + {:ok, pin_data, _} <- Builder.pin(user, activity.object), + {:ok, _pin, _} <- + Pipeline.common_pipeline(pin_data, + local: true, + activity_id: id + ) do + {:ok, activity} + else + {:error, {:side_effects, error}} -> error + error -> error + end + end + + defp create_activity_by_id(id) do + with nil <- Activity.create_by_id_with_object(id) do + {:error, :not_found} + end + end + + defp activity_belongs_to_actor(%{actor: actor}, actor), do: true + defp activity_belongs_to_actor(_, _), do: {:error, :ownership_error} + + defp object_type_is_allowed_for_pin(%{data: %{"type" => type}}) do + with false <- type in ["Note", "Article", "Question"] do + {:error, :not_allowed} + end + end + + defp activity_is_public(activity) do + with false <- Visibility.is_public?(activity) do + {:error, :visibility_error} + end + end + + @spec unpin(String.t(), User.t()) :: {:ok, User.t()} | {:error, term()} + def unpin(id, user) do + with %Activity{} = activity <- create_activity_by_id(id), + {:ok, unpin_data, _} <- Builder.unpin(user, activity.object), + {:ok, _unpin, _} <- + Pipeline.common_pipeline(unpin_data, + local: true, + activity_id: activity.id, + expires_at: activity.data["expires_at"], + featured_address: user.featured_address + ) do + {:ok, activity} + end + end + + def add_mute(user, activity, params \\ %{}) do + expires_in = Map.get(params, :expires_in, 0) + + with {:ok, _} <- ThreadMute.add_mute(user.id, activity.data["context"]), + _ <- Pleroma.Notification.mark_context_as_read(user, activity.data["context"]) do + if expires_in > 0 do + Pleroma.Workers.MuteExpireWorker.enqueue( + "unmute_conversation", + %{"user_id" => user.id, "activity_id" => activity.id}, + schedule_in: expires_in + ) + end + + {:ok, activity} + else + {:error, _} -> {:error, dgettext("errors", "conversation is already muted")} + end + end + + def remove_mute(%User{} = user, %Activity{} = activity) do + ThreadMute.remove_mute(user.id, activity.data["context"]) + {:ok, activity} + end + + def remove_mute(user_id, activity_id) do + with {:user, %User{} = user} <- {:user, User.get_by_id(user_id)}, + {:activity, %Activity{} = activity} <- {:activity, Activity.get_by_id(activity_id)} do + remove_mute(user, activity) + else + {what, result} = error -> + Logger.warning( + "CommonAPI.remove_mute/2 failed. #{what}: #{result}, user_id: #{user_id}, activity_id: #{activity_id}" + ) + + {:error, error} + end + end + + def thread_muted?(%User{id: user_id}, %{data: %{"context" => context}}) + when is_binary(context) do + ThreadMute.exists?(user_id, context) + end + + def thread_muted?(_, _), do: false + + def report(user, data) do + with {:ok, account} <- get_reported_account(data.account_id), + {:ok, {content_html, _, _}} <- make_report_content_html(data[:comment]), + {:ok, statuses} <- get_report_statuses(account, data), + rules <- get_report_rules(Map.get(data, :rule_ids, nil)) do + ActivityPub.flag(%{ + context: Utils.generate_context_id(), + actor: user, + account: account, + statuses: statuses, + content: content_html, + forward: Map.get(data, :forward, false), + rules: rules + }) + end + end + + defp get_reported_account(account_id) do + case User.get_cached_by_id(account_id) do + %User{} = account -> {:ok, account} + _ -> {:error, dgettext("errors", "Account not found")} + end + end + + defp get_report_rules(nil) do + nil + end + + defp get_report_rules(rule_ids) do + rule_ids + |> Rule.get() + |> Enum.map(& &1.id) + end + + def update_report_state(activity_ids, state) when is_list(activity_ids) do + case Utils.update_report_state(activity_ids, state) do + :ok -> {:ok, activity_ids} + _ -> {:error, dgettext("errors", "Could not update state")} + end + end + + def update_report_state(activity_id, state) do + with %Activity{} = activity <- Activity.get_by_id(activity_id, filter: []) do + Utils.update_report_state(activity, state) + else + nil -> {:error, :not_found} + _ -> {:error, dgettext("errors", "Could not update state")} + end + end + + def assign_report_to_account(activity_ids, user) when is_list(activity_ids) do + case Utils.assign_report_to_account(activity_ids, user) do + :ok -> {:ok, activity_ids} + _ -> {:error, dgettext("errors", "Could not assign account")} + end + end + + def assign_report_to_account(activity_id, user) do + with %Activity{} = activity <- Activity.get_by_id(activity_id) do + Utils.assign_report_to_account(activity, user) + else + nil -> {:error, :not_found} + _ -> {:error, dgettext("errors", "Could not assign account")} + end + end + + def update_activity_scope(activity_id, opts \\ %{}) do + with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), + {:ok, activity} <- toggle_sensitive(activity, opts) do + set_visibility(activity, opts) + else + nil -> {:error, :not_found} + {:error, reason} -> {:error, reason} + end + end + + defp toggle_sensitive(activity, %{sensitive: sensitive}) when sensitive in ~w(true false) do + toggle_sensitive(activity, %{sensitive: String.to_existing_atom(sensitive)}) + end + + defp toggle_sensitive(%Activity{object: object} = activity, %{sensitive: sensitive}) + when is_boolean(sensitive) do + new_data = Map.put(object.data, "sensitive", sensitive) + + {:ok, object} = + object + |> Object.change(%{data: new_data}) + |> Object.update_and_set_cache() + + {:ok, Map.put(activity, :object, object)} + end + + defp toggle_sensitive(activity, _), do: {:ok, activity} + + defp set_visibility(activity, %{visibility: visibility}) do + Utils.update_activity_visibility(activity, visibility) + end + + defp set_visibility(activity, _), do: {:ok, activity} + + def hide_reblogs(%User{} = user, %User{} = target) do + UserRelationship.create_reblog_mute(user, target) + end + + def show_reblogs(%User{} = user, %User{} = target) do + UserRelationship.delete_reblog_mute(user, target) + end + + def get_user(ap_id, fake_record_fallback \\ true) do + cond do + user = User.get_cached_by_ap_id(ap_id) -> + user + + user = User.get_by_guessed_nickname(ap_id) -> + user + + fake_record_fallback -> + # TODO: refactor (fake records is never a good idea) + User.error_user(ap_id) + + true -> + nil + end + end + + def event(user, data, location \\ nil) do + with {:ok, draft} <- ActivityDraft.event(user, data, location) do + ActivityPub.create(draft.changes) + end + end + + def update_event(user, orig_activity, changes, location \\ nil) do + with orig_object <- Object.normalize(orig_activity), + {:ok, new_object} <- make_update_event_data(user, orig_object, changes, location), + {:ok, update_data, _} <- Builder.update(user, new_object), + {:ok, update, _} <- Pipeline.common_pipeline(update_data, local: true) do + {:ok, update} + else + _ -> {:error, nil} + end + end + + defp make_update_event_data(user, orig_object, changes, location) do + kept_params = %{ + visibility: Visibility.get_visibility(orig_object), + in_reply_to_id: + with replied_id when is_binary(replied_id) <- orig_object.data["inReplyTo"], + %Activity{id: activity_id} <- Activity.get_create_by_object_ap_id(replied_id) do + activity_id + else + _ -> nil + end + } + + params = Map.merge(changes, kept_params) + + with {:ok, draft} <- ActivityDraft.event(user, params, location) do + change = + Object.Updater.make_update_object_data(orig_object.data, draft.object, Utils.make_date()) + + {:ok, change} + else + _ -> {:error, nil} + end + end +end diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex new file mode 100644 index 0000000..94d8a8b --- /dev/null +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -0,0 +1,455 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.CommonAPI.ActivityDraft do + alias Pleroma.Activity + alias Pleroma.Conversation.Participation + alias Pleroma.Language.LanguageDetector + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.CommonAPI.Utils + + import Pleroma.Web.Gettext + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] + + defstruct valid?: true, + errors: [], + user: nil, + params: %{}, + status: nil, + summary: nil, + full_payload: nil, + attachments: [], + in_reply_to: nil, + in_reply_to_conversation: nil, + quote_post: nil, + visibility: nil, + expires_at: nil, + extra: nil, + emoji: %{}, + content_html: nil, + mentions: [], + tags: [], + to: [], + cc: [], + context: nil, + sensitive: false, + language: nil, + object: nil, + preview?: false, + changes: %{}, + location: nil, + start_time: nil, + end_time: nil, + location_id: nil, + location_provider: nil + + def new(user, params) do + %__MODULE__{user: user} + |> put_params(params) + end + + def create(user, params) do + user + |> new(params) + |> status() + |> summary() + |> with_valid(&attachments/1) + |> full_payload() + |> expires_at() + |> poll() + |> with_valid(&in_reply_to/1) + |> with_valid(&in_reply_to_conversation/1) + |> with_valid("e_post/1) + |> with_valid(&visibility/1) + |> with_valid("ing_visibility/1) + |> content() + |> with_valid(&to_and_cc/1) + |> with_valid(&context/1) + |> with_valid(&language/1) + |> sensitive() + |> with_valid(&object/1) + |> preview?() + |> with_valid(&changes/1) + |> validate() + end + + def listen(user, params) do + user + |> new(params) + |> visibility() + |> to_and_cc() + |> context() + |> listen_object() + |> with_valid(&changes/1) + |> validate() + end + + defp listen_object(draft) do + object = + draft.params + |> Map.take([:album, :artist, :title, :length, :url]) + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> Map.put("type", "Audio") + |> Map.put("to", draft.to) + |> Map.put("cc", draft.cc) + |> Map.put("actor", draft.user.ap_id) + + %__MODULE__{draft | object: object} + end + + @spec event(any, map) :: {:error, any} | {:ok, %{:valid? => true, optional(any) => any}} + def event(user, params, location \\ nil) do + user + |> new(params) + |> status() + |> visibility() + |> content() + |> to_and_cc() + |> context() + |> with_valid(&language/1) + |> with_valid(&event_banner/1) + |> event_location(location) + |> with_valid(&event_date/1) + |> event_object() + |> with_valid(&changes/1) + |> validate() + end + + defp event_object(draft) do + emoji = Map.merge(Pleroma.Emoji.Formatter.get_emoji_map(draft.full_payload), draft.emoji) + + {:ok, event_data, _meta} = Builder.event(draft) + + object = + event_data + |> Map.put("emoji", emoji) + |> Map.put("source", %{ + "content" => draft.status, + "mediaType" => Utils.get_content_type(draft.params[:content_type]) + }) + |> Map.put("generator", draft.params[:generator]) + |> Map.put("content_type", draft.params[:content_type]) + |> Map.put("language", draft.language) + + %__MODULE__{draft | object: object} + end + + defp put_params(draft, params) do + params = Map.put_new(params, :in_reply_to_status_id, params[:in_reply_to_id]) + %__MODULE__{draft | params: params} + end + + defp status(%{params: %{status: status}} = draft) do + %__MODULE__{draft | status: String.trim(status)} + end + + defp summary(%{params: params} = draft) do + %__MODULE__{draft | summary: Map.get(params, :spoiler_text, "")} + end + + defp full_payload(%{status: status, summary: summary} = draft) do + full_payload = String.trim(status <> summary) + + case Utils.validate_character_limit(full_payload, draft.attachments) do + :ok -> %__MODULE__{draft | full_payload: full_payload} + {:error, message} -> add_error(draft, message) + end + end + + defp attachments(%{params: params} = draft) do + attachments = Utils.attachments_from_ids(params, draft.user) + draft = %__MODULE__{draft | attachments: attachments} + + case Utils.validate_attachments_count(attachments) do + :ok -> draft + {:error, message} -> add_error(draft, message) + end + end + + defp in_reply_to(%{params: %{in_reply_to_status_id: ""}} = draft), do: draft + + defp in_reply_to(%{params: %{in_reply_to_status_id: id}} = draft) when is_binary(id) do + case Activity.get_by_id(id) do + %Activity{} = activity -> + %__MODULE__{draft | in_reply_to: activity} + + _ -> + add_error(draft, dgettext("errors", "The post being replied to was deleted")) + end + end + + defp in_reply_to(%{params: %{in_reply_to_status_id: %Activity{} = in_reply_to}} = draft) do + %__MODULE__{draft | in_reply_to: in_reply_to} + end + + defp in_reply_to(draft), do: draft + + defp quote_post(%{params: %{quote_id: id}} = draft) when not_empty_string(id) do + case Activity.get_by_id_with_object(id) do + %Activity{actor: actor_ap_id} = activity when not_empty_string(actor_ap_id) -> + %__MODULE__{draft | quote_post: activity, mentions: [actor_ap_id]} + + %Activity{} = activity -> + %__MODULE__{draft | quote_post: activity} + + _ -> + draft + end + end + + defp quote_post(draft), do: draft + + defp in_reply_to_conversation(draft) do + in_reply_to_conversation = Participation.get(draft.params[:in_reply_to_conversation_id]) + %__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation} + end + + defp visibility(%{params: params} = draft) do + case CommonAPI.get_visibility(params, draft.in_reply_to, draft.in_reply_to_conversation) do + {visibility, "direct"} when visibility != "direct" -> + add_error(draft, dgettext("errors", "The message visibility must be direct")) + + {visibility, _} -> + %__MODULE__{draft | visibility: visibility} + end + end + + defp can_quote?(_draft, _object, visibility) when visibility in ~w(public unlisted local) do + true + end + + defp can_quote?(draft, object, "private") do + draft.user.ap_id == object.data["actor"] + end + + defp can_quote?(_, _, _) do + false + end + + defp quoting_visibility(%{quote_post: %Activity{}} = draft) do + with %Object{} = object <- Object.normalize(draft.quote_post, fetch: false), + true <- can_quote?(draft, object, Visibility.get_visibility(object)) do + draft + else + _ -> add_error(draft, dgettext("errors", "Cannot quote private message")) + end + end + + defp quoting_visibility(draft), do: draft + + defp expires_at(draft) do + case CommonAPI.check_expiry_date(draft.params[:expires_in]) do + {:ok, expires_at} -> %__MODULE__{draft | expires_at: expires_at} + {:error, message} -> add_error(draft, message) + end + end + + defp poll(draft) do + case Utils.make_poll_data(draft.params) do + {:ok, {poll, poll_emoji}} -> + %__MODULE__{draft | extra: poll, emoji: Map.merge(draft.emoji, poll_emoji)} + + {:error, message} -> + add_error(draft, message) + end + end + + defp content(%{mentions: mentions} = draft) do + {content_html, mentioned_users, tags} = Utils.make_content_html(draft) + + mentioned_ap_ids = + Enum.map(mentioned_users, fn {_, mentioned_user} -> mentioned_user.ap_id end) + + mentions = + mentions + |> Kernel.++(mentioned_ap_ids) + |> Utils.get_addressed_users(draft.params[:to]) + + %__MODULE__{draft | content_html: content_html, mentions: mentions, tags: tags} + end + + defp to_and_cc(draft) do + {to, cc} = Utils.get_to_and_cc(draft) + %__MODULE__{draft | to: to, cc: cc} + end + + defp context(draft) do + context = Utils.make_context(draft.in_reply_to, draft.in_reply_to_conversation) + %__MODULE__{draft | context: context} + end + + defp sensitive(draft) do + sensitive = draft.params[:sensitive] + %__MODULE__{draft | sensitive: sensitive} + end + + defp language(draft) do + language = + Utils.get_valid_language(draft.params[:language]) || + LanguageDetector.detect( + draft.content_html <> " " <> (draft.summary || draft.params[:name]) + ) + + %__MODULE__{draft | language: language} + end + + defp object(draft) do + emoji = Map.merge(Pleroma.Emoji.Formatter.get_emoji_map(draft.full_payload), draft.emoji) + + # Sometimes people create posts with subject containing emoji, + # since subjects are usually copied this will result in a broken + # subject when someone replies from an instance that does not have + # the emoji or has it under different shortcode. This is an attempt + # to mitigate this by copying emoji from inReplyTo if they are present + # in the subject. + summary_emoji = + with %Activity{} <- draft.in_reply_to, + %Object{data: %{"tag" => [_ | _] = tag}} <- Object.normalize(draft.in_reply_to) do + Enum.reduce(tag, %{}, fn + %{"type" => "Emoji", "name" => name, "icon" => %{"url" => url}}, acc -> + if String.contains?(draft.summary, name) do + Map.put(acc, name, url) + else + acc + end + + _, acc -> + acc + end) + else + _ -> %{} + end + + emoji = Map.merge(emoji, summary_emoji) + + {:ok, note_data, _meta} = Builder.note(draft) + + object = + note_data + |> Map.put("emoji", emoji) + |> Map.put("source", %{ + "content" => draft.status, + "mediaType" => Utils.get_content_type(draft.params[:content_type]) + }) + |> Map.put("generator", draft.params[:generator]) + |> Map.put("content_type", draft.params[:content_type]) + |> Map.put("language", draft.language) + + %__MODULE__{draft | object: object} + end + + defp preview?(draft) do + preview? = Pleroma.Web.Utils.Params.truthy_param?(draft.params[:preview]) + %__MODULE__{draft | preview?: preview?} + end + + defp changes(draft) do + direct? = draft.visibility == "direct" + additional = %{"cc" => draft.cc, "directMessage" => direct?} + + additional = + case draft.expires_at do + %DateTime{} = expires_at -> Map.put(additional, "expires_at", expires_at) + _ -> additional + end + + changes = + %{ + to: draft.to, + actor: draft.user, + context: draft.context, + object: draft.object, + additional: additional + } + |> Utils.maybe_add_list_data(draft.user, draft.visibility) + + %__MODULE__{draft | changes: changes} + end + + defp event_date(draft) do + case draft.params[:start_time] do + %DateTime{} = start_time -> + case draft.params[:end_time] do + %DateTime{} = end_time -> + if DateTime.compare(end_time, start_time) == :lt do + add_error(draft, dgettext("errors", "Event can't end before its start")) + else + start_time = start_time |> DateTime.to_iso8601() + end_time = end_time |> DateTime.to_iso8601() + + %__MODULE__{draft | start_time: start_time, end_time: end_time} + end + + _ -> + start_time = start_time |> DateTime.to_iso8601() + + %__MODULE__{draft | start_time: start_time} + end + + _ -> + add_error(draft, dgettext("errors", "Start date is required")) + end + end + + defp event_location(draft, %Geospatial.Address{} = address) do + location = %{ + "type" => "Place", + "name" => address.description, + "id" => address.url, + "address" => %{ + "type" => "PostalAddress", + "streetAddress" => address.street, + "postalCode" => address.postal_code, + "addressLocality" => address.locality, + "addressRegion" => address.region, + "addressCountry" => address.country + } + } + + location = + if is_nil(address.geom) do + location + else + {longitude, latitude} = address.geom.coordinates + + location + |> Map.put("longitude", longitude) + |> Map.put("latitude", latitude) + end + + %__MODULE__{ + draft + | location: location, + location_id: address.origin_id, + location_provider: address.origin_provider + } + end + + defp event_location(draft, _), do: draft + + defp event_banner(draft) do + with media_id when is_binary(media_id) <- draft.params[:banner_id], + %Object{data: data} <- Repo.get(Object, media_id) do + banner = Map.put(data, "name", "Banner") + + %__MODULE__{draft | attachments: [banner]} + else + _ -> draft + end + end + + defp with_valid(%{valid?: true} = draft, func), do: func.(draft) + defp with_valid(draft, _func), do: draft + + defp add_error(draft, message) do + %__MODULE__{draft | valid?: false, errors: [message | draft.errors]} + end + + defp validate(%{valid?: true} = draft), do: {:ok, draft} + defp validate(%{errors: [message | _]}), do: {:error, message} +end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex new file mode 100644 index 0000000..65ba3ff --- /dev/null +++ b/lib/pleroma/web/common_api/utils.ex @@ -0,0 +1,533 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.CommonAPI.Utils do + import Pleroma.Web.Gettext + + alias Calendar.Strftime + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.Conversation.Participation + alias Pleroma.Formatter + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI.ActivityDraft + alias Pleroma.Web.MediaProxy + alias Pleroma.Web.Plugs.AuthenticationPlug + alias Pleroma.Web.Utils.Params + + require Logger + require Pleroma.Constants + + @supported_locales ~w( + aa ab ae af ak am an ar as av ay az ba be bg bh bi bm bn bo br bs ca ce ch co cr cs cu cv cy da + de dv dz ee el en eo es et eu fa ff fi fj fo fr fy ga gd gl gn gu gv ha he hi ho hr ht hu hy hz + ia id ie ig ii ik io is it iu ja jv ka kg ki kj kk kl km kn ko kr ks ku kv kw ky la lb lg li ln + lo lt lu lv mg mh mi mk ml mn mr ms mt my na nb nd ne ng nl nn no nr nv ny oc oj om or os pa pi + pl ps pt qu rm rn ro ru rw sa sc sd se sg si sk sl sm sn so sq sr ss st su sv sw ta te tg th ti + tk tl tn to tr ts tt tw ty ug uk ur uz ve vi vo wa wo xh yi yo za zh zu ast ckb kab kmr zgh + ) + + def attachments_from_ids(%{media_ids: ids, descriptions: desc}, user) do + attachments_from_ids_descs(ids, desc, user) + end + + def attachments_from_ids(%{media_ids: ids}, user) do + attachments_from_ids_no_descs(ids, user) + end + + def attachments_from_ids(_, _), do: [] + + def attachments_from_ids_no_descs([], _), do: [] + + def attachments_from_ids_no_descs(ids, user) do + Enum.map(ids, fn media_id -> + case get_attachment(media_id, user) do + %Object{data: data} -> data + _ -> nil + end + end) + |> Enum.reject(&is_nil/1) + end + + def attachments_from_ids_descs([], _, _), do: [] + + def attachments_from_ids_descs(ids, descs_str, user) do + {_, descs} = Jason.decode(descs_str) + + Enum.map(ids, fn media_id -> + with %Object{data: data} <- get_attachment(media_id, user) do + Map.put(data, "name", descs[media_id]) + end + end) + |> Enum.reject(&is_nil/1) + end + + defp get_attachment(media_id, user) do + with %Object{data: data} = object <- Repo.get(Object, media_id), + %{"type" => type} when type in Pleroma.Constants.upload_object_types() <- data, + :ok <- Object.authorize_access(object, user) do + object + else + _ -> nil + end + end + + @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())} + + def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do + participation = Repo.preload(participation, :recipients) + {Enum.map(participation.recipients, & &1.ap_id), []} + end + + def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do + to = + case visibility do + "public" -> [Pleroma.Constants.as_public() | draft.mentions] + "local" -> [Utils.as_local_public() | draft.mentions] + end + + cc = [draft.user.follower_address] + + if draft.in_reply_to do + {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc} + else + {to, cc} + end + end + + def get_to_and_cc(%{visibility: "unlisted"} = draft) do + to = [draft.user.follower_address | draft.mentions] + cc = [Pleroma.Constants.as_public()] + + if draft.in_reply_to do + {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc} + else + {to, cc} + end + end + + def get_to_and_cc(%{visibility: "private"} = draft) do + {to, cc} = get_to_and_cc(struct(draft, visibility: "direct")) + {[draft.user.follower_address | to], cc} + end + + def get_to_and_cc(%{visibility: "direct"} = draft) do + # If the OP is a DM already, add the implicit actor. + if draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do + {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []} + else + {draft.mentions, []} + end + end + + def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []} + + def get_addressed_users(_, to) when is_list(to) do + User.get_ap_ids_by_nicknames(to) + end + + def get_addressed_users(mentioned_users, _), do: mentioned_users + + def maybe_add_list_data(activity_params, user, {:list, list_id}) do + case Pleroma.List.get(list_id, user) do + %Pleroma.List{} = list -> + activity_params + |> put_in([:additional, "bcc"], [list.ap_id]) + |> put_in([:additional, "listMessage"], list.ap_id) + |> put_in([:object, "listMessage"], list.ap_id) + + _ -> + activity_params + end + end + + def maybe_add_list_data(activity_params, _, _), do: activity_params + + def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data) + when is_binary(expires_in) do + # In some cases mastofe sends out strings instead of integers + data + |> put_in(["poll", "expires_in"], String.to_integer(expires_in)) + |> make_poll_data() + end + + def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data) + when is_list(options) do + limits = Config.get([:instance, :poll_limits]) + + options = options |> Enum.uniq() + + with :ok <- validate_poll_expiration(expires_in, limits), + :ok <- validate_poll_options_amount(options, limits), + :ok <- validate_poll_options_length(options, limits) do + {option_notes, emoji} = + Enum.map_reduce(options, %{}, fn option, emoji -> + note = %{ + "name" => option, + "type" => "Note", + "replies" => %{"type" => "Collection", "totalItems" => 0} + } + + {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))} + end) + + end_time = + DateTime.utc_now() + |> DateTime.add(expires_in) + |> DateTime.to_iso8601() + + key = if Params.truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf" + poll = %{"type" => "Question", key => option_notes, "closed" => end_time} + + {:ok, {poll, emoji}} + end + end + + def make_poll_data(%{"poll" => poll}) when is_map(poll) do + {:error, "Invalid poll"} + end + + def make_poll_data(_data) do + {:ok, {%{}, %{}}} + end + + defp validate_poll_options_amount(options, %{max_options: max_options}) do + cond do + Enum.count(options) < 2 -> + {:error, "Poll must contain at least 2 options"} + + Enum.count(options) > max_options -> + {:error, "Poll can't contain more than #{max_options} options"} + + true -> + :ok + end + end + + defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do + if Enum.any?(options, &(String.length(&1) > max_option_chars)) do + {:error, "Poll options cannot be longer than #{max_option_chars} characters each"} + else + :ok + end + end + + defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do + cond do + expires_in > max -> {:error, "Expiration date is too far in the future"} + expires_in < min -> {:error, "Expiration date is too soon"} + true -> :ok + end + end + + def make_content_html(%ActivityDraft{} = draft) do + attachment_links = + draft.params + |> Map.get("attachment_links", Config.get([:instance, :attachment_links])) + |> Params.truthy_param?() + + content_type = get_content_type(draft.params[:content_type]) + + options = + if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do + [safe_mention: true] + else + [] + end + + draft.status + |> format_input(content_type, options) + |> maybe_add_attachments(draft.attachments, attachment_links) + end + + def get_content_type(content_type) do + if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do + content_type + else + "text/plain" + end + end + + def make_context(_, %Participation{} = participation) do + Repo.preload(participation, :conversation).conversation.ap_id + end + + def make_context(%Activity{data: %{"context" => context}}, _), do: context + def make_context(_, _), do: Utils.generate_context_id() + + def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed + + def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do + text = add_attachments(text, attachments) + {text, mentions, tags} + end + + def add_attachments(text, attachments) do + attachment_text = Enum.map(attachments, &build_attachment_link/1) + Enum.join([text | attachment_text], "
    ") + end + + defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do + name = attachment["name"] || URI.decode(Path.basename(href)) + href = MediaProxy.url(href) + "#{shortname(name)}" + end + + defp build_attachment_link(_), do: "" + + def format_input(text, format, options \\ []) + + @doc """ + Formatting text to plain text, BBCode, HTML, or Markdown + """ + def format_input(text, "text/plain", options) do + text + |> Formatter.html_escape("text/plain") + |> Formatter.linkify(options) + |> (fn {text, mentions, tags} -> + {String.replace(text, ~r/\r?\n/, "
    "), mentions, tags} + end).() + end + + def format_input(text, "text/bbcode", options) do + text + |> String.replace(~r/\r/, "") + |> Formatter.html_escape("text/plain") + |> BBCode.to_html() + |> (fn {:ok, html} -> html end).() + |> Formatter.linkify(options) + end + + def format_input(text, "text/html", options) do + text + |> Formatter.html_escape("text/html") + |> Formatter.linkify(options) + end + + def format_input(text, "text/markdown", options) do + text + |> Formatter.mentions_escape(options) + |> Formatter.markdown_to_html() + |> Formatter.linkify(options) + |> Formatter.html_escape("text/html") + end + + def format_naive_asctime(date) do + date |> DateTime.from_naive!("Etc/UTC") |> format_asctime + end + + def format_asctime(date) do + Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y") + end + + def date_to_asctime(date) when is_binary(date) do + with {:ok, date, _offset} <- DateTime.from_iso8601(date) do + format_asctime(date) + else + _e -> + Logger.warning("Date #{date} in wrong format, must be ISO 8601") + "" + end + end + + def date_to_asctime(date) do + Logger.warning("Date #{date} in wrong format, must be ISO 8601") + "" + end + + def to_masto_date(date, default \\ "") + + def to_masto_date(%NaiveDateTime{} = date, _default) do + date + |> NaiveDateTime.to_iso8601() + |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false) + end + + def to_masto_date(date, default) when is_binary(date) do + with {:ok, date} <- NaiveDateTime.from_iso8601(date) do + to_masto_date(date, default) + else + _ -> "" + end + end + + def to_masto_date(_, default), do: default + + defp shortname(name) do + with max_length when max_length > 0 <- + Config.get([Pleroma.Upload, :filename_display_max_length], 30), + true <- String.length(name) > max_length do + String.slice(name, 0..max_length) <> "…" + else + _ -> name + end + end + + @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()} + def confirm_current_password(user, password) do + with %User{local: true} = db_user <- User.get_cached_by_id(user.id), + true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do + {:ok, db_user} + else + _ -> {:error, dgettext("errors", "Invalid password.")} + end + end + + def maybe_notify_to_recipients( + recipients, + %Activity{data: %{"to" => to, "type" => _type}} = _activity + ) do + recipients ++ to + end + + def maybe_notify_to_recipients(recipients, _), do: recipients + + def maybe_notify_mentioned_recipients( + recipients, + %Activity{data: %{"to" => _to, "type" => type} = data} = activity + ) + when type == "Create" do + object = Object.normalize(activity, fetch: false) + + object_data = + cond do + not is_nil(object) -> + object.data + + is_map(data["object"]) -> + data["object"] + + true -> + %{} + end + + tagged_mentions = maybe_extract_mentions(object_data) + + recipients ++ tagged_mentions + end + + def maybe_notify_mentioned_recipients(recipients, _), do: recipients + + def maybe_notify_subscribers( + recipients, + %Activity{data: %{"actor" => actor, "type" => "Create"}} = activity + ) do + # Do not notify subscribers if author is making a reply + with %Object{data: object} <- Object.normalize(activity, fetch: false), + nil <- object["inReplyTo"], + %User{} = user <- User.get_cached_by_ap_id(actor) do + subscriber_ids = + user + |> User.subscriber_users() + |> Enum.filter(&Visibility.visible_for_user?(activity, &1)) + |> Enum.map(& &1.ap_id) + + recipients ++ subscriber_ids + else + _e -> recipients + end + end + + def maybe_notify_subscribers(recipients, _), do: recipients + + def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do + with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do + user + |> User.get_followers() + |> Enum.map(& &1.ap_id) + |> Enum.concat(recipients) + else + _e -> recipients + end + end + + def maybe_notify_followers(recipients, _), do: recipients + + def maybe_notify_participants( + recipients, + %Activity{data: %{"type" => "Update"}} = activity + ) do + with %Object{data: object} <- Object.normalize(activity, fetch: false) do + participant_ids = Map.get(object, "participations", []) + + recipients ++ participant_ids + else + _e -> recipients + end + end + + def maybe_notify_participants(recipients, _), do: recipients + + def maybe_extract_mentions(%{"tag" => tag}) do + tag + |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end) + |> Enum.map(fn x -> x["href"] end) + |> Enum.uniq() + end + + def maybe_extract_mentions(_), do: [] + + def make_report_content_html(nil), do: {:ok, {nil, [], []}} + + def make_report_content_html(comment) do + max_size = Config.get([:instance, :max_report_comment_size], 1000) + + if String.length(comment) <= max_size do + {:ok, format_input(comment, "text/plain")} + else + {:error, + dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)} + end + end + + def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids}) + when is_list(status_ids) do + {:ok, Activity.all_by_actor_and_id(actor, status_ids)} + end + + def get_report_statuses(_, _), do: {:ok, nil} + + def validate_character_limit("" = _full_payload, [] = _attachments) do + {:error, dgettext("errors", "Cannot post an empty status without attachments")} + end + + def validate_character_limit(full_payload, _attachments) do + limit = Config.get([:instance, :limit]) + length = String.length(full_payload) + + if length <= limit do + :ok + else + {:error, dgettext("errors", "The status is over the character limit")} + end + end + + def validate_attachments_count([] = _attachments) do + :ok + end + + def validate_attachments_count(attachments) do + limit = Config.get([:instance, :max_media_attachments]) + count = length(attachments) + + if count <= limit do + :ok + else + {:error, dgettext("errors", "Too many attachments")} + end + end + + def get_valid_language(language) when is_binary(language) do + case language |> String.split("_") |> Enum.at(0) do + locale when locale in @supported_locales -> locale + _ -> nil + end + end + + def get_valid_language(_), do: nil +end diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex new file mode 100644 index 0000000..0c7fc17 --- /dev/null +++ b/lib/pleroma/web/controller_helper.ex @@ -0,0 +1,118 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ControllerHelper do + use Pleroma.Web, :controller + + alias Pleroma.Pagination + alias Pleroma.Web.Utils.Params + + def json_response(conn, status, _) when status in [204, :no_content] do + conn + |> put_resp_header("content-type", "application/json") + |> send_resp(status, "") + end + + def json_response(conn, status, json) do + conn + |> put_status(status) + |> json(json) + end + + @spec fetch_integer_param(map(), String.t(), integer() | nil) :: integer() | nil + def fetch_integer_param(params, name, default \\ nil) do + params + |> Map.get(name, default) + |> param_to_integer(default) + end + + defp param_to_integer(val, _) when is_integer(val), do: val + + defp param_to_integer(val, default) when is_binary(val) do + case Integer.parse(val) do + {res, _} -> res + _ -> default + end + end + + defp param_to_integer(_, default), do: default + + def add_link_headers(conn, entries, extra_params \\ %{}) + + def add_link_headers(%{assigns: %{skip_link_headers: true}} = conn, _entries, _extra_params), + do: conn + + def add_link_headers(conn, entries, extra_params) do + case get_pagination_fields(conn, entries, extra_params) do + %{"next" => next_url, "prev" => prev_url} -> + put_resp_header(conn, "link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"") + + _ -> + conn + end + end + + @id_keys Pagination.page_keys() -- ["limit", "order"] + defp build_pagination_fields(conn, min_id, max_id, extra_params) do + params = + conn.params + |> Map.drop(Map.keys(conn.path_params) |> Enum.map(&String.to_existing_atom/1)) + |> Map.merge(extra_params) + |> Map.drop(@id_keys) + + %{ + "next" => current_url(conn, Map.put(params, :max_id, max_id)), + "prev" => current_url(conn, Map.put(params, :min_id, min_id)), + "id" => current_url(conn) + } + end + + def get_pagination_fields(conn, entries, extra_params \\ %{}) do + case List.last(entries) do + %{pagination_id: max_id} when not is_nil(max_id) -> + %{pagination_id: min_id} = List.first(entries) + + build_pagination_fields(conn, min_id, max_id, extra_params) + + %{id: max_id} -> + %{id: min_id} = List.first(entries) + + build_pagination_fields(conn, min_id, max_id, extra_params) + + _ -> + %{} + end + end + + def assign_account_by_id(conn, _) do + case Pleroma.User.get_cached_by_id(conn.params.id) do + %Pleroma.User{} = account -> assign(conn, :account, account) + nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() + end + end + + def try_render(conn, target, params) when is_binary(target) do + case render(conn, target, params) do + nil -> render_error(conn, :not_implemented, "Can't display this activity") + res -> res + end + end + + def try_render(conn, _, _) do + render_error(conn, :not_implemented, "Can't display this activity") + end + + @doc """ + Returns true if request specifies to include embedded relationships in account objects. + May only be used in selected account-related endpoints; has no effect for status- or + notification-related endpoints. + """ + # Intended for PleromaFE: https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838 + def embed_relationships?(params) do + # To do once OpenAPI transition mess is over: just `truthy_param?(params[:with_relationships])` + params + |> Map.get(:with_relationships, params["with_relationships"]) + |> Params.truthy_param?() + end +end diff --git a/lib/pleroma/web/embed_controller.ex b/lib/pleroma/web/embed_controller.ex new file mode 100644 index 0000000..8b9f0a0 --- /dev/null +++ b/lib/pleroma/web/embed_controller.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.EmbedController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.User + + alias Pleroma.Web.ActivityPub.Visibility + + plug(:put_layout, :embed) + + def show(conn, %{"id" => id}) do + with %Activity{local: true} = activity <- + Activity.get_by_id_with_object(id), + true <- Visibility.is_public?(activity.object) do + {:ok, author} = User.get_or_fetch(activity.object.data["actor"]) + + conn + |> delete_resp_header("x-frame-options") + |> delete_resp_header("content-security-policy") + |> render("show.html", + activity: activity, + author: User.sanitize_html(author), + counts: get_counts(activity) + ) + end + end + + defp get_counts(%Activity{} = activity) do + %Object{data: data} = Object.normalize(activity, fetch: false) + + %{ + likes: Map.get(data, "like_count", 0), + replies: Map.get(data, "repliesCount", 0), + announces: Map.get(data, "announcement_count", 0) + } + end +end diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex new file mode 100644 index 0000000..31c6c71 --- /dev/null +++ b/lib/pleroma/web/endpoint.ex @@ -0,0 +1,203 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Endpoint do + use Phoenix.Endpoint, otp_app: :pleroma + + require Pleroma.Constants + + alias Pleroma.Config + + socket("/live", Phoenix.LiveView.Socket) + + plug(Unplug, + if: Pleroma.Web.Plugs.MetricsPredicate, + do: {PromEx.Plug, path: "/api/metrics", prom_ex_module: Pleroma.PromEx} + ) + + plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) + + plug(Pleroma.Web.Plugs.SetLocalePlug) + plug(CORSPlug) + plug(Pleroma.Web.Plugs.HTTPSecurityPlug) + plug(Pleroma.Web.Plugs.UploadedMedia) + + @static_cache_control "public, no-cache" + + # InstanceStatic needs to be before Plug.Static to be able to override shipped-static files + # If you're adding new paths to `only:` you'll need to configure them in InstanceStatic as well + # Cache-control headers are duplicated in case we turn off etags in the future + plug( + Pleroma.Web.Plugs.InstanceStatic, + at: "/", + from: :pleroma, + only: ["emoji", "images"], + gzip: true, + cache_control_for_etags: "public, max-age=1209600", + headers: %{ + "cache-control" => "public, max-age=1209600" + } + ) + + plug(Pleroma.Web.Plugs.InstanceStatic, + at: "/", + gzip: true, + cache_control_for_etags: @static_cache_control, + headers: %{ + "cache-control" => @static_cache_control + } + ) + + # Careful! No `only` restriction here, as we don't know what frontends contain. + plug(Pleroma.Web.Plugs.FrontendStatic, + at: "/", + frontend_type: :primary, + gzip: true, + cache_control_for_etags: @static_cache_control, + headers: %{ + "cache-control" => @static_cache_control + } + ) + + plug(Plug.Static.IndexHtml, at: "/pleroma/admin/") + + plug(Pleroma.Web.Plugs.FrontendStatic, + at: "/pleroma/admin", + frontend_type: :admin, + gzip: true, + cache_control_for_etags: @static_cache_control, + headers: %{ + "cache-control" => @static_cache_control + } + ) + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phoenix.digest + # when deploying your static files in production. + plug( + Plug.Static, + at: "/", + from: :pleroma, + only: Pleroma.Constants.static_only_files(), + # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength + gzip: true, + cache_control_for_etags: @static_cache_control, + headers: %{ + "cache-control" => @static_cache_control + } + ) + + plug(Plug.Static, + at: "/pleroma/admin/", + from: {:pleroma, "priv/static/adminfe/"} + ) + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + plug(Phoenix.CodeReloader) + end + + plug(Pleroma.Web.Plugs.TrailingFormatPlug) + plug(Plug.RequestId) + plug(Plug.Logger, log: :debug) + + plug(Plug.Parsers, + parsers: [:urlencoded, Pleroma.Web.Multipart, :json], + pass: ["*/*"], + json_decoder: Jason, + # Note: this is compile-time only, won't work for database-config + length: Config.get([:instance, :upload_limit]), + body_reader: {Pleroma.Web.Plugs.DigestPlug, :read_body, []} + ) + + plug(Plug.MethodOverride) + plug(Plug.Head) + + secure_cookies = Config.get([__MODULE__, :secure_cookie_flag]) + + cookie_name = + if secure_cookies, + do: "__Host-pleroma_key", + else: "pleroma_key" + + extra = + Config.get([__MODULE__, :extra_cookie_attrs]) + |> Enum.join(";") + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + plug( + Plug.Session, + store: :cookie, + key: cookie_name, + signing_salt: Config.get([__MODULE__, :signing_salt], "CqaoopA2"), + http_only: true, + secure: secure_cookies, + extra: extra + ) + + plug(Pleroma.Web.Plugs.RemoteIp) + + defmodule Instrumenter do + use Prometheus.PhoenixInstrumenter + end + + defmodule PipelineInstrumenter do + use Prometheus.PlugPipelineInstrumenter + end + + defmodule MetricsExporter do + use Prometheus.PlugExporter + end + + defmodule MetricsExporterCaller do + @behaviour Plug + + def init(opts), do: opts + + def call(conn, opts) do + prometheus_config = Application.get_env(:prometheus, MetricsExporter, []) + ip_whitelist = List.wrap(prometheus_config[:ip_whitelist]) + + cond do + !prometheus_config[:enabled] -> + conn + + ip_whitelist != [] and + !Enum.find(ip_whitelist, fn ip -> + Pleroma.Helpers.InetHelper.parse_address(ip) == {:ok, conn.remote_ip} + end) -> + conn + + true -> + MetricsExporter.call(conn, opts) + end + end + end + + plug(PipelineInstrumenter) + + plug(MetricsExporterCaller) + + plug(Pleroma.Web.Router) + + @doc """ + Dynamically loads configuration from the system environment + on startup. + + It receives the endpoint configuration from the config files + and must return the updated configuration. + """ + def load_from_system_env(config) do + port = System.get_env("PORT") || raise "expected the PORT environment variable to be set" + {:ok, Keyword.put(config, :http, [:inet6, port: port])} + end + + def websocket_url do + String.replace_leading(url(), "http", "ws") + end +end diff --git a/lib/pleroma/web/fallback/legacy_pleroma_api_rerouter_plug.ex b/lib/pleroma/web/fallback/legacy_pleroma_api_rerouter_plug.ex new file mode 100644 index 0000000..6176f3d --- /dev/null +++ b/lib/pleroma/web/fallback/legacy_pleroma_api_rerouter_plug.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Fallback.LegacyPleromaApiRerouterPlug do + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Fallback.RedirectController + + def init(opts), do: opts + + def call(%{path_info: ["api", "pleroma" | path_info_rest]} = conn, _opts) do + new_path_info = ["api", "v1", "pleroma" | path_info_rest] + new_request_path = Enum.join(new_path_info, "/") + + conn + |> Map.merge(%{ + path_info: new_path_info, + request_path: new_request_path + }) + |> Endpoint.call(conn.params) + end + + def call(conn, _opts) do + RedirectController.api_not_implemented(conn, %{}) + end +end diff --git a/lib/pleroma/web/fallback/redirect_controller.ex b/lib/pleroma/web/fallback/redirect_controller.ex new file mode 100644 index 0000000..e5bab8e --- /dev/null +++ b/lib/pleroma/web/fallback/redirect_controller.ex @@ -0,0 +1,114 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Fallback.RedirectController do + use Pleroma.Web, :controller + + require Logger + + alias Pleroma.User + alias Pleroma.Web.Metadata + alias Pleroma.Web.Preload + + def api_not_implemented(conn, _params) do + conn + |> put_status(404) + |> json(%{error: "Not implemented"}) + end + + def redirector(conn, params, code \\ 200) do + redirector_with_ssr(conn, params, [:title, :favicon], code) + end + + def redirector_with_meta(conn, %{"maybe_nickname_or_id" => maybe_nickname_or_id} = params) do + with %User{} = user <- User.get_cached_by_nickname_or_id(maybe_nickname_or_id) do + redirector_with_meta(conn, %{user: user}) + else + nil -> + redirector(conn, params) + end + end + + def redirector_with_meta(conn, params) do + redirector_with_ssr(conn, params, [:tags, :preload, :title, :favicon]) + end + + def redirector_with_preload(conn, %{"path" => ["pleroma", "admin"]}) do + redirect(conn, to: "/pleroma/admin/") + end + + def redirector_with_preload(conn, params) do + redirector_with_ssr(conn, params, [:preload, :title, :favicon]) + end + + defp redirector_with_ssr(conn, params, keys, code \\ 200) do + {:ok, index_content} = File.read(index_file_path()) + + meta = compose_meta(conn, params, keys) + + response = + index_content + |> String.replace("", Enum.join(meta)) + + conn + |> put_resp_content_type("text/html") + |> send_resp(code, response) + end + + def registration_page(conn, params) do + redirector(conn, params) + end + + def empty(conn, _params) do + conn + |> put_status(204) + |> text("") + end + + defp index_file_path do + Pleroma.Web.Plugs.InstanceStatic.file_path("index.html") + end + + defp compose_meta(conn, params, attrs) when is_list(attrs) do + Enum.map(attrs, fn attr -> + build_meta(attr, {conn, params}) + end) + end + + defp build_meta(:tags, {conn, params}) do + try do + Metadata.build_tags(params) + rescue + e -> + Logger.error( + "Metadata rendering for #{conn.request_path} failed.\n" <> + Exception.format(:error, e, __STACKTRACE__) + ) + + "" + end + end + + defp build_meta(:preload, {conn, params}) do + try do + Preload.build_tags(conn, params) + rescue + e -> + Logger.error( + "Preloading for #{conn.request_path} failed.\n" <> + Exception.format(:error, e, __STACKTRACE__) + ) + + "" + end + end + + defp build_meta(:title, _) do + "#{Pleroma.Config.get([:instance, :name])}" + end + + defp build_meta(:favicon, _) do + "" + end +end diff --git a/lib/pleroma/web/federator.ex b/lib/pleroma/web/federator.ex new file mode 100644 index 0000000..84b77cd --- /dev/null +++ b/lib/pleroma/web/federator.ex @@ -0,0 +1,112 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Federator do + alias Pleroma.Activity + alias Pleroma.Object.Containment + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.Federator.Publisher + alias Pleroma.Workers.PublisherWorker + alias Pleroma.Workers.ReceiverWorker + + require Logger + + @behaviour Pleroma.Web.Federator.Publishing + + @doc """ + Returns `true` if the distance to target object does not exceed max configured value. + Serves to prevent fetching of very long threads, especially useful on smaller instances. + Addresses [memory leaks on recursive replies fetching](https://git.pleroma.social/pleroma/pleroma/issues/161). + Applies to fetching of both ancestor (reply-to) and child (reply) objects. + """ + # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength + def allowed_thread_distance?(distance) do + max_distance = Pleroma.Config.get([:instance, :federation_incoming_replies_max_depth]) + + if max_distance && max_distance >= 0 do + # Default depth is 0 (an object has zero distance from itself in its thread) + (distance || 0) <= max_distance + else + true + end + end + + # Client API + + def incoming_ap_doc(params) do + ReceiverWorker.enqueue("incoming_ap_doc", %{"params" => params}) + end + + @impl true + def publish(%{id: "pleroma:fakeid"} = activity) do + perform(:publish, activity) + end + + @impl true + def publish(%Pleroma.Activity{data: %{"type" => type}} = activity) do + PublisherWorker.enqueue("publish", %{"activity_id" => activity.id}, + priority: publish_priority(type) + ) + end + + defp publish_priority("Delete"), do: 3 + defp publish_priority(_), do: 0 + + # Job Worker Callbacks + + @spec perform(atom(), module(), any()) :: {:ok, any()} | {:error, any()} + def perform(:publish_one, module, params) do + apply(module, :publish_one, [params]) + end + + def perform(:publish, activity) do + Logger.debug(fn -> "Running publish for #{activity.data["id"]}" end) + + %User{} = actor = User.get_cached_by_ap_id(activity.data["actor"]) + Publisher.publish(actor, activity) + end + + def perform(:incoming_ap_doc, params) do + Logger.debug("Handling incoming AP activity") + + actor = + params + |> Map.get("actor") + |> Utils.get_ap_id() + + # NOTE: we use the actor ID to do the containment, this is fine because an + # actor shouldn't be acting on objects outside their own AP server. + with {_, {:ok, _user}} <- {:actor, User.get_or_fetch_by_ap_id(actor)}, + nil <- Activity.normalize(params["id"]), + {_, :ok} <- + {:correct_origin?, Containment.contain_origin_from_id(actor, params)}, + {:ok, activity} <- Transmogrifier.handle_incoming(params) do + {:ok, activity} + else + {:correct_origin?, _} -> + Logger.debug("Origin containment failure for #{params["id"]}") + {:error, :origin_containment_failed} + + %Activity{} -> + Logger.debug("Already had #{params["id"]}") + {:error, :already_present} + + {:actor, e} -> + Logger.debug("Unhandled actor #{actor}, #{inspect(e)}") + {:error, e} + + {:error, {:validate_object, _}} = e -> + Logger.error("Incoming AP doc validation error: #{inspect(e)}") + Logger.debug(Jason.encode!(params, pretty: true)) + e + + e -> + # Just drop those for now + Logger.debug(fn -> "Unhandled activity\n" <> Jason.encode!(params, pretty: true) end) + {:error, e} + end + end +end diff --git a/lib/pleroma/web/federator/publisher.ex b/lib/pleroma/web/federator/publisher.ex new file mode 100644 index 0000000..a45796e --- /dev/null +++ b/lib/pleroma/web/federator/publisher.ex @@ -0,0 +1,109 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Federator.Publisher do + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Workers.PublisherWorker + + require Logger + + @moduledoc """ + Defines the contract used by federation implementations to publish messages to + their peers. + """ + + @doc """ + Determine whether an activity can be relayed using the federation module. + """ + @callback is_representable?(Pleroma.Activity.t()) :: boolean() + + @doc """ + Relays an activity to a specified peer, determined by the parameters. The + parameters used are controlled by the federation module. + """ + @callback publish_one(Map.t()) :: {:ok, Map.t()} | {:error, any()} + + @doc """ + Enqueue publishing a single activity. + """ + @spec enqueue_one(module(), Map.t()) :: :ok + def enqueue_one(module, %{} = params) do + PublisherWorker.enqueue( + "publish_one", + %{"module" => to_string(module), "params" => params} + ) + end + + @doc """ + Relays an activity to all specified peers. + """ + @callback publish(User.t(), Activity.t()) :: :ok | {:error, any()} + + @spec publish(User.t(), Activity.t()) :: :ok + def publish(%User{} = user, %Activity{} = activity) do + Config.get([:instance, :federation_publisher_modules]) + |> Enum.each(fn module -> + if module.is_representable?(activity) do + Logger.debug("Publishing #{activity.data["id"]} using #{inspect(module)}") + module.publish(user, activity) + end + end) + + :ok + end + + @doc """ + Gathers links used by an outgoing federation module for WebFinger output. + """ + @callback gather_webfinger_links(User.t()) :: list() + + @spec gather_webfinger_links(User.t()) :: list() + def gather_webfinger_links(%User{} = user) do + Config.get([:instance, :federation_publisher_modules]) + |> Enum.reduce([], fn module, links -> + links ++ module.gather_webfinger_links(user) + end) + end + + @doc """ + Gathers nodeinfo protocol names supported by the federation module. + """ + @callback gather_nodeinfo_protocol_names() :: list() + + @spec gather_nodeinfo_protocol_names() :: list() + def gather_nodeinfo_protocol_names do + Config.get([:instance, :federation_publisher_modules]) + |> Enum.reduce([], fn module, links -> + links ++ module.gather_nodeinfo_protocol_names() + end) + end + + @doc """ + Gathers a set of remote users given an IR envelope. + """ + def remote_users(%User{id: user_id}, %{data: %{"to" => to} = data}) do + cc = Map.get(data, "cc", []) + + bcc = + data + |> Map.get("bcc", []) + |> Enum.reduce([], fn ap_id, bcc -> + case Pleroma.List.get_by_ap_id(ap_id) do + %Pleroma.List{user_id: ^user_id} = list -> + {:ok, following} = Pleroma.List.get_following(list) + bcc ++ Enum.map(following, & &1.ap_id) + + _ -> + bcc + end + end) + + [to, cc, bcc] + |> Enum.concat() + |> Enum.map(&User.get_cached_by_ap_id/1) + |> Enum.filter(fn user -> user && !user.local end) + end +end diff --git a/lib/pleroma/web/federator/publishing.ex b/lib/pleroma/web/federator/publishing.ex new file mode 100644 index 0000000..3a242b8 --- /dev/null +++ b/lib/pleroma/web/federator/publishing.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Federator.Publishing do + @callback publish(map()) :: any() +end diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex new file mode 100644 index 0000000..034722e --- /dev/null +++ b/lib/pleroma/web/feed/feed_view.ex @@ -0,0 +1,190 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Feed.FeedView do + use Phoenix.HTML + use Pleroma.Web, :view + + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.Gettext + alias Pleroma.Web.MediaProxy + + require Pleroma.Constants + + @days ~w(Mon Tue Wed Thu Fri Sat Sun) + @months ~w(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec) + + def prepare_activity(activity, opts \\ []) do + object = Object.normalize(activity, fetch: false) + + actor = + if opts[:actor] do + Pleroma.User.get_cached_by_ap_id(activity.actor) + end + + %{ + activity: activity, + object: object, + data: Map.get(object, :data), + actor: actor + } + end + + def most_recent_update(activities) do + with %{updated_at: updated_at} <- List.first(activities) do + to_rfc3339(updated_at) + end + end + + def most_recent_update(activities, user, :atom) do + (List.first(activities) || user).updated_at + |> to_rfc3339() + end + + def most_recent_update(activities, user, :rss) do + (List.first(activities) || user).updated_at + |> to_rfc2822() + end + + def feed_logo do + case Pleroma.Config.get([:feed, :logo]) do + nil -> + "#{Pleroma.Web.Endpoint.url()}/static/logo.svg" + + logo -> + "#{Pleroma.Web.Endpoint.url()}#{logo}" + end + |> MediaProxy.url() + end + + def email(user) do + user.nickname <> "@" <> Pleroma.Web.Endpoint.host() + end + + def logo(user) do + user + |> User.avatar_url() + |> MediaProxy.url() + end + + def last_activity(activities), do: List.last(activities) + + def activity_title(%{"content" => content} = data, opts \\ %{}) do + summary = Map.get(data, "summary", "") + + title = + cond do + summary != "" -> summary + content != "" -> activity_content(data) + true -> "a post" + end + + title + |> Pleroma.Web.Metadata.Utils.scrub_html_and_truncate(opts[:max_length], opts[:omission]) + |> HtmlEntities.encode() + end + + def activity_description(data) do + content = activity_content(data) + summary = data["summary"] + + cond do + content != "" -> escape(content) + summary != "" -> escape(summary) + true -> escape(data["type"]) + end + end + + def activity_content(%{"content" => content}) do + content + |> String.replace(~r/[\n\r]/, "") + end + + def activity_content(_), do: "" + + def activity_context(activity), do: escape(activity.data["context"]) + + def attachment_href(attachment) do + attachment["url"] + |> hd() + |> Map.get("href") + end + + def attachment_type(attachment) do + attachment["url"] + |> hd() + |> Map.get("mediaType") + end + + def get_href(id) do + with %Object{data: %{"external_url" => external_url}} <- Object.get_cached_by_ap_id(id) do + external_url + else + _e -> id + end + end + + def escape(html) do + html + |> html_escape() + |> safe_to_string() + end + + @spec to_rfc3339(String.t() | NativeDateTime.t()) :: String.t() + def to_rfc3339(date) when is_binary(date) do + date + |> Timex.parse!("{ISO:Extended}") + |> to_rfc3339() + end + + def to_rfc3339(nd) do + nd + |> Timex.to_datetime() + |> Timex.format!("{RFC3339}") + end + + @spec to_rfc2822(String.t() | DateTime.t() | NativeDateTime.t()) :: String.t() + def to_rfc2822(datestr) when is_binary(datestr) do + datestr + |> Timex.parse!("{ISO:Extended}") + |> to_rfc2822() + end + + def to_rfc2822(%DateTime{} = date) do + date + |> DateTime.to_naive() + |> NaiveDateTime.to_erl() + |> rfc2822_from_erl() + end + + def to_rfc2822(nd) do + nd + |> Timex.to_datetime() + |> DateTime.to_naive() + |> NaiveDateTime.to_erl() + |> rfc2822_from_erl() + end + + @doc """ + Builds a RFC2822 timestamp from an Erlang timestamp + [RFC2822 3.3 - Date and Time Specification](https://tools.ietf.org/html/rfc2822#section-3.3) + This function always assumes the Erlang timestamp is in Universal time, not Local time + """ + def rfc2822_from_erl({{year, month, day} = date, {hour, minute, second}}) do + day_name = Enum.at(@days, :calendar.day_of_the_week(date) - 1) + month_name = Enum.at(@months, month - 1) + + date_part = "#{day_name}, #{day} #{month_name} #{year}" + time_part = "#{pad(hour)}:#{pad(minute)}:#{pad(second)}" + + date_part <> " " <> time_part <> " +0000" + end + + defp pad(num) do + num + |> Integer.to_string() + |> String.pad_leading(2, "0") + end +end diff --git a/lib/pleroma/web/feed/tag_controller.ex b/lib/pleroma/web/feed/tag_controller.ex new file mode 100644 index 0000000..e607673 --- /dev/null +++ b/lib/pleroma/web/feed/tag_controller.ex @@ -0,0 +1,48 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Feed.TagController do + use Pleroma.Web, :controller + + alias Pleroma.Config + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.Feed.FeedView + + def feed(conn, params) do + if Config.get!([:instance, :public]) do + render_feed(conn, params) + else + render_error(conn, :not_found, "Not found") + end + end + + defp render_feed(conn, %{"tag" => raw_tag} = params) do + {format, tag} = parse_tag(raw_tag) + + activities = + %{type: ["Create"], tag: tag} + |> Pleroma.Maps.put_if_present(:max_id, params["max_id"]) + |> ActivityPub.fetch_public_activities() + + conn + |> put_resp_content_type("application/#{format}+xml") + |> put_view(FeedView) + |> render("tag.#{format}", + activities: activities, + tag: tag, + feed_config: Config.get([:feed]) + ) + end + + @spec parse_tag(binary() | any()) :: {format :: String.t(), tag :: String.t()} + defp parse_tag(raw_tag) do + case is_binary(raw_tag) && Enum.reverse(String.split(raw_tag, ".")) do + [format | tag] when format in ["rss", "atom"] -> + {format, Enum.join(tag, ".")} + + _ -> + {"atom", raw_tag} + end + end +end diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex new file mode 100644 index 0000000..6bf1ac2 --- /dev/null +++ b/lib/pleroma/web/feed/user_controller.ex @@ -0,0 +1,83 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Feed.UserController do + use Pleroma.Web, :controller + + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.ActivityPubController + alias Pleroma.Web.Feed.FeedView + + plug(Pleroma.Web.Plugs.SetFormatPlug when action in [:feed_redirect]) + + action_fallback(:errors) + + def feed_redirect(%{assigns: %{format: "html"}} = conn, %{"nickname" => nickname}) do + with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname_or_id(nickname)} do + Pleroma.Web.Fallback.RedirectController.redirector_with_meta(conn, %{user: user}) + else + _ -> Pleroma.Web.Fallback.RedirectController.redirector(conn, nil) + end + end + + def feed_redirect(%{assigns: %{format: format}} = conn, _params) + when format in ["json", "activity+json"] do + ActivityPubController.call(conn, :user) + end + + def feed_redirect(%{assigns: assigns} = conn, %{"nickname" => nickname}) do + format = Map.get(assigns, :format, "atom") + format = if format in ["atom", "rss"], do: format, else: "atom" + + with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do + redirect(conn, external: "#{Routes.user_feed_url(conn, :feed, user.nickname)}.#{format}") + end + end + + def feed(conn, %{"nickname" => nickname} = params) do + format = get_format(conn) + + format = + if format in ["atom", "rss"] do + format + else + "atom" + end + + with {_, %User{local: true} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)}, + {_, :visible} <- {:visibility, User.visible_for(user, _reading_user = nil)} do + activities = + %{ + type: ["Create"], + actor_id: user.ap_id + } + |> Pleroma.Maps.put_if_present(:max_id, params["max_id"]) + |> ActivityPub.fetch_public_or_unlisted_activities() + + conn + |> put_resp_content_type("application/#{format}+xml") + |> put_view(FeedView) + |> render("user.#{format}", + user: user, + activities: activities, + feed_config: Config.get([:feed]) + ) + end + end + + def errors(conn, {:error, :not_found}) do + render_error(conn, :not_found, "Not found") + end + + def errors(conn, {:fetch_user, %User{local: false}}), do: errors(conn, {:error, :not_found}) + def errors(conn, {:fetch_user, nil}), do: errors(conn, {:error, :not_found}) + + def errors(conn, {:visibility, _}), do: errors(conn, {:error, :not_found}) + + def errors(conn, _) do + render_error(conn, :internal_server_error, "Something went wrong") + end +end diff --git a/lib/pleroma/web/gettext.ex b/lib/pleroma/web/gettext.ex new file mode 100644 index 0000000..5ef49d8 --- /dev/null +++ b/lib/pleroma/web/gettext.ex @@ -0,0 +1,220 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), + your module gains a set of macros for translations, for example: + + import Pleroma.Web.Gettext + + # Simple translation + gettext "Here is the string to translate" + + # Plural translation + ngettext "Here is the string to translate", + "Here are the strings to translate", + 3 + + # Domain-based translation + dgettext "errors", "Here is the error message to translate" + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext, otp_app: :pleroma + + def language_tag do + # Naive implementation: HTML lang attribute uses BCP 47, which + # uses - as a separator. + # https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang + + Gettext.get_locale() + |> String.replace("_", "-", global: true) + end + + def normalize_locale(locale) do + if is_binary(locale) do + String.replace(locale, "-", "_", global: true) + else + nil + end + end + + def supports_locale?(locale) do + Pleroma.Web.Gettext + |> Gettext.known_locales() + |> Enum.member?(locale) + end + + def variant?(locale), do: String.contains?(locale, "_") + + def language_for_variant(locale) do + Enum.at(String.split(locale, "_"), 0) + end + + def ensure_fallbacks(locales) do + locales + |> Enum.flat_map(fn locale -> + others = + other_supported_variants_of_locale(locale) + |> Enum.filter(fn l -> not Enum.member?(locales, l) end) + + [locale] ++ others + end) + end + + def other_supported_variants_of_locale(locale) do + cond do + supports_locale?(locale) -> + [] + + variant?(locale) -> + lang = language_for_variant(locale) + if supports_locale?(lang), do: [lang], else: [] + + true -> + Gettext.known_locales(Pleroma.Web.Gettext) + |> Enum.filter(fn l -> String.starts_with?(l, locale <> "_") end) + end + end + + def get_locales do + Process.get({Pleroma.Web.Gettext, :locales}, []) + end + + def is_locale_list(locales) do + Enum.all?(locales, &is_binary/1) + end + + def put_locales(locales) do + if is_locale_list(locales) do + Process.put({Pleroma.Web.Gettext, :locales}, Enum.uniq(locales)) + Gettext.put_locale(Enum.at(locales, 0, Gettext.get_locale())) + :ok + else + {:error, :not_locale_list} + end + end + + def locale_or_default(locale) do + if supports_locale?(locale) do + locale + else + Gettext.get_locale() + end + end + + def with_locales_func(locales, fun) do + prev_locales = Process.get({Pleroma.Web.Gettext, :locales}) + put_locales(locales) + + try do + fun.() + after + if prev_locales do + put_locales(prev_locales) + else + Process.delete({Pleroma.Web.Gettext, :locales}) + Process.delete(Gettext) + end + end + end + + defmacro with_locales(locales, do: fun) do + quote do + Pleroma.Web.Gettext.with_locales_func(unquote(locales), fn -> + unquote(fun) + end) + end + end + + def to_locale_list(locale) when is_binary(locale) do + locale + |> String.split(",") + |> Enum.filter(&supports_locale?/1) + end + + def to_locale_list(_), do: [] + + defmacro with_locale_or_default(locale, do: fun) do + quote do + Pleroma.Web.Gettext.with_locales_func( + Pleroma.Web.Gettext.to_locale_list(unquote(locale)) + |> Enum.concat(Pleroma.Web.Gettext.get_locales()), + fn -> + unquote(fun) + end + ) + end + end + + defp next_locale(locale, list) do + index = Enum.find_index(list, fn item -> item == locale end) + + if not is_nil(index) do + Enum.at(list, index + 1) + else + nil + end + end + + # We do not yet have a proper English translation. The "English" + # version is currently but the fallback msgid. However, this + # will not work if the user puts English as the first language, + # and at the same time specifies other languages, as gettext will + # think the English translation is missing, and call + # handle_missing_translation functions. This may result in + # text in other languages being shown even if English is preferred + # by the user. + # + # To prevent this, we do not allow fallbacking when the current + # locale missing a translation is English. + defp should_fallback?(locale) do + locale != "en" + end + + def handle_missing_translation(locale, domain, msgctxt, msgid, bindings) do + next = next_locale(locale, get_locales()) + + if is_nil(next) or not should_fallback?(locale) do + super(locale, domain, msgctxt, msgid, bindings) + else + {:ok, + Gettext.with_locale(next, fn -> + Gettext.dpgettext(Pleroma.Web.Gettext, domain, msgctxt, msgid, bindings) + end)} + end + end + + def handle_missing_plural_translation( + locale, + domain, + msgctxt, + msgid, + msgid_plural, + n, + bindings + ) do + next = next_locale(locale, get_locales()) + + if is_nil(next) or not should_fallback?(locale) do + super(locale, domain, msgctxt, msgid, msgid_plural, n, bindings) + else + {:ok, + Gettext.with_locale(next, fn -> + Gettext.dpngettext( + Pleroma.Web.Gettext, + domain, + msgctxt, + msgid, + msgid_plural, + n, + bindings + ) + end)} + end + end +end diff --git a/lib/pleroma/web/instance_document.ex b/lib/pleroma/web/instance_document.ex new file mode 100644 index 0000000..9da3c50 --- /dev/null +++ b/lib/pleroma/web/instance_document.ex @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.InstanceDocument do + alias Pleroma.Config + alias Pleroma.Web.Endpoint + + @instance_documents %{ + "terms-of-service" => "/static/terms-of-service.html", + "instance-panel" => "/instance/panel.html" + } + + @spec get(String.t()) :: {:ok, String.t()} | {:error, atom()} + def get(document_name) do + case Map.fetch(@instance_documents, document_name) do + {:ok, path} -> {:ok, path} + _ -> {:error, :not_found} + end + end + + @spec put(String.t(), String.t()) :: {:ok, String.t()} | {:error, atom()} + def put(document_name, origin_path) do + with {_, {:ok, destination_path}} <- + {:instance_document, Map.fetch(@instance_documents, document_name)}, + :ok <- put_file(origin_path, destination_path) do + {:ok, Path.join(Endpoint.url(), destination_path)} + else + {:instance_document, :error} -> {:error, :not_found} + error -> error + end + end + + @spec delete(String.t()) :: :ok | {:error, atom()} + def delete(document_name) do + with {_, {:ok, path}} <- {:instance_document, Map.fetch(@instance_documents, document_name)}, + instance_static_dir_path <- instance_static_dir(path), + :ok <- File.rm(instance_static_dir_path) do + :ok + else + {:instance_document, :error} -> {:error, :not_found} + {:error, :enoent} -> {:error, :not_found} + error -> error + end + end + + defp put_file(origin_path, destination_path) do + with destination <- instance_static_dir(destination_path), + {_, :ok} <- {:mkdir_p, File.mkdir_p(Path.dirname(destination))}, + {_, {:ok, _}} <- {:copy, File.copy(origin_path, destination)} do + :ok + else + {error, _} -> {:error, error} + end + end + + defp instance_static_dir(filename) do + [:instance, :static_dir] + |> Config.get!() + |> Path.join(filename) + end +end diff --git a/lib/pleroma/web/mailer/subscription_controller.ex b/lib/pleroma/web/mailer/subscription_controller.ex new file mode 100644 index 0000000..f2fc8fb --- /dev/null +++ b/lib/pleroma/web/mailer/subscription_controller.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Mailer.SubscriptionController do + use Pleroma.Web, :controller + + alias Pleroma.JWT + alias Pleroma.Repo + alias Pleroma.User + + def unsubscribe(conn, %{"token" => encoded_token}) do + with {:ok, token} <- Base.decode64(encoded_token), + {:ok, claims} <- JWT.verify_and_validate(token), + %{"act" => %{"unsubscribe" => type}, "sub" => uid} <- claims, + %User{} = user <- Repo.get(User, uid), + {:ok, _user} <- User.switch_email_notifications(user, type, false) do + render(conn, "unsubscribe_success.html", email: user.email) + else + _err -> + render(conn, "unsubscribe_failure.html") + end + end +end diff --git a/lib/pleroma/web/manifest_controller.ex b/lib/pleroma/web/manifest_controller.ex new file mode 100644 index 0000000..3b02e4b --- /dev/null +++ b/lib/pleroma/web/manifest_controller.ex @@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ManifestController do + use Pleroma.Web, :controller + + plug(:skip_auth when action == :show) + + @doc "GET /manifest.json" + def show(conn, _params) do + render(conn, "manifest.json") + end +end diff --git a/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex new file mode 100644 index 0000000..a1eac7f --- /dev/null +++ b/lib/pleroma/web/mastodon_api/admin/controllers/account_controller.ex @@ -0,0 +1,220 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [ + add_link_headers: 2, + assign_account_by_id: 2, + json_response: 3 + ] + + alias Pleroma.Activity + alias Pleroma.ModerationLog + alias Pleroma.Pagination + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Plugs.OAuthScopesPlug + + @filter_params ~W( + local external active needing_approval deactivated nickname name email staff + ) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:read:accounts"]} + when action in [:index, :show] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:accounts"]} + when action in [ + :delete, + :enable, + :account_action, + :approve, + :reject + ] + ) + + plug( + :assign_account_by_id + when action in [ + :show, + :delete, + :enable, + :account_action, + :approve, + :reject + ] + ) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation + + def index(conn, params) do + users = + params + |> build_criteria() + |> User.Query.build() + |> Pagination.fetch_paginated(params) + + conn + |> add_link_headers(users) + |> render("index.json", users: users) + end + + def show(%{assigns: %{user: _admin, account: user}} = conn, _params) do + render(conn, "show.json", user: user) + end + + def account_action( + %{assigns: %{user: admin, account: user}, body_params: %{type: type} = body_params} = + conn, + _params + ) do + {:ok, _user} = handle_account_action(user, admin, type) + + resolve_report(admin, body_params) + + json_response(conn, :no_content, "") + end + + def delete(%{assigns: %{user: admin, account: user}} = conn, _params) do + {:ok, delete_data, _} = Builder.delete(admin, user.ap_id) + Pipeline.common_pipeline(delete_data, local: true) + + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "delete" + }) + + render(conn, "show.json", user: user) + end + + def enable(%{assigns: %{user: admin, account: user}} = conn, _params) do + {:ok, user} = User.set_activation(user, true) + + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "activate" + }) + + render(conn, "show.json", user: user) + end + + def approve(%{assigns: %{user: admin, account: user}} = conn, _params) do + {:ok, user} = User.approve(user) + + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "approve" + }) + + render(conn, "show.json", user: user) + end + + def reject(%{assigns: %{user: admin, account: user}} = conn, _params) do + with {:ok, _} <- User.reject(user) do + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "reject" + }) + + render(conn, "show.json", user: user) + else + {:error, error} -> + json_response(conn, :bad_request, %{error: error}) + end + end + + defp handle_account_action(%User{local: true} = user, admin, "disable") do + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "deactivate" + }) + + User.set_activation(user, false) + end + + defp handle_account_action(user, _admin, _type) do + {:ok, user} + end + + defp build_criteria(params) do + %{} + |> maybe_filter_local(params) + |> maybe_filter_external(params) + |> maybe_filter_active(params) + |> maybe_filter_needing_approval(params) + |> maybe_filter_deactivated(params) + |> maybe_filter_nickname(params) + |> maybe_filter_name(params) + |> maybe_filter_email(params) + |> maybe_filter_staff(params) + end + + defp maybe_filter_local(criteria, %{local: true} = _params), + do: Map.put(criteria, :local, true) + + defp maybe_filter_local(criteria, %{local: false} = _params), + do: Map.put(criteria, :external, true) + + defp maybe_filter_external(criteria, %{remote: true} = _params), + do: Map.put(criteria, :external, true) + + defp maybe_filter_external(criteria, %{remote: false} = _params), + do: Map.put(criteria, :local, true) + + defp maybe_filter_active(criteria, %{active: active} = _params), + do: Map.put(criteria, :active, active) + + defp maybe_filter_needing_approval(criteria, %{pending: need_approval} = _params), + do: Map.put(criteria, :need_approval, need_approval) + + defp maybe_filter_deactivated(criteria, %{disabled: deactivated} = _params), + do: Map.put(criteria, :deactivated, deactivated) + + defp maybe_filter_nickname(criteria, %{username: nickname} = _params), + do: Map.put(criteria, :nickname, nickname) + + defp maybe_filter_name(criteria, %{display_name: name} = _params), + do: Map.put(criteria, :name, name) + + defp maybe_filter_email(criteria, %{email: email} = _params), + do: Map.put(criteria, :email, email) + + defp maybe_filter_staff(criteria, %{staff: staff} = _params), + do: Map.put(criteria, :staff, staff) + + for filter_param <- @filter_params do + defp unquote(:"maybe_filter_#{filter_param}")(criteria, _params), do: criteria + end + + defp resolve_report(admin, %{report_id: id}) do + with {:ok, activity} <- CommonAPI.update_report_state(id, "resolved"), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: activity, + subject_actor: report.user_actor + }) + end + end + + defp resolve_report(_admin, _params) do + end +end diff --git a/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex new file mode 100644 index 0000000..35a10fa --- /dev/null +++ b/lib/pleroma/web/mastodon_api/admin/controllers/report_controller.ex @@ -0,0 +1,146 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [ + add_link_headers: 2, + json_response: 3 + ] + + alias Pleroma.Activity + alias Pleroma.ModerationLog + alias Pleroma.Pagination + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug(OAuthScopesPlug, %{scopes: ["admin:read:reports"]} when action in [:index, :show]) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write:reports"]} + when action in [:resolve, :reopen, :assign_to_self, :unassign] + ) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation + + def index(conn, params) do + opts = + %{} + |> Map.put(:type, "Flag") + |> Map.put(:skip_preload, true) + |> Map.put(:preload_report_notes, true) + |> Map.put(:total, true) + |> restrict_state(params) + |> restrict_actor(params) + + reports = + ActivityPub.fetch_activities_query([], opts) + |> Pagination.fetch_paginated(params) + + conn + |> add_link_headers(reports) + |> render("index.json", reports: reports) + end + + def show(conn, %{id: id}) do + with %Activity{} = report <- Activity.get_report(id) do + render(conn, "show.json", Report.extract_report_info(report)) + else + _ -> {:error, :not_found} + end + end + + def resolve(%{assigns: %{user: admin}} = conn, %{id: id}) do + with {:ok, activity} <- CommonAPI.update_report_state(id, "resolved"), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: activity, + subject_actor: report.user_actor + }) + + render(conn, "show.json", Report.extract_report_info(activity)) + else + {:error, error} -> + json_response(conn, :bad_request, %{error: error}) + end + end + + def reopen(%{assigns: %{user: admin}} = conn, %{id: id}) do + with {:ok, activity} <- CommonAPI.update_report_state(id, "open"), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: activity, + subject_actor: report.user_actor + }) + + render(conn, "show.json", Report.extract_report_info(report)) + else + {:error, error} -> + json_response(conn, :bad_request, %{error: error}) + end + end + + def assign_to_self(%{assigns: %{user: admin}} = conn, %{id: id}) do + with {:ok, activity} <- CommonAPI.assign_report_to_account(id, admin.id), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_assigned", + actor: admin, + subject: activity, + subject_actor: report.user_actor, + assigned_account: admin.nickname + }) + + render(conn, "show.json", Report.extract_report_info(report)) + else + {:error, error} -> + json_response(conn, :bad_request, %{error: error}) + end + end + + def unassign(%{assigns: %{user: admin}} = conn, %{id: id}) do + with {:ok, activity} <- CommonAPI.assign_report_to_account(id, nil), + report <- Activity.get_by_id_with_user_actor(activity.id) do + ModerationLog.insert_log(%{ + action: "report_unassigned", + actor: admin, + subject: activity, + subject_actor: report.user_actor + }) + + render(conn, "show.json", Report.extract_report_info(report)) + else + {:error, error} -> + json_response(conn, :bad_request, %{error: error}) + end + end + + defp restrict_state(opts, %{resolved: true}), do: Map.put(opts, :state, "resolved") + + defp restrict_state(opts, %{resolved: false}), do: Map.put(opts, :state, "open") + + defp restrict_state(opts, _params), do: opts + + defp restrict_actor(opts, %{account_id: actor}) do + with %User{ap_id: ap_id} <- User.get_by_id(actor) do + Map.put(opts, :actor_id, ap_id) + else + _ -> Map.put(opts, :actor_id, actor) + end + end + + defp restrict_actor(opts, _params), do: opts +end diff --git a/lib/pleroma/web/mastodon_api/admin/views/account_view.ex b/lib/pleroma/web/mastodon_api/admin/views/account_view.ex new file mode 100644 index 0000000..7abc685 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/admin/views/account_view.ex @@ -0,0 +1,65 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.Admin.AccountView do + use Pleroma.Web, :view + + alias Pleroma.User + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI + + def render("index.json", %{users: users}) do + render_many(users, __MODULE__, "show.json", as: :user) + end + + def render("show.json", %{user: user}) do + account = + MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true}) + + %{ + id: user.id, + username: username_from_nickname(user.nickname), + domain: domain_from_nickname(user.nickname), + created_at: Utils.to_masto_date(user.inserted_at), + email: user.email, + ip: nil, + role: role(user), + confirmed: user.is_confirmed, + sensitized: nil, + suspened: nil, + silenced: nil, + disabled: !user.is_active, + approved: user.is_approved, + locale: nil, + invite_request: user.registration_reason, + ips: nil, + account: account + } + end + + defp username_from_nickname(string) when is_binary(string) do + hd(String.split(string, "@")) + end + + defp username_from_nickname(_), do: nil + + defp domain_from_nickname(string) when is_binary(string) do + String.split(string, "@") + |> Enum.at(1, nil) + end + + defp domain_from_nickname(_), do: nil + + defp role(%User{is_admin: true}) do + "admin" + end + + defp role(%User{is_moderator: true}) do + "moderator" + end + + defp role(_user) do + nil + end +end diff --git a/lib/pleroma/web/mastodon_api/admin/views/report_view.ex b/lib/pleroma/web/mastodon_api/admin/views/report_view.ex new file mode 100644 index 0000000..a0bedef --- /dev/null +++ b/lib/pleroma/web/mastodon_api/admin/views/report_view.ex @@ -0,0 +1,74 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.Admin.ReportView do + use Pleroma.Web, :view + + alias Pleroma.HTML + alias Pleroma.Rule + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.Admin.AccountView + alias Pleroma.Web.MastodonAPI.InstanceView + alias Pleroma.Web.MastodonAPI.StatusView + + def render("index.json", %{reports: reports}) do + reports + |> Enum.map(&Report.extract_report_info/1) + |> Enum.map(&render(__MODULE__, "show.json", &1)) + end + + def render("show.json", %{ + report: report, + user: account, + account: target_account, + assigned_account: assigned_account, + statuses: statuses + }) do + created_at = Utils.to_masto_date(report.data["published"]) + + content = + unless is_nil(report.data["content"]) do + HTML.filter_tags(report.data["content"]) + else + nil + end + + assigned_account = + if assigned_account do + AccountView.render("show.json", %{user: assigned_account}) + else + nil + end + + %{ + id: report.id, + action_taken: report.data["state"] != "open", + category: "other", + comment: content, + created_at: created_at, + updated_at: created_at, + account: AccountView.render("show.json", %{user: account}), + target_account: AccountView.render("show.json", %{user: target_account}), + assigned_account: assigned_account, + action_taken_by_account: nil, + statuses: + StatusView.render("index.json", %{ + activities: statuses, + as: :activity + }), + rules: rules(Map.get(report.data, "rules", nil)) + } + end + + defp rules(nil) do + [] + end + + defp rules(rule_ids) do + rule_ids + |> Rule.get() + |> render_many(InstanceView, "rule.json", as: :rule) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex new file mode 100644 index 0000000..afa5646 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -0,0 +1,627 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AccountController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [ + add_link_headers: 2, + assign_account_by_id: 2, + embed_relationships?: 1, + json_response: 3 + ] + + alias Pleroma.Maps + alias Pleroma.User + alias Pleroma.UserNote + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.ListView + alias Pleroma.Web.MastodonAPI.MastodonAPI + alias Pleroma.Web.MastodonAPI.MastodonAPIController + alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.OAuth.OAuthController + alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Web.Plugs.RateLimiter + alias Pleroma.Web.TwitterAPI.TwitterAPI + alias Pleroma.Web.Utils.Params + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug(:skip_auth when action in [:create]) + + plug(:skip_public_check when action in [:show, :statuses]) + + plug( + OAuthScopesPlug, + %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} + when action in [:show, :followers, :following, :lookup] + ) + + plug( + OAuthScopesPlug, + %{fallback: :proceed_unauthenticated, scopes: ["read:statuses"]} + when action == :statuses + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read:accounts"]} + when action in [:verify_credentials, :endorsements, :identity_proofs] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:accounts"]} + when action in [:update_credentials, :note, :endorse, :unendorse] + ) + + plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :lists) + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "read:blocks"]} when action == :blocks + ) + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:blocks"]} when action in [:block, :unblock] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read:follows"]} when action in [:relationships, :familiar_followers] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:follows"]} + when action in [:follow_by_uri, :follow, :unfollow, :remove_from_followers] + ) + + plug(OAuthScopesPlug, %{scopes: ["follow", "read:mutes"]} when action == :mutes) + + plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action in [:mute, :unmute]) + + @relationship_actions [:follow, :unfollow, :remove_from_followers] + @needs_account ~W( + followers following lists follow unfollow mute unmute block unblock + note endorse unendorse remove_from_followers + )a + + plug( + RateLimiter, + [name: :relation_id_action, params: [:id, :uri]] when action in @relationship_actions + ) + + plug(RateLimiter, [name: :relations_actions] when action in @relationship_actions) + plug(RateLimiter, [name: :app_account_creation] when action == :create) + plug(:assign_account_by_id when action in @needs_account) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AccountOperation + + @doc "POST /api/v1/accounts" + def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do + with :ok <- validate_email_param(params), + :ok <- TwitterAPI.validate_captcha(app, params), + {:ok, user} <- TwitterAPI.register_user(params), + {_, {:ok, token}} <- + {:login, OAuthController.login(user, app, app.scopes)} do + OAuthController.after_token_exchange(conn, %{user: user, token: token}) + else + {:login, {:account_status, :confirmation_pending}} -> + json_response(conn, :ok, %{ + message: "You have been registered. Please check your email for further instructions.", + identifier: "missing_confirmed_email" + }) + + {:login, {:account_status, :approval_pending}} -> + json_response(conn, :ok, %{ + message: + "You have been registered. You'll be able to log in once your account is approved.", + identifier: "awaiting_approval" + }) + + {:login, _} -> + json_response(conn, :ok, %{ + message: + "You have been registered. Some post-registration steps may be pending. " <> + "Please log in manually.", + identifier: "manual_login_required" + }) + + {:error, error} -> + json_response(conn, :bad_request, %{error: error}) + end + end + + def create(%{assigns: %{app: _app}} = conn, _) do + render_error(conn, :bad_request, "Missing parameters") + end + + def create(conn, _) do + render_error(conn, :forbidden, "Invalid credentials") + end + + defp validate_email_param(%{email: email}) when not is_nil(email), do: :ok + + defp validate_email_param(_) do + case Pleroma.Config.get([:instance, :account_activation_required]) do + true -> {:error, dgettext("errors", "Missing parameter: %{name}", name: "email")} + _ -> :ok + end + end + + @doc "GET /api/v1/accounts/verify_credentials" + def verify_credentials(%{assigns: %{user: user}} = conn, _) do + render(conn, "show.json", + user: user, + for: user, + with_pleroma_settings: true + ) + end + + @doc "PATCH /api/v1/accounts/update_credentials" + def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _params) do + params = + params + |> Enum.filter(fn {_, value} -> not is_nil(value) end) + |> Enum.into(%{}) + + # We use an empty string as a special value to reset + # avatars, banners, backgrounds + user_image_value = fn + "" -> {:ok, nil} + value -> {:ok, value} + end + + user_params = + [ + :no_rich_text, + :hide_followers_count, + :hide_follows_count, + :hide_followers, + :hide_follows, + :hide_favorites, + :show_role, + :skip_thread_containment, + :allow_following_move, + :also_known_as, + :accepts_chat_messages, + :accepts_email_list, + :show_birthday + ] + |> Enum.reduce(%{}, fn key, acc -> + Maps.put_if_present(acc, key, params[key], &{:ok, Params.truthy_param?(&1)}) + end) + |> Maps.put_if_present(:name, params[:display_name]) + |> Maps.put_if_present(:bio, params[:note]) + |> Maps.put_if_present(:raw_bio, params[:note]) + |> Maps.put_if_present(:avatar, params[:avatar], user_image_value) + |> Maps.put_if_present(:banner, params[:header], user_image_value) + |> Maps.put_if_present(:background, params[:pleroma_background_image], user_image_value) + |> Maps.put_if_present( + :raw_fields, + params[:fields_attributes], + &{:ok, normalize_fields_attributes(&1)} + ) + |> Maps.put_if_present(:pleroma_settings_store, params[:pleroma_settings_store]) + |> Maps.put_if_present(:default_scope, params[:default_scope]) + |> Maps.put_if_present(:default_scope, params["source"]["privacy"]) + |> Maps.put_if_present(:actor_type, params[:bot], fn bot -> + if bot, do: {:ok, "Service"}, else: {:ok, "Person"} + end) + |> Maps.put_if_present(:actor_type, params[:actor_type]) + |> Maps.put_if_present(:also_known_as, params[:also_known_as]) + # Note: param name is indeed :locked (not an error) + |> Maps.put_if_present(:is_locked, params[:locked]) + # Note: param name is indeed :discoverable (not an error) + |> Maps.put_if_present(:is_discoverable, params[:discoverable]) + |> Maps.put_if_present(:birthday, params[:birthday]) + |> Maps.put_if_present(:location, params[:location]) + |> Maps.put_if_present(:language, Pleroma.Web.Gettext.normalize_locale(params[:language])) + + # What happens here: + # + # We want to update the user through the pipeline, but the ActivityPub + # update information is not quite enough for this, because this also + # contains local settings that don't federate and don't even appear + # in the Update activity. + # + # So we first build the normal local changeset, then apply it to the + # user data, but don't persist it. With this, we generate the object + # data for our update activity. We feed this and the changeset as meta + # inforation into the pipeline, where they will be properly updated and + # federated. + with changeset <- User.update_changeset(user, user_params), + {:ok, unpersisted_user} <- Ecto.Changeset.apply_action(changeset, :update), + updated_object <- + Pleroma.Web.ActivityPub.UserView.render("user.json", user: unpersisted_user) + |> Map.delete("@context"), + {:ok, update_data, []} <- Builder.update(user, updated_object), + {:ok, _update, _} <- + Pipeline.common_pipeline(update_data, + local: true, + user_update_changeset: changeset + ) do + render(conn, "show.json", + user: unpersisted_user, + for: unpersisted_user, + with_pleroma_settings: true + ) + else + {:error, %Ecto.Changeset{errors: [avatar: {"file is too large", _}]}} -> + render_error(conn, :request_entity_too_large, "File is too large") + + {:error, %Ecto.Changeset{errors: [banner: {"file is too large", _}]}} -> + render_error(conn, :request_entity_too_large, "File is too large") + + {:error, %Ecto.Changeset{errors: [background: {"file is too large", _}]}} -> + render_error(conn, :request_entity_too_large, "File is too large") + + {:error, %Ecto.Changeset{errors: [{:bio, {_, _}} | _]}} -> + render_error(conn, :request_entity_too_large, "Bio is too long") + + {:error, %Ecto.Changeset{errors: [{:name, {_, _}} | _]}} -> + render_error(conn, :request_entity_too_large, "Name is too long") + + {:error, %Ecto.Changeset{errors: [{:fields, {"invalid", _}} | _]}} -> + render_error(conn, :request_entity_too_large, "One or more field entries are too long") + + {:error, %Ecto.Changeset{errors: [{:fields, {_, _}} | _]}} -> + render_error(conn, :request_entity_too_large, "Too many field entries") + + _e -> + render_error(conn, :forbidden, "Invalid request") + end + end + + defp normalize_fields_attributes(fields) do + if Enum.all?(fields, &is_tuple/1) do + Enum.map(fields, fn {_, %{} = field} -> + %{"name" => field.name, "value" => field.value} + end) + else + Enum.map(fields, fn + %{} = field -> %{"name" => field.name, "value" => field.value} + field -> field + end) + end + end + + @doc "GET /api/v1/accounts/relationships" + def relationships(%{assigns: %{user: user}} = conn, %{id: id}) do + targets = User.get_all_by_ids(List.wrap(id)) + + render(conn, "relationships.json", user: user, targets: targets) + end + + # Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array. + def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, []) + + @doc "GET /api/v1/accounts/:id" + def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id} = params) do + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user), + :visible <- User.visible_for(user, for_user) do + render(conn, "show.json", + user: user, + for: for_user, + embed_relationships: embed_relationships?(params) + ) + else + error -> user_visibility_error(conn, error) + end + end + + @doc "GET /api/v1/accounts/:id/statuses" + def statuses(%{assigns: %{user: reading_user}} = conn, params) do + with %User{} = user <- User.get_cached_by_nickname_or_id(params.id, for: reading_user), + :visible <- User.visible_for(user, reading_user) do + params = + params + |> Map.delete(:tagged) + |> Map.put(:tag, params[:tagged]) + + activities = ActivityPub.fetch_user_activities(user, reading_user, params) + + conn + |> add_link_headers(activities) + |> put_view(StatusView) + |> render("index.json", + activities: activities, + for: reading_user, + as: :activity, + with_muted: Map.get(params, :with_muted, false) + ) + else + error -> user_visibility_error(conn, error) + end + end + + defp user_visibility_error(conn, error) do + case error do + :restrict_unauthenticated -> + render_error(conn, :unauthorized, "This API requires an authenticated user") + + _ -> + render_error(conn, :not_found, "Can't find user") + end + end + + @doc "GET /api/v1/accounts/:id/followers" + def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do + params = + params + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Enum.into(%{}) + + followers = + cond do + for_user && user.id == for_user.id -> MastodonAPI.get_followers(user, params) + user.hide_followers -> [] + true -> MastodonAPI.get_followers(user, params) + end + + conn + |> add_link_headers(followers) + # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223 + |> render("index.json", + for: for_user, + users: followers, + as: :user, + embed_relationships: embed_relationships?(params) + ) + end + + @doc "GET /api/v1/accounts/:id/following" + def following(%{assigns: %{user: for_user, account: user}} = conn, params) do + params = + params + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Enum.into(%{}) + + followers = + cond do + for_user && user.id == for_user.id -> MastodonAPI.get_friends(user, params) + user.hide_follows -> [] + true -> MastodonAPI.get_friends(user, params) + end + + conn + |> add_link_headers(followers) + # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223 + |> render("index.json", + for: for_user, + users: followers, + as: :user, + embed_relationships: embed_relationships?(params) + ) + end + + @doc "GET /api/v1/accounts/:id/lists" + def lists(%{assigns: %{user: user, account: account}} = conn, _params) do + lists = Pleroma.List.get_lists_account_belongs(user, account) + + conn + |> put_view(ListView) + |> render("index.json", lists: lists) + end + + @doc "POST /api/v1/accounts/:id/follow" + def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do + {:error, "Can not follow yourself"} + end + + def follow(%{body_params: params, assigns: %{user: follower, account: followed}} = conn, _) do + with {:ok, follower} <- MastodonAPI.follow(follower, followed, params) do + render(conn, "relationship.json", user: follower, target: followed) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/unfollow" + def unfollow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do + {:error, "Can not unfollow yourself"} + end + + def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) do + with {:ok, follower} <- CommonAPI.unfollow(follower, followed) do + render(conn, "relationship.json", user: follower, target: followed) + end + end + + @doc "POST /api/v1/accounts/:id/mute" + def mute(%{assigns: %{user: muter, account: muted}, body_params: params} = conn, _params) do + params = + params + |> Map.put_new(:duration, Map.get(params, :expires_in, 0)) + + with {:ok, _user_relationships} <- User.mute(muter, muted, params) do + render(conn, "relationship.json", user: muter, target: muted) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/unmute" + def unmute(%{assigns: %{user: muter, account: muted}} = conn, _params) do + with {:ok, _user_relationships} <- User.unmute(muter, muted) do + render(conn, "relationship.json", user: muter, target: muted) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/block" + def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do + with {:ok, _activity} <- CommonAPI.block(blocker, blocked) do + render(conn, "relationship.json", user: blocker, target: blocked) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/unblock" + def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do + with {:ok, _activity} <- CommonAPI.unblock(blocker, blocked) do + render(conn, "relationship.json", user: blocker, target: blocked) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/note" + def note( + %{assigns: %{user: noter, account: target}, body_params: %{comment: comment}} = conn, + _params + ) do + with {:ok, _user_note} <- UserNote.create(noter, target, comment) do + render(conn, "relationship.json", user: noter, target: target) + end + end + + @doc "POST /api/v1/accounts/:id/pin" + def endorse(%{assigns: %{user: endorser, account: endorsed}} = conn, _params) do + with {:ok, _user_relationships} <- User.endorse(endorser, endorsed) do + render(conn, "relationship.json", user: endorser, target: endorsed) + else + {:error, message} -> json_response(conn, :bad_request, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/unpin" + def unendorse(%{assigns: %{user: endorser, account: endorsed}} = conn, _params) do + with {:ok, _user_relationships} <- User.unendorse(endorser, endorsed) do + render(conn, "relationship.json", user: endorser, target: endorsed) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/remove_from_followers" + def remove_from_followers(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do + {:error, "Can not unfollow yourself"} + end + + def remove_from_followers(%{assigns: %{user: followed, account: follower}} = conn, _params) do + with {:ok, follower} <- CommonAPI.reject_follow_request(follower, followed) do + render(conn, "relationship.json", user: followed, target: follower) + else + nil -> + render_error(conn, :not_found, "Record not found") + end + end + + @doc "POST /api/v1/follows" + def follow_by_uri(%{body_params: %{uri: uri}} = conn, _) do + case User.get_cached_by_nickname(uri) do + %User{} = user -> + conn + |> assign(:account, user) + |> follow(%{}) + + nil -> + {:error, :not_found} + end + end + + @doc "GET /api/v1/mutes" + def mutes(%{assigns: %{user: user}} = conn, params) do + users = + user + |> User.muted_users_relation(_restrict_deactivated = true) + |> Pleroma.Pagination.fetch_paginated(params) + + conn + |> add_link_headers(users) + |> render("index.json", + users: users, + for: user, + as: :user, + embed_relationships: embed_relationships?(params), + mutes: true + ) + end + + @doc "GET /api/v1/blocks" + def blocks(%{assigns: %{user: user}} = conn, params) do + users = + user + |> User.blocked_users_relation(_restrict_deactivated = true) + |> Pleroma.Pagination.fetch_paginated(params) + + conn + |> add_link_headers(users) + |> render("index.json", + users: users, + for: user, + as: :user, + embed_relationships: embed_relationships?(params) + ) + end + + @doc "GET /api/v1/accounts/lookup" + def lookup(%{assigns: %{user: for_user}} = conn, %{acct: nickname} = _params) do + with %User{} = user <- User.get_by_nickname(nickname), + :visible <- User.visible_for(user, for_user) do + render(conn, "show.json", + user: user, + skip_visibility_check: true + ) + else + error -> user_visibility_error(conn, error) + end + end + + @doc "GET /api/v1/endorsements" + def endorsements(%{assigns: %{user: user}} = conn, params) do + users = + user + |> User.endorsed_users_relation(_restrict_deactivated = true) + |> Pleroma.Repo.all() + + conn + |> render("index.json", + users: users, + for: user, + as: :user, + embed_relationships: embed_relationships?(params) + ) + end + + @doc "GET /api/v1/accounts/familiar_followers" + def familiar_followers(%{assigns: %{user: user}} = conn, %{id: id}) do + users = + User.get_all_by_ids(List.wrap(id)) + |> Enum.map(&%{id: &1.id, accounts: get_familiar_followers(&1, user)}) + + conn + |> render("familiar_followers.json", + for: user, + users: users, + as: :user + ) + end + + defp get_familiar_followers(%{id: id} = user, %{id: id}) do + User.get_familiar_followers(user, user) + end + + defp get_familiar_followers(%{hide_followers: true}, _current_user) do + [] + end + + defp get_familiar_followers(user, current_user) do + User.get_familiar_followers(user, current_user) + end + + @doc "GET /api/v1/identity_proofs" + def identity_proofs(conn, params), do: MastodonAPIController.empty_array(conn, params) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex new file mode 100644 index 0000000..080af96 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/announcement_controller.ex @@ -0,0 +1,60 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AnnouncementController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [ + json_response: 3 + ] + + alias Pleroma.Announcement + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + # Mastodon docs say this only requires a user token, no scopes needed + # As the op `|` requires at least one scope to be present, we use `&` here. + plug( + OAuthScopesPlug, + %{scopes: [], op: :&} + when action in [:index] + ) + + # Same as in MastodonAPI specs + plug( + OAuthScopesPlug, + %{scopes: ["write:accounts"]} + when action in [:mark_read] + ) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AnnouncementOperation + + @doc "GET /api/v1/announcements" + def index(%{assigns: %{user: user}} = conn, _params) do + render(conn, "index.json", announcements: all_visible(), user: user) + end + + def index(conn, _params) do + render(conn, "index.json", announcements: all_visible(), user: nil) + end + + defp all_visible do + Announcement.list_all_visible() + end + + @doc "POST /api/v1/announcements/:id/dismiss" + def mark_read(%{assigns: %{user: user}} = conn, %{id: id} = _params) do + with announcement when not is_nil(announcement) <- Announcement.get_by_id(id), + {:ok, _} <- Announcement.mark_read_by(announcement, user) do + json_response(conn, :ok, %{}) + else + _ -> + {:error, :not_found} + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex new file mode 100644 index 0000000..844673a --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex @@ -0,0 +1,57 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AppController do + @moduledoc """ + Controller for supporting app-related actions. + If authentication is an option, app tokens (user-unbound) must be supported. + """ + + use Pleroma.Web, :controller + + alias Pleroma.Maps + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Scopes + alias Pleroma.Web.OAuth.Token + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug(:skip_auth when action in [:create, :verify_credentials]) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AppOperation + + @doc "POST /api/v1/apps" + def create(%{body_params: params} = conn, _params) do + scopes = Scopes.fetch_scopes(params, ["read"]) + user_id = get_user_id(conn) + + app_attrs = + params + |> Map.take([:client_name, :redirect_uris, :website]) + |> Map.put(:scopes, scopes) + |> Maps.put_if_present(:user_id, user_id) + + with cs <- App.register_changeset(%App{}, app_attrs), + {:ok, app} <- Repo.insert(cs) do + render(conn, "show.json", app: app) + end + end + + defp get_user_id(%{assigns: %{user: %User{id: user_id}}}), do: user_id + defp get_user_id(_conn), do: nil + + @doc """ + GET /api/v1/apps/verify_credentials + Gets compact non-secret representation of the app. Supports app tokens and user tokens. + """ + def verify_credentials(%{assigns: %{token: %Token{} = token}} = conn, _) do + with %{app: %App{} = app} <- Repo.preload(token, :app) do + render(conn, "compact_non_secret.json", app: app) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex new file mode 100644 index 0000000..fbb54a1 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AuthController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.Web.TwitterAPI.TwitterAPI + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug(Pleroma.Web.Plugs.RateLimiter, [name: :password_reset] when action == :password_reset) + + @doc "POST /auth/password" + def password_reset(conn, params) do + nickname_or_email = params["email"] || params["nickname"] + + TwitterAPI.password_reset(nickname_or_email) + + json_response(conn, :no_content, "") + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex new file mode 100644 index 0000000..9cc6225 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex @@ -0,0 +1,48 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ConversationController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + + alias Pleroma.Conversation.Participation + alias Pleroma.Repo + alias Pleroma.Web.Plugs.OAuthScopesPlug + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action == :index) + plug(OAuthScopesPlug, %{scopes: ["write:conversations"]} when action != :index) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ConversationOperation + + @doc "GET /api/v1/conversations" + def index(%{assigns: %{user: user}} = conn, params) do + participations = Participation.for_user_with_last_activity_id(user, params) + + conn + |> add_link_headers(participations) + |> render("participations.json", participations: participations, for: user) + end + + @doc "POST /api/v1/conversations/:id/read" + def mark_as_read(%{assigns: %{user: user}} = conn, %{id: participation_id}) do + with %Participation{} = participation <- + Repo.get_by(Participation, id: participation_id, user_id: user.id), + {:ok, participation} <- Participation.mark_as_read(participation) do + render(conn, "participation.json", participation: participation, for: user) + end + end + + @doc "DELETE /api/v1/conversations/:id" + def delete(%{assigns: %{user: user}} = conn, %{id: participation_id}) do + with %Participation{} = participation <- + Repo.get_by(Participation, id: participation_id, user_id: user.id), + {:ok, _} <- Participation.delete(participation) do + json(conn, %{}) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex new file mode 100644 index 0000000..8b27b0b --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex @@ -0,0 +1,17 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.CustomEmojiController do + use Pleroma.Web, :controller + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug(:skip_auth when action == :index) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.CustomEmojiOperation + + def index(conn, _params) do + render(conn, "index.json", custom_emojis: Pleroma.Emoji.get_all()) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/directory_controller.ex b/lib/pleroma/web/mastodon_api/controllers/directory_controller.ex new file mode 100644 index 0000000..253f06c --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/directory_controller.ex @@ -0,0 +1,82 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.DirectoryController do + use Pleroma.Web, :controller + + import Ecto.Query + alias Pleroma.Pagination + alias Pleroma.User + alias Pleroma.UserRelationship + alias Pleroma.Web.MastodonAPI.AccountView + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug(:skip_auth when action == "index") + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.DirectoryOperation + + @doc "GET /api/v1/directory" + def index(%{assigns: %{user: user}} = conn, params) do + with true <- Pleroma.Config.get([:instance, :profile_directory]) do + limit = Map.get(params, :limit, 20) |> min(80) + + users = + User.Query.build(%{is_discoverable: true, invisible: false, limit: limit}) + |> order_by_creation_date(params) + |> exclude_remote(params) + |> exclude_user(user) + |> exclude_relationships(user, [:block, :mute]) + |> Pagination.fetch_paginated(params, :offset) + + conn + |> put_view(AccountView) + |> render("index.json", for: user, users: users, as: :user) + else + _ -> json(conn, []) + end + end + + defp order_by_creation_date(query, %{order: "new"}) do + query + end + + defp order_by_creation_date(query, _params) do + query + |> order_by([u], desc_nulls_last: u.last_status_at) + end + + defp exclude_remote(query, %{local: true}) do + where(query, [u], u.local == true) + end + + defp exclude_remote(query, _params) do + query + end + + defp exclude_user(query, %User{id: user_id}) do + where(query, [u], u.id != ^user_id) + end + + defp exclude_user(query, _user) do + query + end + + defp exclude_relationships(query, %User{id: user_id}, relationship_types) do + query + |> join(:left, [u], r in UserRelationship, + as: :user_relationships, + on: + r.target_id == u.id and r.source_id == ^user_id and + r.relationship_type in ^relationship_types + ) + |> where([user_relationships: r], is_nil(r.target_id)) + end + + defp exclude_relationships(query, _user, _relationship_types) do + query + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex new file mode 100644 index 0000000..b2e347e --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex @@ -0,0 +1,50 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.DomainBlockController do + use Pleroma.Web, :controller + + alias Pleroma.User + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.DomainBlockOperation + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "read:blocks"]} when action == :index + ) + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:blocks"]} when action != :index + ) + + @doc "GET /api/v1/domain_blocks" + def index(%{assigns: %{user: user}} = conn, _) do + json(conn, Map.get(user, :domain_blocks, [])) + end + + @doc "POST /api/v1/domain_blocks" + def create(%{assigns: %{user: blocker}, body_params: %{domain: domain}} = conn, _params) do + User.block_domain(blocker, domain) + json(conn, %{}) + end + + def create(%{assigns: %{user: blocker}} = conn, %{domain: domain}) do + User.block_domain(blocker, domain) + json(conn, %{}) + end + + @doc "DELETE /api/v1/domain_blocks" + def delete(%{assigns: %{user: blocker}, body_params: %{domain: domain}} = conn, _params) do + User.unblock_domain(blocker, domain) + json(conn, %{}) + end + + def delete(%{assigns: %{user: blocker}} = conn, %{domain: domain}) do + User.unblock_domain(blocker, domain) + json(conn, %{}) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex b/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex new file mode 100644 index 0000000..1c650eb --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex @@ -0,0 +1,44 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.FallbackController do + use Pleroma.Web, :controller + + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + error_message = + changeset + |> Ecto.Changeset.traverse_errors(fn {message, _opt} -> message end) + |> Enum.map_join(", ", fn {_k, v} -> v end) + + conn + |> put_status(:unprocessable_entity) + |> json(%{error: error_message}) + end + + def call(conn, {:error, :not_found}) do + render_error(conn, :not_found, "Record not found") + end + + def call(conn, {:error, :forbidden}) do + render_error(conn, :forbidden, "Access denied") + end + + def call(conn, {:error, error_message}) do + conn + |> put_status(:bad_request) + |> json(%{error: error_message}) + end + + def call(conn, {:error, status, message}) do + conn + |> put_status(status) + |> json(%{error: message}) + end + + def call(conn, _) do + conn + |> put_status(:internal_server_error) + |> json(dgettext("errors", "Something went wrong")) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex new file mode 100644 index 0000000..0959b4b --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex @@ -0,0 +1,86 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.FilterController do + use Pleroma.Web, :controller + + alias Pleroma.Filter + alias Pleroma.Web.Plugs.OAuthScopesPlug + + @oauth_read_actions [:show, :index] + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["read:filters"]} when action in @oauth_read_actions) + + plug( + OAuthScopesPlug, + %{scopes: ["write:filters"]} when action not in @oauth_read_actions + ) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FilterOperation + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc "GET /api/v1/filters" + def index(%{assigns: %{user: user}} = conn, _) do + filters = Filter.get_filters(user) + + render(conn, "index.json", filters: filters) + end + + @doc "POST /api/v1/filters" + def create(%{assigns: %{user: user}, body_params: params} = conn, _) do + with {:ok, response} <- + params + |> Map.put(:user_id, user.id) + |> Map.put(:hide, params[:irreversible]) + |> Map.delete(:irreversible) + |> Filter.create() do + render(conn, "show.json", filter: response) + end + end + + @doc "GET /api/v1/filters/:id" + def show(%{assigns: %{user: user}} = conn, %{id: filter_id}) do + with %Filter{} = filter <- Filter.get(filter_id, user) do + render(conn, "show.json", filter: filter) + else + nil -> {:error, :not_found} + end + end + + @doc "PUT /api/v1/filters/:id" + def update( + %{assigns: %{user: user}, body_params: params} = conn, + %{id: filter_id} + ) do + params = + if is_boolean(params[:irreversible]) do + params + |> Map.put(:hide, params[:irreversible]) + |> Map.delete(:irreversible) + else + params + end + + with %Filter{} = filter <- Filter.get(filter_id, user), + {:ok, %Filter{} = filter} <- Filter.update(filter, params) do + render(conn, "show.json", filter: filter) + else + nil -> {:error, :not_found} + error -> error + end + end + + @doc "DELETE /api/v1/filters/:id" + def delete(%{assigns: %{user: user}} = conn, %{id: filter_id}) do + with %Filter{} = filter <- Filter.get(filter_id, user), + {:ok, _} <- Filter.delete(filter) do + json(conn, %{}) + else + nil -> {:error, :not_found} + error -> error + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex new file mode 100644 index 0000000..ba6d074 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.FollowRequestController do + use Pleroma.Web, :controller + + alias Pleroma.User + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(:assign_follower when action != :index) + + action_fallback(:errors) + + plug(OAuthScopesPlug, %{scopes: ["follow", "read:follows"]} when action == :index) + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:follows"]} when action != :index + ) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FollowRequestOperation + + @doc "GET /api/v1/follow_requests" + def index(%{assigns: %{user: followed}} = conn, _params) do + follow_requests = User.get_follow_requests(followed) + + render(conn, "index.json", for: followed, users: follow_requests, as: :user) + end + + @doc "POST /api/v1/follow_requests/:id/authorize" + def authorize(%{assigns: %{user: followed, follower: follower}} = conn, _params) do + with {:ok, follower} <- CommonAPI.accept_follow_request(follower, followed) do + render(conn, "relationship.json", user: followed, target: follower) + end + end + + @doc "POST /api/v1/follow_requests/:id/reject" + def reject(%{assigns: %{user: followed, follower: follower}} = conn, _params) do + with {:ok, follower} <- CommonAPI.reject_follow_request(follower, followed) do + render(conn, "relationship.json", user: followed, target: follower) + end + end + + defp assign_follower(%{params: %{id: id}} = conn, _) do + case User.get_cached_by_id(id) do + %User{} = follower -> assign(conn, :follower, follower) + nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() + end + end + + defp errors(conn, {:error, message}) do + conn + |> put_status(:forbidden) + |> json(%{error: message}) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex new file mode 100644 index 0000000..cf5bbf1 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex @@ -0,0 +1,43 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.InstanceController do + use Pleroma.Web, :controller + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug(:skip_auth) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.InstanceOperation + + @doc "GET /api/v1/instance" + def show(conn, _params) do + render(conn, "show.json") + end + + @doc "GET /api/v2/instance" + def show2(conn, _params) do + render(conn, "show2.json") + end + + @doc "GET /api/v1/instance/peers" + def peers(conn, _params) do + json(conn, Pleroma.Stats.get_peers()) + end + + @doc "GET /api/v1/instance/rules" + def rules(conn, _params) do + render(conn, "rules.json") + end + + @doc "GET /api/v1/instance/domain_blocks" + def domain_blocks(conn, _params) do + render(conn, "domain_blocks.json") + end + + @doc "GET /api/v1/instance/translation_languages" + def translation_languages(conn, _params) do + render(conn, "translation_languages.json") + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex new file mode 100644 index 0000000..2117aae --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex @@ -0,0 +1,99 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ListController do + use Pleroma.Web, :controller + + alias Pleroma.User + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.Plugs.OAuthScopesPlug + + @oauth_read_actions [:index, :show, :list_accounts] + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(:list_by_id_and_user when action not in [:index, :create]) + plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action in @oauth_read_actions) + plug(OAuthScopesPlug, %{scopes: ["write:lists"]} when action not in @oauth_read_actions) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ListOperation + + # GET /api/v1/lists + def index(%{assigns: %{user: user}} = conn, opts) do + lists = Pleroma.List.for_user(user, opts) + render(conn, "index.json", lists: lists) + end + + # POST /api/v1/lists + def create(%{assigns: %{user: user}, body_params: %{title: title}} = conn, _) do + with {:ok, %Pleroma.List{} = list} <- Pleroma.List.create(title, user) do + render(conn, "show.json", list: list) + end + end + + # GET /api/v1/lists/:id + def show(%{assigns: %{list: list}} = conn, _) do + render(conn, "show.json", list: list) + end + + # PUT /api/v1/lists/:id + def update(%{assigns: %{list: list}, body_params: %{title: title}} = conn, _) do + with {:ok, list} <- Pleroma.List.rename(list, title) do + render(conn, "show.json", list: list) + end + end + + # DELETE /api/v1/lists/:id + def delete(%{assigns: %{list: list}} = conn, _) do + with {:ok, _list} <- Pleroma.List.delete(list) do + json(conn, %{}) + end + end + + # GET /api/v1/lists/:id/accounts + def list_accounts(%{assigns: %{user: user, list: list}} = conn, _) do + with {:ok, users} <- Pleroma.List.get_following(list) do + conn + |> put_view(AccountView) + |> render("index.json", for: user, users: users, as: :user) + end + end + + # POST /api/v1/lists/:id/accounts + def add_to_list(%{assigns: %{list: list}, body_params: %{account_ids: account_ids}} = conn, _) do + Enum.each(account_ids, fn account_id -> + with %User{} = followed <- User.get_cached_by_id(account_id) do + Pleroma.List.follow(list, followed) + end + end) + + json(conn, %{}) + end + + # DELETE /api/v1/lists/:id/accounts + def remove_from_list( + %{assigns: %{list: list}, params: %{account_ids: account_ids}} = conn, + _ + ) do + Enum.each(account_ids, fn account_id -> + with %User{} = followed <- User.get_cached_by_id(account_id) do + Pleroma.List.unfollow(list, followed) + end + end) + + json(conn, %{}) + end + + def remove_from_list(%{body_params: params} = conn, _) do + remove_from_list(%{conn | params: params}, %{}) + end + + defp list_by_id_and_user(%{assigns: %{user: user}, params: %{id: id}} = conn, _) do + case Pleroma.List.get(id, user) do + %Pleroma.List{} = list -> assign(conn, :list, list) + nil -> conn |> render_error(:not_found, "List not found") |> halt() + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex new file mode 100644 index 0000000..4ad30f3 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.MarkerController do + use Pleroma.Web, :controller + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"]} + when action == :index + ) + + plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :upsert) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MarkerOperation + + # GET /api/v1/markers + def index(%{assigns: %{user: user}} = conn, params) do + markers = Pleroma.Marker.get_markers(user, params[:timeline]) + render(conn, "markers.json", %{markers: markers}) + end + + # POST /api/v1/markers + def upsert(%{assigns: %{user: user}, body_params: params} = conn, _) do + params = Map.new(params, fn {key, value} -> {to_string(key), value} end) + + with {:ok, result} <- Pleroma.Marker.upsert(user, params), + markers <- Map.values(result) do + render(conn, "markers.json", %{markers: markers}) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex new file mode 100644 index 0000000..0aa7b37 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do + @moduledoc """ + Contains stubs for unimplemented Mastodon API endpoints. + + Note: instead of routing directly to this controller's action, + it's preferable to define an action in relevant (non-generic) controller, + set up OAuth rules for it and call this controller's function from it. + """ + + use Pleroma.Web, :controller + + require Logger + + plug(:skip_auth when action in [:empty_array, :empty_object]) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + def empty_array(conn, _) do + Logger.debug("Unimplemented, returning an empty array (list)") + json(conn, []) + end + + def empty_object(conn, _) do + Logger.debug("Unimplemented, returning an empty object (map)") + json(conn, %{}) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex new file mode 100644 index 0000000..7d9a63c --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -0,0 +1,80 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.MediaController do + use Pleroma.Web, :controller + + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.Plugs.OAuthScopesPlug + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(Majic.Plug, [pool: Pleroma.MajicPool] when action in [:create, :create2]) + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug(OAuthScopesPlug, %{scopes: ["read:media"]} when action == :show) + plug(OAuthScopesPlug, %{scopes: ["write:media"]} when action != :show) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MediaOperation + + @doc "POST /api/v1/media" + def create(%{assigns: %{user: user}, body_params: %{file: file} = data} = conn, _) do + with {:ok, object} <- + ActivityPub.upload( + file, + actor: User.ap_id(user), + description: Map.get(data, :description) + ) do + attachment_data = Map.put(object.data, "id", object.id) + + render(conn, "attachment.json", %{attachment: attachment_data}) + end + end + + def create(_conn, _data), do: {:error, :bad_request} + + @doc "POST /api/v2/media" + def create2(%{assigns: %{user: user}, body_params: %{file: file} = data} = conn, _) do + with {:ok, object} <- + ActivityPub.upload( + file, + actor: User.ap_id(user), + description: Map.get(data, :description) + ) do + attachment_data = Map.put(object.data, "id", object.id) + + conn + |> put_status(202) + |> render("attachment.json", %{attachment: attachment_data}) + end + end + + def create2(_conn, _data), do: {:error, :bad_request} + + @doc "PUT /api/v1/media/:id" + def update(%{assigns: %{user: user}, body_params: %{description: description}} = conn, %{id: id}) do + with %Object{} = object <- Object.get_by_id(id), + :ok <- Object.authorize_access(object, user), + {:ok, %Object{data: data}} <- Object.update_data(object, %{"name" => description}) do + attachment_data = Map.put(data, "id", object.id) + + render(conn, "attachment.json", %{attachment: attachment_data}) + end + end + + def update(conn, data), do: show(conn, data) + + @doc "GET /api/v1/media/:id" + def show(%{assigns: %{user: user}} = conn, %{id: id}) do + with %Object{data: data, id: object_id} = object <- Object.get_by_id(id), + :ok <- Object.authorize_access(object, user) do + attachment_data = Map.put(data, "id", object_id) + + render(conn, "attachment.json", %{attachment: attachment_data}) + end + end + + def show(_conn, _data), do: {:error, :bad_request} +end diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex new file mode 100644 index 0000000..734fde4 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -0,0 +1,117 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.NotificationController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + + alias Pleroma.Notification + alias Pleroma.Web.MastodonAPI.MastodonAPI + alias Pleroma.Web.Plugs.OAuthScopesPlug + + @oauth_read_actions [:show, :index] + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["read:notifications"]} when action in @oauth_read_actions + ) + + plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action not in @oauth_read_actions) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.NotificationOperation + + # GET /api/v1/notifications + def index(conn, %{account_id: account_id} = params) do + case Pleroma.User.get_cached_by_id(account_id) do + %{ap_id: account_ap_id} -> + params = + params + |> Map.delete(:account_id) + |> Map.put(:account_ap_id, account_ap_id) + + index(conn, params) + + _ -> + conn + |> put_status(:not_found) + |> json(%{"error" => "Account is not found"}) + end + end + + @default_notification_types ~w{ + mention + follow + follow_request + reblog + favourite + move + pleroma:emoji_reaction + poll + status + update + pleroma:participation_request + pleroma:participation_accepted + pleroma:event_reminder + pleroma:event_update + } + def index(%{assigns: %{user: user}} = conn, params) do + params = + Map.new(params, fn {k, v} -> {to_string(k), v} end) + |> Map.put_new("types", Map.get(params, :include_types, @default_notification_types)) + + notifications = MastodonAPI.get_notifications(user, params) + + conn + |> add_link_headers(notifications) + |> render("index.json", + notifications: notifications, + for: user + ) + end + + # GET /api/v1/notifications/:id + def show(%{assigns: %{user: user}} = conn, %{id: id}) do + with {:ok, notification} <- Notification.get(user, id) do + render(conn, "show.json", notification: notification, for: user) + else + {:error, reason} -> + conn + |> put_status(:forbidden) + |> json(%{"error" => reason}) + end + end + + # POST /api/v1/notifications/clear + def clear(%{assigns: %{user: user}} = conn, _params) do + Notification.clear(user) + json(conn, %{}) + end + + # POST /api/v1/notifications/:id/dismiss + + def dismiss(%{assigns: %{user: user}} = conn, %{id: id} = _params) do + with {:ok, _notif} <- Notification.dismiss(user, id) do + json(conn, %{}) + else + {:error, reason} -> + conn + |> put_status(:forbidden) + |> json(%{"error" => reason}) + end + end + + # POST /api/v1/notifications/dismiss (deprecated) + def dismiss_via_body(%{body_params: params} = conn, _) do + dismiss(conn, params) + end + + # DELETE /api/v1/notifications/destroy_multiple + def destroy_multiple(%{assigns: %{user: user}} = conn, %{ids: ids} = _params) do + Notification.destroy_multiple(user, ids) + json(conn, %{}) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex new file mode 100644 index 0000000..002c210 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -0,0 +1,67 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.PollController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [try_render: 3, json_response: 3] + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Plugs.OAuthScopesPlug + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} when action == :show + ) + + plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :vote) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PollOperation + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + + @doc "GET /api/v1/polls/:id" + def show(%{assigns: %{user: user}} = conn, %{id: id}) do + with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60), + %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), + true <- Visibility.visible_for_user?(activity, user) do + try_render(conn, "show.json", %{object: object, for: user}) + else + error when is_nil(error) or error == false -> + render_error(conn, :not_found, "Record not found") + end + end + + @doc "POST /api/v1/polls/:id/votes" + def vote(%{assigns: %{user: user}, body_params: %{choices: choices}} = conn, %{id: id}) do + with %Object{data: %{"type" => "Question"}} = object <- Object.get_by_id(id), + %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), + true <- Visibility.visible_for_user?(activity, user), + {:ok, _activities, object} <- get_cached_vote_or_vote(user, object, choices) do + try_render(conn, "show.json", %{object: object, for: user}) + else + nil -> render_error(conn, :not_found, "Record not found") + false -> render_error(conn, :not_found, "Record not found") + {:error, message} -> json_response(conn, :unprocessable_entity, %{error: message}) + end + end + + defp get_cached_vote_or_vote(user, object, choices) do + idempotency_key = "polls:#{user.id}:#{object.data["id"]}" + + @cachex.fetch!(:idempotency_cache, idempotency_key, fn _ -> + case CommonAPI.vote(user, object, choices) do + {:error, _message} = res -> {:ignore, res} + res -> {:commit, res} + end + end) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex new file mode 100644 index 0000000..3db80d7 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ReportController do + use Pleroma.Web, :controller + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.Plugs.OAuthScopesPlug, %{scopes: ["write:reports"]} when action == :create) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ReportOperation + + @doc "POST /api/v1/reports" + def create(%{assigns: %{user: user}, body_params: params} = conn, _) do + with {:ok, activity} <- Pleroma.Web.CommonAPI.report(user, params) do + render(conn, "show.json", activity: activity) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex new file mode 100644 index 0000000..0392fce --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex @@ -0,0 +1,61 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + + alias Pleroma.ScheduledActivity + alias Pleroma.Web.MastodonAPI.MastodonAPI + alias Pleroma.Web.Plugs.OAuthScopesPlug + + @oauth_read_actions [:show, :index] + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in @oauth_read_actions) + plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action not in @oauth_read_actions) + plug(:assign_scheduled_activity when action != :index) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ScheduledActivityOperation + + @doc "GET /api/v1/scheduled_statuses" + def index(%{assigns: %{user: user}} = conn, params) do + params = Map.new(params, fn {key, value} -> {to_string(key), value} end) + + with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do + conn + |> add_link_headers(scheduled_activities) + |> render("index.json", scheduled_activities: scheduled_activities) + end + end + + @doc "GET /api/v1/scheduled_statuses/:id" + def show(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params) do + render(conn, "show.json", scheduled_activity: scheduled_activity) + end + + @doc "PUT /api/v1/scheduled_statuses/:id" + def update(%{assigns: %{scheduled_activity: scheduled_activity}, body_params: params} = conn, _) do + with {:ok, scheduled_activity} <- ScheduledActivity.update(scheduled_activity, params) do + render(conn, "show.json", scheduled_activity: scheduled_activity) + end + end + + @doc "DELETE /api/v1/scheduled_statuses/:id" + def delete(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params) do + with {:ok, scheduled_activity} <- ScheduledActivity.delete(scheduled_activity) do + render(conn, "show.json", scheduled_activity: scheduled_activity) + end + end + + defp assign_scheduled_activity(%{assigns: %{user: user}, params: %{id: id}} = conn, _) do + case ScheduledActivity.get(user, id) do + %ScheduledActivity{} = activity -> assign(conn, :scheduled_activity, activity) + nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex new file mode 100644 index 0000000..e4acba2 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -0,0 +1,193 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.SearchController do + use Pleroma.Web, :controller + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ControllerHelper + alias Pleroma.Web.Endpoint + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Web.Plugs.RateLimiter + + require Logger + + @search_limit 40 + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + # Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search) + plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated}) + + # Note: on private instances auth is required (EnsurePublicOrAuthenticatedPlug is not skipped) + + plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search]) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SearchOperation + + def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do + accounts = User.search(query, search_options(params, user)) + + conn + |> put_view(AccountView) + |> render("index.json", + users: accounts, + for: user, + as: :user + ) + end + + def search2(conn, params), do: do_search(:v2, conn, params) + def search(conn, params), do: do_search(:v1, conn, params) + + defp do_search(version, %{assigns: %{user: user}} = conn, %{q: query} = params) do + query = String.trim(query) + options = search_options(params, user) + timeout = Keyword.get(Repo.config(), :timeout, 15_000) + default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []} + + result = + default_values + |> Enum.map(fn {resource, default_value} -> + if params[:type] in [nil, resource] do + {resource, fn -> resource_search(version, resource, query, options) end} + else + {resource, fn -> default_value end} + end + end) + |> Task.async_stream(fn {resource, f} -> {resource, with_fallback(f)} end, + timeout: timeout, + on_timeout: :kill_task + ) + |> Enum.reduce(default_values, fn + {:ok, {resource, result}}, acc -> + Map.put(acc, resource, result) + + _error, acc -> + acc + end) + + json(conn, result) + end + + defp search_options(params, user) do + [ + resolve: params[:resolve], + following: params[:following], + limit: min(params[:limit], @search_limit), + offset: params[:offset], + type: params[:type], + author: get_author(params), + embed_relationships: ControllerHelper.embed_relationships?(params), + for_user: user + ] + |> Enum.filter(&elem(&1, 1)) + end + + defp resource_search(_, "accounts", query, options) do + accounts = with_fallback(fn -> User.search(query, options) end) + + AccountView.render("index.json", + users: accounts, + for: options[:for_user], + embed_relationships: options[:embed_relationships] + ) + end + + defp resource_search(_, "statuses", query, options) do + statuses = with_fallback(fn -> Pleroma.Search.search(query, options) end) + + StatusView.render("index.json", + activities: statuses, + for: options[:for_user], + as: :activity + ) + end + + defp resource_search(:v2, "hashtags", query, options) do + tags_path = Endpoint.url() <> "/tag/" + + query + |> prepare_tags(options) + |> Enum.map(fn tag -> + %{name: tag, url: tags_path <> tag} + end) + end + + defp resource_search(:v1, "hashtags", query, options) do + prepare_tags(query, options) + end + + defp prepare_tags(query, options) do + tags = + query + |> preprocess_uri_query() + |> String.split(~r/[^#\w]+/u, trim: true) + |> Enum.uniq_by(&String.downcase/1) + + explicit_tags = Enum.filter(tags, fn tag -> String.starts_with?(tag, "#") end) + + tags = + if Enum.any?(explicit_tags) do + explicit_tags + else + tags + end + + tags = Enum.map(tags, fn tag -> String.trim_leading(tag, "#") end) + + tags = + if Enum.empty?(explicit_tags) && !options[:skip_joined_tag] do + add_joined_tag(tags) + else + tags + end + + Pleroma.Pagination.paginate(tags, options) + end + + defp add_joined_tag(tags) do + tags + |> Kernel.++([joined_tag(tags)]) + |> Enum.uniq_by(&String.downcase/1) + end + + # If `query` is a URI, returns last component of its path, otherwise returns `query` + defp preprocess_uri_query(query) do + if query =~ ~r/https?:\/\// do + query + |> String.trim_trailing("/") + |> URI.parse() + |> Map.get(:path) + |> String.split("/") + |> Enum.at(-1) + else + query + end + end + + defp joined_tag(tags) do + tags + |> Enum.map(fn tag -> String.capitalize(tag) end) + |> Enum.join() + end + + defp with_fallback(f, fallback \\ []) do + try do + f.() + rescue + error -> + Logger.error("#{__MODULE__} search error: #{inspect(error)}") + fallback + end + end + + defp get_author(%{account_id: account_id}) when is_binary(account_id), + do: User.get_cached_by_id(account_id) + + defp get_author(_params), do: nil +end diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex new file mode 100644 index 0000000..520def0 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -0,0 +1,540 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.StatusController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [try_render: 3, add_link_headers: 2] + + require Ecto.Query + + alias Pleroma.Activity + alias Pleroma.Bookmark + alias Pleroma.Language.Translation + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.ScheduledActivity + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.ScheduledActivityView + alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Web.Plugs.RateLimiter + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug(:skip_public_check when action in [:index, :show]) + + @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []} + + plug( + OAuthScopesPlug, + %{@unauthenticated_access | scopes: ["read:statuses"]} + when action in [ + :index, + :show, + :card, + :context, + :show_history, + :show_source, + :translate + ] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:statuses"]} + when action in [ + :create, + :delete, + :reblog, + :unreblog, + :update + ] + ) + + plug(OAuthScopesPlug, %{scopes: ["read:favourites"]} when action == :favourites) + + plug( + OAuthScopesPlug, + %{scopes: ["write:favourites"]} when action in [:favourite, :unfavourite] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:mutes"]} when action in [:mute_conversation, :unmute_conversation] + ) + + plug( + OAuthScopesPlug, + %{@unauthenticated_access | scopes: ["read:accounts"]} + when action in [:favourited_by, :reblogged_by] + ) + + plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action in [:pin, :unpin]) + + # Note: scope not present in Mastodon: read:bookmarks + plug(OAuthScopesPlug, %{scopes: ["read:bookmarks"]} when action == :bookmarks) + + # Note: scope not present in Mastodon: write:bookmarks + plug( + OAuthScopesPlug, + %{scopes: ["write:bookmarks"]} when action in [:bookmark, :unbookmark] + ) + + @rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete translate)a + + plug( + RateLimiter, + [name: :status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: [:id]] + when action in ~w(reblog unreblog)a + ) + + plug( + RateLimiter, + [name: :status_id_action, bucket_name: "status_id_action:fav_unfav", params: [:id]] + when action in ~w(favourite unfavourite)a + ) + + plug(RateLimiter, [name: :statuses_actions] when action in @rate_limited_status_actions) + + plug(Pleroma.Web.Plugs.SetApplicationPlug, [] when action in [:create, :update]) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.StatusOperation + + @doc """ + GET `/api/v1/statuses?ids[]=1&ids[]=2` + + `ids` query param is required + """ + def index(%{assigns: %{user: user}} = conn, %{ids: ids} = params) do + limit = 100 + + activities = + ids + |> Enum.take(limit) + |> Activity.all_by_ids_with_object() + |> Enum.filter(&Visibility.visible_for_user?(&1, user)) + + render(conn, "index.json", + activities: activities, + for: user, + as: :activity, + with_muted: Map.get(params, :with_muted, false) + ) + end + + @doc """ + POST /api/v1/statuses + """ + # Creates a scheduled status when `scheduled_at` param is present and it's far enough + def create( + %{ + assigns: %{user: user}, + body_params: %{status: _, scheduled_at: scheduled_at} = params + } = conn, + _ + ) + when not is_nil(scheduled_at) do + params = + params + |> Map.put(:in_reply_to_status_id, params[:in_reply_to_id]) + |> Map.put(:generator, conn.assigns.application) + + attrs = %{ + params: Map.new(params, fn {key, value} -> {to_string(key), value} end), + scheduled_at: scheduled_at + } + + with {:far_enough, true} <- {:far_enough, ScheduledActivity.far_enough?(scheduled_at)}, + {:ok, scheduled_activity} <- ScheduledActivity.create(user, attrs) do + conn + |> put_view(ScheduledActivityView) + |> render("show.json", scheduled_activity: scheduled_activity) + else + {:far_enough, _} -> + params = Map.drop(params, [:scheduled_at]) + create(%Plug.Conn{conn | body_params: params}, %{}) + + error -> + error + end + end + + # Creates a regular status + def create(%{assigns: %{user: user}, body_params: %{status: _} = params} = conn, _) do + params = + params + |> Map.put(:in_reply_to_status_id, params[:in_reply_to_id]) + |> Map.put(:generator, conn.assigns.application) + + with {:ok, activity} <- CommonAPI.post(user, params) do + try_render(conn, "show.json", + activity: activity, + for: user, + as: :activity, + with_direct_conversation_id: true + ) + else + {:error, {:reject, message}} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: message}) + + {:error, message} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: message}) + end + end + + def create(%{assigns: %{user: _user}, body_params: %{media_ids: _} = params} = conn, _) do + params = Map.put(params, :status, "") + create(%Plug.Conn{conn | body_params: params}, %{}) + end + + @doc "GET /api/v1/statuses/:id/history" + def show_history(%{assigns: assigns} = conn, %{id: id} = params) do + with user = assigns[:user], + %Activity{} = activity <- Activity.get_by_id_with_object(id), + true <- Visibility.visible_for_user?(activity, user) do + try_render(conn, "history.json", + activity: activity, + for: user, + with_direct_conversation_id: true, + with_muted: Map.get(params, :with_muted, false) + ) + else + _ -> {:error, :not_found} + end + end + + @doc "GET /api/v1/statuses/:id/source" + def show_source(%{assigns: assigns} = conn, %{id: id} = _params) do + with user = assigns[:user], + %Activity{} = activity <- Activity.get_by_id_with_object(id), + true <- Visibility.visible_for_user?(activity, user) do + try_render(conn, "source.json", + activity: activity, + for: user + ) + else + _ -> {:error, :not_found} + end + end + + @doc "PUT /api/v1/statuses/:id" + def update(%{assigns: %{user: user}, body_params: body_params} = conn, %{id: id} = params) do + with {_, %Activity{}} = {_, activity} <- {:activity, Activity.get_by_id_with_object(id)}, + {_, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, + {_, true} <- {:is_create, activity.data["type"] == "Create"}, + actor <- Activity.user_actor(activity), + {_, true} <- {:own_status, actor.id == user.id}, + {_, true} <- {:not_event, activity.object.data["type"] != "Event"}, + changes <- body_params |> Map.put(:generator, conn.assigns.application), + {_, {:ok, _update_activity}} <- {:pipeline, CommonAPI.update(user, activity, changes)}, + {_, %Activity{}} = {_, activity} <- {:refetched, Activity.get_by_id_with_object(id)} do + try_render(conn, "show.json", + activity: activity, + for: user, + with_direct_conversation_id: true, + with_muted: Map.get(params, :with_muted, false) + ) + else + {:own_status, _} -> {:error, :forbidden} + {:not_event, _} -> {:error, :unprocessable_entity, "Use event update route"} + {:pipeline, _} -> {:error, :internal_server_error} + _ -> {:error, :not_found} + end + end + + @doc "GET /api/v1/statuses/:id" + def show(%{assigns: %{user: user}} = conn, %{id: id} = params) do + with %Activity{} = activity <- Activity.get_by_id_with_object(id), + true <- Visibility.visible_for_user?(activity, user) do + try_render(conn, "show.json", + activity: activity, + for: user, + with_direct_conversation_id: true, + with_muted: Map.get(params, :with_muted, false) + ) + else + _ -> {:error, :not_found} + end + end + + @doc "DELETE /api/v1/statuses/:id" + def delete(%{assigns: %{user: user}} = conn, %{id: id}) do + with %Activity{} = activity <- Activity.get_by_id_with_object(id), + {:ok, %Activity{}} <- CommonAPI.delete(id, user) do + try_render(conn, "show.json", + activity: activity, + for: user, + with_direct_conversation_id: true, + with_source: true + ) + else + _e -> {:error, :not_found} + end + end + + @doc "POST /api/v1/statuses/:id/reblog" + def reblog(%{assigns: %{user: user}, body_params: params} = conn, %{id: ap_id_or_id}) do + with {:ok, announce} <- CommonAPI.repeat(ap_id_or_id, user, params), + %Activity{} = announce <- Activity.normalize(announce.data) do + try_render(conn, "show.json", %{activity: announce, for: user, as: :activity}) + end + end + + @doc "POST /api/v1/statuses/:id/unreblog" + def unreblog(%{assigns: %{user: user}} = conn, %{id: activity_id}) do + with {:ok, _unannounce} <- CommonAPI.unrepeat(activity_id, user), + %Activity{} = activity <- Activity.get_by_id(activity_id) do + try_render(conn, "show.json", %{activity: activity, for: user, as: :activity}) + end + end + + @doc "POST /api/v1/statuses/:id/favourite" + def favourite(%{assigns: %{user: user}} = conn, %{id: activity_id}) do + with {:ok, _fav} <- CommonAPI.favorite(user, activity_id), + %Activity{} = activity <- Activity.get_by_id(activity_id) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/unfavourite" + def unfavourite(%{assigns: %{user: user}} = conn, %{id: activity_id}) do + with {:ok, _unfav} <- CommonAPI.unfavorite(activity_id, user), + %Activity{} = activity <- Activity.get_by_id(activity_id) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/pin" + def pin(%{assigns: %{user: user}} = conn, %{id: ap_id_or_id}) do + with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + else + {:error, :pinned_statuses_limit_reached} -> + {:error, "You have already pinned the maximum number of statuses"} + + {:error, :ownership_error} -> + {:error, :unprocessable_entity, "Someone else's status cannot be pinned"} + + {:error, :visibility_error} -> + {:error, :unprocessable_entity, "Non-public status cannot be pinned"} + + error -> + error + end + end + + @doc "POST /api/v1/statuses/:id/unpin" + def unpin(%{assigns: %{user: user}} = conn, %{id: ap_id_or_id}) do + with {:ok, activity} <- CommonAPI.unpin(ap_id_or_id, user) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/bookmark" + def bookmark(%{assigns: %{user: user}} = conn, %{id: id}) do + with %Activity{} = activity <- Activity.get_by_id_with_object(id), + %User{} = user <- User.get_cached_by_nickname(user.nickname), + true <- Visibility.visible_for_user?(activity, user), + {:ok, _bookmark} <- Bookmark.create(user.id, activity.id) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/unbookmark" + def unbookmark(%{assigns: %{user: user}} = conn, %{id: id}) do + with %Activity{} = activity <- Activity.get_by_id_with_object(id), + %User{} = user <- User.get_cached_by_nickname(user.nickname), + true <- Visibility.visible_for_user?(activity, user), + {:ok, _bookmark} <- Bookmark.destroy(user.id, activity.id) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/mute" + def mute_conversation(%{assigns: %{user: user}, body_params: params} = conn, %{id: id}) do + with %Activity{} = activity <- Activity.get_by_id(id), + {:ok, activity} <- CommonAPI.add_mute(user, activity, params) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/unmute" + def unmute_conversation(%{assigns: %{user: user}} = conn, %{id: id}) do + with %Activity{} = activity <- Activity.get_by_id(id), + {:ok, activity} <- CommonAPI.remove_mute(user, activity) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "GET /api/v1/statuses/:id/card" + @deprecated "https://github.com/tootsuite/mastodon/pull/11213" + def card(%{assigns: %{user: user}} = conn, %{id: status_id}) do + with %Activity{} = activity <- Activity.get_by_id(status_id), + true <- Visibility.visible_for_user?(activity, user) do + data = Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) + render(conn, "card.json", %{embed: data}) + else + _ -> render_error(conn, :not_found, "Record not found") + end + end + + @doc "GET /api/v1/statuses/:id/favourited_by" + def favourited_by(%{assigns: %{user: user}} = conn, %{id: id}) do + with true <- Pleroma.Config.get([:instance, :show_reactions]), + %Activity{} = activity <- Activity.get_by_id_with_object(id), + {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, + %Object{data: %{"likes" => likes}} <- Object.normalize(activity, fetch: false) do + users = + User + |> Ecto.Query.where([u], u.ap_id in ^likes) + |> Repo.all() + |> Enum.filter(&(not User.blocks?(user, &1))) + + conn + |> put_view(AccountView) + |> render("index.json", for: user, users: users, as: :user) + else + {:visible, false} -> {:error, :not_found} + _ -> json(conn, []) + end + end + + @doc "GET /api/v1/statuses/:id/reblogged_by" + def reblogged_by(%{assigns: %{user: user}} = conn, %{id: id}) do + with %Activity{} = activity <- Activity.get_by_id_with_object(id), + {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, + %Object{data: %{"announcements" => announces, "id" => ap_id}} <- + Object.normalize(activity, fetch: false) do + announces = + "Announce" + |> Activity.Queries.by_type() + |> Ecto.Query.where([a], a.actor in ^announces) + # this is to use the index + |> Activity.Queries.by_object_id(ap_id) + |> Repo.all() + |> Enum.filter(&Visibility.visible_for_user?(&1, user)) + |> Enum.map(& &1.actor) + |> Enum.uniq() + + users = + User + |> Ecto.Query.where([u], u.ap_id in ^announces) + |> Repo.all() + |> Enum.filter(&(not User.blocks?(user, &1))) + + conn + |> put_view(AccountView) + |> render("index.json", for: user, users: users, as: :user) + else + {:visible, false} -> {:error, :not_found} + _ -> json(conn, []) + end + end + + @doc "GET /api/v1/statuses/:id/context" + def context(%{assigns: %{user: user}} = conn, %{id: id}) do + with %Activity{} = activity <- Activity.get_by_id(id) do + activities = + ActivityPub.fetch_activities_for_context(activity.data["context"], %{ + blocking_user: user, + user: user, + exclude_id: activity.id + }) + + render(conn, "context.json", activity: activity, activities: activities, user: user) + end + end + + @doc "POST /api/v1/statuses/:id/translate" + def translate(%{body_params: params, assigns: %{user: user}} = conn, %{id: status_id}) do + with {:authentication, true} <- + {:authentication, + !is_nil(user) || + Pleroma.Config.get([Pleroma.Language.Translation, :allow_unauthenticated])}, + %Activity{object: object} <- Activity.get_by_id_with_object(status_id), + {:visibility, visibility} when visibility in ["public", "unlisted"] <- + {:visibility, Visibility.get_visibility(object)}, + {:allow_remote, true} <- + {:allow_remote, + Object.local?(object) || + Pleroma.Config.get([Pleroma.Language.Translation, :allow_remote])}, + {:language, language} when is_binary(language) <- + {:language, Map.get(params, :target_language) || user.language}, + {:ok, result} <- + Translation.translate( + object.data["content"], + object.data["language"], + language + ) do + render(conn, "translation.json", result) + else + {:authentication, false} -> + render_error(conn, :unauthorized, "Authorization is required to translate statuses") + + {:allow_remote, false} -> + render_error(conn, :bad_request, "You can't translate remote posts") + + {:language, nil} -> + render_error(conn, :bad_request, "Language not specified") + + {:visibility, _} -> + render_error(conn, :not_found, "Record not found") + + {:error, :not_found} -> + render_error(conn, :not_found, "Translation service not configured") + + {:error, error} when error in [:unexpected_response, :quota_exceeded, :too_many_requests] -> + render_error(conn, :service_unavailable, "Translation service not available") + + nil -> + render_error(conn, :not_found, "Record not found") + end + end + + @doc "GET /api/v1/favourites" + def favourites(%{assigns: %{user: %User{} = user}} = conn, params) do + activities = ActivityPub.fetch_favourites(user, params) + + conn + |> add_link_headers(activities) + |> render("index.json", + activities: activities, + for: user, + as: :activity + ) + end + + @doc "GET /api/v1/bookmarks" + def bookmarks(%{assigns: %{user: user}} = conn, params) do + user = User.get_cached_by_id(user.id) + + bookmarks = + user.id + |> Bookmark.for_user_query() + |> Pleroma.Pagination.fetch_paginated(params) + + activities = + bookmarks + |> Enum.map(fn b -> Map.put(b.activity, :bookmark, Map.delete(b, :activity)) end) + + conn + |> add_link_headers(bookmarks) + |> render("index.json", + activities: activities, + for: user, + as: :activity + ) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex new file mode 100644 index 0000000..9cc0071 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex @@ -0,0 +1,77 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.SubscriptionController do + @moduledoc "The module represents functions to manage user subscriptions." + use Pleroma.Web, :controller + + alias Pleroma.Web.Push + alias Pleroma.Web.Push.Subscription + + action_fallback(:errors) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(:restrict_push_enabled) + plug(Pleroma.Web.Plugs.OAuthScopesPlug, %{scopes: ["push"]}) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SubscriptionOperation + + # Creates PushSubscription + # POST /api/v1/push/subscription + # + def create(%{assigns: %{user: user, token: token}, body_params: params} = conn, _) do + with {:ok, _} <- Subscription.delete_if_exists(user, token), + {:ok, subscription} <- Subscription.create(user, token, params) do + render(conn, "show.json", subscription: subscription) + end + end + + # Gets PushSubscription + # GET /api/v1/push/subscription + # + def show(%{assigns: %{user: user, token: token}} = conn, _params) do + with {:ok, subscription} <- Subscription.get(user, token) do + render(conn, "show.json", subscription: subscription) + end + end + + # Updates PushSubscription + # PUT /api/v1/push/subscription + # + def update(%{assigns: %{user: user, token: token}, body_params: params} = conn, _) do + with {:ok, subscription} <- Subscription.update(user, token, params) do + render(conn, "show.json", subscription: subscription) + end + end + + # Deletes PushSubscription + # DELETE /api/v1/push/subscription + # + def delete(%{assigns: %{user: user, token: token}} = conn, _params) do + with {:ok, _response} <- Subscription.delete(user, token), + do: json(conn, %{}) + end + + defp restrict_push_enabled(conn, _) do + if Push.enabled() do + conn + else + conn + |> render_error(:forbidden, "Web push subscription is disabled on this Pleroma instance") + |> halt() + end + end + + # fallback action + # + def errors(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> json(%{error: dgettext("errors", "Record not found")}) + end + + def errors(conn, _) do + Pleroma.Web.MastodonAPI.FallbackController.call(conn, nil) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex new file mode 100644 index 0000000..69ae70a --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex @@ -0,0 +1,120 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.SuggestionController do + use Pleroma.Web, :controller + import Ecto.Query + alias Pleroma.FollowingRelationship + alias Pleroma.User + alias Pleroma.UserRelationship + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.Plugs.OAuthScopesPlug, %{scopes: ["read"]} when action in [:index, :index2]) + plug(Pleroma.Web.Plugs.OAuthScopesPlug, %{scopes: ["write"]} when action in [:dismiss]) + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %OpenApiSpex.Operation{ + tags: ["Suggestions"], + summary: "Follow suggestions (Not implemented)", + operationId: "SuggestionController.index", + responses: %{ + 200 => Pleroma.Web.ApiSpec.Helpers.empty_array_response() + } + } + end + + def index2_operation do + %OpenApiSpex.Operation{ + tags: ["Suggestions"], + summary: "Follow suggestions", + operationId: "SuggestionController.index2", + responses: %{ + 200 => Pleroma.Web.ApiSpec.Helpers.empty_array_response() + } + } + end + + def dismiss_operation do + %OpenApiSpex.Operation{ + tags: ["Suggestions"], + summary: "Remove a suggestion", + operationId: "SuggestionController.dismiss", + parameters: [ + OpenApiSpex.Operation.parameter( + :account_id, + :path, + %OpenApiSpex.Schema{type: :string}, + "Account to dismiss", + required: true + ) + ], + responses: %{ + 200 => Pleroma.Web.ApiSpec.Helpers.empty_object_response() + } + } + end + + @doc "GET /api/v1/suggestions" + def index(conn, params), + do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params) + + @doc "GET /api/v2/suggestions" + def index2(%{assigns: %{user: user}} = conn, params) do + limit = Map.get(params, :limit, 40) |> min(80) + + users = + %{is_suggested: true, invisible: false, limit: limit} + |> User.Query.build() + |> exclude_user(user) + |> exclude_relationships(user, [:block, :mute, :suggestion_dismiss]) + |> exclude_following(user) + |> Pleroma.Repo.all() + + render(conn, "index.json", %{ + users: users, + source: :staff, + for: user, + skip_visibility_check: true + }) + end + + defp exclude_user(query, %User{id: user_id}) do + where(query, [u], u.id != ^user_id) + end + + defp exclude_relationships(query, %User{id: user_id}, relationship_types) do + query + |> join(:left, [u], r in UserRelationship, + as: :user_relationships, + on: + r.target_id == u.id and r.source_id == ^user_id and + r.relationship_type in ^relationship_types + ) + |> where([user_relationships: r], is_nil(r.target_id)) + end + + defp exclude_following(query, %User{id: user_id}) do + query + |> join(:left, [u], r in FollowingRelationship, + as: :following_relationships, + on: r.following_id == u.id and r.follower_id == ^user_id and r.state == :follow_accept + ) + |> where([following_relationships: r], is_nil(r.following_id)) + end + + @doc "DELETE /api/v1/suggestions/:account_id" + def dismiss(%{assigns: %{user: source}} = conn, %{account_id: user_id}) do + with %User{} = target <- User.get_cached_by_id(user_id), + {:ok, _} <- UserRelationship.create(:suggestion_dismiss, source, target) do + json(conn, %{}) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex new file mode 100644 index 0000000..293c61b --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -0,0 +1,210 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.TimelineController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [add_link_headers: 2, add_link_headers: 3] + + alias Pleroma.Config + alias Pleroma.Pagination + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Web.Plugs.RateLimiter + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(:skip_public_check when action in [:public, :hashtag]) + + # TODO: Replace with a macro when there is a Phoenix release with the following commit in it: + # https://github.com/phoenixframework/phoenix/commit/2e8c63c01fec4dde5467dbbbf9705ff9e780735e + + plug(RateLimiter, [name: :timeline, bucket_name: :direct_timeline] when action == :direct) + plug(RateLimiter, [name: :timeline, bucket_name: :public_timeline] when action == :public) + plug(RateLimiter, [name: :timeline, bucket_name: :home_timeline] when action == :home) + plug(RateLimiter, [name: :timeline, bucket_name: :hashtag_timeline] when action == :hashtag) + plug(RateLimiter, [name: :timeline, bucket_name: :list_timeline] when action == :list) + + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct]) + plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list) + + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} + when action in [:public, :hashtag] + ) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TimelineOperation + + # GET /api/v1/timelines/home + def home(%{assigns: %{user: user}} = conn, params) do + params = + params + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_filtering_user, user) + |> Map.put(:announce_filtering_user, user) + |> Map.put(:user, user) + |> Map.put(:local_only, params[:local]) + |> Map.delete(:local) + + activities = + [user.ap_id | User.following(user)] + |> ActivityPub.fetch_activities(params) + |> Enum.reverse() + + conn + |> add_link_headers(activities) + |> render("index.json", + activities: activities, + for: user, + as: :activity, + with_muted: Map.get(params, :with_muted, false) + ) + end + + # GET /api/v1/timelines/direct + def direct(%{assigns: %{user: user}} = conn, params) do + params = + params + |> Map.put(:type, "Create") + |> Map.put(:blocking_user, user) + |> Map.put(:user, user) + |> Map.put(:visibility, "direct") + + activities = + [user.ap_id] + |> ActivityPub.fetch_activities_query(params) + |> Pagination.fetch_paginated(params) + + conn + |> add_link_headers(activities) + |> render("index.json", + activities: activities, + for: user, + as: :activity + ) + end + + defp restrict_unauthenticated?(true = _local_only) do + Config.restrict_unauthenticated_access?(:timelines, :local) + end + + defp restrict_unauthenticated?(_) do + Config.restrict_unauthenticated_access?(:timelines, :federated) + end + + # GET /api/v1/timelines/public + def public(%{assigns: %{user: user}} = conn, params) do + local_only = params[:local] + + if is_nil(user) and restrict_unauthenticated?(local_only) do + fail_on_bad_auth(conn) + else + activities = + params + |> Map.put(:type, ["Create"]) + |> Map.put(:local_only, local_only) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_filtering_user, user) + |> Map.put(:instance, params[:instance]) + # Restricts unfederated content to authenticated users + |> Map.put(:includes_local_public, not is_nil(user)) + |> ActivityPub.fetch_public_activities() + + conn + |> add_link_headers(activities, %{"local" => local_only}) + |> render("index.json", + activities: activities, + for: user, + as: :activity, + with_muted: Map.get(params, :with_muted, false) + ) + end + end + + defp fail_on_bad_auth(conn) do + render_error(conn, :unauthorized, "authorization required for timeline view") + end + + defp hashtag_fetching(params, user, local_only) do + # Note: not sanitizing tag options at this stage (may be mix-cased, have duplicates etc.) + tags_any = + [params[:tag], params[:any]] + |> List.flatten() + |> Enum.filter(& &1) + + tag_all = Map.get(params, :all, []) + tag_reject = Map.get(params, :none, []) + + params + |> Map.put(:type, "Create") + |> Map.put(:local_only, local_only) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:tag, tags_any) + |> Map.put(:tag_all, tag_all) + |> Map.put(:tag_reject, tag_reject) + |> ActivityPub.fetch_public_activities() + end + + # GET /api/v1/timelines/tag/:tag + def hashtag(%{assigns: %{user: user}} = conn, params) do + local_only = params[:local] + + if is_nil(user) and restrict_unauthenticated?(local_only) do + fail_on_bad_auth(conn) + else + activities = hashtag_fetching(params, user, local_only) + + conn + |> add_link_headers(activities, %{"local" => local_only}) + |> render("index.json", + activities: activities, + for: user, + as: :activity, + with_muted: Map.get(params, :with_muted, false) + ) + end + end + + # GET /api/v1/timelines/list/:list_id + def list(%{assigns: %{user: user}} = conn, %{list_id: id} = params) do + with %Pleroma.List{title: _title, following: following} <- Pleroma.List.get(id, user) do + params = + params + |> Map.put(:type, "Create") + |> Map.put(:blocking_user, user) + |> Map.put(:user, user) + |> Map.put(:muting_user, user) + |> Map.put(:local_only, params[:local]) + + # we must filter the following list for the user to avoid leaking statuses the user + # does not actually have permission to see (for more info, peruse security issue #270). + + user_following = User.following(user) + + activities = + following + |> Enum.filter(fn x -> x in user_following end) + |> ActivityPub.fetch_activities_bounded(following, params) + |> Enum.reverse() + + conn + |> add_link_headers(activities) + |> render("index.json", + activities: activities, + for: user, + as: :activity, + with_muted: Map.get(params, :with_muted, false) + ) + else + _e -> render_error(conn, :forbidden, "Error.") + end + end +end diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex new file mode 100644 index 0000000..467dc2f --- /dev/null +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -0,0 +1,121 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.MastodonAPI do + import Ecto.Query + import Ecto.Changeset + + alias Pleroma.Notification + alias Pleroma.Pagination + alias Pleroma.ScheduledActivity + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + @spec follow(User.t(), User.t(), map) :: {:ok, User.t()} | {:error, String.t()} + def follow(follower, followed, params \\ %{}) do + result = + if not User.following?(follower, followed) do + CommonAPI.follow(follower, followed) + else + {:ok, follower, followed, nil} + end + + with {:ok, follower, _followed, _} <- result do + options = cast_params(params) + set_reblogs_visibility(options[:reblogs], result) + set_subscription(options[:notify], result) + {:ok, follower} + end + end + + defp set_reblogs_visibility(false, {:ok, follower, followed, _}) do + CommonAPI.hide_reblogs(follower, followed) + end + + defp set_reblogs_visibility(_, {:ok, follower, followed, _}) do + CommonAPI.show_reblogs(follower, followed) + end + + defp set_subscription(true, {:ok, follower, followed, _}) do + User.subscribe(follower, followed) + end + + defp set_subscription(false, {:ok, follower, followed, _}) do + User.unsubscribe(follower, followed) + end + + defp set_subscription(_, _), do: {:ok, nil} + + @spec get_followers(User.t(), map()) :: list(User.t()) + def get_followers(user, params \\ %{}) do + user + |> User.get_followers_query() + |> Pagination.fetch_paginated(params) + end + + def get_friends(user, params \\ %{}) do + user + |> User.get_friends_query() + |> Pagination.fetch_paginated(params) + end + + def get_notifications(user, params \\ %{}) do + options = + cast_params(params) |> Map.update(:include_types, [], fn include_types -> include_types end) + + options = + if ("pleroma:report" not in options.include_types and + User.privileged?(user, :reports_manage_reports)) or + User.privileged?(user, :reports_manage_reports) do + options + else + options + |> Map.update(:exclude_types, ["pleroma:report"], fn current_exclude_types -> + current_exclude_types ++ ["pleroma:report"] + end) + end + + user + |> Notification.for_user_query(options) + |> restrict(:types, options) + |> restrict(:exclude_types, options) + |> restrict(:account_ap_id, options) + |> Pagination.fetch_paginated(params) + end + + def get_scheduled_activities(user, params \\ %{}) do + user + |> ScheduledActivity.for_user_query() + |> Pagination.fetch_paginated(params) + end + + defp cast_params(params) do + param_types = %{ + exclude_types: {:array, :string}, + types: {:array, :string}, + exclude_visibilities: {:array, :string}, + reblogs: :boolean, + with_muted: :boolean, + account_ap_id: :string, + notify: :boolean + } + + changeset = cast({%{}, param_types}, params, Map.keys(param_types)) + changeset.changes + end + + defp restrict(query, :types, %{types: mastodon_types = [_ | _]}) do + where(query, [n], n.type in ^mastodon_types) + end + + defp restrict(query, :exclude_types, %{exclude_types: mastodon_types = [_ | _]}) do + where(query, [n], n.type not in ^mastodon_types) + end + + defp restrict(query, :account_ap_id, %{account_ap_id: account_ap_id}) do + where(query, [n, a], a.actor == ^account_ap_id) + end + + defp restrict(query, _, _), do: query +end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex new file mode 100644 index 0000000..adb7ebc --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -0,0 +1,498 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AccountView do + use Pleroma.Web, :view + + alias Pleroma.FollowingRelationship + alias Pleroma.User + alias Pleroma.UserNote + alias Pleroma.UserRelationship + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MediaProxy + + def render("index.json", %{users: users} = opts) do + reading_user = opts[:for] + + relationships_opt = + cond do + Map.has_key?(opts, :relationships) -> + opts[:relationships] + + is_nil(reading_user) || !opts[:embed_relationships] -> + UserRelationship.view_relationships_option(nil, []) + + true -> + UserRelationship.view_relationships_option(reading_user, users) + end + + opts = + opts + |> Map.merge(%{relationships: relationships_opt, as: :user}) + |> Map.delete(:users) + + users + |> render_many(AccountView, "show.json", opts) + |> Enum.filter(&Enum.any?/1) + end + + @doc """ + Renders specified user account. + :skip_visibility_check option skips visibility check and renders any user (local or remote) + regardless of [:pleroma, :restrict_unauthenticated] setting. + :for option specifies the requester and can be a User record or nil. + Only use `user: user, for: user` when `user` is the actual requester of own profile. + """ + def render("show.json", %{user: _user, skip_visibility_check: true} = opts) do + do_render("show.json", opts) + end + + def render("show.json", %{user: user, for: for_user_or_nil} = opts) do + if User.visible_for(user, for_user_or_nil) == :visible do + do_render("show.json", opts) + else + %{} + end + end + + def render("show.json", _) do + raise "In order to prevent account accessibility issues, " <> + ":skip_visibility_check or :for option is required." + end + + def render("mention.json", %{user: user}) do + %{ + id: to_string(user.id), + acct: user.nickname, + username: username_from_nickname(user.nickname), + url: user.uri || user.ap_id + } + end + + def render("relationship.json", %{user: nil, target: _target}) do + %{} + end + + def render( + "relationship.json", + %{user: %User{} = reading_user, target: %User{} = target} = opts + ) do + user_relationships = get_in(opts, [:relationships, :user_relationships]) + following_relationships = get_in(opts, [:relationships, :following_relationships]) + + follow_state = + if following_relationships do + user_to_target_following_relation = + FollowingRelationship.find(following_relationships, reading_user, target) + + User.get_follow_state(reading_user, target, user_to_target_following_relation) + else + User.get_follow_state(reading_user, target) + end + + followed_by = + if following_relationships do + case FollowingRelationship.find(following_relationships, target, reading_user) do + %{state: :follow_accept} -> true + _ -> false + end + else + User.following?(target, reading_user) + end + + subscribing = + UserRelationship.exists?( + user_relationships, + :inverse_subscription, + target, + reading_user, + &User.subscribed_to?(&2, &1) + ) + + # NOTE: adjust UserRelationship.view_relationships_option/2 on new relation-related flags + %{ + id: to_string(target.id), + following: follow_state == :follow_accept, + followed_by: followed_by, + blocking: + UserRelationship.exists?( + user_relationships, + :block, + reading_user, + target, + &User.blocks_user?(&1, &2) + ), + blocked_by: + UserRelationship.exists?( + user_relationships, + :block, + target, + reading_user, + &User.blocks_user?(&1, &2) + ), + muting: + UserRelationship.exists?( + user_relationships, + :mute, + reading_user, + target, + &User.mutes?(&1, &2) + ), + muting_notifications: + UserRelationship.exists?( + user_relationships, + :notification_mute, + reading_user, + target, + &User.muted_notifications?(&1, &2) + ), + subscribing: subscribing, + notifying: subscribing, + requested: follow_state == :follow_pending, + domain_blocking: User.blocks_domain?(reading_user, target), + showing_reblogs: + not UserRelationship.exists?( + user_relationships, + :reblog_mute, + reading_user, + target, + &User.muting_reblogs?(&1, &2) + ), + note: + UserNote.show( + reading_user, + target + ), + endorsed: + UserRelationship.exists?( + user_relationships, + :endorsement, + target, + reading_user, + &User.endorses?(&2, &1) + ) + } + end + + def render("relationships.json", %{user: user, targets: targets} = opts) do + relationships_opt = + cond do + Map.has_key?(opts, :relationships) -> + opts[:relationships] + + is_nil(user) -> + UserRelationship.view_relationships_option(nil, []) + + true -> + UserRelationship.view_relationships_option(user, targets) + end + + render_opts = %{as: :target, user: user, relationships: relationships_opt} + render_many(targets, AccountView, "relationship.json", render_opts) + end + + def render("familiar_followers.json", %{users: users} = opts) do + opts = + opts + |> Map.merge(%{as: :user}) + |> Map.delete(:users) + + users + |> render_many(AccountView, "familiar_followers.json", opts) + end + + def render("familiar_followers.json", %{user: %{id: id, accounts: accounts}} = opts) do + accounts = + accounts + |> render_many(AccountView, "show.json", opts) + |> Enum.filter(&Enum.any?/1) + + %{id: id, accounts: accounts} + end + + defp do_render("show.json", %{user: user} = opts) do + user = User.sanitize_html(user, User.html_filter_policy(opts[:for])) + display_name = user.name || user.nickname + + avatar = User.avatar_url(user) |> MediaProxy.url() + avatar_static = User.avatar_url(user) |> MediaProxy.preview_url(static: true) + header = User.banner_url(user) |> MediaProxy.url() + header_static = User.banner_url(user) |> MediaProxy.preview_url(static: true) + + following_count = + if !user.hide_follows_count or !user.hide_follows or opts[:for] == user, + do: user.following_count, + else: 0 + + followers_count = + if !user.hide_followers_count or !user.hide_followers or opts[:for] == user, + do: user.follower_count, + else: 0 + + bot = user.actor_type == "Service" + + emojis = + Enum.map(user.emoji, fn {shortcode, raw_url} -> + url = MediaProxy.url(raw_url) + + %{ + shortcode: shortcode, + url: url, + static_url: url, + visible_in_picker: false + } + end) + + relationship = + if opts[:embed_relationships] do + render("relationship.json", %{ + user: opts[:for], + target: user, + relationships: opts[:relationships] + }) + else + %{} + end + + favicon = + if Pleroma.Config.get([:instances_favicons, :enabled]) do + user + |> Map.get(:ap_id, "") + |> URI.parse() + |> URI.merge("/") + |> Pleroma.Instances.Instance.get_or_update_favicon() + |> MediaProxy.url() + else + nil + end + + %{ + id: to_string(user.id), + username: username_from_nickname(user.nickname), + acct: user.nickname, + display_name: display_name, + locked: user.is_locked, + created_at: Utils.to_masto_date(user.inserted_at), + followers_count: followers_count, + following_count: following_count, + statuses_count: user.note_count, + note: user.bio, + url: user.uri || user.ap_id, + avatar: avatar, + avatar_static: avatar_static, + header: header, + header_static: header_static, + emojis: emojis, + fields: user.fields, + bot: bot, + source: %{ + note: user.raw_bio || "", + sensitive: false, + fields: user.raw_fields, + pleroma: %{ + discoverable: user.is_discoverable, + actor_type: user.actor_type + } + }, + last_status_at: Utils.to_masto_date(user.last_status_at, nil), + + # Pleroma extensions + # Note: it's insecure to output :email but fully-qualified nickname may serve as safe stub + fqn: User.full_nickname(user), + pleroma: %{ + ap_id: user.ap_id, + also_known_as: user.also_known_as, + is_confirmed: user.is_confirmed, + is_suggested: user.is_suggested, + tags: user.tags, + hide_followers_count: user.hide_followers_count, + hide_follows_count: user.hide_follows_count, + hide_followers: user.hide_followers, + hide_follows: user.hide_follows, + hide_favorites: user.hide_favorites, + relationship: relationship, + skip_thread_containment: user.skip_thread_containment, + background_image: image_url(user.background) |> MediaProxy.url(), + accepts_chat_messages: user.accepts_chat_messages, + favicon: favicon, + location: user.location + } + } + |> maybe_put_role(user, opts[:for]) + |> maybe_put_privileges(user, opts[:for]) + |> maybe_put_settings(user, opts[:for], opts) + |> maybe_put_notification_settings(user, opts[:for]) + |> maybe_put_settings_store(user, opts[:for], opts) + |> maybe_put_activation_status(user, opts[:for]) + |> maybe_put_follow_requests_count(user, opts[:for]) + |> maybe_put_allow_following_move(user, opts[:for]) + |> maybe_put_unread_conversation_count(user, opts[:for]) + |> maybe_put_unread_notification_count(user, opts[:for]) + |> maybe_put_accepts_email_list(user, opts[:for]) + |> maybe_put_email_address(user, opts[:for]) + |> maybe_put_mute_expires_at(user, opts[:for], opts) + |> maybe_show_birthday(user, opts[:for]) + end + + defp username_from_nickname(string) when is_binary(string) do + hd(String.split(string, "@")) + end + + defp username_from_nickname(_), do: nil + + defp maybe_put_follow_requests_count( + data, + %User{id: user_id} = user, + %User{id: user_id} + ) do + count = + User.get_follow_requests(user) + |> length() + + data + |> Kernel.put_in([:follow_requests_count], count) + end + + defp maybe_put_follow_requests_count(data, _, _), do: data + + defp maybe_put_settings( + data, + %User{id: user_id} = user, + %User{id: user_id}, + _opts + ) do + data + |> Kernel.put_in([:source, :privacy], user.default_scope) + |> Kernel.put_in([:source, :pleroma, :show_role], user.show_role) + |> Kernel.put_in([:source, :pleroma, :no_rich_text], user.no_rich_text) + |> Kernel.put_in([:source, :pleroma, :show_birthday], user.show_birthday) + end + + defp maybe_put_settings(data, _, _, _), do: data + + defp maybe_put_settings_store(data, %User{} = user, %User{}, %{ + with_pleroma_settings: true + }) do + data + |> Kernel.put_in([:pleroma, :settings_store], user.pleroma_settings_store) + end + + defp maybe_put_settings_store(data, _, _, _), do: data + + defp maybe_put_role(data, %User{show_role: true} = user, _) do + put_role(data, user) + end + + defp maybe_put_role(data, %User{id: user_id} = user, %User{id: user_id}) do + put_role(data, user) + end + + defp maybe_put_role(data, _, _), do: data + + defp put_role(data, user) do + data + |> Kernel.put_in([:pleroma, :is_admin], user.is_admin) + |> Kernel.put_in([:pleroma, :is_moderator], user.is_moderator) + end + + defp maybe_put_privileges(data, %User{id: user_id} = user, %User{id: user_id}) do + put_privileges(data, user) + end + + defp maybe_put_privileges(data, _, _), do: data + + defp put_privileges(data, user) do + Kernel.put_in(data, [:pleroma, :privileges], User.privileges(user)) + end + + defp maybe_put_notification_settings(data, %User{id: user_id} = user, %User{id: user_id}) do + Kernel.put_in( + data, + [:pleroma, :notification_settings], + Map.from_struct(user.notification_settings) + ) + end + + defp maybe_put_notification_settings(data, _, _), do: data + + defp maybe_put_allow_following_move(data, %User{id: user_id} = user, %User{id: user_id}) do + Kernel.put_in(data, [:pleroma, :allow_following_move], user.allow_following_move) + end + + defp maybe_put_allow_following_move(data, _, _), do: data + + defp maybe_put_activation_status(data, user, user_for) do + if User.privileged?(user_for, :users_manage_activation_state), + do: Kernel.put_in(data, [:pleroma, :deactivated], !user.is_active), + else: data + end + + defp maybe_put_unread_conversation_count(data, %User{id: user_id} = user, %User{id: user_id}) do + data + |> Kernel.put_in( + [:pleroma, :unread_conversation_count], + Pleroma.Conversation.Participation.unread_count(user) + ) + end + + defp maybe_put_unread_conversation_count(data, _, _), do: data + + defp maybe_put_unread_notification_count(data, %User{id: user_id}, %User{id: user_id} = user) do + Kernel.put_in( + data, + [:pleroma, :unread_notifications_count], + Pleroma.Notification.unread_notifications_count(user) + ) + end + + defp maybe_put_unread_notification_count(data, _, _), do: data + + defp maybe_put_accepts_email_list(data, %User{id: user_id}, %User{id: user_id} = user) do + Kernel.put_in( + data, + [:pleroma, :accepts_email_list], + user.accepts_email_list + ) + end + + defp maybe_put_accepts_email_list(data, _, _), do: data + + defp maybe_put_email_address(data, %User{id: user_id}, %User{id: user_id} = user) do + Kernel.put_in( + data, + [:pleroma, :email], + user.email + ) + end + + defp maybe_put_email_address(data, _, _), do: data + + defp maybe_put_mute_expires_at(data, %User{} = user, target, %{mutes: true}) do + Map.put( + data, + :mute_expires_at, + UserRelationship.get_mute_expire_date(target, user) + ) + end + + defp maybe_put_mute_expires_at(data, _, _, _), do: data + + defp maybe_show_birthday(data, %User{id: user_id} = user, %User{id: user_id}) do + data + |> Kernel.put_in([:pleroma, :birthday], user.birthday) + end + + defp maybe_show_birthday(data, %User{show_birthday: true} = user, _) do + data + |> Kernel.put_in([:pleroma, :birthday], user.birthday) + end + + defp maybe_show_birthday(data, _, _) do + data + end + + defp image_url(%{"url" => [%{"href" => href} | _]}), do: href + defp image_url(_), do: nil +end diff --git a/lib/pleroma/web/mastodon_api/views/announcement_view.ex b/lib/pleroma/web/mastodon_api/views/announcement_view.ex new file mode 100644 index 0000000..93fdfb1 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/announcement_view.ex @@ -0,0 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AnnouncementView do + use Pleroma.Web, :view + + def render("index.json", %{announcements: announcements, user: user}) do + render_many(announcements, __MODULE__, "show.json", user: user) + end + + def render("show.json", %{announcement: announcement, user: user}) do + Pleroma.Announcement.render_json(announcement, for: user) + end +end diff --git a/lib/pleroma/web/mastodon_api/views/app_view.ex b/lib/pleroma/web/mastodon_api/views/app_view.ex new file mode 100644 index 0000000..92cccd4 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/app_view.ex @@ -0,0 +1,50 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AppView do + use Pleroma.Web, :view + + alias Pleroma.Web.OAuth.App + + def render("index.json", %{apps: apps, count: count, page_size: page_size, admin: true}) do + %{ + apps: render_many(apps, Pleroma.Web.MastodonAPI.AppView, "show.json", %{admin: true}), + count: count, + page_size: page_size + } + end + + def render("show.json", %{admin: true, app: %App{} = app} = assigns) do + "show.json" + |> render(Map.delete(assigns, :admin)) + |> Map.put(:trusted, app.trusted) + |> Map.put(:id, app.id) + end + + def render("show.json", %{app: %App{} = app}) do + %{ + id: app.id |> to_string, + name: app.client_name, + client_id: app.client_id, + client_secret: app.client_secret, + redirect_uri: app.redirect_uris, + website: app.website + } + |> with_vapid_key() + end + + def render("compact_non_secret.json", %{app: %App{website: website, client_name: name}}) do + %{ + name: name, + website: website + } + |> with_vapid_key() + end + + defp with_vapid_key(data) do + vapid_key = Application.get_env(:web_push_encryption, :vapid_details, [])[:public_key] + + Pleroma.Maps.put_if_present(data, "vapid_key", vapid_key) + end +end diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex new file mode 100644 index 0000000..f6577cd --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -0,0 +1,58 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ConversationView do + use Pleroma.Web, :view + + alias Pleroma.Activity + alias Pleroma.Repo + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.StatusView + + def render("participations.json", %{participations: participations, for: user}) do + safe_render_many(participations, __MODULE__, "participation.json", %{ + as: :participation, + for: user + }) + end + + def render("participation.json", %{participation: participation, for: user}) do + participation = Repo.preload(participation, conversation: [], recipients: []) + + last_activity_id = + with nil <- participation.last_activity_id do + ActivityPub.fetch_latest_direct_activity_id_for_context( + participation.conversation.ap_id, + %{ + user: user, + blocking_user: user + } + ) + end + + activity = Activity.get_by_id_with_object(last_activity_id) + + # Conversations return all users except the current user, + # except when the current user is the only participant + users = + if length(participation.recipients) > 1 do + Enum.reject(participation.recipients, &(&1.id == user.id)) + else + participation.recipients + end + + %{ + id: participation.id |> to_string(), + accounts: render(AccountView, "index.json", users: users, for: user), + unread: !participation.read, + last_status: + render(StatusView, "show.json", + activity: activity, + direct_conversation_id: participation.id, + for: user + ) + } + end +end diff --git a/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex new file mode 100644 index 0000000..cd59ab9 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.CustomEmojiView do + use Pleroma.Web, :view + + alias Pleroma.Emoji + alias Pleroma.Web.Endpoint + + def render("index.json", %{custom_emojis: custom_emojis}) do + render_many(custom_emojis, __MODULE__, "show.json") + end + + def render("show.json", %{custom_emoji: {shortcode, %Emoji{file: relative_url, tags: tags}}}) do + url = Endpoint.url() |> URI.merge(relative_url) |> to_string() + + %{ + "shortcode" => shortcode, + "static_url" => url, + "visible_in_picker" => true, + "url" => url, + "tags" => tags, + # Assuming that a comma is authorized in the category name + "category" => tags |> List.delete("Custom") |> Enum.join(",") + } + end +end diff --git a/lib/pleroma/web/mastodon_api/views/filter_view.ex b/lib/pleroma/web/mastodon_api/views/filter_view.ex new file mode 100644 index 0000000..0c97061 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/filter_view.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.FilterView do + use Pleroma.Web, :view + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.FilterView + + def render("index.json", %{filters: filters}) do + render_many(filters, FilterView, "show.json") + end + + def render("show.json", %{filter: filter}) do + expires_at = + if filter.expires_at do + Utils.to_masto_date(filter.expires_at) + else + nil + end + + %{ + id: to_string(filter.filter_id), + phrase: filter.phrase, + context: filter.context, + expires_at: expires_at, + irreversible: filter.hide, + whole_word: filter.whole_word + } + end +end diff --git a/lib/pleroma/web/mastodon_api/views/follow_request_view.ex b/lib/pleroma/web/mastodon_api/views/follow_request_view.ex new file mode 100644 index 0000000..5e50bc2 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/follow_request_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.FollowRequestView do + use Pleroma.Web, :view + alias Pleroma.Web.MastodonAPI + + def render(view, opts), do: MastodonAPI.AccountView.render(view, opts) +end diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex new file mode 100644 index 0000000..017e582 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -0,0 +1,364 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.InstanceView do + use Pleroma.Web, :view + + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.ActivityPub.MRF + alias Pleroma.Web.MastodonAPI + + @mastodon_api_level "2.7.2" + + @block_severities %{ + federated_timeline_removal: "silence", + reject: "suspend" + } + + def render("show.json", _) do + instance = Config.get(:instance) + + %{ + uri: Pleroma.Web.WebFinger.domain(), + title: Keyword.get(instance, :name), + description: Keyword.get(instance, :description), + short_description: Keyword.get(instance, :short_description), + version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.compat_version()})", + email: Keyword.get(instance, :email), + urls: %{ + streaming_api: Pleroma.Web.Endpoint.websocket_url() + }, + stats: Pleroma.Stats.get_stats(), + thumbnail: + URI.merge(Pleroma.Web.Endpoint.url(), Keyword.get(instance, :instance_thumbnail)) + |> to_string, + languages: Keyword.get(instance, :languages, ["en"]), + registrations: Keyword.get(instance, :registrations_open), + approval_required: Keyword.get(instance, :account_approval_required), + configuration: configuration(), + contact_account: contact_account(Keyword.get(instance, :contact_username)), + rules: render(__MODULE__, "rules.json"), + # Extra (not present in Mastodon): + max_toot_chars: Keyword.get(instance, :limit), + max_media_attachments: Keyword.get(instance, :max_media_attachments), + poll_limits: Keyword.get(instance, :poll_limits), + upload_limit: Keyword.get(instance, :upload_limit), + avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit), + background_upload_limit: Keyword.get(instance, :background_upload_limit), + banner_upload_limit: Keyword.get(instance, :banner_upload_limit), + background_image: Pleroma.Web.Endpoint.url() <> Keyword.get(instance, :background_image), + description_limit: Keyword.get(instance, :description_limit), + pleroma: pleroma_configuration(instance), + soapbox: %{ + version: Soapbox.version() + } + } + end + + def render("show2.json", _) do + instance = Config.get(:instance) + + %{ + domain: Pleroma.Web.WebFinger.domain(), + title: Keyword.get(instance, :name), + version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.compat_version()})", + source_url: Pleroma.Application.repository(), + description: Keyword.get(instance, :short_description), + usage: %{users: %{active_month: Pleroma.User.active_user_count()}}, + thumbnail: %{ + url: + URI.merge(Pleroma.Web.Endpoint.url(), Keyword.get(instance, :instance_thumbnail)) + |> to_string + }, + languages: Keyword.get(instance, :languages, ["en"]), + configuration: configuration2(), + registrations: %{ + enabled: Keyword.get(instance, :registrations_open), + approval_required: Keyword.get(instance, :account_approval_required), + message: nil + }, + contact: %{ + email: Keyword.get(instance, :email), + account: contact_account(Keyword.get(instance, :contact_username)) + }, + rules: render(__MODULE__, "rules.json"), + # Extra (not present in Mastodon): + pleroma: pleroma_configuration2(instance), + soapbox: %{ + version: Soapbox.version() + } + } + end + + def render("rules.json", _) do + Pleroma.Rule.query() + |> Pleroma.Repo.all() + |> render_many(__MODULE__, "rule.json", as: :rule) + end + + def render("rule.json", %{rule: rule}) do + %{ + id: rule.id, + text: rule.text + } + end + + def render("domain_blocks.json", _) do + if Config.get([:mrf, :transparency]) do + exclusions = Config.get([:mrf, :transparency_exclusions]) |> MRF.instance_list_from_tuples() + + domain_blocks = + Config.get(:mrf_simple) + |> Enum.map(fn {rule, instances} -> + MRF.normalize_instance_list(instances) + |> Enum.reject(fn {host, _} -> + host in exclusions or not Map.has_key?(@block_severities, rule) + end) + |> Enum.map(fn {host, reason} -> + %{ + domain: host, + digest: :crypto.hash(:sha256, host) |> Base.encode16(case: :lower), + severity: Map.get(@block_severities, rule), + comment: reason + } + end) + end) + |> List.flatten() + + domain_blocks + else + [] + end + end + + def render("translation_languages.json", _) do + with true <- Pleroma.Language.Translation.configured?(), + {:ok, languages} <- Pleroma.Language.Translation.languages_matrix() do + languages + else + _ -> %{} + end + end + + def features do + [ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "v2_suggestions", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + "quote_posting", + "editing", + if Config.get([:activitypub, :blockers_visible]) do + "blockers_visible" + end, + if Config.get([:media_proxy, :enabled]) do + "media_proxy" + end, + if Config.get([:gopher, :enabled]) do + "gopher" + end, + if Config.get([:instance, :allow_relay]) do + "relay" + end, + if Config.get([:instance, :safe_dm_mentions]) do + "safe_dm_mentions" + end, + "pleroma_emoji_reactions", + "pleroma_custom_emoji_reactions", + "pleroma_chat_messages", + "email_list", + if Config.get([:instance, :show_reactions]) do + "exposable_reactions" + end, + if Config.get([:instance, :profile_directory]) do + "profile_directory" + end, + "pleroma:get:main/ostatus", + if Pleroma.Language.Translation.configured?() do + "translation" + end, + "events" + ] + |> Enum.filter(& &1) + end + + def federation do + quarantined = Config.get([:instance, :quarantined_instances], []) + + if Config.get([:mrf, :transparency]) do + {:ok, data} = MRF.describe() + + data + |> Map.put( + :quarantined_instances, + Enum.map(quarantined, fn {instance, _reason} -> instance end) + ) + # This is for backwards compatibility. We originally didn't sent + # extra info like a reason why an instance was rejected/quarantined/etc. + # Because we didn't want to break backwards compatibility it was decided + # to add an extra "info" key. + |> Map.put(:quarantined_instances_info, %{ + "quarantined_instances" => + quarantined + |> Enum.map(fn {instance, reason} -> {instance, %{"reason" => reason}} end) + |> Map.new() + }) + else + %{} + end + |> Map.put(:enabled, Config.get([:instance, :federating])) + end + + defp fields_limits do + %{ + max_fields: Config.get([:instance, :max_account_fields]), + max_remote_fields: Config.get([:instance, :max_remote_account_fields]), + name_length: Config.get([:instance, :account_field_name_length]), + value_length: Config.get([:instance, :account_field_value_length]) + } + end + + defp configuration do + %{ + statuses: %{ + max_characters: Config.get([:instance, :limit]), + max_media_attachments: Config.get([:instance, :max_media_attachments]) + }, + media_attachments: %{ + image_size_limit: Config.get([:instance, :upload_limit]), + video_size_limit: Config.get([:instance, :upload_limit]) + }, + polls: %{ + max_options: Config.get([:instance, :poll_limits, :max_options]), + max_characters_per_option: Config.get([:instance, :poll_limits, :max_option_chars]), + min_expiration: Config.get([:instance, :poll_limits, :min_expiration]), + max_expiration: Config.get([:instance, :poll_limits, :max_expiration]) + } + } + end + + defp configuration2 do + configuration() + |> Map.merge(%{ + urls: %{streaming: Pleroma.Web.Endpoint.websocket_url()}, + translation: %{enabled: Pleroma.Language.Translation.configured?()}, + vapid: %{public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)} + }) + end + + defp restrict_unauthenticated do + Config.get([:restrict_unauthenticated]) + |> Enum.map(fn {category, features} -> + features = + Enum.map(features, fn + {feature, is_enabled} when is_boolean(is_enabled) -> {feature, is_enabled} + {feature, :if_instance_is_private} -> {feature, !Config.get!([:instance, :public])} + end) + |> Enum.into(%{}) + + {category, features} + end) + |> Enum.into(%{}) + end + + defp pleroma_configuration(instance) do + %{ + metadata: %{ + account_activation_required: Keyword.get(instance, :account_activation_required), + features: features(), + federation: federation(), + fields_limits: fields_limits(), + post_formats: Config.get([:instance, :allowed_post_formats]), + privileged_staff: Config.get([:instance, :privileged_staff]), + birthday_required: Config.get([:instance, :birthday_required]), + birthday_min_age: Config.get([:instance, :birthday_min_age]), + migration_cooldown_period: Config.get([:instance, :migration_cooldown_period]), + restrict_unauthenticated: restrict_unauthenticated(), + translation: translation_configuration(), + markup: markup() + }, + stats: %{mau: Pleroma.User.active_user_count()}, + vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key), + oauth_consumer_strategies: Pleroma.Config.oauth_consumer_strategies(), + favicon: + URI.merge(Pleroma.Web.Endpoint.url(), Keyword.get(instance, :favicon)) + |> to_string + } + end + + defp pleroma_configuration2(instance) do + configuration = pleroma_configuration(instance) + + configuration + |> Map.merge(%{ + metadata: + configuration.metadata + |> Map.merge(%{ + avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit), + background_upload_limit: Keyword.get(instance, :background_upload_limit), + banner_upload_limit: Keyword.get(instance, :banner_upload_limit), + background_image: + Pleroma.Web.Endpoint.url() <> Keyword.get(instance, :background_image), + description_limit: Keyword.get(instance, :description_limit) + }) + }) + end + + defp translation_configuration do + enabled = Pleroma.Language.Translation.configured?() + + source_languages = + with true <- enabled, + {:ok, languages} <- Pleroma.Language.Translation.supported_languages(:source) do + languages + else + _ -> nil + end + + target_languages = + with true <- enabled, + {:ok, languages} <- Pleroma.Language.Translation.supported_languages(:target) do + languages + else + _ -> nil + end + + %{ + source_languages: source_languages, + target_languages: target_languages, + allow_unauthenticated: Config.get([Pleroma.Language.Translation, :allow_unauthenticated]), + allow_remote: Config.get([Pleroma.Language.Translation, :allow_remote]) + } + end + + defp contact_account(nil), do: nil + + defp contact_account("@" <> username) do + contact_account(username) + end + + defp contact_account(username) do + user = User.get_cached_by_nickname(username) + + if user do + MastodonAPI.AccountView.render("show.json", %{user: user, for: nil}) + else + nil + end + end + + defp markup do + %{ + allow_inline_images: Config.get([:markup, :allow_inline_images]), + allow_headings: Config.get([:markup, :allow_headings]), + allow_tables: Config.get([:markup, :allow_tables]) + } + end +end diff --git a/lib/pleroma/web/mastodon_api/views/list_view.ex b/lib/pleroma/web/mastodon_api/views/list_view.ex new file mode 100644 index 0000000..a7ae7c5 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/list_view.ex @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ListView do + use Pleroma.Web, :view + alias Pleroma.Web.MastodonAPI.ListView + + def render("index.json", %{lists: lists} = opts) do + render_many(lists, ListView, "show.json", opts) + end + + def render("show.json", %{list: list}) do + %{ + id: to_string(list.id), + title: list.title + } + end +end diff --git a/lib/pleroma/web/mastodon_api/views/marker_view.ex b/lib/pleroma/web/mastodon_api/views/marker_view.ex new file mode 100644 index 0000000..944769b --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/marker_view.ex @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.MarkerView do + use Pleroma.Web, :view + + def render("markers.json", %{markers: markers}) do + Map.new(markers, fn m -> + {m.timeline, + %{ + last_read_id: m.last_read_id, + version: m.lock_version, + updated_at: NaiveDateTime.to_iso8601(m.updated_at), + pleroma: %{ + unread_count: m.unread_count + } + }} + end) + end +end diff --git a/lib/pleroma/web/mastodon_api/views/media_view.ex b/lib/pleroma/web/mastodon_api/views/media_view.ex new file mode 100644 index 0000000..4db72de --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/media_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.MediaView do + use Pleroma.Web, :view + alias Pleroma.Web.MastodonAPI + + def render(view, opts), do: MastodonAPI.StatusView.render(view, opts) +end diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex new file mode 100644 index 0000000..c067c62 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -0,0 +1,195 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.NotificationView do + use Pleroma.Web, :view + + alias Pleroma.Activity + alias Pleroma.Chat.MessageReference + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.UserRelationship + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.AdminAPI.ReportView + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.NotificationView + alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.MediaProxy + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView + + defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id + + defp object_id_for(%{data: %{"object" => id}}) when is_binary(id), do: id + + @parent_types ~w{Like Announce EmojiReact Update} + + def render("index.json", %{notifications: notifications, for: reading_user} = opts) do + activities = Enum.map(notifications, & &1.activity) + + parent_activities = + activities + |> Enum.filter(fn + %{data: %{"type" => type}} -> + type in @parent_types + end) + |> Enum.map(&object_id_for/1) + |> Activity.create_by_object_ap_id() + |> Activity.with_preloaded_object(:left) + |> Pleroma.Repo.all() + + relationships_opt = + cond do + Map.has_key?(opts, :relationships) -> + opts[:relationships] + + is_nil(reading_user) -> + UserRelationship.view_relationships_option(nil, []) + + true -> + move_activities_targets = + activities + |> Enum.filter(&(&1.data["type"] == "Move")) + |> Enum.map(&User.get_cached_by_ap_id(&1.data["target"])) + |> Enum.filter(& &1) + + actors = + activities + |> Enum.map(fn a -> User.get_cached_by_ap_id(a.data["actor"]) end) + |> Enum.filter(& &1) + |> Kernel.++(move_activities_targets) + + UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes) + end + + opts = + opts + |> Map.put(:parent_activities, parent_activities) + |> Map.put(:relationships, relationships_opt) + + safe_render_many(notifications, NotificationView, "show.json", opts) + end + + def render( + "show.json", + %{ + notification: %Notification{activity: activity} = notification, + for: reading_user + } = opts + ) do + actor = User.get_cached_by_ap_id(activity.data["actor"]) + + parent_activity_fn = fn -> + if opts[:parent_activities] do + Activity.Queries.find_by_object_ap_id(opts[:parent_activities], object_id_for(activity)) + else + Activity.get_create_by_object_ap_id(object_id_for(activity)) + end + end + + # Note: :relationships contain user mutes (needed for :muted flag in :status) + status_render_opts = %{relationships: opts[:relationships]} + account = AccountView.render("show.json", %{user: actor, for: reading_user}) + + response = %{ + id: to_string(notification.id), + type: notification.type, + created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), + account: account, + pleroma: %{ + is_muted: User.mutes?(reading_user, actor), + is_seen: notification.seen + } + } + + case notification.type do + type when type in ["mention", "status", "poll", "pleroma:event_reminder"] -> + put_status(response, activity, reading_user, status_render_opts) + + "favourite" -> + put_status(response, parent_activity_fn.(), reading_user, status_render_opts) + + "reblog" -> + put_status(response, parent_activity_fn.(), reading_user, status_render_opts) + + type when type in ["update", "pleroma:event_update"] -> + put_status(response, parent_activity_fn.(), reading_user, status_render_opts) + + "move" -> + put_target(response, activity, reading_user, %{}) + + "pleroma:emoji_reaction" -> + response + |> put_status(parent_activity_fn.(), reading_user, status_render_opts) + |> put_emoji(activity) + + "pleroma:chat_mention" -> + put_chat_message(response, activity, reading_user, status_render_opts) + + "pleroma:report" -> + put_report(response, activity) + + "pleroma:participation_accepted" -> + request_activity = Activity.get_by_ap_id(activity.data["object"]) + create_activity = Activity.get_create_by_object_ap_id(request_activity.data["object"]) + + response + |> put_status(create_activity, reading_user, status_render_opts) + |> put_participation_request(request_activity) + + "pleroma:participation_request" -> + create_activity = Activity.get_create_by_object_ap_id(activity.data["object"]) + + response + |> put_status(create_activity, reading_user, status_render_opts) + |> put_participation_request(activity) + + type when type in ["follow", "follow_request"] -> + response + end + end + + defp put_report(response, activity) do + report_render = ReportView.render("show.json", Report.extract_report_info(activity)) + + Map.put(response, :report, report_render) + end + + defp put_emoji(response, activity) do + response + |> Map.put(:emoji, activity.data["content"]) + |> Map.put(:emoji_url, MediaProxy.url(Pleroma.Emoji.emoji_url(activity.data))) + end + + defp put_chat_message(response, activity, reading_user, opts) do + object = Object.normalize(activity, fetch: false) + author = User.get_cached_by_ap_id(object.data["actor"]) + chat = Pleroma.Chat.get(reading_user.id, author.ap_id) + cm_ref = MessageReference.for_chat_and_object(chat, object) + render_opts = Map.merge(opts, %{for: reading_user, chat_message_reference: cm_ref}) + chat_message_render = MessageReferenceView.render("show.json", render_opts) + + Map.put(response, :chat_message, chat_message_render) + end + + defp put_status(response, activity, reading_user, opts) do + status_render_opts = Map.merge(opts, %{activity: activity, for: reading_user}) + status_render = StatusView.render("show.json", status_render_opts) + + Map.put(response, :status, status_render) + end + + defp put_target(response, activity, reading_user, opts) do + target_user = User.get_cached_by_ap_id(activity.data["target"]) + target_render_opts = Map.merge(opts, %{user: target_user, for: reading_user}) + target_render = AccountView.render("show.json", target_render_opts) + + Map.put(response, :target, target_render) + end + + defp put_participation_request(response, activity) do + Map.put(response, :participation_message, activity.data["participationMessage"]) + end +end diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex new file mode 100644 index 0000000..5bc482c --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -0,0 +1,109 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.PollView do + use Pleroma.Web, :view + + alias Pleroma.Web.CommonAPI.Utils + + def render("show.json", %{object: object, multiple: multiple, options: options} = params) do + {end_time, expired} = end_time_and_expired(object) + {options, votes_count} = options_and_votes_count(options) + + poll = %{ + # Mastodon uses separate ids for polls, but an object can't have + # more than one poll embedded so object id is fine + id: to_string(object.id), + expires_at: end_time, + expired: expired, + multiple: multiple, + votes_count: votes_count, + voters_count: voters_count(object), + options: options, + emojis: Pleroma.Web.MastodonAPI.StatusView.build_emojis(object.data["emoji"]), + pleroma: %{ + non_anonymous: object.data["nonAnonymous"] || false + } + } + + if params[:for] do + # when unauthenticated Mastodon doesn't include `voted` & `own_votes` keys in response + {voted, own_votes} = voted_and_own_votes(params, options) + Map.merge(poll, %{voted: voted, own_votes: own_votes}) + else + poll + end + end + + def render("show.json", %{object: object} = params) do + case object.data do + %{"anyOf" => [_ | _] = options} -> + render(__MODULE__, "show.json", Map.merge(params, %{multiple: true, options: options})) + + %{"oneOf" => [_ | _] = options} -> + render(__MODULE__, "show.json", Map.merge(params, %{multiple: false, options: options})) + + _ -> + nil + end + end + + defp end_time_and_expired(object) do + if object.data["closed"] do + end_time = NaiveDateTime.from_iso8601!(object.data["closed"]) + expired = NaiveDateTime.compare(end_time, NaiveDateTime.utc_now()) == :lt + + {Utils.to_masto_date(end_time), expired} + else + {nil, false} + end + end + + defp options_and_votes_count(options) do + Enum.map_reduce(options, 0, fn %{"name" => name} = option, count -> + current_count = option["replies"]["totalItems"] || 0 + + {%{ + title: name, + votes_count: current_count + }, current_count + count} + end) + end + + defp voters_count(%{data: %{"votersCount" => voters}}) when is_number(voters) do + voters + end + + defp voters_count(%{data: %{"voters" => [_ | _] = voters}}) do + length(voters) + end + + defp voters_count(_), do: 0 + + defp voted_and_own_votes(%{object: object} = params, options) do + if params[:for] do + existing_votes = + Pleroma.Web.ActivityPub.Utils.get_existing_votes(params[:for].ap_id, object) + + voted = existing_votes != [] or params[:for].ap_id == object.data["actor"] + + own_votes = + if voted do + titles = Enum.map(options, & &1[:title]) + + Enum.reduce(existing_votes, [], fn vote, acc -> + data = vote |> Map.get(:object) |> Map.get(:data) + index = Enum.find_index(titles, &(&1 == data["name"])) + [index | acc] + end) + else + [] + end + + {voted, own_votes} + else + {false, []} + end + end +end diff --git a/lib/pleroma/web/mastodon_api/views/report_view.ex b/lib/pleroma/web/mastodon_api/views/report_view.ex new file mode 100644 index 0000000..983f7bd --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/report_view.ex @@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ReportView do + use Pleroma.Web, :view + + def render("show.json", %{activity: activity}) do + %{ + id: to_string(activity.id), + action_taken: false + } + end +end diff --git a/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex new file mode 100644 index 0000000..772d22f --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ScheduledActivityView do + use Pleroma.Web, :view + + alias Pleroma.ScheduledActivity + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.StatusView + + def render("index.json", %{scheduled_activities: scheduled_activities}) do + render_many(scheduled_activities, __MODULE__, "show.json") + end + + def render("show.json", %{scheduled_activity: %ScheduledActivity{} = scheduled_activity}) do + %{ + id: to_string(scheduled_activity.id), + scheduled_at: CommonAPI.Utils.to_masto_date(scheduled_activity.scheduled_at), + params: status_params(scheduled_activity.params) + } + |> with_media_attachments(scheduled_activity) + end + + defp with_media_attachments(data, %{params: %{"media_attachments" => media_attachments}}) do + attachments = render_many(media_attachments, StatusView, "attachment.json", as: :attachment) + Map.put(data, :media_attachments, attachments) + end + + defp with_media_attachments(data, _), do: data + + defp status_params(params) do + %{ + text: params["status"], + sensitive: params["sensitive"], + spoiler_text: params["spoiler_text"], + visibility: params["visibility"], + scheduled_at: params["scheduled_at"], + poll: params["poll"], + in_reply_to_id: params["in_reply_to_id"], + expires_in: params["expires_in"] + } + |> Pleroma.Maps.put_if_present(:media_ids, params["media_ids"]) + end +end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex new file mode 100644 index 0000000..4d8dbff --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -0,0 +1,915 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.StatusView do + use Pleroma.Web, :view + + require Pleroma.Constants + + alias Pleroma.Activity + alias Pleroma.HTML + alias Pleroma.Maps + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.UserRelationship + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.PollView + alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.MediaProxy + alias Pleroma.Web.PleromaAPI.EmojiReactionController + alias Pleroma.Web.RichMedia.Parser.Card + alias Pleroma.Web.RichMedia.Parser.Embed + + import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2] + + # This is a naive way to do this, just spawning a process per activity + # to fetch the preview. However it should be fine considering + # pagination is restricted to 40 activities at a time + defp fetch_rich_media_for_activities(activities) do + Enum.each(activities, fn activity -> + spawn(fn -> + Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) + end) + end) + end + + # TODO: Add cached version. + defp get_replied_to_activities([]), do: %{} + + defp get_replied_to_activities(activities) do + activities + |> Enum.map(fn + %{data: %{"type" => "Create"}} = activity -> + object = Object.normalize(activity, fetch: false) + object && object.data["inReplyTo"] != "" && object.data["inReplyTo"] + + _ -> + nil + end) + |> Enum.filter(& &1) + |> Activity.create_by_object_ap_id_with_object() + |> Repo.all() + |> Enum.reduce(%{}, fn activity, acc -> + object = Object.normalize(activity, fetch: false) + if object, do: Map.put(acc, object.data["id"], activity), else: acc + end) + end + + defp get_quoted_activities([]), do: %{} + + defp get_quoted_activities(activities) do + activities + |> Enum.map(fn + %{data: %{"type" => "Create"}} = activity -> + object = Object.normalize(activity, fetch: false) + object && object.data["quoteUrl"] != "" && object.data["quoteUrl"] + + _ -> + nil + end) + |> Enum.filter(& &1) + |> Activity.create_by_object_ap_id_with_object() + |> Repo.all() + |> Enum.reduce(%{}, fn activity, acc -> + object = Object.normalize(activity, fetch: false) + if object, do: Map.put(acc, object.data["id"], activity), else: acc + end) + end + + # DEPRECATED This field seems to be a left-over from the StatusNet era. + # If your application uses `pleroma.conversation_id`: this field is deprecated. + # It is currently stubbed instead by doing a CRC32 of the context, and + # clearing the MSB to avoid overflow exceptions with signed integers on the + # different clients using this field (Java/Kotlin code, mostly; see Husky.) + # This should be removed in a future version of Pleroma. Pleroma-FE currently + # depends on this field, as well. + defp get_context_id(%{data: %{"context" => context}}) when is_binary(context) do + import Bitwise + + :erlang.crc32(context) + |> band(bnot(0x8000_0000)) + end + + defp get_context_id(_), do: nil + + # Check if the user reblogged this status + defp reblogged?(activity, %User{ap_id: ap_id}) do + with %Object{data: %{"announcements" => announcements}} when is_list(announcements) <- + Object.normalize(activity, fetch: false) do + ap_id in announcements + else + _ -> false + end + end + + # False if the user is logged out + defp reblogged?(_activity, _user), do: false + + def render("index.json", opts) do + reading_user = opts[:for] + + # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list + activities = Enum.filter(opts.activities, & &1) + + # Start fetching rich media before doing anything else, so that later calls to get the cards + # only block for timeout in the worst case, as opposed to + # length(activities_with_links) * timeout + fetch_rich_media_for_activities(activities) + replied_to_activities = get_replied_to_activities(activities) + quoted_activities = get_quoted_activities(activities) + + parent_activities = + activities + |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"])) + |> Enum.map(&Object.normalize(&1, fetch: false).data["id"]) + |> Activity.create_by_object_ap_id() + |> Activity.with_preloaded_object(:left) + |> Activity.with_preloaded_bookmark(reading_user) + |> Activity.with_set_thread_muted_field(reading_user) + |> Repo.all() + + relationships_opt = + cond do + Map.has_key?(opts, :relationships) -> + opts[:relationships] + + is_nil(reading_user) -> + UserRelationship.view_relationships_option(nil, []) + + true -> + # Note: unresolved users are filtered out + actors = + (activities ++ parent_activities) + |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false)) + |> Enum.filter(& &1) + + UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes) + end + + opts = + opts + |> Map.put(:replied_to_activities, replied_to_activities) + |> Map.put(:quoted_activities, quoted_activities) + |> Map.put(:parent_activities, parent_activities) + |> Map.put(:relationships, relationships_opt) + + safe_render_many(activities, StatusView, "show.json", opts) + end + + def render( + "show.json", + %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts + ) do + user = CommonAPI.get_user(activity.data["actor"]) + created_at = Utils.to_masto_date(activity.data["published"]) + object = Object.normalize(activity, fetch: false) + + reblogged_parent_activity = + if opts[:parent_activities] do + Activity.Queries.find_by_object_ap_id( + opts[:parent_activities], + object.data["id"] + ) + else + Activity.create_by_object_ap_id(object.data["id"]) + |> Activity.with_preloaded_bookmark(opts[:for]) + |> Activity.with_set_thread_muted_field(opts[:for]) + |> Repo.one() + end + + reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity) + reblogged = render("show.json", reblog_rendering_opts) + + favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) + + bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil + + mentions = + activity.recipients + |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end) + |> Enum.filter(& &1) + |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end) + + {pinned?, pinned_at} = pin_data(object, user) + + %{ + id: to_string(activity.id), + uri: object.data["id"], + url: object.data["id"], + account: + AccountView.render("show.json", %{ + user: user, + for: opts[:for] + }), + in_reply_to_id: nil, + in_reply_to_account_id: nil, + reblog: reblogged, + content: reblogged[:content] || "", + created_at: created_at, + reblogs_count: 0, + replies_count: 0, + favourites_count: 0, + reblogged: reblogged?(reblogged_parent_activity, opts[:for]), + favourited: present?(favorited), + bookmarked: present?(bookmarked), + muted: false, + pinned: pinned?, + sensitive: false, + spoiler_text: "", + visibility: get_visibility(activity), + media_attachments: reblogged[:media_attachments] || [], + mentions: mentions, + tags: reblogged[:tags] || [], + application: build_application(object.data["generator"]), + language: object.data["language"], + emojis: [], + pleroma: %{ + local: activity.local, + pinned_at: pinned_at + } + } + end + + def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do + object = Object.normalize(activity, fetch: false) + + user = CommonAPI.get_user(activity.data["actor"]) + user_follower_address = user.follower_address + + like_count = object.data["like_count"] || 0 + announcement_count = object.data["announcement_count"] || 0 + + hashtags = Object.hashtags(object) + sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw") + + tags = Object.tags(object) + + tag_mentions = + tags + |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end) + |> Enum.map(fn tag -> tag["href"] end) + + mentions = + (object.data["to"] ++ tag_mentions) + |> Enum.uniq() + |> Enum.map(fn + Pleroma.Constants.as_public() -> nil + ^user_follower_address -> nil + ap_id -> User.get_cached_by_ap_id(ap_id) + end) + |> Enum.filter(& &1) + |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end) + + favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) + + bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil + + client_posted_this_activity = opts[:for] && user.id == opts[:for].id + + expires_at = + with true <- client_posted_this_activity, + %Oban.Job{scheduled_at: scheduled_at} <- + Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do + scheduled_at + else + _ -> nil + end + + thread_muted? = + cond do + is_nil(opts[:for]) -> false + is_boolean(activity.thread_muted?) -> activity.thread_muted? + true -> CommonAPI.thread_muted?(opts[:for], activity) + end + + attachment_data = object.data["attachment"] || [] + attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment) + + created_at = Utils.to_masto_date(object.data["published"]) + + edited_at = + with %{"updated" => updated} <- object.data, + date <- Utils.to_masto_date(updated), + true <- date != "" do + date + else + _ -> + nil + end + + reply_to = get_reply_to(activity, opts) + reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"]) + + quote_activity = get_quote(activity, opts) + + quote_id = + case quote_activity do + %Activity{id: id} -> id + _ -> nil + end + + quote_post = + if visible_for_user?(quote_activity, opts[:for]) and opts[:show_quote] != false do + quote_rendering_opts = Map.merge(opts, %{activity: quote_activity, show_quote: false}) + render("show.json", quote_rendering_opts) + else + nil + end + + history_len = + 1 + + (Object.Updater.history_for(object.data) + |> Map.get("orderedItems") + |> length()) + + # See render("history.json", ...) for more details + # Here the implicit index of the current content is 0 + chrono_order = history_len - 1 + + content = + object + |> render_content() + + content_html = + content + |> Activity.HTML.get_cached_scrubbed_html_for_activity( + User.html_filter_policy(opts[:for]), + activity, + "mastoapi:content:#{chrono_order}" + ) + + content_plaintext = + content + |> Activity.HTML.get_cached_stripped_html_for_activity( + activity, + "mastoapi:content:#{chrono_order}" + ) + + summary = object.data["summary"] || "" + + card = + render("card.json", %{ + embed: Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) + }) + + url = + if user.local do + Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity) + else + object.data["url"] || object.data["external_url"] || object.data["id"] + end + + direct_conversation_id = + with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]}, + {_, true} <- {:include_id, opts[:with_direct_conversation_id]}, + {_, %User{} = for_user} <- {:for_user, opts[:for]} do + Activity.direct_conversation_id(activity, for_user) + else + {:direct_conversation_id, participation_id} when is_integer(participation_id) -> + participation_id + + _e -> + nil + end + + emoji_reactions = + object + |> Object.get_emoji_reactions() + |> EmojiReactionController.filter_allowed_users( + opts[:for], + Map.get(opts, :with_muted, false) + ) + |> Stream.map(fn {emoji, users, url} -> + build_emoji_map(emoji, users, url, opts[:for]) + end) + |> Enum.to_list() + + # Status muted state (would do 1 request per status unless user mutes are preloaded) + muted = + thread_muted? || + UserRelationship.exists?( + get_in(opts, [:relationships, :user_relationships]), + :mute, + opts[:for], + user, + fn for_user, user -> User.mutes?(for_user, user) end + ) + + {pinned?, pinned_at} = pin_data(object, user) + + %{ + id: to_string(activity.id), + uri: object.data["id"], + url: url, + account: + AccountView.render("show.json", %{ + user: user, + for: opts[:for] + }), + in_reply_to_id: reply_to && to_string(reply_to.id), + in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id), + reblog: nil, + card: card, + content: content_html, + text: opts[:with_source] && get_source_text(object.data["source"]), + created_at: created_at, + edited_at: edited_at, + reblogs_count: announcement_count, + replies_count: object.data["repliesCount"] || 0, + favourites_count: like_count, + reblogged: reblogged?(activity, opts[:for]), + favourited: present?(favorited), + bookmarked: present?(bookmarked), + muted: muted, + pinned: pinned?, + sensitive: sensitive, + spoiler_text: summary, + visibility: get_visibility(object), + media_attachments: attachments, + poll: render(PollView, "show.json", object: object, for: opts[:for]), + mentions: mentions, + tags: build_tags(tags), + application: build_application(object.data["generator"]), + language: object.data["language"], + emojis: build_emojis(object.data["emoji"]), + pleroma: %{ + local: activity.local, + conversation_id: get_context_id(activity), + context: object.data["context"], + in_reply_to_account_acct: reply_to_user && reply_to_user.nickname, + quote: quote_post, + quote_id: quote_id, + quote_url: object.data["quoteUrl"], + quote_visible: visible_for_user?(quote_activity, opts[:for]), + content: %{"text/plain" => content_plaintext}, + spoiler_text: %{"text/plain" => summary}, + expires_at: expires_at, + direct_conversation_id: direct_conversation_id, + thread_muted: thread_muted?, + emoji_reactions: emoji_reactions, + parent_visible: visible_for_user?(reply_to, opts[:for]), + pinned_at: pinned_at, + content_type: opts[:with_source] && (object.data["content_type"] || "text/plain"), + quotes_count: object.data["quotesCount"] || 0, + event: build_event(object.data, opts[:for]) + } + } + end + + def render("show.json", _) do + nil + end + + def render("card.json", %{embed: %Embed{} = embed}) do + with {:ok, %Card{} = card} <- Card.parse(embed) do + Card.to_map(card) + else + _ -> nil + end + end + + def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do + object = Object.normalize(activity, fetch: false) + + hashtags = Object.hashtags(object) + + user = CommonAPI.get_user(activity.data["actor"]) + + past_history = + Object.Updater.history_for(object.data) + |> Map.get("orderedItems") + |> Enum.map(&Map.put(&1, "id", object.data["id"])) + |> Enum.map(&%Object{data: &1, id: object.id}) + + history = + [object | past_history] + # Mastodon expects the original to be at the first + |> Enum.reverse() + |> Enum.with_index() + |> Enum.map(fn {object, chrono_order} -> + %{ + # The history is prepended every time there is a new edit. + # In chrono_order, the oldest item is always at 0, and so on. + # The chrono_order is an invariant kept between edits. + chrono_order: chrono_order, + object: object + } + end) + + individual_opts = + opts + |> Map.put(:as, :item) + |> Map.put(:user, user) + |> Map.put(:hashtags, hashtags) + + render_many(history, StatusView, "history_item.json", individual_opts) + end + + def render( + "history_item.json", + %{ + activity: activity, + user: user, + item: %{object: object, chrono_order: chrono_order}, + hashtags: hashtags + } = opts + ) do + sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw") + + attachment_data = object.data["attachment"] || [] + attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment) + + created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"]) + + content = + object + |> render_content() + + content_html = + content + |> Activity.HTML.get_cached_scrubbed_html_for_activity( + User.html_filter_policy(opts[:for]), + activity, + "mastoapi:content:#{chrono_order}" + ) + + summary = object.data["summary"] || "" + + %{ + account: + AccountView.render("show.json", %{ + user: user, + for: opts[:for] + }), + content: content_html, + sensitive: sensitive, + spoiler_text: summary, + created_at: created_at, + media_attachments: attachments, + emojis: build_emojis(object.data["emoji"]), + poll: render(PollView, "show.json", object: object, for: opts[:for]) + } + end + + def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do + object = Object.normalize(activity, fetch: false) + + %{ + id: activity.id, + text: get_source_text(Map.get(object.data, "source", "")), + spoiler_text: Map.get(object.data, "summary", ""), + content_type: get_source_content_type(object.data["source"]), + location: build_source_location(object.data) + } + end + + def render("card.json", %{rich_media: rich_media, page_url: page_url}) do + page_url_data = URI.parse(page_url) + + page_url_data = + if is_binary(rich_media["url"]) do + URI.merge(page_url_data, URI.parse(rich_media["url"])) + else + page_url_data + end + + page_url = page_url_data |> to_string + + image_url_data = + if is_binary(rich_media["image"]) do + URI.parse(rich_media["image"]) + else + nil + end + + image_url = build_image_url(image_url_data, page_url_data) + + %{ + type: "link", + provider_name: page_url_data.host, + provider_url: page_url_data.scheme <> "://" <> page_url_data.host, + url: page_url, + image: image_url |> MediaProxy.url(), + title: rich_media["title"] || "", + description: rich_media["description"] || "", + pleroma: %{ + opengraph: rich_media + } + } + end + + def render("card.json", %{embed: %Card{} = card}), do: Card.to_map(card) + def render("card.json", _), do: nil + + def render("attachment.json", %{attachment: attachment}) do + [attachment_url | _] = attachment["url"] + media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image" + href_remote = attachment_url["href"] + href = href_remote |> MediaProxy.url() + href_preview = attachment_url["href"] |> MediaProxy.preview_url() + meta = render("attachment_meta.json", %{attachment: attachment}) + + type = + cond do + String.contains?(media_type, "image") -> "image" + String.contains?(media_type, "video") -> "video" + String.contains?(media_type, "audio") -> "audio" + true -> "unknown" + end + + attachment_id = + with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]}, + {_, %Object{data: _object_data, id: object_id}} <- + {:object, Object.get_by_ap_id(ap_id)} do + to_string(object_id) + else + _ -> + <> = :crypto.hash(:md5, href) + to_string(attachment["id"] || hash_id) + end + + %{ + id: attachment_id, + url: href, + remote_url: href_remote, + preview_url: href_preview, + text_url: href, + type: type, + description: attachment["name"], + pleroma: %{mime_type: media_type}, + blurhash: attachment["blurhash"] + } + |> Maps.put_if_present(:meta, meta) + end + + def render("attachment_meta.json", %{ + attachment: %{"url" => [%{"width" => width, "height" => height} | _]} + }) + when is_integer(width) and is_integer(height) do + %{ + original: %{ + width: width, + height: height, + aspect: width / height + } + } + end + + def render("attachment_meta.json", _), do: nil + + def render("context.json", %{activity: activity, activities: activities, user: user}) do + %{ancestors: ancestors, descendants: descendants} = + activities + |> Enum.reverse() + |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end) + |> Map.put_new(:ancestors, []) + |> Map.put_new(:descendants, []) + + %{ + ancestors: render("index.json", for: user, activities: ancestors, as: :activity), + descendants: render("index.json", for: user, activities: descendants, as: :activity) + } + end + + def render("translation.json", %{ + content: content, + detected_source_language: detected_source_language, + provider: provider + }) do + %{content: content, detected_source_language: detected_source_language, provider: provider} + end + + def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do + object = Object.normalize(activity, fetch: false) + + with nil <- replied_to_activities[object.data["inReplyTo"]] do + # If user didn't participate in the thread + Activity.get_in_reply_to_activity(activity) + end + end + + def get_reply_to(%{data: %{"object" => _object}} = activity, _) do + object = Object.normalize(activity, fetch: false) + + if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do + Activity.get_create_by_object_ap_id(object.data["inReplyTo"]) + else + nil + end + end + + def get_quote(activity, %{quoted_activities: quoted_activities}) do + object = Object.normalize(activity, fetch: false) + + with nil <- quoted_activities[object.data["quoteUrl"]] do + # For when a quote post is inside an Announce + Activity.get_create_by_object_ap_id_with_object(object.data["quoteUrl"]) + end + end + + def get_quote(%{data: %{"object" => _object}} = activity, _) do + object = Object.normalize(activity, fetch: false) + + if object.data["quoteUrl"] && object.data["quoteUrl"] != "" do + Activity.get_create_by_object_ap_id(object.data["quoteUrl"]) + else + nil + end + end + + def render_content(%{data: %{"name" => name, "type" => type}} = object) + when not is_nil(name) and name != "" and type != "Event" do + url = object.data["url"] || object.data["id"] + + "

    #{name}

    #{object.data["content"]}" + end + + def render_content(object), do: object.data["content"] || "" + + @doc """ + Builds a dictionary tags. + + ## Examples + + iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"]) + [{"name": "fediverse", "url": "/tag/fediverse"}, + {"name": "nextcloud", "url": "/tag/nextcloud"}] + + """ + @spec build_tags(list(any())) :: list(map()) + def build_tags(object_tags) when is_list(object_tags) do + object_tags + |> Enum.filter(&is_binary/1) + |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"}) + end + + def build_tags(_), do: [] + + @doc """ + Builds list emojis. + + Arguments: `nil` or list tuple of name and url. + + Returns list emojis. + + ## Examples + + iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}]) + [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}] + + """ + @spec build_emojis(nil | list(tuple())) :: list(map()) + def build_emojis(nil), do: [] + + def build_emojis(emojis) do + emojis + |> Enum.map(fn {name, url} -> + name = HTML.strip_tags(name) + + url = + url + |> HTML.strip_tags() + |> MediaProxy.url() + + %{shortcode: name, url: url, static_url: url, visible_in_picker: false} + end) + end + + defp build_event(%{"type" => "Event"} = data, for_user) do + %{ + name: data["name"], + start_time: data["startTime"], + end_time: data["endTime"], + join_mode: data["joinMode"], + participants_count: data["participation_count"], + location: build_event_location(data["location"]), + join_state: build_event_join_state(for_user, data["id"]), + participation_request_count: maybe_put_participation_request_count(data, for_user) + } + end + + defp build_event(_, _), do: nil + + defp build_event_location(%{"type" => "Place"} = location) do + %{ + name: location["name"], + url: location["url"], + longitude: location["longitude"], + latitude: location["latitude"] + } + |> maybe_put_address(location["address"]) + end + + defp build_event_location(_), do: nil + + defp maybe_put_address(location, %{"type" => "PostalAddress"} = address) do + Map.merge(location, %{ + street: address["streetAddress"], + postal_code: address["postalCode"], + locality: address["addressLocality"], + region: address["addressRegion"], + country: address["addressCountry"] + }) + end + + defp maybe_put_address(location, _), do: location + + defp build_event_join_state(%{ap_id: actor}, id) do + latest_join = Pleroma.Web.ActivityPub.Utils.get_existing_join(actor, id) + + if latest_join do + latest_join.data["state"] + end + end + + defp build_event_join_state(_, _), do: nil + + defp maybe_put_participation_request_count(%{"actor" => actor} = data, %{ap_id: actor}) do + data["participation_request_count"] + end + + defp maybe_put_participation_request_count(_, _), do: nil + + defp present?(nil), do: false + defp present?(false), do: false + defp present?(_), do: true + + defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do + if pinned_at = pinned_objects[object_id] do + {true, Utils.to_masto_date(pinned_at)} + else + {false, nil} + end + end + + defp build_emoji_map(emoji, users, url, current_user) do + %{ + name: Pleroma.Web.PleromaAPI.EmojiReactionView.emoji_name(emoji, url), + count: length(users), + url: MediaProxy.url(url), + me: !!(current_user && current_user.ap_id in users), + account_ids: Enum.map(users, fn user -> User.get_cached_by_ap_id(user).id end) + } + end + + @spec build_application(map() | nil) :: map() | nil + defp build_application(%{"type" => _type, "name" => name, "url" => url}), + do: %{name: name, website: url} + + defp build_application(_), do: nil + + # Workaround for Elixir issue #10771 + # Avoid applying URI.merge unless necessary + # TODO: revert to always attempting URI.merge(image_url_data, page_url_data) + # when Elixir 1.12 is the minimum supported version + @spec build_image_url(struct() | nil, struct()) :: String.t() | nil + defp build_image_url( + %URI{scheme: image_scheme, host: image_host} = image_url_data, + %URI{} = _page_url_data + ) + when not is_nil(image_scheme) and not is_nil(image_host) do + image_url_data |> to_string + end + + defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do + URI.merge(page_url_data, image_url_data) |> to_string + end + + defp build_image_url(_, _), do: nil + + defp get_source_text(%{"content" => content} = _source) do + content + end + + defp get_source_text(source) when is_binary(source) do + source + end + + defp get_source_text(_) do + "" + end + + defp get_source_content_type(%{"mediaType" => type} = _source) do + type + end + + defp get_source_content_type(_source) do + Utils.get_content_type(nil) + end + + def build_source_location(%{"location_id" => location_id}) when is_binary(location_id) do + location = Geospatial.Service.service().get_by_id(location_id) |> List.first() + + if location do + Pleroma.Web.PleromaAPI.SearchView.render("show_location.json", %{location: location}) + else + nil + end + end + + def build_source_location(_), do: nil +end diff --git a/lib/pleroma/web/mastodon_api/views/subscription_view.ex b/lib/pleroma/web/mastodon_api/views/subscription_view.ex new file mode 100644 index 0000000..baa1e03 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/subscription_view.ex @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.SubscriptionView do + use Pleroma.Web, :view + alias Pleroma.Web.Push + + def render("show.json", %{subscription: subscription}) do + %{ + id: to_string(subscription.id), + endpoint: subscription.endpoint, + alerts: Map.get(subscription.data, "alerts"), + server_key: server_key() + } + end + + defp server_key, do: Keyword.get(Push.vapid_config(), :public_key) +end diff --git a/lib/pleroma/web/mastodon_api/views/suggestion_view.ex b/lib/pleroma/web/mastodon_api/views/suggestion_view.ex new file mode 100644 index 0000000..d3df4ef --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/suggestion_view.ex @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.SuggestionView do + use Pleroma.Web, :view + alias Pleroma.Web.MastodonAPI.AccountView + + @source_types [:staff, :global, :past_interactions] + + def render("index.json", %{users: users} = opts) do + Enum.map(users, fn user -> + opts = + opts + |> Map.put(:user, user) + |> Map.delete(:users) + + render("show.json", opts) + end) + end + + def render("show.json", %{source: source, user: _user} = opts) when source in @source_types do + %{ + source: source, + account: AccountView.render("show.json", opts) + } + end +end diff --git a/lib/pleroma/web/mastodon_api/views/timeline_view.ex b/lib/pleroma/web/mastodon_api/views/timeline_view.ex new file mode 100644 index 0000000..702eb7e --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/timeline_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.TimelineView do + use Pleroma.Web, :view + alias Pleroma.Web.MastodonAPI + + def render(view, opts), do: MastodonAPI.StatusView.render(view, opts) +end diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex new file mode 100644 index 0000000..07c2b62 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -0,0 +1,257 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do + require Logger + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.Streamer + alias Pleroma.Web.StreamerView + + @behaviour :cowboy_websocket + + # Client ping period. + @tick :timer.seconds(30) + # Cowboy timeout period. + @timeout :timer.seconds(60) + # Hibernate every X messages + @hibernate_every 100 + + def init(%{qs: qs} = req, state) do + with params <- Enum.into(:cow_qs.parse_qs(qs), %{}), + sec_websocket <- :cowboy_req.header("sec-websocket-protocol", req, nil), + access_token <- Map.get(params, "access_token"), + {:ok, user, oauth_token} <- authenticate_request(access_token, sec_websocket), + {:ok, topic} <- Streamer.get_topic(params["stream"], user, oauth_token, params) do + req = + if sec_websocket do + :cowboy_req.set_resp_header("sec-websocket-protocol", sec_websocket, req) + else + req + end + + topics = + if topic do + [topic] + else + [] + end + + {:cowboy_websocket, req, + %{user: user, topics: topics, oauth_token: oauth_token, count: 0, timer: nil}, + %{idle_timeout: @timeout}} + else + {:error, :bad_topic} -> + Logger.debug("#{__MODULE__} bad topic #{inspect(req)}") + req = :cowboy_req.reply(404, req) + {:ok, req, state} + + {:error, :unauthorized} -> + Logger.debug("#{__MODULE__} authentication error: #{inspect(req)}") + req = :cowboy_req.reply(401, req) + {:ok, req, state} + end + end + + def websocket_init(state) do + Logger.debug( + "#{__MODULE__} accepted websocket connection for user #{(state.user || %{id: "anonymous"}).id}, topics #{state.topics}" + ) + + Enum.each(state.topics, fn topic -> Streamer.add_socket(topic, state.oauth_token) end) + {:ok, %{state | timer: timer()}} + end + + # Client's Pong frame. + def websocket_handle(:pong, state) do + if state.timer, do: Process.cancel_timer(state.timer) + {:ok, %{state | timer: timer()}} + end + + # We only receive pings for now + def websocket_handle(:ping, state), do: {:ok, state} + + def websocket_handle({:text, text}, state) do + with {:ok, %{} = event} <- Jason.decode(text) do + handle_client_event(event, state) + else + _ -> + Logger.error("#{__MODULE__} received non-JSON event: #{inspect(text)}") + {:ok, state} + end + end + + def websocket_handle(frame, state) do + Logger.error("#{__MODULE__} received frame: #{inspect(frame)}") + {:ok, state} + end + + def websocket_info({:render_with_user, view, template, item, topic}, state) do + user = %User{} = User.get_cached_by_ap_id(state.user.ap_id) + + unless Streamer.filtered_by_user?(user, item) do + websocket_info({:text, view.render(template, item, user, topic)}, %{state | user: user}) + else + {:ok, state} + end + end + + def websocket_info({:text, message}, state) do + # If the websocket processed X messages, force an hibernate/GC. + # We don't hibernate at every message to balance CPU usage/latency with RAM usage. + if state.count > @hibernate_every do + {:reply, {:text, message}, %{state | count: 0}, :hibernate} + else + {:reply, {:text, message}, %{state | count: state.count + 1}} + end + end + + # Ping tick. We don't re-queue a timer there, it is instead queued when :pong is received. + # As we hibernate there, reset the count to 0. + # If the client misses :pong, Cowboy will automatically timeout the connection after + # `@idle_timeout`. + def websocket_info(:tick, state) do + {:reply, :ping, %{state | timer: nil, count: 0}, :hibernate} + end + + def websocket_info(:close, state) do + {:stop, state} + end + + # State can be `[]` only in case we terminate before switching to websocket, + # we already log errors for these cases in `init/1`, so just do nothing here + def terminate(_reason, _req, []), do: :ok + + def terminate(reason, _req, state) do + Logger.debug( + "#{__MODULE__} terminating websocket connection for user #{(state.user || %{id: "anonymous"}).id}, topics #{state.topics || "?"}: #{inspect(reason)}" + ) + + Enum.each(state.topics, fn topic -> Streamer.remove_socket(topic) end) + :ok + end + + # Public streams without authentication. + defp authenticate_request(nil, nil) do + {:ok, nil, nil} + end + + # Authenticated streams. + defp authenticate_request(access_token, sec_websocket) do + token = access_token || sec_websocket + + with true <- is_bitstring(token), + oauth_token = %Token{user_id: user_id} <- Repo.get_by(Token, token: token), + user = %User{} <- User.get_cached_by_id(user_id) do + {:ok, user, oauth_token} + else + _ -> {:error, :unauthorized} + end + end + + defp timer do + Process.send_after(self(), :tick, @tick) + end + + defp handle_client_event(%{"type" => "subscribe", "stream" => _topic} = params, state) do + with {_, {:ok, topic}} <- + {:topic, Streamer.get_topic(params["stream"], state.user, state.oauth_token, params)}, + {_, false} <- {:subscribed, topic in state.topics} do + Streamer.add_socket(topic, state.oauth_token) + + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{type: "subscribe", result: "success"})} + ], %{state | topics: [topic | state.topics]}} + else + {:subscribed, true} -> + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{type: "subscribe", result: "ignored"})} + ], state} + + {:topic, {:error, error}} -> + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{ + type: "subscribe", + result: "error", + error: error + })} + ], state} + end + end + + defp handle_client_event(%{"type" => "unsubscribe", "stream" => _topic} = params, state) do + with {_, {:ok, topic}} <- + {:topic, Streamer.get_topic(params["stream"], state.user, state.oauth_token, params)}, + {_, true} <- {:subscribed, topic in state.topics} do + Streamer.remove_socket(topic) + + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{type: "unsubscribe", result: "success"})} + ], %{state | topics: List.delete(state.topics, topic)}} + else + {:subscribed, false} -> + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{type: "unsubscribe", result: "ignored"})} + ], state} + + {:topic, {:error, error}} -> + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{ + type: "unsubscribe", + result: "error", + error: error + })} + ], state} + end + end + + defp handle_client_event( + %{"type" => "pleroma:authenticate", "token" => access_token} = _params, + state + ) do + with {:auth, nil, nil} <- {:auth, state.user, state.oauth_token}, + {:ok, user, oauth_token} <- authenticate_request(access_token, nil) do + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{ + type: "pleroma:authenticate", + result: "success" + })} + ], %{state | user: user, oauth_token: oauth_token}} + else + {:auth, _, _} -> + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{ + type: "pleroma:authenticate", + result: "error", + error: :already_authenticated + })} + ], state} + + _ -> + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{ + type: "pleroma:authenticate", + result: "error", + error: :unauthorized + })} + ], state} + end + end + + defp handle_client_event(params, state) do + Logger.error("#{__MODULE__} received unknown event: #{inspect(params)}") + {[], state} + end +end diff --git a/lib/pleroma/web/media_proxy.ex b/lib/pleroma/web/media_proxy.ex new file mode 100644 index 0000000..eab3748 --- /dev/null +++ b/lib/pleroma/web/media_proxy.ex @@ -0,0 +1,186 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MediaProxy do + alias Pleroma.Config + alias Pleroma.Helpers.UriHelper + alias Pleroma.Upload + alias Pleroma.Web.Endpoint + alias Pleroma.Web.MediaProxy.Invalidation + + @base64_opts [padding: false] + @cache_table :banned_urls_cache + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + + def cache_table, do: @cache_table + + @spec in_banned_urls(String.t()) :: boolean() + def in_banned_urls(url), do: elem(@cachex.exists?(@cache_table, url(url)), 1) + + def remove_from_banned_urls(urls) when is_list(urls) do + @cachex.execute!(@cache_table, fn cache -> + Enum.each(Invalidation.prepare_urls(urls), &@cachex.del(cache, &1)) + end) + end + + def remove_from_banned_urls(url) when is_binary(url) do + @cachex.del(@cache_table, url(url)) + end + + def put_in_banned_urls(urls) when is_list(urls) do + @cachex.execute!(@cache_table, fn cache -> + Enum.each(Invalidation.prepare_urls(urls), &@cachex.put(cache, &1, true)) + end) + end + + def put_in_banned_urls(url) when is_binary(url) do + @cachex.put(@cache_table, url(url), true) + end + + def url(url) when is_nil(url) or url == "", do: nil + def url("/" <> _ = url), do: url + + def url(url) do + if enabled?() and url_proxiable?(url) do + encode_url(url) + else + url + end + end + + @spec url_proxiable?(String.t()) :: boolean() + def url_proxiable?(url) do + not local?(url) and not whitelisted?(url) + end + + def preview_url(url, preview_params \\ []) do + if preview_enabled?() do + encode_preview_url(url, preview_params) + else + url(url) + end + end + + def enabled?, do: Config.get([:media_proxy, :enabled], false) + + # Note: media proxy must be enabled for media preview proxy in order to load all + # non-local non-whitelisted URLs through it and be sure that body size constraint is preserved. + def preview_enabled?, do: enabled?() and !!Config.get([:media_preview_proxy, :enabled]) + + def local?(url), do: String.starts_with?(url, Endpoint.url()) + + def whitelisted?(url) do + %{host: domain} = URI.parse(url) + + mediaproxy_whitelist_domains = + [:media_proxy, :whitelist] + |> Config.get() + |> Kernel.++(["#{Upload.base_url()}"]) + |> Enum.map(&maybe_get_domain_from_url/1) + + domain in mediaproxy_whitelist_domains + end + + defp maybe_get_domain_from_url("http" <> _ = url) do + URI.parse(url).host + end + + defp maybe_get_domain_from_url(domain), do: domain + + defp base64_sig64(url) do + base64 = Base.url_encode64(url, @base64_opts) + + sig64 = + base64 + |> signed_url() + |> Base.url_encode64(@base64_opts) + + {base64, sig64} + end + + def encode_url(url) do + {base64, sig64} = base64_sig64(url) + + build_url(sig64, base64, filename(url)) + end + + def encode_preview_url(url, preview_params \\ []) do + {base64, sig64} = base64_sig64(url) + + build_preview_url(sig64, base64, filename(url), preview_params) + end + + def decode_url(sig, url) do + with {:ok, sig} <- Base.url_decode64(sig, @base64_opts), + signature when signature == sig <- signed_url(url) do + {:ok, Base.url_decode64!(url, @base64_opts)} + else + _ -> {:error, :invalid_signature} + end + end + + def decode_url(encoded) do + [_, "proxy", sig, base64 | _] = URI.parse(encoded).path |> String.split("/") + decode_url(sig, base64) + end + + defp signed_url(url) do + :crypto.mac(:hmac, :sha256, Config.get([Endpoint, :secret_key_base]), url) + end + + def filename(url_or_path) do + if path = URI.parse(url_or_path).path, do: Path.basename(path) + end + + def base_url do + Config.get([:media_proxy, :base_url], Endpoint.url()) + end + + defp proxy_url(path, sig_base64, url_base64, filename) do + [ + base_url(), + path, + sig_base64, + url_base64, + filename + ] + |> Enum.filter(& &1) + |> Path.join() + end + + def build_url(sig_base64, url_base64, filename \\ nil) do + proxy_url("proxy", sig_base64, url_base64, filename) + end + + def build_preview_url(sig_base64, url_base64, filename \\ nil, preview_params \\ []) do + uri = proxy_url("proxy/preview", sig_base64, url_base64, filename) + + UriHelper.modify_uri_params(uri, preview_params) + end + + def verify_request_path_and_url( + %Plug.Conn{params: %{"filename" => _}, request_path: request_path}, + url + ) do + verify_request_path_and_url(request_path, url) + end + + def verify_request_path_and_url(request_path, url) when is_binary(request_path) do + filename = filename(url) + + if filename && not basename_matches?(request_path, filename) do + {:wrong_filename, filename} + else + :ok + end + end + + def verify_request_path_and_url(_, _), do: :ok + + defp basename_matches?(path, filename) do + basename = Path.basename(path) + basename == filename or URI.decode(basename) == filename or URI.encode(basename) == filename + end +end diff --git a/lib/pleroma/web/media_proxy/invalidation.ex b/lib/pleroma/web/media_proxy/invalidation.ex new file mode 100644 index 0000000..ea927fe --- /dev/null +++ b/lib/pleroma/web/media_proxy/invalidation.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MediaProxy.Invalidation do + @moduledoc false + + @callback purge(list(String.t()), Keyword.t()) :: {:ok, list(String.t())} | {:error, String.t()} + + alias Pleroma.Config + alias Pleroma.Web.MediaProxy + + @spec enabled?() :: boolean() + def enabled?, do: Config.get([:media_proxy, :invalidation, :enabled]) + + @spec purge(list(String.t()) | String.t()) :: {:ok, list(String.t())} | {:error, String.t()} + def purge(urls) do + prepared_urls = prepare_urls(urls) + + if enabled?() do + do_purge(prepared_urls) + else + {:ok, prepared_urls} + end + end + + defp do_purge(urls) do + provider = Config.get([:media_proxy, :invalidation, :provider]) + options = Config.get(provider) + provider.purge(urls, options) + end + + def prepare_urls(urls) do + urls + |> List.wrap() + |> Enum.map(fn url -> [MediaProxy.url(url), MediaProxy.preview_url(url)] end) + |> List.flatten() + |> Enum.uniq() + end +end diff --git a/lib/pleroma/web/media_proxy/invalidation/http.ex b/lib/pleroma/web/media_proxy/invalidation/http.ex new file mode 100644 index 0000000..28ea749 --- /dev/null +++ b/lib/pleroma/web/media_proxy/invalidation/http.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MediaProxy.Invalidation.Http do + @moduledoc false + @behaviour Pleroma.Web.MediaProxy.Invalidation + + require Logger + + @impl Pleroma.Web.MediaProxy.Invalidation + def purge(urls, opts \\ []) do + method = Keyword.get(opts, :method, :purge) + headers = Keyword.get(opts, :headers, []) + options = Keyword.get(opts, :options, []) + + Logger.debug("Running cache purge: #{inspect(urls)}") + + Enum.each(urls, fn url -> + with {:error, error} <- do_purge(method, url, headers, options) do + Logger.error("Error while cache purge: url - #{url}, error: #{inspect(error)}") + end + end) + + {:ok, urls} + end + + defp do_purge(method, url, headers, options) do + case Pleroma.HTTP.request(method, url, "", headers, options) do + {:ok, %{status: status} = env} when 400 <= status and status < 500 -> + {:error, env} + + {:error, _} = error -> + error + + _ -> + {:ok, "success"} + end + end +end diff --git a/lib/pleroma/web/media_proxy/invalidation/script.ex b/lib/pleroma/web/media_proxy/invalidation/script.ex new file mode 100644 index 0000000..784178f --- /dev/null +++ b/lib/pleroma/web/media_proxy/invalidation/script.ex @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MediaProxy.Invalidation.Script do + @moduledoc false + + @behaviour Pleroma.Web.MediaProxy.Invalidation + + require Logger + + @impl Pleroma.Web.MediaProxy.Invalidation + def purge(urls, opts \\ []) do + args = + urls + |> maybe_format_urls(Keyword.get(opts, :url_format)) + |> List.wrap() + |> Enum.uniq() + |> Enum.join(" ") + + opts + |> Keyword.get(:script_path) + |> do_purge([args]) + |> handle_result(urls) + end + + defp do_purge(script_path, args) when is_binary(script_path) do + path = Path.expand(script_path) + Logger.debug("Running cache purge: #{inspect(args)}, #{inspect(path)}") + System.cmd(path, args) + rescue + error -> error + end + + defp do_purge(_, _), do: {:error, "not found script path"} + + defp handle_result({_result, 0}, urls), do: {:ok, urls} + defp handle_result({:error, error}, urls), do: handle_result(error, urls) + + defp handle_result(error, _) do + Logger.error("Error while cache purge: #{inspect(error)}") + {:error, inspect(error)} + end + + def maybe_format_urls(urls, :htcacheclean) do + urls + |> Enum.map(fn url -> + uri = URI.parse(url) + + query = + if !is_nil(uri.query) do + "?" <> uri.query + else + "?" + end + + uri.scheme <> "://" <> uri.host <> ":#{inspect(uri.port)}" <> uri.path <> query + end) + end + + def maybe_format_urls(urls, _), do: urls +end diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex new file mode 100644 index 0000000..bda5b36 --- /dev/null +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -0,0 +1,212 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MediaProxy.MediaProxyController do + use Pleroma.Web, :controller + + alias Pleroma.Config + alias Pleroma.Helpers.MediaHelper + alias Pleroma.Helpers.UriHelper + alias Pleroma.ReverseProxy + alias Pleroma.Web.MediaProxy + alias Plug.Conn + + plug(:sandbox) + + def remote(conn, %{"sig" => sig64, "url" => url64}) do + with {_, true} <- {:enabled, MediaProxy.enabled?()}, + {:ok, url} <- MediaProxy.decode_url(sig64, url64), + {_, false} <- {:in_banned_urls, MediaProxy.in_banned_urls(url)}, + :ok <- MediaProxy.verify_request_path_and_url(conn, url) do + ReverseProxy.call(conn, url, media_proxy_opts()) + else + {:enabled, false} -> + send_resp(conn, 404, Conn.Status.reason_phrase(404)) + + {:in_banned_urls, true} -> + send_resp(conn, 404, Conn.Status.reason_phrase(404)) + + {:error, :invalid_signature} -> + send_resp(conn, 403, Conn.Status.reason_phrase(403)) + + {:wrong_filename, filename} -> + redirect(conn, external: MediaProxy.build_url(sig64, url64, filename)) + end + end + + def preview(%Conn{} = conn, %{"sig" => sig64, "url" => url64}) do + with {_, true} <- {:enabled, MediaProxy.preview_enabled?()}, + {:ok, url} <- MediaProxy.decode_url(sig64, url64), + :ok <- MediaProxy.verify_request_path_and_url(conn, url) do + handle_preview(conn, url) + else + {:enabled, false} -> + send_resp(conn, 404, Conn.Status.reason_phrase(404)) + + {:error, :invalid_signature} -> + send_resp(conn, 403, Conn.Status.reason_phrase(403)) + + {:wrong_filename, filename} -> + redirect(conn, external: MediaProxy.build_preview_url(sig64, url64, filename)) + end + end + + defp handle_preview(conn, url) do + media_proxy_url = MediaProxy.url(url) + + with {:ok, %{status: status} = head_response} when status in 200..299 <- + Pleroma.HTTP.request("HEAD", media_proxy_url, [], [], pool: :media) do + content_type = Tesla.get_header(head_response, "content-type") + content_length = Tesla.get_header(head_response, "content-length") + content_length = content_length && String.to_integer(content_length) + static = conn.params["static"] in ["true", true] + + cond do + static and content_type == "image/gif" -> + handle_jpeg_preview(conn, media_proxy_url) + + static -> + drop_static_param_and_redirect(conn) + + content_type == "image/gif" -> + redirect(conn, external: media_proxy_url) + + min_content_length_for_preview() > 0 and content_length > 0 and + content_length < min_content_length_for_preview() -> + redirect(conn, external: media_proxy_url) + + true -> + handle_preview(content_type, conn, media_proxy_url) + end + else + # If HEAD failed, redirecting to media proxy URI doesn't make much sense; returning an error + {_, %{status: status}} -> + send_resp(conn, :failed_dependency, "Can't fetch HTTP headers (HTTP #{status}).") + + {:error, :recv_response_timeout} -> + send_resp(conn, :failed_dependency, "HEAD request timeout.") + + _ -> + send_resp(conn, :failed_dependency, "Can't fetch HTTP headers.") + end + end + + defp handle_preview("image/png" <> _ = _content_type, conn, media_proxy_url) do + handle_png_preview(conn, media_proxy_url) + end + + defp handle_preview("image/" <> _ = _content_type, conn, media_proxy_url) do + handle_jpeg_preview(conn, media_proxy_url) + end + + defp handle_preview("video/" <> _ = _content_type, conn, media_proxy_url) do + handle_video_preview(conn, media_proxy_url) + end + + defp handle_preview(_unsupported_content_type, conn, media_proxy_url) do + fallback_on_preview_error(conn, media_proxy_url) + end + + defp handle_png_preview(conn, media_proxy_url) do + quality = Config.get!([:media_preview_proxy, :image_quality]) + {thumbnail_max_width, thumbnail_max_height} = thumbnail_max_dimensions() + + with {:ok, thumbnail_binary} <- + MediaHelper.image_resize( + media_proxy_url, + %{ + max_width: thumbnail_max_width, + max_height: thumbnail_max_height, + quality: quality, + format: "png" + } + ) do + conn + |> put_preview_response_headers(["image/png", "preview.png"]) + |> send_resp(200, thumbnail_binary) + else + _ -> + fallback_on_preview_error(conn, media_proxy_url) + end + end + + defp handle_jpeg_preview(conn, media_proxy_url) do + quality = Config.get!([:media_preview_proxy, :image_quality]) + {thumbnail_max_width, thumbnail_max_height} = thumbnail_max_dimensions() + + with {:ok, thumbnail_binary} <- + MediaHelper.image_resize( + media_proxy_url, + %{max_width: thumbnail_max_width, max_height: thumbnail_max_height, quality: quality} + ) do + conn + |> put_preview_response_headers() + |> send_resp(200, thumbnail_binary) + else + _ -> + fallback_on_preview_error(conn, media_proxy_url) + end + end + + defp handle_video_preview(conn, media_proxy_url) do + with {:ok, thumbnail_binary} <- + MediaHelper.video_framegrab(media_proxy_url) do + conn + |> put_preview_response_headers() + |> send_resp(200, thumbnail_binary) + else + _ -> + fallback_on_preview_error(conn, media_proxy_url) + end + end + + defp drop_static_param_and_redirect(conn) do + uri_without_static_param = + conn + |> current_url() + |> UriHelper.modify_uri_params(%{}, ["static"]) + + redirect(conn, external: uri_without_static_param) + end + + defp fallback_on_preview_error(conn, media_proxy_url) do + redirect(conn, external: media_proxy_url) + end + + defp put_preview_response_headers( + conn, + [content_type, filename] = _content_info \\ ["image/jpeg", "preview.jpg"] + ) do + conn + |> put_resp_header("content-type", content_type) + |> put_resp_header("content-disposition", "inline; filename=\"#{filename}\"") + |> put_resp_header("cache-control", ReverseProxy.default_cache_control_header()) + end + + defp thumbnail_max_dimensions do + config = media_preview_proxy_config() + + thumbnail_max_width = Keyword.fetch!(config, :thumbnail_max_width) + thumbnail_max_height = Keyword.fetch!(config, :thumbnail_max_height) + + {thumbnail_max_width, thumbnail_max_height} + end + + defp min_content_length_for_preview do + Keyword.get(media_preview_proxy_config(), :min_content_length, 0) + end + + defp media_preview_proxy_config do + Config.get!([:media_preview_proxy]) + end + + defp media_proxy_opts do + Config.get([:media_proxy, :proxy_opts], []) + end + + defp sandbox(conn, _params) do + conn + |> merge_resp_headers([{"content-security-policy", "sandbox;"}]) + end +end diff --git a/lib/pleroma/web/metadata.ex b/lib/pleroma/web/metadata.ex new file mode 100644 index 0000000..59d0187 --- /dev/null +++ b/lib/pleroma/web/metadata.ex @@ -0,0 +1,54 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata do + alias Phoenix.HTML + + def build_tags(params) do + providers = [ + Pleroma.Web.Metadata.Providers.RelMe, + Pleroma.Web.Metadata.Providers.RestrictIndexing + | activated_providers() + ] + + Enum.reduce(providers, "", fn parser, acc -> + rendered_html = + params + |> parser.build_tags() + |> Enum.map(&to_tag/1) + |> Enum.map(&HTML.safe_to_string/1) + |> Enum.join() + + acc <> rendered_html + end) + end + + def to_tag(data) do + with {name, attrs, _content = []} <- data do + HTML.Tag.tag(name, attrs) + else + {name, attrs, content} -> + HTML.Tag.content_tag(name, content, attrs) + + _ -> + raise ArgumentError, message: "make_tag invalid args" + end + end + + def activity_nsfw?(%{data: %{"sensitive" => sensitive}}) do + Pleroma.Config.get([__MODULE__, :unfurl_nsfw], false) == false and sensitive + end + + def activity_nsfw?(_) do + false + end + + defp activated_providers do + unless Pleroma.Config.restrict_unauthenticated_access?(:activities, :local) do + [Pleroma.Web.Metadata.Providers.Feed | Pleroma.Config.get([__MODULE__, :providers], [])] + else + [] + end + end +end diff --git a/lib/pleroma/web/metadata/player_view.ex b/lib/pleroma/web/metadata/player_view.ex new file mode 100644 index 0000000..59c56a2 --- /dev/null +++ b/lib/pleroma/web/metadata/player_view.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.PlayerView do + use Pleroma.Web, :view + import Phoenix.HTML.Tag, only: [content_tag: 3, tag: 2] + + def render("player.html", %{"mediaType" => type, "href" => href}) do + {tag_type, tag_attrs} = + case type do + "audio" <> _ -> {:audio, []} + "video" <> _ -> {:video, [loop: true]} + end + + content_tag( + tag_type, + [ + tag(:source, src: href, type: type), + "Your browser does not support #{type} playback." + ], + [controls: true] ++ tag_attrs + ) + end +end diff --git a/lib/pleroma/web/metadata/providers/feed.ex b/lib/pleroma/web/metadata/providers/feed.ex new file mode 100644 index 0000000..e97d6a5 --- /dev/null +++ b/lib/pleroma/web/metadata/providers/feed.ex @@ -0,0 +1,23 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.Feed do + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Metadata.Providers.Provider + alias Pleroma.Web.Router.Helpers + + @behaviour Provider + + @impl Provider + def build_tags(%{user: user}) do + [ + {:link, + [ + rel: "alternate", + type: "application/atom+xml", + href: Helpers.user_feed_path(Endpoint, :feed, user.nickname) <> ".atom" + ], []} + ] + end +end diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex new file mode 100644 index 0000000..97d3865 --- /dev/null +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -0,0 +1,148 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.OpenGraph do + alias Pleroma.User + alias Pleroma.Web.MediaProxy + alias Pleroma.Web.Metadata + alias Pleroma.Web.Metadata.Providers.Provider + alias Pleroma.Web.Metadata.Utils + + @behaviour Provider + @media_types ["image", "audio", "video"] + + @impl Provider + def build_tags(%{ + object: object, + url: url, + user: user + }) do + attachments = build_attachments(object) + scrubbed_content = Utils.scrub_html_and_truncate(object) + + [ + {:meta, + [ + property: "og:title", + content: Utils.user_name_string(user) + ], []}, + {:meta, [property: "og:url", content: url], []}, + {:meta, + [ + property: "og:description", + content: scrubbed_content + ], []}, + {:meta, [property: "og:type", content: "article"], []} + ] ++ + if attachments == [] or Metadata.activity_nsfw?(object) do + [ + {:meta, [property: "og:image", content: MediaProxy.preview_url(User.avatar_url(user))], + []}, + {:meta, [property: "og:image:width", content: 150], []}, + {:meta, [property: "og:image:height", content: 150], []} + ] + else + attachments + end + end + + @impl Provider + def build_tags(%{user: user}) do + with truncated_bio = Utils.scrub_html_and_truncate(user.bio) do + [ + {:meta, + [ + property: "og:title", + content: Utils.user_name_string(user) + ], []}, + {:meta, [property: "og:url", content: user.uri || user.ap_id], []}, + {:meta, [property: "og:description", content: truncated_bio], []}, + {:meta, [property: "og:type", content: "article"], []}, + {:meta, [property: "og:image", content: MediaProxy.preview_url(User.avatar_url(user))], + []}, + {:meta, [property: "og:image:width", content: 150], []}, + {:meta, [property: "og:image:height", content: 150], []} + ] + end + end + + defp build_attachments(%{data: %{"attachment" => attachments}}) do + Enum.reduce(attachments, [], fn attachment, acc -> + rendered_tags = + Enum.reduce(attachment["url"], [], fn url, acc -> + # TODO: Whatsapp only wants JPEG or PNGs. It seems that if we add a second og:image + # object when a Video or GIF is attached it will display that in Whatsapp Rich Preview. + case Utils.fetch_media_type(@media_types, url["mediaType"]) do + "audio" -> + [ + {:meta, [property: "og:audio", content: MediaProxy.url(url["href"])], []} + | acc + ] + + # Not using preview_url for this. It saves bandwidth, but the image dimensions will + # be wrong. We generate it on the fly and have no way to capture or analyze the + # image to get the dimensions. This can be an issue for apps/FEs rendering images + # in timelines too, but you can get clever with the aspect ratio metadata as a + # workaround. + "image" -> + [ + {:meta, [property: "og:image", content: MediaProxy.url(url["href"])], []}, + {:meta, [property: "og:image:alt", content: attachment["name"]], []} + | acc + ] + |> maybe_add_dimensions(url) + + "video" -> + [ + {:meta, [property: "og:video", content: MediaProxy.url(url["href"])], []} + | acc + ] + |> maybe_add_dimensions(url) + |> maybe_add_video_thumbnail(url) + + _ -> + acc + end + end) + + acc ++ rendered_tags + end) + end + + defp build_attachments(_), do: [] + + # We can use url["mediaType"] to dynamically fill the metadata + defp maybe_add_dimensions(metadata, url) do + type = url["mediaType"] |> String.split("/") |> List.first() + + cond do + !is_nil(url["height"]) && !is_nil(url["width"]) -> + metadata ++ + [ + {:meta, [property: "og:#{type}:width", content: "#{url["width"]}"], []}, + {:meta, [property: "og:#{type}:height", content: "#{url["height"]}"], []} + ] + + true -> + metadata + end + end + + # Media Preview Proxy makes thumbnails of videos without resizing, so we can trust the + # width and height of the source video. + defp maybe_add_video_thumbnail(metadata, url) do + cond do + Pleroma.Config.get([:media_preview_proxy, :enabled], false) -> + metadata ++ + [ + {:meta, [property: "og:image:width", content: "#{url["width"]}"], []}, + {:meta, [property: "og:image:height", content: "#{url["height"]}"], []}, + {:meta, [property: "og:image", content: MediaProxy.preview_url(url["href"])], []} + ] + + true -> + metadata + end + end +end diff --git a/lib/pleroma/web/metadata/providers/provider.ex b/lib/pleroma/web/metadata/providers/provider.ex new file mode 100644 index 0000000..bb31c4d --- /dev/null +++ b/lib/pleroma/web/metadata/providers/provider.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.Provider do + @callback build_tags(map()) :: list() +end diff --git a/lib/pleroma/web/metadata/providers/rel_me.ex b/lib/pleroma/web/metadata/providers/rel_me.ex new file mode 100644 index 0000000..eabd8cb --- /dev/null +++ b/lib/pleroma/web/metadata/providers/rel_me.ex @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.RelMe do + alias Pleroma.Web.Metadata.Providers.Provider + @behaviour Provider + + @impl Provider + def build_tags(%{user: user}) do + profile_tree = + user.bio + |> append_fields_tag(user.fields) + |> Floki.parse_fragment!() + + (Floki.attribute(profile_tree, "link[rel~=me]", "href") ++ + Floki.attribute(profile_tree, "a[rel~=me]", "href")) + |> Enum.map(fn link -> + {:link, [rel: "me", href: link], []} + end) + end + + defp append_fields_tag(bio, fields) do + fields + |> Enum.reduce(bio, fn %{"value" => v}, res -> res <> v end) + end +end diff --git a/lib/pleroma/web/metadata/providers/restrict_indexing.ex b/lib/pleroma/web/metadata/providers/restrict_indexing.ex new file mode 100644 index 0000000..a43a7c9 --- /dev/null +++ b/lib/pleroma/web/metadata/providers/restrict_indexing.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.RestrictIndexing do + @behaviour Pleroma.Web.Metadata.Providers.Provider + + @moduledoc """ + Restricts indexing of remote and/or non-discoverable users. + """ + + @impl true + def build_tags(%{user: %{local: true, is_discoverable: true}}), do: [] + + def build_tags(_) do + [ + {:meta, + [ + name: "robots", + content: "noindex, noarchive" + ], []} + ] + end +end diff --git a/lib/pleroma/web/metadata/providers/twitter_card.ex b/lib/pleroma/web/metadata/providers/twitter_card.ex new file mode 100644 index 0000000..426022c --- /dev/null +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -0,0 +1,142 @@ +# Pleroma: A lightweight social networking server + +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.TwitterCard do + alias Pleroma.User + alias Pleroma.Web.MediaProxy + alias Pleroma.Web.Metadata + alias Pleroma.Web.Metadata.Providers.Provider + alias Pleroma.Web.Metadata.Utils + + @behaviour Provider + @media_types ["image", "audio", "video"] + + @impl Provider + def build_tags(%{activity_id: id, object: object, user: user}) do + attachments = build_attachments(id, object) + scrubbed_content = Utils.scrub_html_and_truncate(object) + + [ + title_tag(user), + {:meta, [name: "twitter:description", content: scrubbed_content], []} + ] ++ + if attachments == [] or Metadata.activity_nsfw?(object) do + [ + image_tag(user), + {:meta, [name: "twitter:card", content: "summary"], []} + ] + else + attachments + end + end + + @impl Provider + def build_tags(%{user: user}) do + with truncated_bio = Utils.scrub_html_and_truncate(user.bio) do + [ + title_tag(user), + {:meta, [name: "twitter:description", content: truncated_bio], []}, + image_tag(user), + {:meta, [name: "twitter:card", content: "summary"], []} + ] + end + end + + defp title_tag(user) do + {:meta, [name: "twitter:title", content: Utils.user_name_string(user)], []} + end + + def image_tag(user) do + {:meta, [name: "twitter:image", content: MediaProxy.preview_url(User.avatar_url(user))], []} + end + + defp build_attachments(id, %{data: %{"attachment" => attachments}}) do + Enum.reduce(attachments, [], fn attachment, acc -> + rendered_tags = + Enum.reduce(attachment["url"], [], fn url, acc -> + case Utils.fetch_media_type(@media_types, url["mediaType"]) do + "audio" -> + [ + {:meta, [name: "twitter:card", content: "player"], []}, + {:meta, [name: "twitter:player:width", content: "480"], []}, + {:meta, [name: "twitter:player:height", content: "80"], []}, + {:meta, [name: "twitter:player", content: player_url(id)], []} + | acc + ] + + # Not using preview_url for this. It saves bandwidth, but the image dimensions will + # be wrong. We generate it on the fly and have no way to capture or analyze the + # image to get the dimensions. This can be an issue for apps/FEs rendering images + # in timelines too, but you can get clever with the aspect ratio metadata as a + # workaround. + "image" -> + [ + {:meta, [name: "twitter:card", content: "summary_large_image"], []}, + {:meta, + [ + name: "twitter:image", + content: MediaProxy.url(url["href"]) + ], []}, + {:meta, [name: "twitter:image:alt", content: truncate(attachment["name"])], []} + | acc + ] + |> maybe_add_dimensions(url) + + "video" -> + # fallback to old placeholder values + height = url["height"] || 480 + width = url["width"] || 480 + + [ + {:meta, [name: "twitter:card", content: "player"], []}, + {:meta, [name: "twitter:player", content: player_url(id)], []}, + {:meta, [name: "twitter:player:width", content: "#{width}"], []}, + {:meta, [name: "twitter:player:height", content: "#{height}"], []}, + {:meta, [name: "twitter:player:stream", content: MediaProxy.url(url["href"])], + []}, + {:meta, [name: "twitter:player:stream:content_type", content: url["mediaType"]], + []} + | acc + ] + + _ -> + acc + end + end) + + acc ++ rendered_tags + end) + end + + defp build_attachments(_id, _object), do: [] + + defp player_url(id) do + Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice_player, id) + end + + # Videos have problems without dimensions, but we used to not provide WxH for images. + # A default (read: incorrect) fallback for images is likely to cause rendering bugs. + defp maybe_add_dimensions(metadata, url) do + cond do + !is_nil(url["height"]) && !is_nil(url["width"]) -> + metadata ++ + [ + {:meta, [name: "twitter:player:width", content: "#{url["width"]}"], []}, + {:meta, [name: "twitter:player:height", content: "#{url["height"]}"], []} + ] + + true -> + metadata + end + end + + defp truncate(nil), do: "" + + defp truncate(text) do + # truncate to 420 characters + # see https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup + Pleroma.Formatter.truncate(text, 420) + end +end diff --git a/lib/pleroma/web/metadata/utils.ex b/lib/pleroma/web/metadata/utils.ex new file mode 100644 index 0000000..80a8be9 --- /dev/null +++ b/lib/pleroma/web/metadata/utils.ex @@ -0,0 +1,67 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Utils do + alias Pleroma.Activity + alias Pleroma.Emoji + alias Pleroma.Formatter + alias Pleroma.HTML + + defp scrub_html_and_truncate_object_field(field, object) do + field + # html content comes from DB already encoded, decode first and scrub after + |> HtmlEntities.decode() + |> String.replace(~r//, " ") + |> Activity.HTML.get_cached_stripped_html_for_activity(object, "metadata") + |> Emoji.Formatter.demojify() + |> HtmlEntities.decode() + |> Formatter.truncate() + end + + def scrub_html_and_truncate(%{data: %{"summary" => summary}} = object) + when is_binary(summary) and summary != "" do + summary + |> scrub_html_and_truncate_object_field(object) + end + + def scrub_html_and_truncate(%{data: %{"content" => content}} = object) do + content + |> scrub_html_and_truncate_object_field(object) + end + + def scrub_html_and_truncate(content, max_length \\ 200, omission \\ "...") + when is_binary(content) do + content + |> scrub_html + |> Emoji.Formatter.demojify() + |> HtmlEntities.decode() + |> Formatter.truncate(max_length, omission) + end + + def scrub_html(content) when is_binary(content) do + content + # html content comes from DB already encoded, decode first and scrub after + |> HtmlEntities.decode() + |> String.replace(~r//, " ") + |> HTML.strip_tags() + end + + def scrub_html(content), do: content + + def user_name_string(user) do + "#{user.name} " <> + if user.local do + "(@#{user.nickname}@#{Pleroma.Web.Endpoint.host()})" + else + "(@#{user.nickname})" + end + end + + @spec fetch_media_type(list(String.t()), String.t()) :: String.t() | nil + def fetch_media_type(supported_types, media_type) do + Enum.find(supported_types, fn support_type -> + String.starts_with?(media_type, support_type) + end) + end +end diff --git a/lib/pleroma/web/mongoose_im/mongoose_im_controller.ex b/lib/pleroma/web/mongoose_im/mongoose_im_controller.ex new file mode 100644 index 0000000..e819c7c --- /dev/null +++ b/lib/pleroma/web/mongoose_im/mongoose_im_controller.ex @@ -0,0 +1,60 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MongooseIM.MongooseIMController do + use Pleroma.Web, :controller + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.Plugs.AuthenticationPlug + alias Pleroma.Web.Plugs.RateLimiter + + plug(RateLimiter, [name: :authentication] when action in [:user_exists, :check_password]) + plug(RateLimiter, [name: :authentication, params: ["user"]] when action == :check_password) + + def enabled?, do: Pleroma.Config.get([:auth, :mongoose_im], false) + + def user_exists(conn, %{"user" => username}) do + if enabled?() do + with %User{} <- Repo.get_by(User, nickname: username, local: true, is_active: true) do + conn + |> json(true) + else + _ -> + conn + |> put_status(:not_found) + |> json(false) + end + else + conn + |> put_status(:not_found) + |> json(false) + end + end + + def check_password(conn, %{"user" => username, "pass" => password}) do + if enabled?() do + with %User{password_hash: password_hash, is_active: true} <- + Repo.get_by(User, nickname: username, local: true), + true <- AuthenticationPlug.checkpw(password, password_hash) do + conn + |> json(true) + else + false -> + conn + |> put_status(:forbidden) + |> json(false) + + _ -> + conn + |> put_status(:not_found) + |> json(false) + end + else + conn + |> put_status(:not_found) + |> json(false) + end + end +end diff --git a/lib/pleroma/web/multipart.ex b/lib/pleroma/web/multipart.ex new file mode 100644 index 0000000..e24bb14 --- /dev/null +++ b/lib/pleroma/web/multipart.ex @@ -0,0 +1,22 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +# +defmodule Pleroma.Web.Multipart do + @multipart Plug.Parsers.MULTIPART + + def init(opts) do + opts + end + + def parse(conn, "multipart", subtype, headers, opts) do + length = Pleroma.Config.get([:instance, :upload_limit]) + opts = @multipart.init([length: length] ++ opts) + @multipart.parse(conn, "multipart", subtype, headers, opts) + end + + def parse(conn, _type, _subtype, _headers, _opts) do + {:next, conn} + end +end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo.ex b/lib/pleroma/web/nodeinfo/nodeinfo.ex new file mode 100644 index 0000000..901eb83 --- /dev/null +++ b/lib/pleroma/web/nodeinfo/nodeinfo.ex @@ -0,0 +1,97 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Nodeinfo.Nodeinfo do + alias Pleroma.Config + alias Pleroma.Stats + alias Pleroma.User + alias Pleroma.Web.Federator.Publisher + alias Pleroma.Web.MastodonAPI.InstanceView + + # returns a nodeinfo 2.0 map, since 2.1 just adds a repository field + # under software. + def get_nodeinfo("2.0") do + stats = Stats.get_stats() + + staff_accounts = + User.all_superusers() + |> Enum.map(fn u -> u.ap_id end) + + federation = InstanceView.federation() + features = InstanceView.features() + + %{ + version: "2.0", + software: %{ + name: Pleroma.Application.compat_name() |> String.downcase(), + version: Pleroma.Application.version() + }, + protocols: Publisher.gather_nodeinfo_protocol_names(), + services: %{ + inbound: [], + outbound: [] + }, + openRegistrations: Config.get([:instance, :registrations_open]), + usage: %{ + users: %{ + total: Map.get(stats, :user_count, 0), + activeMonth: Pleroma.User.active_user_count(30), + activeHalfyear: Pleroma.User.active_user_count(180) + }, + localPosts: Map.get(stats, :status_count, 0) + }, + metadata: %{ + nodeName: Config.get([:instance, :name]), + nodeDescription: Config.get([:instance, :description]), + private: !Config.get([:instance, :public], true), + suggestions: %{ + enabled: false + }, + staffAccounts: staff_accounts, + roles: %{ + admin: Config.get([:instance, :admin_privileges]), + moderator: Config.get([:instance, :moderator_privileges]) + }, + federation: federation, + pollLimits: Config.get([:instance, :poll_limits]), + postFormats: Config.get([:instance, :allowed_post_formats]), + uploadLimits: %{ + general: Config.get([:instance, :upload_limit]), + avatar: Config.get([:instance, :avatar_upload_limit]), + banner: Config.get([:instance, :banner_upload_limit]), + background: Config.get([:instance, :background_upload_limit]) + }, + fieldsLimits: %{ + maxFields: Config.get([:instance, :max_account_fields]), + maxRemoteFields: Config.get([:instance, :max_remote_account_fields]), + nameLength: Config.get([:instance, :account_field_name_length]), + valueLength: Config.get([:instance, :account_field_value_length]) + }, + accountActivationRequired: Config.get([:instance, :account_activation_required], false), + invitesEnabled: Config.get([:instance, :invites_enabled], false), + mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false), + features: features, + restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]), + skipThreadContainment: Config.get([:instance, :skip_thread_containment], false) + } + } + end + + def get_nodeinfo("2.1") do + raw_response = get_nodeinfo("2.0") + + updated_software = + raw_response + |> Map.get(:software) + |> Map.put(:repository, Pleroma.Application.repository()) + + raw_response + |> Map.put(:software, updated_software) + |> Map.put(:version, "2.1") + end + + def get_nodeinfo(_version) do + {:error, :missing} + end +end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex new file mode 100644 index 0000000..85c2393 --- /dev/null +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -0,0 +1,44 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Nodeinfo.NodeinfoController do + use Pleroma.Web, :controller + + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Nodeinfo.Nodeinfo + + def schemas(conn, _params) do + response = %{ + links: [ + %{ + rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", + href: Endpoint.url() <> "/nodeinfo/2.0.json" + }, + %{ + rel: "http://nodeinfo.diaspora.software/ns/schema/2.1", + href: Endpoint.url() <> "/nodeinfo/2.1.json" + } + ] + } + + json(conn, response) + end + + # Schema definition: https://github.com/jhass/nodeinfo/blob/master/schemas/2.0/schema.json + # and https://github.com/jhass/nodeinfo/blob/master/schemas/2.1/schema.json + def nodeinfo(conn, %{"version" => version}) do + case Nodeinfo.get_nodeinfo(version) do + {:error, :missing} -> + render_error(conn, :not_found, "Nodeinfo schema version not handled") + + node_info -> + conn + |> put_resp_header( + "content-type", + "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" + ) + |> json(node_info) + end + end +end diff --git a/lib/pleroma/web/o_auth.ex b/lib/pleroma/web/o_auth.ex new file mode 100644 index 0000000..d8c68df --- /dev/null +++ b/lib/pleroma/web/o_auth.ex @@ -0,0 +1,6 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth do +end diff --git a/lib/pleroma/web/o_auth/app.ex b/lib/pleroma/web/o_auth/app.ex new file mode 100644 index 0000000..0aa6553 --- /dev/null +++ b/lib/pleroma/web/o_auth/app.ex @@ -0,0 +1,158 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.App do + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + alias Pleroma.Repo + alias Pleroma.User + + @type t :: %__MODULE__{} + + schema "apps" do + field(:client_name, :string) + field(:redirect_uris, :string) + field(:scopes, {:array, :string}, default: []) + field(:website, :string) + field(:client_id, :string) + field(:client_secret, :string) + field(:trusted, :boolean, default: false) + + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + + has_many(:oauth_authorizations, Pleroma.Web.OAuth.Authorization, on_delete: :delete_all) + has_many(:oauth_tokens, Pleroma.Web.OAuth.Token, on_delete: :delete_all) + + timestamps() + end + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(struct, params) do + cast(struct, params, [:client_name, :redirect_uris, :scopes, :website, :trusted, :user_id]) + end + + @spec register_changeset(t(), map()) :: Ecto.Changeset.t() + def register_changeset(struct, params \\ %{}) do + changeset = + struct + |> changeset(params) + |> validate_required([:client_name, :redirect_uris, :scopes]) + + if changeset.valid? do + changeset + |> put_change( + :client_id, + :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + ) + |> put_change( + :client_secret, + :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + ) + else + changeset + end + end + + @spec create(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} + def create(params) do + %__MODULE__{} + |> register_changeset(params) + |> Repo.insert() + end + + @spec update(pos_integer(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} + def update(id, params) do + with %__MODULE__{} = app <- Repo.get(__MODULE__, id) do + app + |> changeset(params) + |> Repo.update() + end + end + + @doc """ + Gets app by attrs or create new with attrs. + And updates the scopes if need. + """ + @spec get_or_make(map(), list(String.t())) :: {:ok, t()} | {:error, Ecto.Changeset.t()} + def get_or_make(attrs, scopes) do + with %__MODULE__{} = app <- Repo.get_by(__MODULE__, attrs) do + update_scopes(app, scopes) + else + _e -> + %__MODULE__{} + |> register_changeset(Map.put(attrs, :scopes, scopes)) + |> Repo.insert() + end + end + + defp update_scopes(%__MODULE__{} = app, []), do: {:ok, app} + defp update_scopes(%__MODULE__{scopes: scopes} = app, scopes), do: {:ok, app} + + defp update_scopes(%__MODULE__{} = app, scopes) do + app + |> change(%{scopes: scopes}) + |> Repo.update() + end + + @spec search(map()) :: {:ok, [t()], non_neg_integer()} + def search(params) do + query = from(a in __MODULE__) + + query = + if params[:client_name] do + from(a in query, where: a.client_name == ^params[:client_name]) + else + query + end + + query = + if params[:client_id] do + from(a in query, where: a.client_id == ^params[:client_id]) + else + query + end + + query = + if Map.has_key?(params, :trusted) do + from(a in query, where: a.trusted == ^params[:trusted]) + else + query + end + + query = + from(u in query, + limit: ^params[:page_size], + offset: ^((params[:page] - 1) * params[:page_size]) + ) + + count = Repo.aggregate(__MODULE__, :count, :id) + + {:ok, Repo.all(query), count} + end + + @spec get_user_apps(User.t()) :: {:ok, [t()], non_neg_integer()} + def get_user_apps(%User{id: user_id}) do + from(a in __MODULE__, where: a.user_id == ^user_id) + |> Repo.all() + end + + @spec destroy(pos_integer()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} + def destroy(id) do + with %__MODULE__{} = app <- Repo.get(__MODULE__, id) do + Repo.delete(app) + end + end + + @spec errors(Ecto.Changeset.t()) :: map() + def errors(changeset) do + Enum.reduce(changeset.errors, %{}, fn + {:client_name, {error, _}}, acc -> + Map.put(acc, :name, error) + + {key, {error, _}}, acc -> + Map.put(acc, key, error) + end) + end +end diff --git a/lib/pleroma/web/o_auth/authorization.ex b/lib/pleroma/web/o_auth/authorization.ex new file mode 100644 index 0000000..593d2d6 --- /dev/null +++ b/lib/pleroma/web/o_auth/authorization.ex @@ -0,0 +1,97 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.Authorization do + use Ecto.Schema + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.Token + + import Ecto.Changeset + import Ecto.Query + + @type t :: %__MODULE__{} + + schema "oauth_authorizations" do + field(:token, :string) + field(:scopes, {:array, :string}, default: []) + field(:valid_until, :naive_datetime_usec) + field(:used, :boolean, default: false) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:app, App) + + timestamps() + end + + @spec create_authorization(App.t(), User.t() | %{}, [String.t()] | nil) :: + {:ok, Authorization.t()} | {:error, Changeset.t()} + def create_authorization(%App{} = app, %User{} = user, scopes \\ nil) do + %{ + scopes: scopes || app.scopes, + user_id: user.id, + app_id: app.id + } + |> create_changeset() + |> Repo.insert() + end + + @spec create_changeset(map()) :: Changeset.t() + def create_changeset(attrs \\ %{}) do + %Authorization{} + |> cast(attrs, [:user_id, :app_id, :scopes, :valid_until]) + |> validate_required([:app_id, :scopes]) + |> add_token() + |> add_lifetime() + end + + defp add_token(changeset) do + token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + put_change(changeset, :token, token) + end + + defp add_lifetime(changeset) do + lifespan = Token.lifespan() + put_change(changeset, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), lifespan)) + end + + @spec use_changeset(Authtorizatiton.t(), map()) :: Changeset.t() + def use_changeset(%Authorization{} = auth, params) do + auth + |> cast(params, [:used]) + |> validate_required([:used]) + end + + @spec use_token(Authorization.t()) :: + {:ok, Authorization.t()} | {:error, Changeset.t()} | {:error, String.t()} + def use_token(%Authorization{used: false, valid_until: valid_until} = auth) do + if NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) < 0 do + Repo.update(use_changeset(auth, %{used: true})) + else + {:error, "token expired"} + end + end + + def use_token(%Authorization{used: true}), do: {:error, "already used"} + + @spec delete_user_authorizations(User.t()) :: {integer(), any()} + def delete_user_authorizations(%User{} = user) do + user + |> delete_by_user_query + |> Repo.delete_all() + end + + def delete_by_user_query(%User{id: user_id}) do + from(a in __MODULE__, where: a.user_id == ^user_id) + end + + @doc "gets auth for app by token" + @spec get_by_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found} + def get_by_token(%App{id: app_id} = _app, token) do + from(t in __MODULE__, where: t.app_id == ^app_id and t.token == ^token) + |> Repo.find_resource() + end +end diff --git a/lib/pleroma/web/o_auth/fallback_controller.ex b/lib/pleroma/web/o_auth/fallback_controller.ex new file mode 100644 index 0000000..684a52c --- /dev/null +++ b/lib/pleroma/web/o_auth/fallback_controller.ex @@ -0,0 +1,32 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.FallbackController do + use Pleroma.Web, :controller + alias Pleroma.Web.OAuth.OAuthController + + def call(conn, {:register, :generic_error}) do + conn + |> put_status(:internal_server_error) + |> put_flash( + :error, + dgettext("errors", "Unknown error, please check the details and try again.") + ) + |> OAuthController.registration_details(conn.params) + end + + def call(conn, {:register, _error}) do + conn + |> put_status(:unauthorized) + |> put_flash(:error, dgettext("errors", "Invalid Username/Password")) + |> OAuthController.registration_details(conn.params) + end + + def call(conn, _error) do + conn + |> put_status(:unauthorized) + |> put_flash(:error, dgettext("errors", "Invalid Username/Password")) + |> OAuthController.authorize(conn.params) + end +end diff --git a/lib/pleroma/web/o_auth/mfa_controller.ex b/lib/pleroma/web/o_auth/mfa_controller.ex new file mode 100644 index 0000000..c4bc4a8 --- /dev/null +++ b/lib/pleroma/web/o_auth/mfa_controller.ex @@ -0,0 +1,97 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.MFAController do + @moduledoc """ + The model represents api to use Multi Factor authentications. + """ + + use Pleroma.Web, :controller + + alias Pleroma.MFA + alias Pleroma.Web.Auth.TOTPAuthenticator + alias Pleroma.Web.OAuth.MFAView, as: View + alias Pleroma.Web.OAuth.OAuthController + alias Pleroma.Web.OAuth.Token + + plug(:fetch_session when action in [:show, :verify]) + plug(:fetch_flash when action in [:show, :verify]) + + @doc """ + Display form to input mfa code or recovery code. + """ + def show(conn, %{"mfa_token" => mfa_token} = params) do + template = Map.get(params, "challenge_type", "totp") + + conn + |> put_view(View) + |> render("#{template}.html", %{ + mfa_token: mfa_token, + redirect_uri: params["redirect_uri"], + state: params["state"] + }) + end + + @doc """ + Verification code and continue authorization. + """ + def verify(conn, %{"mfa" => %{"mfa_token" => mfa_token} = mfa_params} = _) do + with {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token), + {:ok, _} <- validates_challenge(user, mfa_params) do + conn + |> OAuthController.after_create_authorization(auth, %{ + "authorization" => %{ + "redirect_uri" => mfa_params["redirect_uri"], + "state" => mfa_params["state"] + } + }) + else + _ -> + conn + |> put_flash(:error, "Two-factor authentication failed.") + |> put_status(:unauthorized) + |> show(mfa_params) + end + end + + @doc """ + Verification second step of MFA (or recovery) and returns access token. + + ## Endpoint + POST /oauth/mfa/challenge + + params: + `client_id` + `client_secret` + `mfa_token` - access token to check second step of mfa + `challenge_type` - 'totp' or 'recovery' + `code` + + """ + def challenge(conn, %{"mfa_token" => mfa_token} = params) do + with {:ok, app} <- Token.Utils.fetch_app(conn), + {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token), + {:ok, _} <- validates_challenge(user, params), + {:ok, token} <- Token.exchange_token(app, auth) do + OAuthController.after_token_exchange(conn, %{user: user, token: token}) + else + _error -> + conn + |> put_status(400) + |> json(%{error: "Invalid code"}) + end + end + + # Verify TOTP Code + defp validates_challenge(user, %{"challenge_type" => "totp", "code" => code} = _) do + TOTPAuthenticator.verify(code, user) + end + + # Verify Recovery Code + defp validates_challenge(user, %{"challenge_type" => "recovery", "code" => code} = _) do + TOTPAuthenticator.verify_recovery_code(user, code) + end + + defp validates_challenge(_, _), do: {:error, :unsupported_challenge_type} +end diff --git a/lib/pleroma/web/o_auth/mfa_view.ex b/lib/pleroma/web/o_auth/mfa_view.ex new file mode 100644 index 0000000..bcadafe --- /dev/null +++ b/lib/pleroma/web/o_auth/mfa_view.ex @@ -0,0 +1,18 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.MFAView do + use Pleroma.Web, :view + import Phoenix.HTML.Form + alias Pleroma.MFA + alias Pleroma.Web.Gettext + + def render("mfa_response.json", %{token: token, user: user}) do + %{ + error: "mfa_required", + mfa_token: token.token, + supported_challenge_types: MFA.supported_methods(user) + } + end +end diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex new file mode 100644 index 0000000..c1fb4f3 --- /dev/null +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -0,0 +1,633 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.OAuthController do + use Pleroma.Web, :controller + + alias Pleroma.Helpers.AuthHelper + alias Pleroma.Helpers.UriHelper + alias Pleroma.Maps + alias Pleroma.MFA + alias Pleroma.Registration + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.Auth.WrapperAuthenticator, as: Authenticator + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.MFAController + alias Pleroma.Web.OAuth.MFAView + alias Pleroma.Web.OAuth.OAuthView + alias Pleroma.Web.OAuth.Scopes + alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken + alias Pleroma.Web.OAuth.Token.Strategy.Revoke, as: RevokeToken + alias Pleroma.Web.Plugs.RateLimiter + alias Pleroma.Web.Utils.Params + + require Logger + + if Pleroma.Config.oauth_consumer_enabled?(), do: plug(Ueberauth) + + plug(:fetch_session) + plug(:fetch_flash) + + plug(:skip_auth) + + plug(RateLimiter, [name: :authentication] when action == :create_authorization) + + action_fallback(Pleroma.Web.OAuth.FallbackController) + + @oob_token_redirect_uri "urn:ietf:wg:oauth:2.0:oob" + + # Note: this definition is only called from error-handling methods with `conn.params` as 2nd arg + def authorize(%Plug.Conn{} = conn, %{"authorization" => _} = params) do + {auth_attrs, params} = Map.pop(params, "authorization") + authorize(conn, Map.merge(params, auth_attrs)) + end + + def authorize(%Plug.Conn{assigns: %{token: %Token{}}} = conn, %{"force_login" => _} = params) do + if Params.truthy_param?(params["force_login"]) do + do_authorize(conn, params) + else + handle_existing_authorization(conn, params) + end + end + + # Note: the token is set in oauth_plug, but the token and client do not always go together. + # For example, MastodonFE's token is set if user requests with another client, + # after user already authorized to MastodonFE. + # So we have to check client and token. + def authorize( + %Plug.Conn{assigns: %{token: %Token{} = token}} = conn, + %{"client_id" => client_id} = params + ) do + with %Token{} = t <- Repo.get_by(Token, token: token.token) |> Repo.preload(:app), + ^client_id <- t.app.client_id do + handle_existing_authorization(conn, params) + else + _ -> do_authorize(conn, params) + end + end + + def authorize(%Plug.Conn{} = conn, params), do: do_authorize(conn, params) + + defp do_authorize(%Plug.Conn{} = conn, params) do + app = Repo.get_by(App, client_id: params["client_id"]) + available_scopes = (app && app.scopes) || [] + scopes = Scopes.fetch_scopes(params, available_scopes) + + user = + with %{assigns: %{user: %User{} = user}} <- conn do + user + else + _ -> nil + end + + scopes = + if scopes == [] do + available_scopes + else + scopes + end + + # Note: `params` might differ from `conn.params`; use `@params` not `@conn.params` in template + render(conn, Authenticator.auth_template(), %{ + user: user, + app: app && Map.delete(app, :client_secret), + response_type: params["response_type"], + client_id: params["client_id"], + available_scopes: available_scopes, + scopes: scopes, + redirect_uri: params["redirect_uri"], + state: params["state"], + params: params + }) + end + + defp handle_existing_authorization( + %Plug.Conn{assigns: %{token: %Token{} = token}} = conn, + %{"redirect_uri" => @oob_token_redirect_uri} + ) do + render(conn, "oob_token_exists.html", %{token: token}) + end + + defp handle_existing_authorization( + %Plug.Conn{assigns: %{token: %Token{} = token}} = conn, + %{} = params + ) do + app = Repo.preload(token, :app).app + + redirect_uri = + if is_binary(params["redirect_uri"]) do + params["redirect_uri"] + else + default_redirect_uri(app) + end + + if redirect_uri in String.split(app.redirect_uris) do + redirect_uri = redirect_uri(conn, redirect_uri) + url_params = %{access_token: token.token} + url_params = Maps.put_if_present(url_params, :state, params["state"]) + url = UriHelper.modify_uri_params(redirect_uri, url_params) + redirect(conn, external: url) + else + conn + |> put_flash(:error, dgettext("errors", "Unlisted redirect_uri.")) + |> redirect(external: redirect_uri(conn, redirect_uri)) + end + end + + def create_authorization(_, _, opts \\ []) + + def create_authorization(%Plug.Conn{assigns: %{user: %User{} = user}} = conn, params, []) do + create_authorization(conn, params, user: user) + end + + def create_authorization(%Plug.Conn{} = conn, %{"authorization" => _} = params, opts) do + with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]), + {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do + after_create_authorization(conn, auth, params) + else + error -> + handle_create_authorization_error(conn, error, params) + end + end + + def after_create_authorization(%Plug.Conn{} = conn, %Authorization{} = auth, %{ + "authorization" => %{"redirect_uri" => @oob_token_redirect_uri} + }) do + # Enforcing the view to reuse the template when calling from other controllers + conn + |> put_view(OAuthView) + |> render("oob_authorization_created.html", %{auth: auth}) + end + + def after_create_authorization(%Plug.Conn{} = conn, %Authorization{} = auth, %{ + "authorization" => %{"redirect_uri" => redirect_uri} = auth_attrs + }) do + app = Repo.preload(auth, :app).app + + # An extra safety measure before we redirect (also done in `do_create_authorization/2`) + if redirect_uri in String.split(app.redirect_uris) do + redirect_uri = redirect_uri(conn, redirect_uri) + url_params = %{code: auth.token} + url_params = Maps.put_if_present(url_params, :state, auth_attrs["state"]) + url = UriHelper.modify_uri_params(redirect_uri, url_params) + redirect(conn, external: url) + else + conn + |> put_flash(:error, dgettext("errors", "Unlisted redirect_uri.")) + |> redirect(external: redirect_uri(conn, redirect_uri)) + end + end + + defp handle_create_authorization_error( + %Plug.Conn{} = conn, + {:error, scopes_issue}, + %{"authorization" => _} = params + ) + when scopes_issue in [:unsupported_scopes, :missing_scopes] do + # Per https://github.com/tootsuite/mastodon/blob/ + # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L39 + conn + |> put_flash(:error, dgettext("errors", "This action is outside the authorized scopes")) + |> put_status(:unauthorized) + |> authorize(params) + end + + defp handle_create_authorization_error( + %Plug.Conn{} = conn, + {:account_status, :confirmation_pending}, + %{"authorization" => _} = params + ) do + conn + |> put_flash(:error, dgettext("errors", "Your login is missing a confirmed e-mail address")) + |> put_status(:forbidden) + |> authorize(params) + end + + defp handle_create_authorization_error( + %Plug.Conn{} = conn, + {:mfa_required, user, auth, _}, + params + ) do + {:ok, token} = MFA.Token.create(user, auth) + + data = %{ + "mfa_token" => token.token, + "redirect_uri" => params["authorization"]["redirect_uri"], + "state" => params["authorization"]["state"] + } + + MFAController.show(conn, data) + end + + defp handle_create_authorization_error( + %Plug.Conn{} = conn, + {:account_status, :password_reset_pending}, + %{"authorization" => _} = params + ) do + conn + |> put_flash(:error, dgettext("errors", "Password reset is required")) + |> put_status(:forbidden) + |> authorize(params) + end + + defp handle_create_authorization_error( + %Plug.Conn{} = conn, + {:account_status, :deactivated}, + %{"authorization" => _} = params + ) do + conn + |> put_flash(:error, dgettext("errors", "Your account is currently disabled")) + |> put_status(:forbidden) + |> authorize(params) + end + + defp handle_create_authorization_error(%Plug.Conn{} = conn, error, %{"authorization" => _}) do + Authenticator.handle_error(conn, error) + end + + @doc "Renew access_token with refresh_token" + def token_exchange( + %Plug.Conn{} = conn, + %{"grant_type" => "refresh_token", "refresh_token" => token} = _params + ) do + with {:ok, app} <- Token.Utils.fetch_app(conn), + {:ok, %{user: user} = token} <- Token.get_by_refresh_token(app, token), + {:ok, token} <- RefreshToken.grant(token) do + after_token_exchange(conn, %{user: user, token: token}) + else + _error -> render_invalid_credentials_error(conn) + end + end + + def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "authorization_code"} = params) do + with {:ok, app} <- Token.Utils.fetch_app(conn), + fixed_token = Token.Utils.fix_padding(params["code"]), + {:ok, auth} <- Authorization.get_by_token(app, fixed_token), + %User{} = user <- User.get_cached_by_id(auth.user_id), + {:ok, token} <- Token.exchange_token(app, auth) do + after_token_exchange(conn, %{user: user, token: token}) + else + error -> + handle_token_exchange_error(conn, error) + end + end + + def token_exchange( + %Plug.Conn{} = conn, + %{"grant_type" => "password"} = params + ) do + with {:ok, %User{} = user} <- Authenticator.get_user(conn), + {:ok, app} <- Token.Utils.fetch_app(conn), + requested_scopes <- Scopes.fetch_scopes(params, app.scopes), + {:ok, token} <- login(user, app, requested_scopes) do + after_token_exchange(conn, %{user: user, token: token}) + else + error -> + handle_token_exchange_error(conn, error) + end + end + + def token_exchange( + %Plug.Conn{} = conn, + %{"grant_type" => "password", "name" => name, "password" => _password} = params + ) do + params = + params + |> Map.delete("name") + |> Map.put("username", name) + + token_exchange(conn, params) + end + + def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "client_credentials"} = _params) do + with {:ok, app} <- Token.Utils.fetch_app(conn), + {:ok, auth} <- Authorization.create_authorization(app, %User{}), + {:ok, token} <- Token.exchange_token(app, auth) do + after_token_exchange(conn, %{token: token}) + else + _error -> + handle_token_exchange_error(conn, :invalid_credentails) + end + end + + # Bad request + def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params) + + def after_token_exchange(%Plug.Conn{} = conn, %{token: token} = view_params) do + conn + |> AuthHelper.put_session_token(token.token) + |> json(OAuthView.render("token.json", view_params)) + end + + defp handle_token_exchange_error(%Plug.Conn{} = conn, {:mfa_required, user, auth, _}) do + conn + |> put_status(:forbidden) + |> json(build_and_response_mfa_token(user, auth)) + end + + defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :deactivated}) do + render_error( + conn, + :forbidden, + "Your account is currently disabled", + %{}, + "account_is_disabled" + ) + end + + defp handle_token_exchange_error( + %Plug.Conn{} = conn, + {:account_status, :password_reset_pending} + ) do + render_error( + conn, + :forbidden, + "Password reset is required", + %{}, + "password_reset_required" + ) + end + + defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :confirmation_pending}) do + render_error( + conn, + :forbidden, + "Your login is missing a confirmed e-mail address", + %{}, + "missing_confirmed_email" + ) + end + + defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :approval_pending}) do + render_error( + conn, + :forbidden, + "Your account is awaiting approval.", + %{}, + "awaiting_approval" + ) + end + + defp handle_token_exchange_error(%Plug.Conn{} = conn, _error) do + render_invalid_credentials_error(conn) + end + + def token_revoke(%Plug.Conn{} = conn, %{"token" => token}) do + with {:ok, %Token{} = oauth_token} <- Token.get_by_token(token), + {:ok, oauth_token} <- RevokeToken.revoke(oauth_token) do + conn = + with session_token = AuthHelper.get_session_token(conn), + %Token{token: ^session_token} <- oauth_token do + AuthHelper.delete_session_token(conn) + else + _ -> conn + end + + json(conn, %{}) + else + _error -> + # RFC 7009: invalid tokens [in the request] do not cause an error response + json(conn, %{}) + end + end + + def token_revoke(%Plug.Conn{} = conn, params), do: bad_request(conn, params) + + # Response for bad request + defp bad_request(%Plug.Conn{} = conn, _) do + render_error(conn, :internal_server_error, "Bad request") + end + + @doc "Prepares OAuth request to provider for Ueberauth" + def prepare_request(%Plug.Conn{} = conn, %{ + "provider" => provider, + "authorization" => auth_attrs + }) do + scope = + auth_attrs + |> Scopes.fetch_scopes([]) + |> Scopes.to_string() + + state = + auth_attrs + |> Map.delete("scopes") + |> Map.put("scope", scope) + |> Jason.encode!() + + params = + auth_attrs + |> Map.drop(~w(scope scopes client_id redirect_uri)) + |> Map.put("state", state) + + # Handing the request to Ueberauth + redirect(conn, to: Routes.o_auth_path(conn, :request, provider, params)) + end + + def request(%Plug.Conn{} = conn, params) do + message = + if params["provider"] do + dgettext("errors", "Unsupported OAuth provider: %{provider}.", + provider: params["provider"] + ) + else + dgettext("errors", "Bad OAuth request.") + end + + conn + |> put_flash(:error, message) + |> redirect(to: "/") + end + + def callback(%Plug.Conn{assigns: %{ueberauth_failure: failure}} = conn, params) do + params = callback_params(params) + messages = for e <- Map.get(failure, :errors, []), do: e.message + message = Enum.join(messages, "; ") + + conn + |> put_flash( + :error, + dgettext("errors", "Failed to authenticate: %{message}.", message: message) + ) + |> redirect(external: redirect_uri(conn, params["redirect_uri"])) + end + + def callback(%Plug.Conn{} = conn, params) do + params = callback_params(params) + + with {:ok, registration} <- Authenticator.get_registration(conn) do + auth_attrs = Map.take(params, ~w(client_id redirect_uri scope scopes state)) + + case Repo.get_assoc(registration, :user) do + {:ok, user} -> + create_authorization(conn, %{"authorization" => auth_attrs}, user: user) + + _ -> + registration_params = + Map.merge(auth_attrs, %{ + "nickname" => Registration.nickname(registration), + "email" => Registration.email(registration) + }) + + conn + |> put_session_registration_id(registration.id) + |> registration_details(%{"authorization" => registration_params}) + end + else + error -> + Logger.debug(inspect(["OAUTH_ERROR", error, conn.assigns])) + + conn + |> put_flash(:error, dgettext("errors", "Failed to set up user account.")) + |> redirect(external: redirect_uri(conn, params["redirect_uri"])) + end + end + + defp callback_params(%{"state" => state} = params) do + Map.merge(params, Jason.decode!(state)) + end + + def registration_details(%Plug.Conn{} = conn, %{"authorization" => auth_attrs}) do + render(conn, "register.html", %{ + client_id: auth_attrs["client_id"], + redirect_uri: auth_attrs["redirect_uri"], + state: auth_attrs["state"], + scopes: Scopes.fetch_scopes(auth_attrs, []), + nickname: auth_attrs["nickname"], + email: auth_attrs["email"] + }) + end + + def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "connect"} = params) do + with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn), + %Registration{} = registration <- Repo.get(Registration, registration_id), + {_, {:ok, auth, _user}} <- + {:create_authorization, do_create_authorization(conn, params)}, + %User{} = user <- Repo.preload(auth, :user).user, + {:ok, _updated_registration} <- Registration.bind_to_user(registration, user) do + conn + |> put_session_registration_id(nil) + |> after_create_authorization(auth, params) + else + {:create_authorization, error} -> + {:register, handle_create_authorization_error(conn, error, params)} + + _ -> + {:register, :generic_error} + end + end + + def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "register"} = params) do + with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn), + %Registration{} = registration <- Repo.get(Registration, registration_id), + {:ok, user} <- Authenticator.create_from_registration(conn, registration) do + conn + |> put_session_registration_id(nil) + |> create_authorization( + params, + user: user + ) + else + {:error, changeset} -> + message = + Enum.map(changeset.errors, fn {field, {error, _}} -> + "#{field} #{error}" + end) + |> Enum.join("; ") + + message = + String.replace( + message, + "ap_id has already been taken", + "nickname has already been taken" + ) + + conn + |> put_status(:forbidden) + |> put_flash(:error, "Error: #{message}.") + |> registration_details(params) + + _ -> + {:register, :generic_error} + end + end + + defp do_create_authorization(conn, auth_attrs, user \\ nil) + + defp do_create_authorization( + %Plug.Conn{} = conn, + %{ + "authorization" => + %{ + "client_id" => client_id, + "redirect_uri" => redirect_uri + } = auth_attrs + }, + user + ) do + with {_, {:ok, %User{} = user}} <- + {:get_user, (user && {:ok, user}) || Authenticator.get_user(conn)}, + %App{} = app <- Repo.get_by(App, client_id: client_id), + true <- redirect_uri in String.split(app.redirect_uris), + requested_scopes <- Scopes.fetch_scopes(auth_attrs, app.scopes), + {:ok, auth} <- do_create_authorization(user, app, requested_scopes) do + {:ok, auth, user} + end + end + + defp do_create_authorization(%User{} = user, %App{} = app, requested_scopes) + when is_list(requested_scopes) do + with {:account_status, :active} <- {:account_status, User.account_status(user)}, + {:ok, scopes} <- validate_scopes(app, requested_scopes), + {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do + {:ok, auth} + end + end + + # Note: intended to be a private function but opened for AccountController that logs in on signup + @doc "If checks pass, creates authorization and token for given user, app and requested scopes." + def login(%User{} = user, %App{} = app, requested_scopes) when is_list(requested_scopes) do + with {:ok, auth} <- do_create_authorization(user, app, requested_scopes), + {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)}, + {:ok, token} <- Token.exchange_token(app, auth) do + {:ok, token} + end + end + + defp redirect_uri(%Plug.Conn{}, redirect_uri), do: redirect_uri + + defp get_session_registration_id(%Plug.Conn{} = conn), do: get_session(conn, :registration_id) + + defp put_session_registration_id(%Plug.Conn{} = conn, registration_id), + do: put_session(conn, :registration_id, registration_id) + + defp build_and_response_mfa_token(user, auth) do + with {:ok, token} <- MFA.Token.create(user, auth) do + MFAView.render("mfa_response.json", %{token: token, user: user}) + end + end + + @spec validate_scopes(App.t(), map() | list()) :: + {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes} + defp validate_scopes(%App{} = app, params) when is_map(params) do + requested_scopes = Scopes.fetch_scopes(params, app.scopes) + validate_scopes(app, requested_scopes) + end + + defp validate_scopes(%App{} = app, requested_scopes) when is_list(requested_scopes) do + Scopes.validate(requested_scopes, app.scopes) + end + + def default_redirect_uri(%App{} = app) do + app.redirect_uris + |> String.split() + |> Enum.at(0) + end + + defp render_invalid_credentials_error(conn) do + render_error(conn, :bad_request, "Invalid credentials") + end +end diff --git a/lib/pleroma/web/o_auth/o_auth_view.ex b/lib/pleroma/web/o_auth/o_auth_view.ex new file mode 100644 index 0000000..108102c --- /dev/null +++ b/lib/pleroma/web/o_auth/o_auth_view.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.OAuthView do + use Pleroma.Web, :view + import Phoenix.HTML.Form + import Phoenix.HTML + alias Pleroma.Web.Gettext + + alias Pleroma.Web.OAuth.Token.Utils + + def render("token.json", %{token: token} = opts) do + response = %{ + id: token.id, + token_type: "Bearer", + access_token: token.token, + refresh_token: token.refresh_token, + expires_in: NaiveDateTime.diff(token.valid_until, NaiveDateTime.utc_now()), + scope: Enum.join(token.scopes, " "), + created_at: Utils.format_created_at(token) + } + + if user = opts[:user] do + response + |> Map.put(:me, user.ap_id) + else + response + end + end +end diff --git a/lib/pleroma/web/o_auth/scopes.ex b/lib/pleroma/web/o_auth/scopes.ex new file mode 100644 index 0000000..f33fc21 --- /dev/null +++ b/lib/pleroma/web/o_auth/scopes.ex @@ -0,0 +1,76 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.Scopes do + @moduledoc """ + Functions for dealing with scopes. + """ + + alias Pleroma.Web.Plugs.OAuthScopesPlug + + @doc """ + Fetch scopes from request params. + + Note: `scopes` is used by Mastodon — supporting it but sticking to + OAuth's standard `scope` wherever we control it + """ + @spec fetch_scopes(map() | struct(), list()) :: list() + + def fetch_scopes(params, default) do + parse_scopes(params["scope"] || params["scopes"] || params[:scopes], default) + end + + def parse_scopes(scopes, _default) when is_list(scopes) do + Enum.filter(scopes, &(&1 not in [nil, ""])) + end + + def parse_scopes(scopes, default) when is_binary(scopes) do + scopes + |> to_list + |> parse_scopes(default) + end + + def parse_scopes(_, default) do + default + end + + @doc """ + Convert scopes string to list + """ + @spec to_list(binary()) :: [binary()] + def to_list(nil), do: [] + + def to_list(str) do + str + |> String.trim() + |> String.split(~r/[\s,]+/) + end + + @doc """ + Convert scopes list to string + """ + @spec to_string(list()) :: binary() + def to_string(scopes), do: Enum.join(scopes, " ") + + @doc """ + Validates scopes. + """ + @spec validate(list() | nil, list()) :: + {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes} + def validate(blank_scopes, _app_scopes) when blank_scopes in [nil, []], + do: {:error, :missing_scopes} + + def validate(scopes, app_scopes) do + case OAuthScopesPlug.filter_descendants(scopes, app_scopes) do + ^scopes -> {:ok, scopes} + _ -> {:error, :unsupported_scopes} + end + end + + def contains_admin_scopes?(scopes) do + scopes + |> OAuthScopesPlug.filter_descendants(["admin"]) + |> Enum.any?() + end +end diff --git a/lib/pleroma/web/o_auth/token.ex b/lib/pleroma/web/o_auth/token.ex new file mode 100644 index 0000000..26de7bb --- /dev/null +++ b/lib/pleroma/web/o_auth/token.ex @@ -0,0 +1,145 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.Token do + use Ecto.Schema + + import Ecto.Changeset + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.OAuth.Token.Query + + @type t :: %__MODULE__{} + + schema "oauth_tokens" do + field(:token, :string) + field(:refresh_token, :string) + field(:scopes, {:array, :string}, default: []) + field(:valid_until, :naive_datetime_usec) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:app, App) + + timestamps() + end + + def lifespan do + Pleroma.Config.get!([:oauth2, :token_expires_in]) + end + + @doc "Gets token by unique access token" + @spec get_by_token(String.t()) :: {:ok, t()} | {:error, :not_found} + def get_by_token(token) do + token + |> Query.get_by_token() + |> Repo.find_resource() + end + + @doc "Gets token for app by access token" + @spec get_by_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found} + def get_by_token(%App{id: app_id} = _app, token) do + Query.get_by_app(app_id) + |> Query.get_by_token(token) + |> Repo.find_resource() + end + + @doc "Gets token for app by refresh token" + @spec get_by_refresh_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found} + def get_by_refresh_token(%App{id: app_id} = _app, token) do + Query.get_by_app(app_id) + |> Query.get_by_refresh_token(token) + |> Query.preload([:user]) + |> Repo.find_resource() + end + + @spec exchange_token(App.t(), Authorization.t()) :: {:ok, Token.t()} | {:error, Changeset.t()} + def exchange_token(app, auth) do + with {:ok, auth} <- Authorization.use_token(auth), + true <- auth.app_id == app.id do + user = if auth.user_id, do: User.get_cached_by_id(auth.user_id), else: %User{} + + create( + app, + user, + %{scopes: auth.scopes} + ) + end + end + + defp put_token(changeset) do + changeset + |> change(%{token: Token.Utils.generate_token()}) + |> validate_required([:token]) + |> unique_constraint(:token) + end + + defp put_refresh_token(changeset, attrs) do + refresh_token = Map.get(attrs, :refresh_token, Token.Utils.generate_token()) + + changeset + |> change(%{refresh_token: refresh_token}) + |> validate_required([:refresh_token]) + |> unique_constraint(:refresh_token) + end + + defp put_valid_until(changeset, attrs) do + valid_until = + Map.get(attrs, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), lifespan())) + + changeset + |> change(%{valid_until: valid_until}) + |> validate_required([:valid_until]) + end + + @spec create(App.t(), User.t(), map()) :: {:ok, Token} | {:error, Changeset.t()} + def create(%App{} = app, %User{} = user, attrs \\ %{}) do + with {:ok, token} <- do_create(app, user, attrs) do + if Pleroma.Config.get([:oauth2, :clean_expired_tokens]) do + Pleroma.Workers.PurgeExpiredToken.enqueue(%{ + token_id: token.id, + valid_until: DateTime.from_naive!(token.valid_until, "Etc/UTC"), + mod: __MODULE__ + }) + end + + {:ok, token} + end + end + + defp do_create(app, user, attrs) do + %__MODULE__{user_id: user.id, app_id: app.id} + |> cast(%{scopes: attrs[:scopes] || app.scopes}, [:scopes]) + |> validate_required([:scopes, :app_id]) + |> put_valid_until(attrs) + |> put_token() + |> put_refresh_token(attrs) + |> Repo.insert() + end + + def delete_user_tokens(%User{id: user_id}) do + Query.get_by_user(user_id) + |> Repo.delete_all() + end + + def delete_user_token(%User{id: user_id}, token_id) do + Query.get_by_user(user_id) + |> Query.get_by_id(token_id) + |> Repo.delete_all() + end + + def get_user_tokens(%User{id: user_id}) do + Query.get_by_user(user_id) + |> Query.preload([:app]) + |> Repo.all() + end + + def is_expired?(%__MODULE__{valid_until: valid_until}) do + NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) > 0 + end + + def is_expired?(_), do: false +end diff --git a/lib/pleroma/web/o_auth/token/query.ex b/lib/pleroma/web/o_auth/token/query.ex new file mode 100644 index 0000000..4a4d2d3 --- /dev/null +++ b/lib/pleroma/web/o_auth/token/query.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.Token.Query do + @moduledoc """ + Contains queries for OAuth Token. + """ + + import Ecto.Query, only: [from: 2] + + @type query :: Ecto.Queryable.t() | Token.t() + + alias Pleroma.Web.OAuth.Token + + @spec get_by_refresh_token(query, String.t()) :: query + def get_by_refresh_token(query \\ Token, refresh_token) do + from(q in query, where: q.refresh_token == ^refresh_token) + end + + @spec get_by_token(query, String.t()) :: query + def get_by_token(query \\ Token, token) do + from(q in query, where: q.token == ^token) + end + + @spec get_by_app(query, String.t()) :: query + def get_by_app(query \\ Token, app_id) do + from(q in query, where: q.app_id == ^app_id) + end + + @spec get_by_id(query, String.t()) :: query + def get_by_id(query \\ Token, id) do + from(q in query, where: q.id == ^id) + end + + @spec get_by_user(query, String.t()) :: query + def get_by_user(query \\ Token, user_id) do + from(q in query, where: q.user_id == ^user_id) + end + + @spec preload(query, any) :: query + def preload(query \\ Token, assoc_preload \\ []) + + def preload(query, assoc_preload) when is_list(assoc_preload) do + from(q in query, preload: ^assoc_preload) + end + + def preload(query, _assoc_preload), do: query +end diff --git a/lib/pleroma/web/o_auth/token/strategy/refresh_token.ex b/lib/pleroma/web/o_auth/token/strategy/refresh_token.ex new file mode 100644 index 0000000..6b0d950 --- /dev/null +++ b/lib/pleroma/web/o_auth/token/strategy/refresh_token.ex @@ -0,0 +1,58 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.Token.Strategy.RefreshToken do + @moduledoc """ + Functions for dealing with refresh token strategy. + """ + + alias Pleroma.Config + alias Pleroma.Repo + alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.OAuth.Token.Strategy.Revoke + + @doc """ + Will grant access token by refresh token. + """ + @spec grant(Token.t()) :: {:ok, Token.t()} | {:error, any()} + def grant(token) do + access_token = Repo.preload(token, [:user, :app]) + + result = + Repo.transaction(fn -> + token_params = %{ + app: access_token.app, + user: access_token.user, + scopes: access_token.scopes + } + + access_token + |> revoke_access_token() + |> create_access_token(token_params) + end) + + case result do + {:ok, {:error, reason}} -> {:error, reason} + {:ok, {:ok, token}} -> {:ok, token} + {:error, reason} -> {:error, reason} + end + end + + defp revoke_access_token(token) do + Revoke.revoke(token) + end + + defp create_access_token({:error, error}, _), do: {:error, error} + + defp create_access_token({:ok, token}, %{app: app, user: user} = token_params) do + Token.create(app, user, add_refresh_token(token_params, token.refresh_token)) + end + + defp add_refresh_token(params, token) do + case Config.get([:oauth2, :issue_new_refresh_token], false) do + true -> Map.put(params, :refresh_token, token) + false -> params + end + end +end diff --git a/lib/pleroma/web/o_auth/token/strategy/revoke.ex b/lib/pleroma/web/o_auth/token/strategy/revoke.ex new file mode 100644 index 0000000..3b265b3 --- /dev/null +++ b/lib/pleroma/web/o_auth/token/strategy/revoke.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.Token.Strategy.Revoke do + @moduledoc """ + Functions for dealing with revocation. + """ + + alias Pleroma.Repo + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Token + + @doc "Finds and revokes access token for app and by token" + @spec revoke(App.t(), map()) :: {:ok, Token.t()} | {:error, :not_found | Ecto.Changeset.t()} + def revoke(%App{} = app, %{"token" => token} = _attrs) do + with {:ok, token} <- Token.get_by_token(app, token), + do: revoke(token) + end + + @doc "Revokes access token" + @spec revoke(Token.t()) :: {:ok, Token.t()} | {:error, Ecto.Changeset.t()} + def revoke(%Token{} = token) do + with {:ok, token} <- Repo.delete(token) do + Task.Supervisor.start_child( + Pleroma.TaskSupervisor, + Pleroma.Web.Streamer, + :close_streams_by_oauth_token, + [token], + restart: :transient + ) + + {:ok, token} + else + result -> result + end + end +end diff --git a/lib/pleroma/web/o_auth/token/utils.ex b/lib/pleroma/web/o_auth/token/utils.ex new file mode 100644 index 0000000..773cd97 --- /dev/null +++ b/lib/pleroma/web/o_auth/token/utils.ex @@ -0,0 +1,72 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.Token.Utils do + @moduledoc """ + Auxiliary functions for dealing with tokens. + """ + + alias Pleroma.Repo + alias Pleroma.Web.OAuth.App + + @doc "Fetch app by client credentials from request" + @spec fetch_app(Plug.Conn.t()) :: {:ok, App.t()} | {:error, :not_found} + def fetch_app(conn) do + res = + conn + |> fetch_client_credentials() + |> fetch_client + + case res do + %App{} = app -> {:ok, app} + _ -> {:error, :not_found} + end + end + + defp fetch_client({id, secret}) when is_binary(id) and is_binary(secret) do + Repo.get_by(App, client_id: id, client_secret: secret) + end + + defp fetch_client({_id, _secret}), do: nil + + defp fetch_client_credentials(conn) do + # Per RFC 6749, HTTP Basic is preferred to body params + with ["Basic " <> encoded] <- Plug.Conn.get_req_header(conn, "authorization"), + {:ok, decoded} <- Base.decode64(encoded), + [id, secret] <- + Enum.map( + String.split(decoded, ":"), + fn s -> URI.decode_www_form(s) end + ) do + {id, secret} + else + _ -> {conn.params["client_id"], conn.params["client_secret"]} + end + end + + @doc "convert token inserted_at to unix timestamp" + def format_created_at(%{inserted_at: inserted_at} = _token) do + inserted_at + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_unix() + end + + @doc false + @spec generate_token(keyword()) :: binary() + def generate_token(opts \\ []) do + opts + |> Keyword.get(:size, 32) + |> :crypto.strong_rand_bytes() + |> Base.url_encode64(padding: false) + end + + # XXX - for whatever reason our token arrives urlencoded, but Plug.Conn should be + # decoding it. Investigate sometime. + def fix_padding(token) do + token + |> URI.decode() + |> Base.url_decode64!(padding: false) + |> Base.url_encode64(padding: false) + end +end diff --git a/lib/pleroma/web/o_status/o_status_controller.ex b/lib/pleroma/web/o_status/o_status_controller.ex new file mode 100644 index 0000000..ea4994b --- /dev/null +++ b/lib/pleroma/web/o_status/o_status_controller.ex @@ -0,0 +1,140 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OStatus.OStatusController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPubController + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Fallback.RedirectController + alias Pleroma.Web.Metadata.PlayerView + alias Pleroma.Web.Plugs.RateLimiter + alias Pleroma.Web.Router + + plug( + RateLimiter, + [name: :ap_routes, params: ["uuid"]] when action in [:object, :activity] + ) + + plug( + Pleroma.Web.Plugs.SetFormatPlug + when action in [:object, :activity, :notice] + ) + + action_fallback(:errors) + + def object(%{assigns: %{format: format}} = conn, _params) + when format in ["json", "activity+json"] do + ActivityPubController.call(conn, :object) + end + + def object(conn, _params) do + with id <- Endpoint.url() <> conn.request_path, + {_, %Activity{} = activity} <- + {:activity, Activity.get_create_by_object_ap_id_with_object(id)}, + {_, true} <- {:public?, Visibility.is_public?(activity)} do + redirect(conn, to: "/notice/#{activity.id}") + else + reason when reason in [{:public?, false}, {:activity, nil}] -> + {:error, :not_found} + + e -> + e + end + end + + def activity(%{assigns: %{format: format}} = conn, _params) + when format in ["json", "activity+json"] do + ActivityPubController.call(conn, :activity) + end + + def activity(conn, _params) do + with id <- Endpoint.url() <> conn.request_path, + {_, %Activity{} = activity} <- {:activity, Activity.normalize(id)}, + {_, true} <- {:public?, Visibility.is_public?(activity)} do + redirect(conn, to: "/notice/#{activity.id}") + else + reason when reason in [{:public?, false}, {:activity, nil}] -> + {:error, :not_found} + + e -> + e + end + end + + def notice(%{assigns: %{format: format}} = conn, %{"id" => id}) do + with {_, %Activity{} = activity} <- {:activity, Activity.get_by_id_with_object(id)}, + {_, true} <- {:public?, Visibility.is_public?(activity)}, + %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do + cond do + format in ["json", "activity+json"] -> + %{data: %{"id" => redirect_url}} = Object.normalize(activity, fetch: false) + redirect(conn, external: redirect_url) + + activity.data["type"] == "Create" -> + %Object{} = object = Object.normalize(activity, fetch: false) + + RedirectController.redirector_with_meta( + conn, + %{ + activity_id: activity.id, + object: object, + url: Router.Helpers.o_status_url(Endpoint, :notice, activity.id), + user: user + } + ) + + true -> + RedirectController.redirector(conn, nil) + end + else + reason when reason in [{:public?, false}, {:activity, nil}] -> + conn + |> put_status(404) + |> RedirectController.redirector(nil, 404) + + e -> + e + end + end + + # Returns an HTML embedded