Repository: MbinOrg/mbin Branch: main Commit: f186284e7303 Files: 2520 Total size: 8.4 MB Directory structure: gitextract_vqs4lfsd/ ├── .devcontainer/ │ ├── apache-vhost.conf │ ├── devcontainer.json │ └── php_config.ini ├── .dockerignore ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── PULL_REQUEST_TEMPLATE/ │ │ └── pull_request_template.md │ ├── dependabot.yml │ └── workflows/ │ ├── action.yaml │ ├── build-and-publish-pipeline-image.yaml │ ├── build-pipeline-image.yaml │ ├── contrib.yaml │ ├── psalm.yml │ └── stale.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── C4.md ├── CONTRIBUTING.md ├── LICENSE ├── LICENSES/ │ └── Zlib.txt ├── README.md ├── UPGRADE.md ├── assets/ │ ├── app.js │ ├── controllers/ │ │ ├── autogrow_controller.js │ │ ├── clipboard_controller.js │ │ ├── collapsable_controller.js │ │ ├── comment_collapse_controller.js │ │ ├── confirmation_controller.js │ │ ├── entry_link_create_controller.js │ │ ├── form_collection_controller.js │ │ ├── html_refresh_controller.js │ │ ├── image_upload_controller.js │ │ ├── infinite_scroll_controller.js │ │ ├── input_length_controller.js │ │ ├── lightbox_controller.js │ │ ├── markdown_toolbar_controller.js │ │ ├── mbin_controller.js │ │ ├── mentions_controller.js │ │ ├── notifications_controller.js │ │ ├── options_controller.js │ │ ├── password_preview_controller.js │ │ ├── post_controller.js │ │ ├── preview_controller.js │ │ ├── push_controller.js │ │ ├── rich_textarea_controller.js │ │ ├── scroll_top_controller.js │ │ ├── selection_controller.js │ │ ├── settings_row_enum_controller.js │ │ ├── settings_row_switch_controller.js │ │ ├── subject_controller.js │ │ ├── subject_list_controller.js │ │ ├── subs_controller.js │ │ ├── subs_panel_controller.js │ │ ├── thumb_controller.js │ │ └── timeago_controller.js │ ├── controllers.json │ ├── email.js │ ├── stimulus_bootstrap.js │ ├── styles/ │ │ ├── _shared.scss │ │ ├── _variables.scss │ │ ├── app.scss │ │ ├── components/ │ │ │ ├── _announcement.scss │ │ │ ├── _comment.scss │ │ │ ├── _domain.scss │ │ │ ├── _dropdown.scss │ │ │ ├── _emoji_picker.scss │ │ │ ├── _entry.scss │ │ │ ├── _figure_image.scss │ │ │ ├── _figure_lightbox.scss │ │ │ ├── _filter_list.scss │ │ │ ├── _header.scss │ │ │ ├── _infinite_scroll.scss │ │ │ ├── _inline_md.scss │ │ │ ├── _login.scss │ │ │ ├── _magazine.scss │ │ │ ├── _main.scss │ │ │ ├── _media.scss │ │ │ ├── _messages.scss │ │ │ ├── _modlog.scss │ │ │ ├── _monitoring.scss │ │ │ ├── _notification_switch.scss │ │ │ ├── _notifications.scss │ │ │ ├── _pagination.scss │ │ │ ├── _popover.scss │ │ │ ├── _post.scss │ │ │ ├── _preview.scss │ │ │ ├── _search.scss │ │ │ ├── _settings_row.scss │ │ │ ├── _sidebar-subscriptions.scss │ │ │ ├── _sidebar.scss │ │ │ ├── _stats.scss │ │ │ ├── _subject.scss │ │ │ ├── _suggestions.scss │ │ │ ├── _tag.scss │ │ │ ├── _topbar.scss │ │ │ ├── _user.scss │ │ │ └── _vote.scss │ │ ├── emails.scss │ │ ├── layout/ │ │ │ ├── _alerts.scss │ │ │ ├── _breakpoints.scss │ │ │ ├── _forms.scss │ │ │ ├── _icons.scss │ │ │ ├── _images.scss │ │ │ ├── _layout.scss │ │ │ ├── _meta.scss │ │ │ ├── _normalize.scss │ │ │ ├── _options.scss │ │ │ ├── _section.scss │ │ │ ├── _tools.scss │ │ │ └── _typo.scss │ │ ├── mixins/ │ │ │ ├── animations.scss │ │ │ ├── mbin.scss │ │ │ ├── theme-dark.scss │ │ │ ├── theme-light.scss │ │ │ ├── theme-solarized-dark.scss │ │ │ └── theme-solarized-light.scss │ │ ├── pages/ │ │ │ ├── page_bookmarks.scss │ │ │ ├── page_filter_lists.scss │ │ │ ├── page_modlog.scss │ │ │ ├── page_profile.scss │ │ │ ├── post_front.scss │ │ │ └── post_single.scss │ │ └── themes/ │ │ ├── _default.scss │ │ ├── _kbin.scss │ │ ├── _solarized.scss │ │ └── _tokyo-night.scss │ └── utils/ │ ├── debounce.js │ ├── event-source.js │ ├── http.js │ ├── mbin.js │ ├── popover.js │ └── routing.js ├── bin/ │ ├── console │ ├── phpunit │ └── post-upgrade ├── ci/ │ ├── Dockerfile │ ├── ignoredPaths.txt │ └── skipOnExcluded.sh ├── compose.dev.yaml ├── compose.yaml ├── composer.json ├── config/ │ ├── bundles.php │ ├── mbin_routes/ │ │ ├── activity_pub.yaml │ │ ├── admin.yaml │ │ ├── admin_api.yaml │ │ ├── ajax.yaml │ │ ├── api.yaml │ │ ├── bookmark.yaml │ │ ├── bookmark_api.yaml │ │ ├── combined_api.yaml │ │ ├── custom_style.yaml │ │ ├── domain.yaml │ │ ├── domain_api.yaml │ │ ├── entry.yaml │ │ ├── entry_api.yaml │ │ ├── front.yaml │ │ ├── instance_api.yaml │ │ ├── landing.yaml │ │ ├── magazine.yaml │ │ ├── magazine_api.yaml │ │ ├── magazine_mod_request_api.yaml │ │ ├── magazine_panel.yaml │ │ ├── message.yaml │ │ ├── message_api.yaml │ │ ├── moderation_api.yaml │ │ ├── modlog.yaml │ │ ├── notification_api.yaml │ │ ├── notification_settings.yaml │ │ ├── notification_settings_api.yaml │ │ ├── page.yaml │ │ ├── people.yaml │ │ ├── post.yaml │ │ ├── post_api.yaml │ │ ├── search.yaml │ │ ├── search_api.yaml │ │ ├── security.yaml │ │ ├── tag.yaml │ │ ├── user.yaml │ │ └── user_api.yaml │ ├── mbin_serialization/ │ │ ├── badge.yaml │ │ ├── domain.yaml │ │ ├── entry.yaml │ │ ├── entry_comment.yaml │ │ ├── image.yaml │ │ ├── magazine.yaml │ │ ├── post.yaml │ │ ├── post_comment.yaml │ │ └── user.yaml │ ├── packages/ │ │ ├── antispam.yaml │ │ ├── babdev_pagerfanta.yaml │ │ ├── cache.yaml │ │ ├── commonmark.yaml │ │ ├── dama_doctrine_test_bundle.yaml │ │ ├── debug.yaml │ │ ├── dev/ │ │ │ └── rate_limiter.yaml │ │ ├── doctrine.yaml │ │ ├── doctrine_migrations.yaml │ │ ├── fos_js_routing.yaml │ │ ├── framework.yaml │ │ ├── knpu_oauth2_client.yaml │ │ ├── league_oauth2_server.yaml │ │ ├── liip_imagine.yaml │ │ ├── lock.yaml │ │ ├── mailer.yaml │ │ ├── mercure.yaml │ │ ├── messenger.yaml │ │ ├── meteo_concept_h_captcha.yaml │ │ ├── monolog.yaml │ │ ├── nelmio_api_doc.yaml │ │ ├── nelmio_cors.yaml │ │ ├── nyholm_psr7.yaml │ │ ├── oneup_flysystem.yaml │ │ ├── prod/ │ │ │ └── routing.yaml │ │ ├── rate_limiter.yaml │ │ ├── reset_password.yaml │ │ ├── routing.yaml │ │ ├── rss_atom.yaml │ │ ├── scheb_2fa.yaml │ │ ├── security.yaml │ │ ├── test/ │ │ │ ├── framework.yaml │ │ │ ├── messenger.yaml │ │ │ ├── rate_limiter.yaml │ │ │ └── twig.yaml │ │ ├── translation.yaml │ │ ├── twig.yaml │ │ ├── twig_component.yaml │ │ ├── uid.yaml │ │ ├── validator.yaml │ │ ├── web_profiler.yaml │ │ ├── webpack_encore.yaml │ │ └── workflow.yaml │ ├── preload.php │ ├── routes/ │ │ ├── dev/ │ │ │ └── framework.yaml │ │ ├── fos_js_routing.yaml │ │ ├── framework.yaml │ │ ├── league_oauth2_server.yaml │ │ ├── liip_imagine.yaml │ │ ├── nelmio_api_doc.yaml │ │ ├── rss_atom.yaml │ │ ├── scheb_2fa.yaml │ │ ├── security.yaml │ │ ├── ux_autocomplete.yaml │ │ └── web_profiler.yaml │ ├── routes.yaml │ └── services.yaml ├── docker/ │ ├── Caddyfile │ ├── Dockerfile │ ├── conf.d/ │ │ ├── 10-app.ini │ │ ├── 20-app.dev.ini │ │ └── 20-app.prod.ini │ ├── docker-entrypoint.sh │ ├── setup.sh │ ├── tests/ │ │ └── compose.yaml │ └── valkey.conf ├── docs/ │ ├── 01-user/ │ │ ├── 01-user_guide.md │ │ ├── 02-FAQ.md │ │ └── README.md │ ├── 02-admin/ │ │ ├── 01-installation/ │ │ │ ├── 01-bare_metal.md │ │ │ ├── 02-docker.md │ │ │ └── README.md │ │ ├── 02-configuration/ │ │ │ ├── 01-mbin_config_files.md │ │ │ ├── 02-nginx.md │ │ │ ├── 03-lets_encrypt.md │ │ │ ├── 04-postgresql.md │ │ │ ├── 05-redis.md │ │ │ └── README.md │ │ ├── 03-optional-features/ │ │ │ ├── 01-mercure.md │ │ │ ├── 02-sso.md │ │ │ ├── 03-captcha.md │ │ │ ├── 04-user_application.md │ │ │ ├── 05-image_metadata_cleaning.md │ │ │ ├── 06-s3_storage.md │ │ │ ├── 07-anubis.md │ │ │ ├── 08-monitoring.md │ │ │ ├── 09-image-compression.md │ │ │ └── README.md │ │ ├── 04-running-mbin/ │ │ │ ├── 01-first_setup.md │ │ │ ├── 02-backup.md │ │ │ ├── 03-upgrades.md │ │ │ ├── 04-messenger.md │ │ │ ├── 05-cli.md │ │ │ └── README.md │ │ ├── 05-troubleshooting/ │ │ │ ├── 01-bare_metal.md │ │ │ ├── 02-docker.md │ │ │ └── README.md │ │ ├── FAQ.md │ │ └── README.md │ ├── 03-contributing/ │ │ ├── 01-getting_started.md │ │ ├── 02-linting.md │ │ ├── 03-project-overview.md │ │ ├── 04-about-federation.md │ │ └── README.md │ ├── 04-app_developers/ │ │ └── README.md │ ├── 05-fediverse_developers/ │ │ └── README.md │ ├── README.md │ └── postman/ │ ├── kbin.postman_collection.json │ └── kbin.postman_environment.json ├── eslint.config.mjs ├── migrations/ │ ├── .gitignore │ ├── Version20210527210529.php │ ├── Version20210830133327.php │ ├── Version20211016124104.php │ ├── Version20211107140830.php │ ├── Version20211113102713.php │ ├── Version20211117170048.php │ ├── Version20211121182824.php │ ├── Version20211205133802.php │ ├── Version20211220092653.php │ ├── Version20211231174542.php │ ├── Version20220116141404.php │ ├── Version20220123173726.php │ ├── Version20220125212007.php │ ├── Version20220131190012.php │ ├── Version20220204202829.php │ ├── Version20220206143129.php │ ├── Version20220208192443.php │ ├── Version20220216211707.php │ ├── Version20220218220935.php │ ├── Version20220306181222.php │ ├── Version20220308201003.php │ ├── Version20220320191810.php │ ├── Version20220404185534.php │ ├── Version20220407171552.php │ ├── Version20220408100230.php │ ├── Version20220411203149.php │ ├── Version20220421082111.php │ ├── Version20220621144628.php │ ├── Version20220705184724.php │ ├── Version20220716120139.php │ ├── Version20220716142146.php │ ├── Version20220717101149.php │ ├── Version20220723095813.php │ ├── Version20220723182602.php │ ├── Version20220801085018.php │ ├── Version20220808150935.php │ ├── Version20220903070858.php │ ├── Version20220911120737.php │ ├── Version20220917102655.php │ ├── Version20220918140533.php │ ├── Version20220924182955.php │ ├── Version20221015120344.php │ ├── Version20221030095047.php │ ├── Version20221108164813.php │ ├── Version20221109161753.php │ ├── Version20221116150037.php │ ├── Version20221121125723.php │ ├── Version20221124162526.php │ ├── Version20221128212959.php │ ├── Version20221202114605.php │ ├── Version20221202134944.php │ ├── Version20221202140020.php │ ├── Version20221214153611.php │ ├── Version20221222124812.php │ ├── Version20221229160511.php │ ├── Version20221229162448.php │ ├── Version20230125123959.php │ ├── Version20230306134010.php │ ├── Version20230314134010.php │ ├── Version20230323160934.php │ ├── Version20230323170745.php │ ├── Version20230325084833.php │ ├── Version20230325101955.php │ ├── Version20230404080956.php │ ├── Version20230411133416.php │ ├── Version20230411143354.php │ ├── Version20230412211534.php │ ├── Version20230425103236.php │ ├── Version20230428130129.php │ ├── Version20230429053840.php │ ├── Version20230429143017.php │ ├── Version20230504124307.php │ ├── Version20230514143119.php │ ├── Version20230521145244.php │ ├── Version20230522135602.php │ ├── Version20230525203803.php │ ├── Version20230615085154.php │ ├── Version20230615091124.php │ ├── Version20230615203020.php │ ├── Version20230701125418.php │ ├── Version20230712132025.php │ ├── Version20230715034515.php │ ├── Version20230718160422.php │ ├── Version20230719060447.php │ ├── Version20230729063543.php │ ├── Version20230812151754.php │ ├── Version20230820234418.php │ ├── Version20230902082312.php │ ├── Version20230906095436.php │ ├── Version20231019023030.php │ ├── Version20231019190634.php │ ├── Version20231103004800.php │ ├── Version20231103070928.php │ ├── Version20231107204142.php │ ├── Version20231108084451.php │ ├── Version20231112133420.php │ ├── Version20231113165549.php │ ├── Version20231119012320.php │ ├── Version20231120164429.php │ ├── Version20231121010453.php │ ├── Version20231130203400.php │ ├── Version20240113214751.php │ ├── Version20240216110804.php │ ├── Version20240217103834.php │ ├── Version20240217141231.php │ ├── Version20240313222328.php │ ├── Version20240315124130.php │ ├── Version20240317163312.php │ ├── Version20240330101300.php │ ├── Version20240402190028.php │ ├── Version20240405131611.php │ ├── Version20240405134821.php │ ├── Version20240409072525.php │ ├── Version20240412010024.php │ ├── Version20240503224350.php │ ├── Version20240515122858.php │ ├── Version20240528172429.php │ ├── Version20240529115400.php │ ├── Version20240603190838.php │ ├── Version20240603230734.php │ ├── Version20240612234046.php │ ├── Version20240614120443.php │ ├── Version20240615225744.php │ ├── Version20240625162714.php │ ├── Version20240628142700.php │ ├── Version20240628145441.php │ ├── Version20240701113000.php │ ├── Version20240706005744.php │ ├── Version20240715181419.php │ ├── Version20240718232800.php │ ├── Version20240729174207.php │ ├── Version20240815162107.php │ ├── Version20240820201944.php │ ├── Version20240831151328.php │ ├── Version20240923164233.php │ ├── Version20241104162329.php │ ├── Version20241124155724.php │ ├── Version20241125210454.php │ ├── Version20250128125727.php │ ├── Version20250203232039.php │ ├── Version20250204152300.php │ ├── Version20250706115844.php │ ├── Version20250723183702.php │ ├── Version20250802102904.php │ ├── Version20250812194529.php │ ├── Version20250813132233.php │ ├── Version20250907112001.php │ ├── Version20250924105525.php │ ├── Version20251022104152.php │ ├── Version20251022115254.php │ ├── Version20251031174052.php │ ├── Version20251118112235.php │ ├── Version20251129140919.php │ ├── Version20251206145724.php │ ├── Version20251214111055.php │ ├── Version20260113103210.php │ ├── Version20260113151625.php │ ├── Version20260118131639.php │ ├── Version20260118142727.php │ ├── Version20260120175744.php │ ├── Version20260127111110.php │ ├── Version20260201131000.php │ ├── Version20260224224633.php │ ├── Version20260303103217.php │ ├── Version20260303142852.php │ ├── Version20260315190023.php │ └── Version20260330132857.php ├── package.json ├── phpstan.dist.neon ├── phpunit.xml.dist ├── public/ │ ├── assets/ │ │ └── icons/ │ │ └── mbin-shortcut-base-file.psd │ ├── index.php │ ├── js/ │ │ └── fos_js_routes.json │ ├── manifest.json │ ├── robots.txt │ └── sw.js ├── src/ │ ├── ActivityPub/ │ │ ├── ActorHandle.php │ │ ├── JsonRd.php │ │ └── JsonRdLink.php │ ├── ArgumentValueResolver/ │ │ ├── FavouriteResolver.php │ │ ├── MagazineResolver.php │ │ ├── ReportResolver.php │ │ ├── UserResolver.php │ │ └── VotableResolver.php │ ├── Command/ │ │ ├── ActorUpdateCommand.php │ │ ├── AdminCommand.php │ │ ├── ApImportObject.php │ │ ├── AwesomeBot/ │ │ │ ├── AwesomeBotEntries.php │ │ │ ├── AwesomeBotFixtures.php │ │ │ └── AwesomeBotMagazine.php │ │ ├── CheckDuplicatesUsersMagazines.php │ │ ├── DeleteMonitoringDataCommand.php │ │ ├── DeleteOrphanedImagesCommand.php │ │ ├── DeleteUserCommand.php │ │ ├── DocumentationGenerateFederationCommand.php │ │ ├── ImageCacheCommand.php │ │ ├── MagazineCreateCommand.php │ │ ├── MagazineUnsubCommand.php │ │ ├── ModeratorCommand.php │ │ ├── MoveEntriesByTagCommand.php │ │ ├── MovePostsByTagCommand.php │ │ ├── PostMagazinesUpdateCommand.php │ │ ├── RefreshImageMetaDataCommand.php │ │ ├── RemoveAccountsMarkedForDeletion.php │ │ ├── RemoveDMAndBanCommand.php │ │ ├── RemoveDeadMessagesCommand.php │ │ ├── RemoveDuplicatesCommand.php │ │ ├── RemoveFailedMessagesCommand.php │ │ ├── RemoveOldImagesCommand.php │ │ ├── RemoveRemoteMediaCommand.php │ │ ├── SubMagazineCommand.php │ │ ├── Update/ │ │ │ ├── ApKeysUpdateCommand.php │ │ │ ├── Async/ │ │ │ │ ├── ImageBlurhashHandler.php │ │ │ │ ├── ImageBlurhashMessage.php │ │ │ │ ├── NoteVisibilityHandler.php │ │ │ │ └── NoteVisibilityMessage.php │ │ │ ├── ImageBlurhashUpdateCommand.php │ │ │ ├── LocalMagazineApProfile.php │ │ │ ├── NoteVisibilityUpdateCommand.php │ │ │ ├── PostCommentRootUpdateCommand.php │ │ │ ├── PushKeysUpdateCommand.php │ │ │ ├── RemoveMagazineNameFromTagsCommand.php │ │ │ ├── RemoveRemoteEntriesFromLocalDomainCommand.php │ │ │ ├── SlugUpdateCommand.php │ │ │ ├── TagsUpdateCommand.php │ │ │ └── UserLastActiveUpdateCommand.php │ │ ├── UserCommand.php │ │ ├── UserPasswordCommand.php │ │ ├── UserRotatePrivateKeys.php │ │ ├── UserUnsubCommand.php │ │ └── VerifyCommand.php │ ├── Controller/ │ │ ├── .gitignore │ │ ├── AboutController.php │ │ ├── AbstractController.php │ │ ├── ActivityPub/ │ │ │ ├── ContextsController.php │ │ │ ├── EntryCommentController.php │ │ │ ├── EntryController.php │ │ │ ├── HostMetaController.php │ │ │ ├── InstanceController.php │ │ │ ├── InstanceOutboxController.php │ │ │ ├── Magazine/ │ │ │ │ ├── MagazineController.php │ │ │ │ ├── MagazineFollowersController.php │ │ │ │ ├── MagazineInboxController.php │ │ │ │ ├── MagazineModeratorsController.php │ │ │ │ ├── MagazineOutboxController.php │ │ │ │ └── MagazinePinnedController.php │ │ │ ├── MessageController.php │ │ │ ├── NodeInfoController.php │ │ │ ├── ObjectController.php │ │ │ ├── PostCommentController.php │ │ │ ├── PostController.php │ │ │ ├── ReportController.php │ │ │ ├── SharedInboxController.php │ │ │ ├── User/ │ │ │ │ ├── UserController.php │ │ │ │ ├── UserFollowersController.php │ │ │ │ ├── UserInboxController.php │ │ │ │ └── UserOutboxController.php │ │ │ └── WebFingerController.php │ │ ├── Admin/ │ │ │ ├── AdminClearCacheController.php │ │ │ ├── AdminDashboardController.php │ │ │ ├── AdminDeletionController.php │ │ │ ├── AdminFederationController.php │ │ │ ├── AdminMagazineOwnershipRequestController.php │ │ │ ├── AdminModeratorController.php │ │ │ ├── AdminMonitoringController.php │ │ │ ├── AdminPagesController.php │ │ │ ├── AdminReportController.php │ │ │ ├── AdminSettingsController.php │ │ │ ├── AdminSignupRequestsController.php │ │ │ └── AdminUsersController.php │ │ ├── AgentController.php │ │ ├── AjaxController.php │ │ ├── Api/ │ │ │ ├── BaseApi.php │ │ │ ├── Bookmark/ │ │ │ │ ├── BookmarkApiController.php │ │ │ │ └── BookmarkListApiController.php │ │ │ ├── Combined/ │ │ │ │ └── CombinedRetrieveApi.php │ │ │ ├── Domain/ │ │ │ │ ├── DomainBaseApi.php │ │ │ │ ├── DomainBlockApi.php │ │ │ │ ├── DomainRetrieveApi.php │ │ │ │ └── DomainSubscribeApi.php │ │ │ ├── Entry/ │ │ │ │ ├── Admin/ │ │ │ │ │ ├── EntriesChangeMagazineApi.php │ │ │ │ │ └── EntriesPurgeApi.php │ │ │ │ ├── Comments/ │ │ │ │ │ ├── Admin/ │ │ │ │ │ │ └── EntryCommentsPurgeApi.php │ │ │ │ │ ├── DomainEntryCommentsRetrieveApi.php │ │ │ │ │ ├── EntryCommentsActivityApi.php │ │ │ │ │ ├── EntryCommentsCreateApi.php │ │ │ │ │ ├── EntryCommentsDeleteApi.php │ │ │ │ │ ├── EntryCommentsFavouriteApi.php │ │ │ │ │ ├── EntryCommentsReportApi.php │ │ │ │ │ ├── EntryCommentsRetrieveApi.php │ │ │ │ │ ├── EntryCommentsUpdateApi.php │ │ │ │ │ ├── EntryCommentsVoteApi.php │ │ │ │ │ ├── Moderate/ │ │ │ │ │ │ ├── EntryCommentsSetAdultApi.php │ │ │ │ │ │ ├── EntryCommentsSetLanguageApi.php │ │ │ │ │ │ └── EntryCommentsTrashApi.php │ │ │ │ │ └── UserEntryCommentsRetrieveApi.php │ │ │ │ ├── DomainEntriesRetrieveApi.php │ │ │ │ ├── EntriesActivityApi.php │ │ │ │ ├── EntriesBaseApi.php │ │ │ │ ├── EntriesDeleteApi.php │ │ │ │ ├── EntriesFavouriteApi.php │ │ │ │ ├── EntriesReportApi.php │ │ │ │ ├── EntriesRetrieveApi.php │ │ │ │ ├── EntriesUpdateApi.php │ │ │ │ ├── EntriesVoteApi.php │ │ │ │ ├── MagazineEntriesRetrieveApi.php │ │ │ │ ├── MagazineEntryCreateApi.php │ │ │ │ ├── Moderate/ │ │ │ │ │ ├── EntriesLockApi.php │ │ │ │ │ ├── EntriesPinApi.php │ │ │ │ │ ├── EntriesSetAdultApi.php │ │ │ │ │ ├── EntriesSetLanguageApi.php │ │ │ │ │ └── EntriesTrashApi.php │ │ │ │ └── UserEntriesRetrieveApi.php │ │ │ ├── EntryComments.php │ │ │ ├── Instance/ │ │ │ │ ├── Admin/ │ │ │ │ │ ├── InstanceRetrieveSettingsApi.php │ │ │ │ │ ├── InstanceUpdateFederationApi.php │ │ │ │ │ ├── InstanceUpdatePagesApi.php │ │ │ │ │ └── InstanceUpdateSettingsApi.php │ │ │ │ ├── InstanceBaseApi.php │ │ │ │ ├── InstanceDetailsApi.php │ │ │ │ ├── InstanceModLogApi.php │ │ │ │ ├── InstanceRetrieveFederationApi.php │ │ │ │ ├── InstanceRetrieveInfoApi.php │ │ │ │ └── InstanceRetrieveStatsApi.php │ │ │ ├── Magazine/ │ │ │ │ ├── Admin/ │ │ │ │ │ ├── MagazineAddBadgesApi.php │ │ │ │ │ ├── MagazineAddModeratorsApi.php │ │ │ │ │ ├── MagazineAddTagsApi.php │ │ │ │ │ ├── MagazineCreateApi.php │ │ │ │ │ ├── MagazineDeleteApi.php │ │ │ │ │ ├── MagazineDeleteBannerApi.php │ │ │ │ │ ├── MagazineDeleteIconApi.php │ │ │ │ │ ├── MagazinePurgeApi.php │ │ │ │ │ ├── MagazineRemoveBadgesApi.php │ │ │ │ │ ├── MagazineRemoveModeratorsApi.php │ │ │ │ │ ├── MagazineRemoveTagsApi.php │ │ │ │ │ ├── MagazineRetrieveStatsApi.php │ │ │ │ │ ├── MagazineUpdateApi.php │ │ │ │ │ └── MagazineUpdateThemeApi.php │ │ │ │ ├── MagazineBaseApi.php │ │ │ │ ├── MagazineBlockApi.php │ │ │ │ ├── MagazineModLogApi.php │ │ │ │ ├── MagazineRetrieveApi.php │ │ │ │ ├── MagazineRetrieveThemeApi.php │ │ │ │ ├── MagazineSubscribeApi.php │ │ │ │ └── Moderate/ │ │ │ │ ├── MagazineBansRetrieveApi.php │ │ │ │ ├── MagazineModOwnerRequestApi.php │ │ │ │ ├── MagazineReportsAcceptApi.php │ │ │ │ ├── MagazineReportsRejectApi.php │ │ │ │ ├── MagazineReportsRetrieveApi.php │ │ │ │ ├── MagazineTrashedRetrieveApi.php │ │ │ │ └── MagazineUserBanApi.php │ │ │ ├── MagazineBadges.php │ │ │ ├── Message/ │ │ │ │ ├── MessageBaseApi.php │ │ │ │ ├── MessageReadApi.php │ │ │ │ ├── MessageRetrieveApi.php │ │ │ │ ├── MessageThreadCreateApi.php │ │ │ │ └── MessageThreadReplyApi.php │ │ │ ├── Notification/ │ │ │ │ ├── NotificationBaseApi.php │ │ │ │ ├── NotificationPurgeApi.php │ │ │ │ ├── NotificationPushApi.php │ │ │ │ ├── NotificationReadApi.php │ │ │ │ ├── NotificationRetrieveApi.php │ │ │ │ └── NotificationSettingApi.php │ │ │ ├── OAuth2/ │ │ │ │ ├── Admin/ │ │ │ │ │ ├── RetrieveClientApi.php │ │ │ │ │ └── RetrieveClientStatsApi.php │ │ │ │ ├── CreateClientApi.php │ │ │ │ ├── DeleteClientApi.php │ │ │ │ └── RevokeTokenApi.php │ │ │ ├── Post/ │ │ │ │ ├── Admin/ │ │ │ │ │ └── PostsPurgeApi.php │ │ │ │ ├── Comments/ │ │ │ │ │ ├── Admin/ │ │ │ │ │ │ └── PostCommentsPurgeApi.php │ │ │ │ │ ├── Moderate/ │ │ │ │ │ │ ├── PostCommentsSetAdultApi.php │ │ │ │ │ │ ├── PostCommentsSetLanguageApi.php │ │ │ │ │ │ └── PostCommentsTrashApi.php │ │ │ │ │ ├── PostCommentsActivityApi.php │ │ │ │ │ ├── PostCommentsCreateApi.php │ │ │ │ │ ├── PostCommentsDeleteApi.php │ │ │ │ │ ├── PostCommentsFavouriteApi.php │ │ │ │ │ ├── PostCommentsReportApi.php │ │ │ │ │ ├── PostCommentsRetrieveApi.php │ │ │ │ │ ├── PostCommentsUpdateApi.php │ │ │ │ │ ├── PostCommentsVoteApi.php │ │ │ │ │ └── UserPostCommentsRetrieveApi.php │ │ │ │ ├── Moderate/ │ │ │ │ │ ├── PostsLockApi.php │ │ │ │ │ ├── PostsPinApi.php │ │ │ │ │ ├── PostsSetAdultApi.php │ │ │ │ │ ├── PostsSetLanguageApi.php │ │ │ │ │ └── PostsTrashApi.php │ │ │ │ ├── PostsActivityApi.php │ │ │ │ ├── PostsBaseApi.php │ │ │ │ ├── PostsCreateApi.php │ │ │ │ ├── PostsDeleteApi.php │ │ │ │ ├── PostsFavouriteApi.php │ │ │ │ ├── PostsReportApi.php │ │ │ │ ├── PostsRetrieveApi.php │ │ │ │ ├── PostsUpdateApi.php │ │ │ │ ├── PostsVoteApi.php │ │ │ │ └── UserPostsRetrieveApi.php │ │ │ ├── PostComments.php │ │ │ ├── RandomMagazine.php │ │ │ ├── Search/ │ │ │ │ └── SearchRetrieveApi.php │ │ │ └── User/ │ │ │ ├── Admin/ │ │ │ │ ├── UserApplicationApi.php │ │ │ │ ├── UserBanApi.php │ │ │ │ ├── UserDeleteApi.php │ │ │ │ ├── UserPurgeApi.php │ │ │ │ ├── UserRetrieveBannedApi.php │ │ │ │ └── UserVerifyApi.php │ │ │ ├── UserBaseApi.php │ │ │ ├── UserBlockApi.php │ │ │ ├── UserContentApi.php │ │ │ ├── UserDeleteImagesApi.php │ │ │ ├── UserFilterListApi.php │ │ │ ├── UserFollowApi.php │ │ │ ├── UserModeratesApi.php │ │ │ ├── UserRetrieveApi.php │ │ │ ├── UserRetrieveOAuthConsentsApi.php │ │ │ ├── UserUpdateApi.php │ │ │ ├── UserUpdateImagesApi.php │ │ │ └── UserUpdateOAuthConsentsApi.php │ │ ├── BookmarkController.php │ │ ├── BookmarkListController.php │ │ ├── BoostController.php │ │ ├── ContactController.php │ │ ├── CrosspostController.php │ │ ├── CustomStyleController.php │ │ ├── Domain/ │ │ │ ├── DomainBlockController.php │ │ │ ├── DomainCommentFrontController.php │ │ │ ├── DomainFrontController.php │ │ │ └── DomainSubController.php │ │ ├── Entry/ │ │ │ ├── Comment/ │ │ │ │ ├── EntryCommentChangeAdultController.php │ │ │ │ ├── EntryCommentChangeLangController.php │ │ │ │ ├── EntryCommentCreateController.php │ │ │ │ ├── EntryCommentDeleteController.php │ │ │ │ ├── EntryCommentDeleteImageController.php │ │ │ │ ├── EntryCommentEditController.php │ │ │ │ ├── EntryCommentFavouriteController.php │ │ │ │ ├── EntryCommentFrontController.php │ │ │ │ ├── EntryCommentModerateController.php │ │ │ │ ├── EntryCommentResponseTrait.php │ │ │ │ ├── EntryCommentViewController.php │ │ │ │ └── EntryCommentVotersController.php │ │ │ ├── EntryChangeAdultController.php │ │ │ ├── EntryChangeLangController.php │ │ │ ├── EntryChangeMagazineController.php │ │ │ ├── EntryCreateController.php │ │ │ ├── EntryDeleteController.php │ │ │ ├── EntryDeleteImageController.php │ │ │ ├── EntryEditController.php │ │ │ ├── EntryFavouriteController.php │ │ │ ├── EntryFrontController.php │ │ │ ├── EntryLockController.php │ │ │ ├── EntryModerateController.php │ │ │ ├── EntryPinController.php │ │ │ ├── EntrySingleController.php │ │ │ ├── EntryTemplateTrait.php │ │ │ └── EntryVotersController.php │ │ ├── FaqController.php │ │ ├── FavouriteController.php │ │ ├── FederationController.php │ │ ├── Magazine/ │ │ │ ├── MagazineAbandonedController.php │ │ │ ├── MagazineBlockController.php │ │ │ ├── MagazineCreateController.php │ │ │ ├── MagazineDeleteController.php │ │ │ ├── MagazineListController.php │ │ │ ├── MagazineModController.php │ │ │ ├── MagazineModeratorRequestController.php │ │ │ ├── MagazineOwnershipRequestController.php │ │ │ ├── MagazinePeopleFrontController.php │ │ │ ├── MagazineRemoveSubscriptionsController.php │ │ │ ├── MagazineSubController.php │ │ │ └── Panel/ │ │ │ ├── MagazineBadgeController.php │ │ │ ├── MagazineBanController.php │ │ │ ├── MagazineEditController.php │ │ │ ├── MagazineModeratorController.php │ │ │ ├── MagazineModeratorRequestsController.php │ │ │ ├── MagazineReportController.php │ │ │ ├── MagazineStatsController.php │ │ │ ├── MagazineTagController.php │ │ │ ├── MagazineThemeController.php │ │ │ └── MagazineTrashController.php │ │ ├── Message/ │ │ │ ├── MessageCreateThreadController.php │ │ │ ├── MessageThreadController.php │ │ │ └── MessageThreadListController.php │ │ ├── ModlogController.php │ │ ├── NotificationSettingsController.php │ │ ├── People/ │ │ │ └── PeopleFrontController.php │ │ ├── Post/ │ │ │ ├── Comment/ │ │ │ │ ├── PostCommentChangeAdultController.php │ │ │ │ ├── PostCommentChangeLangController.php │ │ │ │ ├── PostCommentCreateController.php │ │ │ │ ├── PostCommentDeleteController.php │ │ │ │ ├── PostCommentDeleteImageController.php │ │ │ │ ├── PostCommentEditController.php │ │ │ │ ├── PostCommentFavouriteController.php │ │ │ │ ├── PostCommentModerateController.php │ │ │ │ ├── PostCommentResponseTrait.php │ │ │ │ └── PostCommentVotersController.php │ │ │ ├── PostChangeAdultController.php │ │ │ ├── PostChangeLangController.php │ │ │ ├── PostChangeMagazineController.php │ │ │ ├── PostCreateController.php │ │ │ ├── PostDeleteController.php │ │ │ ├── PostDeleteImageController.php │ │ │ ├── PostEditController.php │ │ │ ├── PostFavouriteController.php │ │ │ ├── PostLockController.php │ │ │ ├── PostModerateController.php │ │ │ ├── PostPinController.php │ │ │ ├── PostSingleController.php │ │ │ └── PostVotersController.php │ │ ├── PrivacyPolicyController.php │ │ ├── ReportController.php │ │ ├── SearchController.php │ │ ├── Security/ │ │ │ ├── AuthentikController.php │ │ │ ├── AzureController.php │ │ │ ├── DiscordController.php │ │ │ ├── FacebookController.php │ │ │ ├── GithubController.php │ │ │ ├── GoogleController.php │ │ │ ├── KeycloakController.php │ │ │ ├── LoginController.php │ │ │ ├── LogoutController.php │ │ │ ├── PrivacyPortalController.php │ │ │ ├── RegisterController.php │ │ │ ├── ResendActivationEmailController.php │ │ │ ├── ResetPasswordController.php │ │ │ ├── SimpleLoginController.php │ │ │ ├── VerifyEmailController.php │ │ │ └── ZitadelController.php │ │ ├── StatsController.php │ │ ├── Tag/ │ │ │ ├── TagBanController.php │ │ │ ├── TagCommentFrontController.php │ │ │ ├── TagEntryFrontController.php │ │ │ ├── TagOverviewController.php │ │ │ ├── TagPeopleFrontController.php │ │ │ └── TagPostFrontController.php │ │ ├── TermsController.php │ │ ├── Traits/ │ │ │ └── PrivateContentTrait.php │ │ ├── User/ │ │ │ ├── AccountDeletionController.php │ │ │ ├── FilterListsController.php │ │ │ ├── Profile/ │ │ │ │ ├── User2FAController.php │ │ │ │ ├── UserBlockController.php │ │ │ │ ├── UserEditController.php │ │ │ │ ├── UserNotificationController.php │ │ │ │ ├── UserReportsController.php │ │ │ │ ├── UserReportsModController.php │ │ │ │ ├── UserSettingController.php │ │ │ │ ├── UserStatsController.php │ │ │ │ ├── UserSubController.php │ │ │ │ └── UserVerifyController.php │ │ │ ├── ThemeSettingsController.php │ │ │ ├── UserAvatarDeleteController.php │ │ │ ├── UserBanController.php │ │ │ ├── UserBlockController.php │ │ │ ├── UserCoverDeleteController.php │ │ │ ├── UserDeleteController.php │ │ │ ├── UserFollowController.php │ │ │ ├── UserFrontController.php │ │ │ ├── UserNoteController.php │ │ │ ├── UserRemoveFollowing.php │ │ │ ├── UserReputationController.php │ │ │ ├── UserSuspendController.php │ │ │ └── UserThemeController.php │ │ └── VoteController.php │ ├── DTO/ │ │ ├── ActivitiesResponseDto.php │ │ ├── ActivityPub/ │ │ │ ├── ImageDto.php │ │ │ └── VideoDto.php │ │ ├── BadgeDto.php │ │ ├── BadgeResponseDto.php │ │ ├── BookmarkListDto.php │ │ ├── BookmarksDto.php │ │ ├── ClientAccessStatsResponseDto.php │ │ ├── ClientConsentsRequestDto.php │ │ ├── ClientConsentsResponseDto.php │ │ ├── ClientResponseDto.php │ │ ├── ConfirmDefederationDto.php │ │ ├── ContactDto.php │ │ ├── ContentRequestDto.php │ │ ├── ContentResponseDto.php │ │ ├── ContentStatsResponseDto.php │ │ ├── Contracts/ │ │ │ ├── UserDtoInterface.php │ │ │ └── VisibilityAwareDtoTrait.php │ │ ├── DomainDto.php │ │ ├── EntryCommentDto.php │ │ ├── EntryCommentRequestDto.php │ │ ├── EntryCommentResponseDto.php │ │ ├── EntryDto.php │ │ ├── EntryRequestDto.php │ │ ├── EntryResponseDto.php │ │ ├── ExtendedContentResponseDto.php │ │ ├── FederationSettingsDto.php │ │ ├── GroupedMonitoringQueryDto.php │ │ ├── ImageDto.php │ │ ├── ImageUploadDto.php │ │ ├── InstanceDto.php │ │ ├── InstancesDto.php │ │ ├── InstancesDtoV2.php │ │ ├── MagazineBanDto.php │ │ ├── MagazineBanResponseDto.php │ │ ├── MagazineDto.php │ │ ├── MagazineLogResponseDto.php │ │ ├── MagazineRequestDto.php │ │ ├── MagazineResponseDto.php │ │ ├── MagazineSmallResponseDto.php │ │ ├── MagazineThemeDto.php │ │ ├── MagazineThemeRequestDto.php │ │ ├── MagazineThemeResponseDto.php │ │ ├── MagazineUpdateRequestDto.php │ │ ├── MessageDto.php │ │ ├── MessageResponseDto.php │ │ ├── MessageThreadResponseDto.php │ │ ├── ModeratorDto.php │ │ ├── ModeratorResponseDto.php │ │ ├── ModlogFilterDto.php │ │ ├── MonitoringExecutionContextFilterDto.php │ │ ├── NotificationPushSubscriptionRequestDto.php │ │ ├── OAuth2ClientDto.php │ │ ├── PageDto.php │ │ ├── PostCommentDto.php │ │ ├── PostCommentRequestDto.php │ │ ├── PostCommentResponseDto.php │ │ ├── PostDto.php │ │ ├── PostRequestDto.php │ │ ├── PostResponseDto.php │ │ ├── RemoteInstanceDto.php │ │ ├── ReportDto.php │ │ ├── ReportRequestDto.php │ │ ├── ReportResponseDto.php │ │ ├── SearchDto.php │ │ ├── SearchResponseDto.php │ │ ├── SettingsDto.php │ │ ├── SiteResponseDto.php │ │ ├── Temp2FADto.php │ │ ├── ToggleCreatedDto.php │ │ ├── UserBanResponseDto.php │ │ ├── UserDto.php │ │ ├── UserFilterListDto.php │ │ ├── UserFilterListResponseDto.php │ │ ├── UserFilterWordDto.php │ │ ├── UserNoteDto.php │ │ ├── UserProfileRequestDto.php │ │ ├── UserResponseDto.php │ │ ├── UserSettingsDto.php │ │ ├── UserSignupResponseDto.php │ │ ├── UserSmallResponseDto.php │ │ └── VoteStatsResponseDto.php │ ├── DataFixtures/ │ │ ├── BaseFixture.php │ │ ├── EntryCommentFixtures.php │ │ ├── EntryFixtures.php │ │ ├── MagazineFixtures.php │ │ ├── PostCommentFixtures.php │ │ ├── PostFixtures.php │ │ ├── ReportFixtures.php │ │ ├── SubFixtures.php │ │ ├── UserFixtures.php │ │ └── VoteFixtures.php │ ├── DoctrineExtensions/ │ │ └── DBAL/ │ │ └── Types/ │ │ ├── Citext.php │ │ ├── EnumApplicationStatus.php │ │ ├── EnumDirectMessageSettings.php │ │ ├── EnumFrontContentOptions.php │ │ ├── EnumNotificationStatus.php │ │ ├── EnumSortOptions.php │ │ └── EnumType.php │ ├── Document/ │ │ └── .gitignore │ ├── Entity/ │ │ ├── .gitignore │ │ ├── Activity.php │ │ ├── ApActivity.php │ │ ├── Badge.php │ │ ├── Bookmark.php │ │ ├── BookmarkList.php │ │ ├── Client.php │ │ ├── Contracts/ │ │ │ ├── ActivityPubActivityInterface.php │ │ │ ├── ActivityPubActorInterface.php │ │ │ ├── ApiResourceInterface.php │ │ │ ├── CommentInterface.php │ │ │ ├── ContentInterface.php │ │ │ ├── ContentVisibilityInterface.php │ │ │ ├── DomainInterface.php │ │ │ ├── FavouriteInterface.php │ │ │ ├── NotificationInterface.php │ │ │ ├── RankingInterface.php │ │ │ ├── ReportInterface.php │ │ │ ├── VisibilityInterface.php │ │ │ ├── VotableInterface.php │ │ │ └── VoteInterface.php │ │ ├── Domain.php │ │ ├── DomainBlock.php │ │ ├── DomainSubscription.php │ │ ├── Embed.php │ │ ├── Entry.php │ │ ├── EntryBadge.php │ │ ├── EntryComment.php │ │ ├── EntryCommentCreatedNotification.php │ │ ├── EntryCommentDeletedNotification.php │ │ ├── EntryCommentEditedNotification.php │ │ ├── EntryCommentFavourite.php │ │ ├── EntryCommentMentionedNotification.php │ │ ├── EntryCommentReplyNotification.php │ │ ├── EntryCommentReport.php │ │ ├── EntryCommentVote.php │ │ ├── EntryCreatedNotification.php │ │ ├── EntryDeletedNotification.php │ │ ├── EntryEditedNotification.php │ │ ├── EntryFavourite.php │ │ ├── EntryMentionedNotification.php │ │ ├── EntryReport.php │ │ ├── EntryVote.php │ │ ├── Favourite.php │ │ ├── Hashtag.php │ │ ├── HashtagLink.php │ │ ├── Image.php │ │ ├── Instance.php │ │ ├── Magazine.php │ │ ├── MagazineBan.php │ │ ├── MagazineBanNotification.php │ │ ├── MagazineBlock.php │ │ ├── MagazineLog.php │ │ ├── MagazineLogBan.php │ │ ├── MagazineLogEntryCommentDeleted.php │ │ ├── MagazineLogEntryCommentRestored.php │ │ ├── MagazineLogEntryDeleted.php │ │ ├── MagazineLogEntryLocked.php │ │ ├── MagazineLogEntryPinned.php │ │ ├── MagazineLogEntryRestored.php │ │ ├── MagazineLogEntryUnlocked.php │ │ ├── MagazineLogEntryUnpinned.php │ │ ├── MagazineLogModeratorAdd.php │ │ ├── MagazineLogModeratorRemove.php │ │ ├── MagazineLogPostCommentDeleted.php │ │ ├── MagazineLogPostCommentRestored.php │ │ ├── MagazineLogPostDeleted.php │ │ ├── MagazineLogPostLocked.php │ │ ├── MagazineLogPostRestored.php │ │ ├── MagazineLogPostUnlocked.php │ │ ├── MagazineOwnershipRequest.php │ │ ├── MagazineSubscription.php │ │ ├── MagazineSubscriptionRequest.php │ │ ├── MagazineUnBanNotification.php │ │ ├── Message.php │ │ ├── MessageNotification.php │ │ ├── MessageThread.php │ │ ├── Moderator.php │ │ ├── ModeratorRequest.php │ │ ├── MonitoringCurlRequest.php │ │ ├── MonitoringExecutionContext.php │ │ ├── MonitoringQuery.php │ │ ├── MonitoringQueryString.php │ │ ├── MonitoringTwigRender.php │ │ ├── NewSignupNotification.php │ │ ├── Notification.php │ │ ├── NotificationSettings.php │ │ ├── OAuth2ClientAccess.php │ │ ├── OAuth2UserConsent.php │ │ ├── Post.php │ │ ├── PostComment.php │ │ ├── PostCommentCreatedNotification.php │ │ ├── PostCommentDeletedNotification.php │ │ ├── PostCommentEditedNotification.php │ │ ├── PostCommentFavourite.php │ │ ├── PostCommentMentionedNotification.php │ │ ├── PostCommentReplyNotification.php │ │ ├── PostCommentReport.php │ │ ├── PostCommentVote.php │ │ ├── PostCreatedNotification.php │ │ ├── PostDeletedNotification.php │ │ ├── PostEditedNotification.php │ │ ├── PostFavourite.php │ │ ├── PostMentionedNotification.php │ │ ├── PostReport.php │ │ ├── PostVote.php │ │ ├── Report.php │ │ ├── ReportApprovedNotification.php │ │ ├── ReportCreatedNotification.php │ │ ├── ReportRejectedNotification.php │ │ ├── ResetPasswordRequest.php │ │ ├── Settings.php │ │ ├── Site.php │ │ ├── Traits/ │ │ │ ├── ActivityPubActivityTrait.php │ │ │ ├── ActivityPubActorTrait.php │ │ │ ├── ConsideredAtTrait.php │ │ │ ├── CreatedAtTrait.php │ │ │ ├── EditedAtTrait.php │ │ │ ├── MonitoringPerformanceTrait.php │ │ │ ├── RankingTrait.php │ │ │ ├── UpdatedAtTrait.php │ │ │ ├── VisibilityTrait.php │ │ │ └── VotableTrait.php │ │ ├── User.php │ │ ├── UserBlock.php │ │ ├── UserFilterList.php │ │ ├── UserFollow.php │ │ ├── UserFollowRequest.php │ │ ├── UserNote.php │ │ ├── UserPushSubscription.php │ │ └── Vote.php │ ├── Enums/ │ │ ├── EApplicationStatus.php │ │ ├── EDirectMessageSettings.php │ │ ├── EFrontContentOptions.php │ │ ├── ENotificationStatus.php │ │ ├── EPushNotificationType.php │ │ └── ESortOptions.php │ ├── Event/ │ │ ├── ActivityPub/ │ │ │ ├── CurlRequestBeginningEvent.php │ │ │ ├── CurlRequestFinishedEvent.php │ │ │ └── WebfingerResponseEvent.php │ │ ├── DomainBlockedEvent.php │ │ ├── DomainSubscribedEvent.php │ │ ├── Entry/ │ │ │ ├── EntryBeforeDeletedEvent.php │ │ │ ├── EntryBeforePurgeEvent.php │ │ │ ├── EntryCreatedEvent.php │ │ │ ├── EntryDeletedEvent.php │ │ │ ├── EntryEditedEvent.php │ │ │ ├── EntryHasBeenSeenEvent.php │ │ │ ├── EntryLockEvent.php │ │ │ ├── EntryPinEvent.php │ │ │ ├── EntryRestoredEvent.php │ │ │ └── PostLockEvent.php │ │ ├── EntryComment/ │ │ │ ├── EntryCommentBeforeDeletedEvent.php │ │ │ ├── EntryCommentBeforePurgeEvent.php │ │ │ ├── EntryCommentCreatedEvent.php │ │ │ ├── EntryCommentDeletedEvent.php │ │ │ ├── EntryCommentEditedEvent.php │ │ │ ├── EntryCommentPurgedEvent.php │ │ │ └── EntryCommentRestoredEvent.php │ │ ├── FavouriteEvent.php │ │ ├── ImagePostProcessEvent.php │ │ ├── Instance/ │ │ │ └── InstanceBanEvent.php │ │ ├── Magazine/ │ │ │ ├── MagazineBanEvent.php │ │ │ ├── MagazineBlockedEvent.php │ │ │ ├── MagazineModeratorAddedEvent.php │ │ │ ├── MagazineModeratorRemovedEvent.php │ │ │ ├── MagazineSubscribedEvent.php │ │ │ └── MagazineUpdatedEvent.php │ │ ├── NotificationCreatedEvent.php │ │ ├── Post/ │ │ │ ├── PostBeforeDeletedEvent.php │ │ │ ├── PostBeforePurgeEvent.php │ │ │ ├── PostCreatedEvent.php │ │ │ ├── PostDeletedEvent.php │ │ │ ├── PostEditedEvent.php │ │ │ ├── PostHasBeenSeenEvent.php │ │ │ └── PostRestoredEvent.php │ │ ├── PostComment/ │ │ │ ├── PostCommentBeforeDeletedEvent.php │ │ │ ├── PostCommentBeforePurgeEvent.php │ │ │ ├── PostCommentCreatedEvent.php │ │ │ ├── PostCommentDeletedEvent.php │ │ │ ├── PostCommentEditedEvent.php │ │ │ ├── PostCommentPurgedEvent.php │ │ │ └── PostCommentRestoredEvent.php │ │ ├── Report/ │ │ │ ├── ReportApprovedEvent.php │ │ │ ├── ReportRejectedEvent.php │ │ │ └── SubjectReportedEvent.php │ │ ├── User/ │ │ │ ├── UserApplicationApprovedEvent.php │ │ │ ├── UserApplicationRejectedEvent.php │ │ │ ├── UserBlockEvent.php │ │ │ ├── UserEditedEvent.php │ │ │ └── UserFollowEvent.php │ │ └── VoteEvent.php │ ├── EventListener/ │ │ ├── ContentNotificationPurgeListener.php │ │ ├── FederationStatusListener.php │ │ ├── LanguageListener.php │ │ ├── MagazineVisibilityListener.php │ │ └── UserActivityListener.php │ ├── EventSubscriber/ │ │ ├── ActivityPub/ │ │ │ ├── GroupWebFingerProfileSubscriber.php │ │ │ ├── GroupWebFingerSubscriber.php │ │ │ ├── MagazineFollowSubscriber.php │ │ │ ├── MagazineModeratorAddedRemovedSubscriber.php │ │ │ ├── UserFollowSubscriber.php │ │ │ ├── UserWebFingerProfileSubscriber.php │ │ │ └── UserWebFingerSubscriber.php │ │ ├── AuthorizationCodeSubscriber.php │ │ ├── ContentCountSubscriber.php │ │ ├── Domain/ │ │ │ ├── DomainBlockSubscriber.php │ │ │ └── DomainFollowSubscriber.php │ │ ├── Entry/ │ │ │ ├── EntryCreateSubscriber.php │ │ │ ├── EntryDeleteSubscriber.php │ │ │ ├── EntryEditSubscriber.php │ │ │ ├── EntryPinSubscriber.php │ │ │ ├── EntryShowSubscriber.php │ │ │ └── LockSubscriber.php │ │ ├── EntryComment/ │ │ │ ├── EntryCommentCreateSubscriber.php │ │ │ ├── EntryCommentDeleteSubscriber.php │ │ │ └── EntryCommentEditSubscriber.php │ │ ├── FavouriteHandleSubscriber.php │ │ ├── Image/ │ │ │ ├── ExifCleanerSubscriber.php │ │ │ └── ImageCompressSubscriber.php │ │ ├── Instance/ │ │ │ └── InstanceBanSubscriber.php │ │ ├── LogoutSubscriber.php │ │ ├── Magazine/ │ │ │ ├── MagazineBanSubscriber.php │ │ │ ├── MagazineBlockSubscriber.php │ │ │ ├── MagazineLogSubscriber.php │ │ │ └── MagazineUpdatedSubscriber.php │ │ ├── Monitoring/ │ │ │ ├── CurlRequestSubscriber.php │ │ │ ├── KernelEventsSubscriber.php │ │ │ └── MessengerEventsSubscriber.php │ │ ├── NotificationCreatedSubscriber.php │ │ ├── Post/ │ │ │ ├── PostCreateSubscriber.php │ │ │ ├── PostDeleteSubscriber.php │ │ │ ├── PostEditSubscriber.php │ │ │ └── PostShowSubscriber.php │ │ ├── PostComment/ │ │ │ ├── PostCommentCreateSubscriber.php │ │ │ ├── PostCommentDeleteSubscriber.php │ │ │ └── PostCommentEditSubscriber.php │ │ ├── ReportApprovedSubscriber.php │ │ ├── ReportHandleSubscriber.php │ │ ├── ReportRejectedSubscriber.php │ │ ├── SubjectReportedSubscriber.php │ │ ├── TwigGlobalSubscriber.php │ │ ├── User/ │ │ │ ├── UserApplicationSubscriber.php │ │ │ ├── UserBlockSubscriber.php │ │ │ └── UserEditedSubscriber.php │ │ └── VoteHandleSubscriber.php │ ├── Exception/ │ │ ├── BadRequestDtoException.php │ │ ├── BadUrlException.php │ │ ├── CorruptedFileException.php │ │ ├── EntityNotFoundException.php │ │ ├── EntryLockedException.php │ │ ├── FavouritedAlreadyException.php │ │ ├── ImageDownloadTooLargeException.php │ │ ├── InboxForwardingException.php │ │ ├── InstanceBannedException.php │ │ ├── InvalidApGetException.php │ │ ├── InvalidApPostException.php │ │ ├── InvalidApSignatureException.php │ │ ├── InvalidUserPublicKeyException.php │ │ ├── InvalidWebfingerException.php │ │ ├── PostLockedException.php │ │ ├── PostingRestrictedException.php │ │ ├── SubjectHasBeenReportedException.php │ │ ├── TagBannedException.php │ │ ├── UserBannedException.php │ │ ├── UserBlockedException.php │ │ ├── UserCannotBeBanned.php │ │ ├── UserCannotReceiveDirectMessage.php │ │ └── UserDeletedException.php │ ├── Factory/ │ │ ├── ActivityPub/ │ │ │ ├── ActivityFactory.php │ │ │ ├── AddRemoveFactory.php │ │ │ ├── BlockFactory.php │ │ │ ├── CollectionFactory.php │ │ │ ├── EntryCommentNoteFactory.php │ │ │ ├── EntryPageFactory.php │ │ │ ├── FlagFactory.php │ │ │ ├── GroupFactory.php │ │ │ ├── InstanceFactory.php │ │ │ ├── LockFactory.php │ │ │ ├── MessageFactory.php │ │ │ ├── NodeInfoFactory.php │ │ │ ├── PersonFactory.php │ │ │ ├── PostCommentNoteFactory.php │ │ │ ├── PostNoteFactory.php │ │ │ └── TombstoneFactory.php │ │ ├── BadgeFactory.php │ │ ├── ClientConsentsFactory.php │ │ ├── ClientFactory.php │ │ ├── ContentActivityDtoFactory.php │ │ ├── ContentManagerFactory.php │ │ ├── DomainFactory.php │ │ ├── EntryCommentFactory.php │ │ ├── EntryFactory.php │ │ ├── FavouriteFactory.php │ │ ├── ImageFactory.php │ │ ├── MagazineFactory.php │ │ ├── MessageFactory.php │ │ ├── ModeratorFactory.php │ │ ├── PostCommentFactory.php │ │ ├── PostFactory.php │ │ ├── ReportFactory.php │ │ ├── UserFactory.php │ │ └── VoteFactory.php │ ├── Feed/ │ │ └── Provider.php │ ├── Form/ │ │ ├── BadgeType.php │ │ ├── BookmarkListType.php │ │ ├── ChangePasswordFormType.php │ │ ├── ConfirmDefederationType.php │ │ ├── Constraint/ │ │ │ └── ImageConstraint.php │ │ ├── ContactType.php │ │ ├── DataTransformer/ │ │ │ ├── BadgeCollectionToStringTransformer.php │ │ │ ├── FeaturedMagazinesBarTransformer.php │ │ │ ├── TagTransformer.php │ │ │ └── UserTransformer.php │ │ ├── EntryCommentType.php │ │ ├── EntryEditType.php │ │ ├── EntryType.php │ │ ├── EventListener/ │ │ │ ├── AddFieldsOnUserEdit.php │ │ │ ├── AvatarListener.php │ │ │ ├── CaptchaListener.php │ │ │ ├── DefaultLanguage.php │ │ │ ├── DisableFieldsOnEntryEdit.php │ │ │ ├── DisableFieldsOnMagazineEdit.php │ │ │ ├── DisableFieldsOnUserEdit.php │ │ │ ├── ImageListener.php │ │ │ ├── RemoveFieldsOnEntryImageEdit.php │ │ │ ├── RemoveFieldsOnEntryLinkCreate.php │ │ │ └── RemoveRulesFieldIfEmpty.php │ │ ├── Extension/ │ │ │ └── NoValidateExtension.php │ │ ├── FederationSettingsType.php │ │ ├── LangType.php │ │ ├── MagazineBanType.php │ │ ├── MagazinePageViewType.php │ │ ├── MagazineTagsType.php │ │ ├── MagazineThemeType.php │ │ ├── MagazineType.php │ │ ├── MessageType.php │ │ ├── ModeratorType.php │ │ ├── ModlogFilterType.php │ │ ├── MonitoringExecutionContextFilterType.php │ │ ├── PageType.php │ │ ├── PostCommentType.php │ │ ├── PostType.php │ │ ├── ReportType.php │ │ ├── ResendEmailActivationFormType.php │ │ ├── ResetPasswordRequestFormType.php │ │ ├── SearchType.php │ │ ├── SettingsType.php │ │ ├── Type/ │ │ │ ├── BadgesType.php │ │ │ ├── LanguageType.php │ │ │ ├── MagazineAutocompleteType.php │ │ │ └── UserAutocompleteType.php │ │ ├── UserAccountDeletionType.php │ │ ├── UserBasicType.php │ │ ├── UserDisable2FAType.php │ │ ├── UserEmailType.php │ │ ├── UserFilterListType.php │ │ ├── UserFilterWordType.php │ │ ├── UserNoteType.php │ │ ├── UserPasswordType.php │ │ ├── UserRegenerate2FABackupType.php │ │ ├── UserRegisterType.php │ │ ├── UserSettingsType.php │ │ └── UserTwoFactorType.php │ ├── Kernel.php │ ├── Markdown/ │ │ ├── CommonMark/ │ │ │ ├── CommunityLinkParser.php │ │ │ ├── DetailsBlockParser.php │ │ │ ├── DetailsBlockRenderer.php │ │ │ ├── DetailsBlockStartParser.php │ │ │ ├── EmbedElement.php │ │ │ ├── ExternalImagesRenderer.php │ │ │ ├── ExternalLinkRenderer.php │ │ │ ├── MentionLinkParser.php │ │ │ ├── MentionType.php │ │ │ ├── Node/ │ │ │ │ ├── ActivityPubMentionLink.php │ │ │ │ ├── ActorSearchLink.php │ │ │ │ ├── CommunityLink.php │ │ │ │ ├── DetailsBlock.php │ │ │ │ ├── MentionLink.php │ │ │ │ ├── RoutedMentionLink.php │ │ │ │ ├── TagLink.php │ │ │ │ └── UnresolvableLink.php │ │ │ ├── TagLinkParser.php │ │ │ └── UnresolvableLinkRenderer.php │ │ ├── Event/ │ │ │ ├── BuildCacheContext.php │ │ │ └── ConvertMarkdown.php │ │ ├── Factory/ │ │ │ ├── ConverterFactory.php │ │ │ └── EnvironmentFactory.php │ │ ├── Listener/ │ │ │ ├── CacheMarkdownListener.php │ │ │ └── ConvertMarkdownListener.php │ │ ├── MarkdownConverter.php │ │ ├── MarkdownExtension.php │ │ └── RenderTarget.php │ ├── Message/ │ │ ├── ActivityPub/ │ │ │ ├── Inbox/ │ │ │ │ ├── ActivityMessage.php │ │ │ │ ├── AddMessage.php │ │ │ │ ├── AnnounceMessage.php │ │ │ │ ├── BlockMessage.php │ │ │ │ ├── ChainActivityMessage.php │ │ │ │ ├── CreateMessage.php │ │ │ │ ├── DeleteMessage.php │ │ │ │ ├── DislikeMessage.php │ │ │ │ ├── EntryPinMessage.php │ │ │ │ ├── FlagMessage.php │ │ │ │ ├── FollowMessage.php │ │ │ │ ├── LikeMessage.php │ │ │ │ ├── LockMessage.php │ │ │ │ ├── RemoveMessage.php │ │ │ │ └── UpdateMessage.php │ │ │ ├── Outbox/ │ │ │ │ ├── AddMessage.php │ │ │ │ ├── AnnounceLikeMessage.php │ │ │ │ ├── AnnounceMessage.php │ │ │ │ ├── BlockMessage.php │ │ │ │ ├── CreateMessage.php │ │ │ │ ├── DeleteMessage.php │ │ │ │ ├── DeliverMessage.php │ │ │ │ ├── EntryPinMessage.php │ │ │ │ ├── FlagMessage.php │ │ │ │ ├── FollowMessage.php │ │ │ │ ├── GenericAnnounceMessage.php │ │ │ │ ├── LikeMessage.php │ │ │ │ ├── LockMessage.php │ │ │ │ ├── RemoveMessage.php │ │ │ │ └── UpdateMessage.php │ │ │ └── UpdateActorMessage.php │ │ ├── ClearDeadMessagesMessage.php │ │ ├── ClearDeletedUserMessage.php │ │ ├── Contracts/ │ │ │ ├── ActivityPubInboxInterface.php │ │ │ ├── ActivityPubInboxReceiveInterface.php │ │ │ ├── ActivityPubOutboxDeliverInterface.php │ │ │ ├── ActivityPubOutboxInterface.php │ │ │ ├── ActivityPubResolveInterface.php │ │ │ ├── AsyncMessageInterface.php │ │ │ ├── MessageInterface.php │ │ │ ├── SchedulerInterface.php │ │ │ └── SendConfirmationEmailInterface.php │ │ ├── DeleteImageMessage.php │ │ ├── DeleteUserMessage.php │ │ ├── EntryEmbedMessage.php │ │ ├── LinkEmbedMessage.php │ │ ├── MagazinePurgeMessage.php │ │ ├── Notification/ │ │ │ ├── EntryCommentCreatedNotificationMessage.php │ │ │ ├── EntryCommentDeletedNotificationMessage.php │ │ │ ├── EntryCommentEditedNotificationMessage.php │ │ │ ├── EntryCreatedNotificationMessage.php │ │ │ ├── EntryDeletedNotificationMessage.php │ │ │ ├── EntryEditedNotificationMessage.php │ │ │ ├── FavouriteNotificationMessage.php │ │ │ ├── MagazineBanNotificationMessage.php │ │ │ ├── PostCommentCreatedNotificationMessage.php │ │ │ ├── PostCommentDeletedNotificationMessage.php │ │ │ ├── PostCommentEditedNotificationMessage.php │ │ │ ├── PostCreatedNotificationMessage.php │ │ │ ├── PostDeletedNotificationMessage.php │ │ │ ├── PostEditedNotificationMessage.php │ │ │ ├── SentNewSignupNotificationMessage.php │ │ │ └── VoteNotificationMessage.php │ │ ├── UserApplicationAnswerMessage.php │ │ ├── UserCreatedMessage.php │ │ └── UserUpdatedMessage.php │ ├── MessageHandler/ │ │ ├── ActivityPub/ │ │ │ ├── Inbox/ │ │ │ │ ├── ActivityHandler.php │ │ │ │ ├── AddHandler.php │ │ │ │ ├── AnnounceHandler.php │ │ │ │ ├── BlockHandler.php │ │ │ │ ├── ChainActivityHandler.php │ │ │ │ ├── CreateHandler.php │ │ │ │ ├── DeleteHandler.php │ │ │ │ ├── DislikeHandler.php │ │ │ │ ├── FlagHandler.php │ │ │ │ ├── FollowHandler.php │ │ │ │ ├── LikeHandler.php │ │ │ │ ├── LockHandler.php │ │ │ │ ├── RemoveHandler.php │ │ │ │ └── UpdateHandler.php │ │ │ ├── Outbox/ │ │ │ │ ├── AddHandler.php │ │ │ │ ├── AnnounceHandler.php │ │ │ │ ├── AnnounceLikeHandler.php │ │ │ │ ├── BlockHandler.php │ │ │ │ ├── CreateHandler.php │ │ │ │ ├── DeleteHandler.php │ │ │ │ ├── DeliverHandler.php │ │ │ │ ├── EntryPinMessageHandler.php │ │ │ │ ├── FlagHandler.php │ │ │ │ ├── FollowHandler.php │ │ │ │ ├── GenericAnnounceHandler.php │ │ │ │ ├── LikeHandler.php │ │ │ │ ├── LockHandler.php │ │ │ │ ├── RemoveHandler.php │ │ │ │ └── UpdateHandler.php │ │ │ └── UpdateActorHandler.php │ │ ├── AttachEntryEmbedHandler.php │ │ ├── ClearDeadMessagesHandler.php │ │ ├── ClearDeletedUserHandler.php │ │ ├── DeleteImageHandler.php │ │ ├── DeleteUserHandler.php │ │ ├── LinkEmbedHandler.php │ │ ├── MagazinePurgeHandler.php │ │ ├── MbinMessageHandler.php │ │ ├── Notification/ │ │ │ ├── SentEntryCommentCreatedNotificationHandler.php │ │ │ ├── SentEntryCommentDeletedNotificationHandler.php │ │ │ ├── SentEntryCommentEditedNotificationHandler.php │ │ │ ├── SentEntryCreatedNotificationHandler.php │ │ │ ├── SentEntryDeletedNotificationHandler.php │ │ │ ├── SentEntryEditedNotificationHandler.php │ │ │ ├── SentFavouriteNotificationHandler.php │ │ │ ├── SentMagazineBanNotificationHandler.php │ │ │ ├── SentNewSignupNotificationHandler.php │ │ │ ├── SentPostCommentCreatedNotificationHandler.php │ │ │ ├── SentPostCommentDeletedNotificationHandler.php │ │ │ ├── SentPostCommentEditedNotificationHandler.php │ │ │ ├── SentPostCreatedNotificationHandler.php │ │ │ ├── SentPostDeletedNotificationHandler.php │ │ │ ├── SentPostEditedNotificationHandler.php │ │ │ └── SentVoteNotificationHandler.php │ │ ├── SendApplicationAnswerMailHandler.php │ │ └── SentUserConfirmationEmailHandler.php │ ├── Middleware/ │ │ └── Monitoring/ │ │ ├── DoctrineConnectionMiddleware.php │ │ ├── DoctrineDriverMiddleware.php │ │ ├── DoctrineMiddleware.php │ │ └── DoctrineStatementMiddleware.php │ ├── PageView/ │ │ ├── ContentPageView.php │ │ ├── EntryCommentPageView.php │ │ ├── EntryPageView.php │ │ ├── MagazinePageView.php │ │ ├── MessageThreadPageView.php │ │ ├── PostCommentPageView.php │ │ └── PostPageView.php │ ├── Pagination/ │ │ ├── AdapterFactory.php │ │ ├── CachingQueryAdapter.php │ │ ├── Cursor/ │ │ │ ├── CursorAdapterInterface.php │ │ │ ├── CursorPagination.php │ │ │ ├── CursorPaginationInterface.php │ │ │ └── NativeQueryCursorAdapter.php │ │ ├── NativeQueryAdapter.php │ │ ├── Pagerfanta.php │ │ ├── QueryAdapter.php │ │ └── Transformation/ │ │ ├── ContentPopulationTransformer.php │ │ ├── ResultTransformer.php │ │ └── VoidTransformer.php │ ├── Payloads/ │ │ ├── NodeInfo/ │ │ │ ├── NodeInfo.php │ │ │ ├── NodeInfoServices.php │ │ │ ├── NodeInfoSoftware.php │ │ │ ├── NodeInfoSoftware21.php │ │ │ ├── NodeInfoUsage.php │ │ │ ├── NodeInfoUsageUsers.php │ │ │ ├── WellKnownEndpoint.php │ │ │ └── WellKnownNodeInfo.php │ │ ├── NotificationsCountResponsePayload.php │ │ ├── PushNotification.php │ │ ├── RegisterPushRequestPayload.php │ │ ├── TestPushRequestPayload.php │ │ └── UnRegisterPushRequestPayload.php │ ├── Provider/ │ │ ├── Authentik.php │ │ ├── AuthentikResourceOwner.php │ │ ├── SimpleLogin.php │ │ ├── SimpleLoginResourceOwner.php │ │ ├── Zitadel.php │ │ └── ZitadelResourceOwner.php │ ├── Repository/ │ │ ├── .gitignore │ │ ├── ActivityRepository.php │ │ ├── ApActivityRepository.php │ │ ├── BadgeRepository.php │ │ ├── BookmarkListRepository.php │ │ ├── BookmarkRepository.php │ │ ├── ContentRepository.php │ │ ├── Criteria.php │ │ ├── DomainRepository.php │ │ ├── DomainSubscriptionRepository.php │ │ ├── EmbedRepository.php │ │ ├── EntryCommentRepository.php │ │ ├── EntryRepository.php │ │ ├── FavouriteRepository.php │ │ ├── ImageRepository.php │ │ ├── InstanceRepository.php │ │ ├── MagazineBanRepository.php │ │ ├── MagazineBlockRepository.php │ │ ├── MagazineLogRepository.php │ │ ├── MagazineOwnershipRequestRepository.php │ │ ├── MagazineRepository.php │ │ ├── MagazineSubscriptionRepository.php │ │ ├── MagazineSubscriptionRequestRepository.php │ │ ├── MessageRepository.php │ │ ├── MessageThreadRepository.php │ │ ├── ModeratorRequestRepository.php │ │ ├── MonitoringRepository.php │ │ ├── NotificationRepository.php │ │ ├── NotificationSettingsRepository.php │ │ ├── OAuth2ClientAccessRepository.php │ │ ├── OAuth2UserConsentRepository.php │ │ ├── PostCommentRepository.php │ │ ├── PostRepository.php │ │ ├── ReportRepository.php │ │ ├── ReputationRepository.php │ │ ├── ResetPasswordRequestRepository.php │ │ ├── SearchRepository.php │ │ ├── SettingsRepository.php │ │ ├── SiteRepository.php │ │ ├── StatsContentRepository.php │ │ ├── StatsRepository.php │ │ ├── StatsVotesRepository.php │ │ ├── TagLinkRepository.php │ │ ├── TagRepository.php │ │ ├── UserBlockRepository.php │ │ ├── UserFollowRepository.php │ │ ├── UserFollowRequestRepository.php │ │ ├── UserNoteRepository.php │ │ ├── UserPushSubscriptionRepository.php │ │ ├── UserRepository.php │ │ └── VoteRepository.php │ ├── Scheduler/ │ │ └── MbinTaskProvider.php │ ├── Schema/ │ │ ├── ContentSchema.php │ │ ├── CursorPaginationSchema.php │ │ ├── Errors/ │ │ │ ├── BadRequestErrorSchema.php │ │ │ ├── ForbiddenErrorSchema.php │ │ │ ├── NotFoundErrorSchema.php │ │ │ ├── TooManyRequestsErrorSchema.php │ │ │ └── UnauthorizedErrorSchema.php │ │ ├── InfoSchema.php │ │ ├── NotificationSchema.php │ │ ├── PaginationSchema.php │ │ └── SearchActorSchema.php │ ├── Security/ │ │ ├── AuthentikAuthenticator.php │ │ ├── AzureAuthenticator.php │ │ ├── DiscordAuthenticator.php │ │ ├── EmailVerifier.php │ │ ├── FacebookAuthenticator.php │ │ ├── GithubAuthenticator.php │ │ ├── GoogleAuthenticator.php │ │ ├── KbinAuthenticator.php │ │ ├── KeycloakAuthenticator.php │ │ ├── MbinOAuthAuthenticatorBase.php │ │ ├── OAuth/ │ │ │ └── ClientCredentialsGrant.php │ │ ├── PrivacyPortalAuthenticator.php │ │ ├── SimpleLoginAuthenticator.php │ │ ├── UserChecker.php │ │ ├── Voter/ │ │ │ ├── EntryCommentVoter.php │ │ │ ├── EntryVoter.php │ │ │ ├── FilterListVoter.php │ │ │ ├── MagazineVoter.php │ │ │ ├── MessageThreadVoter.php │ │ │ ├── MessageVoter.php │ │ │ ├── NotificationVoter.php │ │ │ ├── OAuth2UserConsentVoter.php │ │ │ ├── PostCommentVoter.php │ │ │ ├── PostVoter.php │ │ │ ├── PrivateInstanceVoter.php │ │ │ └── UserVoter.php │ │ └── ZitadelAuthenticator.php │ ├── Service/ │ │ ├── ActivityPub/ │ │ │ ├── ActivityJsonBuilder.php │ │ │ ├── ActivityPubContent.php │ │ │ ├── ApHttpClient.php │ │ │ ├── ApHttpClientInterface.php │ │ │ ├── ApObjectExtractor.php │ │ │ ├── ContextsProvider.php │ │ │ ├── DeleteService.php │ │ │ ├── HttpSignature.php │ │ │ ├── KeysGenerator.php │ │ │ ├── MarkdownConverter.php │ │ │ ├── Note.php │ │ │ ├── Page.php │ │ │ ├── SignatureValidator.php │ │ │ ├── StrikethroughConverter.php │ │ │ ├── Webfinger/ │ │ │ │ ├── WebFinger.php │ │ │ │ ├── WebFingerFactory.php │ │ │ │ └── WebFingerParameters.php │ │ │ └── Wrapper/ │ │ │ ├── AnnounceWrapper.php │ │ │ ├── CollectionInfoWrapper.php │ │ │ ├── CollectionItemsWrapper.php │ │ │ ├── CreateWrapper.php │ │ │ ├── DeleteWrapper.php │ │ │ ├── FollowResponseWrapper.php │ │ │ ├── FollowWrapper.php │ │ │ ├── ImageWrapper.php │ │ │ ├── LikeWrapper.php │ │ │ ├── MentionsWrapper.php │ │ │ ├── TagsWrapper.php │ │ │ ├── UndoWrapper.php │ │ │ └── UpdateWrapper.php │ │ ├── ActivityPubManager.php │ │ ├── BadgeManager.php │ │ ├── BookmarkManager.php │ │ ├── CacheService.php │ │ ├── ContactManager.php │ │ ├── Contracts/ │ │ │ ├── ContentManagerInterface.php │ │ │ ├── ContentNotificationManagerInterface.php │ │ │ └── ManagerInterface.php │ │ ├── DeliverManager.php │ │ ├── DomainManager.php │ │ ├── EntryCommentManager.php │ │ ├── EntryManager.php │ │ ├── FactoryResolver.php │ │ ├── FavouriteManager.php │ │ ├── FeedManager.php │ │ ├── GenerateHtmlClassService.php │ │ ├── ImageManager.php │ │ ├── ImageManagerInterface.php │ │ ├── InstanceManager.php │ │ ├── InstanceStatsManager.php │ │ ├── IpResolver.php │ │ ├── MagazineManager.php │ │ ├── MentionManager.php │ │ ├── MessageManager.php │ │ ├── Monitor.php │ │ ├── MonologFilterHandler.php │ │ ├── Notification/ │ │ │ ├── EntryCommentNotificationManager.php │ │ │ ├── EntryNotificationManager.php │ │ │ ├── MagazineBanNotificationManager.php │ │ │ ├── MessageNotificationManager.php │ │ │ ├── NotificationTrait.php │ │ │ ├── PostCommentNotificationManager.php │ │ │ ├── PostNotificationManager.php │ │ │ ├── ReportNotificationManager.php │ │ │ ├── SignupNotificationManager.php │ │ │ └── UserPushSubscriptionManager.php │ │ ├── NotificationManager.php │ │ ├── NotificationManagerTypeResolver.php │ │ ├── OAuthTokenRevoker.php │ │ ├── PeopleManager.php │ │ ├── PostCommentManager.php │ │ ├── PostManager.php │ │ ├── ProjectInfoService.php │ │ ├── RemoteInstanceManager.php │ │ ├── ReportManager.php │ │ ├── ReputationManager.php │ │ ├── SearchManager.php │ │ ├── SettingsManager.php │ │ ├── StatsManager.php │ │ ├── SubjectOverviewManager.php │ │ ├── TagExtractor.php │ │ ├── TagManager.php │ │ ├── TwoFactorManager.php │ │ ├── UserManager.php │ │ ├── UserNoteManager.php │ │ ├── UserSettingsManager.php │ │ ├── VideoManager.php │ │ ├── VotableRepositoryResolver.php │ │ └── VoteManager.php │ ├── Twig/ │ │ ├── Components/ │ │ │ ├── ActiveUsersComponent.php │ │ │ ├── AnnouncementComponent.php │ │ │ ├── BlurhashImageComponent.php │ │ │ ├── BookmarkListComponent.php │ │ │ ├── BookmarkMenuListComponent.php │ │ │ ├── BookmarkStandardComponent.php │ │ │ ├── BoostComponent.php │ │ │ ├── CursorPaginationComponent.php │ │ │ ├── DateComponent.php │ │ │ ├── DateEditedComponent.php │ │ │ ├── DomainComponent.php │ │ │ ├── DomainSubComponent.php │ │ │ ├── EditorToolbarComponent.php │ │ │ ├── EntriesCrossComponent.php │ │ │ ├── EntryCommentComponent.php │ │ │ ├── EntryCommentInlineComponent.php │ │ │ ├── EntryCommentsNestedComponent.php │ │ │ ├── EntryComponent.php │ │ │ ├── EntryCrossComponent.php │ │ │ ├── EntryInlineComponent.php │ │ │ ├── EntryInlineMdComponent.php │ │ │ ├── FavouriteComponent.php │ │ │ ├── FeaturedMagazinesComponent.php │ │ │ ├── FilterListComponent.php │ │ │ ├── InstanceList.php │ │ │ ├── LoaderComponent.php │ │ │ ├── LoginSocialsComponent.php │ │ │ ├── MagazineBoxComponent.php │ │ │ ├── MagazineInlineComponent.php │ │ │ ├── MagazineSubComponent.php │ │ │ ├── MonitoringTwigRenderComponent.php │ │ │ ├── NotificationSwitch.php │ │ │ ├── PostCombinedComponent.php │ │ │ ├── PostCommentCombinedComponent.php │ │ │ ├── PostCommentComponent.php │ │ │ ├── PostCommentInlineComponent.php │ │ │ ├── PostCommentsNestedComponent.php │ │ │ ├── PostCommentsPreviewComponent.php │ │ │ ├── PostComponent.php │ │ │ ├── PostInlineMdComponent.php │ │ │ ├── RelatedEntriesComponent.php │ │ │ ├── RelatedMagazinesComponent.php │ │ │ ├── RelatedPostsComponent.php │ │ │ ├── ReportListComponent.php │ │ │ ├── SettingsRowEnumComponent.php │ │ │ ├── SettingsRowSwitchComponent.php │ │ │ ├── SidebarSubscriptionComponent.php │ │ │ ├── TagActionComponent.php │ │ │ ├── UserActionsComponent.php │ │ │ ├── UserAvatarComponent.php │ │ │ ├── UserBoxComponent.php │ │ │ ├── UserFormActionsComponent.php │ │ │ ├── UserImageComponent.php │ │ │ ├── UserInlineBoxComponent.php │ │ │ ├── UserInlineComponent.php │ │ │ ├── VoteComponent.php │ │ │ └── VotersInlineComponent.php │ │ ├── Extension/ │ │ │ ├── AdminExtension.php │ │ │ ├── BookmarkExtension.php │ │ │ ├── ContextExtension.php │ │ │ ├── CounterExtension.php │ │ │ ├── DomainExtension.php │ │ │ ├── EmailExtension.php │ │ │ ├── FormattingExtension.php │ │ │ ├── FrontExtension.php │ │ │ ├── LinkExtension.php │ │ │ ├── MagazineExtension.php │ │ │ ├── MediaExtension.php │ │ │ ├── MonitorExtension.php │ │ │ ├── NavbarExtension.php │ │ │ ├── SettingsExtension.php │ │ │ ├── SubjectExtension.php │ │ │ ├── UrlExtension.php │ │ │ └── UserExtension.php │ │ └── Runtime/ │ │ ├── AdminExtensionRuntime.php │ │ ├── BookmarkExtensionRuntime.php │ │ ├── ContextExtensionRuntime.php │ │ ├── CounterExtensionRuntime.php │ │ ├── DomainExtensionRuntime.php │ │ ├── EmailExtensionRuntime.php │ │ ├── FormattingExtensionRuntime.php │ │ ├── FrontExtensionRuntime.php │ │ ├── LinkExtensionRuntime.php │ │ ├── MagazineExtensionRuntime.php │ │ ├── MediaExtensionRuntime.php │ │ ├── NavbarExtensionRuntime.php │ │ ├── SettingsExtensionRuntime.php │ │ ├── SubjectExtensionRuntime.php │ │ ├── UrlExtensionRuntime.php │ │ └── UserExtensionRuntime.php │ ├── Utils/ │ │ ├── AddErrorDetailsStampListener.php │ │ ├── ArrayUtils.php │ │ ├── DownvotesMode.php │ │ ├── Embed.php │ │ ├── ExifCleanMode.php │ │ ├── ExifCleaner.php │ │ ├── GeneralUtil.php │ │ ├── ImageOrigin.php │ │ ├── IriGenerator.php │ │ ├── JsonldUtils.php │ │ ├── RegPatterns.php │ │ ├── Slugger.php │ │ ├── SqlHelpers.php │ │ ├── SubscriptionSort.php │ │ ├── UrlCleaner.php │ │ └── UrlUtils.php │ └── Validator/ │ ├── NoSurroundingWhitespace.php │ ├── NoSurroundingWhitespaceValidator.php │ ├── Unique.php │ └── UniqueValidator.php ├── templates/ │ ├── _email/ │ │ ├── application_approved.html.twig │ │ ├── application_rejected.html.twig │ │ ├── confirmation_email.html.twig │ │ ├── contact.html.twig │ │ ├── delete_account_request.html.twig │ │ ├── email_base.html.twig │ │ └── reset_pass_confirm.html.twig │ ├── admin/ │ │ ├── _options.html.twig │ │ ├── dashboard.html.twig │ │ ├── deletion_magazines.html.twig │ │ ├── deletion_users.html.twig │ │ ├── federation.html.twig │ │ ├── federation_defederate_instance.html.twig │ │ ├── magazine_ownership.html.twig │ │ ├── moderators.html.twig │ │ ├── monitoring/ │ │ │ ├── _monitoring_single_options.html.twig │ │ │ ├── _monitoring_single_overview.html.twig │ │ │ ├── _monitoring_single_queries.html.twig │ │ │ ├── _monitoring_single_requests.html.twig │ │ │ ├── _monitoring_single_twig.html.twig │ │ │ ├── monitoring.html.twig │ │ │ └── monitoring_single.html.twig │ │ ├── pages.html.twig │ │ ├── reports.html.twig │ │ ├── settings.html.twig │ │ ├── signup_requests.html.twig │ │ └── users.html.twig │ ├── base.html.twig │ ├── bookmark/ │ │ ├── _form_edit.html.twig │ │ ├── _options.html.twig │ │ ├── edit.html.twig │ │ ├── front.html.twig │ │ └── overview.html.twig │ ├── bundles/ │ │ ├── NelmioApiDocBundle/ │ │ │ └── SwaggerUi/ │ │ │ └── index.html.twig │ │ └── TwigBundle/ │ │ └── Exception/ │ │ ├── error.html.twig │ │ ├── error403.html.twig │ │ ├── error404.html.twig │ │ ├── error429.html.twig │ │ └── error500.html.twig │ ├── components/ │ │ ├── _ajax.html.twig │ │ ├── _cached.html.twig │ │ ├── _comment_collapse_button.html.twig │ │ ├── _details_label.css.twig │ │ ├── _entry_comments_nested_hidden_private_threads.html.twig │ │ ├── _figure_entry.html.twig │ │ ├── _figure_image.html.twig │ │ ├── _loading_icon.html.twig │ │ ├── _post_comments_nested_hidden_private_threads.html.twig │ │ ├── _settings_row_enum.html.twig │ │ ├── _settings_row_switch.html.twig │ │ ├── active_users.html.twig │ │ ├── announcement.html.twig │ │ ├── blurhash_image.html.twig │ │ ├── bookmark_list.html.twig │ │ ├── bookmark_menu_list.html.twig │ │ ├── bookmark_standard.html.twig │ │ ├── boost.html.twig │ │ ├── cursor_pagination.html.twig │ │ ├── date.html.twig │ │ ├── date_edited.html.twig │ │ ├── domain.html.twig │ │ ├── domain_sub.html.twig │ │ ├── editor_toolbar.html.twig │ │ ├── entries_cross.html.twig │ │ ├── entry.html.twig │ │ ├── entry_comment.html.twig │ │ ├── entry_comment_inline_md.html.twig │ │ ├── entry_comments_nested.html.twig │ │ ├── entry_cross.html.twig │ │ ├── entry_inline.html.twig │ │ ├── entry_inline_md.html.twig │ │ ├── favourite.html.twig │ │ ├── featured_magazines.html.twig │ │ ├── filter_list.html.twig │ │ ├── instance_list.html.twig │ │ ├── loader.html.twig │ │ ├── login_socials.html.twig │ │ ├── magazine_box.html.twig │ │ ├── magazine_inline.html.twig │ │ ├── magazine_inline_md.html.twig │ │ ├── magazine_sub.html.twig │ │ ├── monitoring_twig_render.html.twig │ │ ├── notification_switch.html.twig │ │ ├── post.html.twig │ │ ├── post_combined.html.twig │ │ ├── post_comment.html.twig │ │ ├── post_comment_combined.html.twig │ │ ├── post_comment_inline_md.html.twig │ │ ├── post_comments_nested.html.twig │ │ ├── post_comments_preview.html.twig │ │ ├── post_inline_md.html.twig │ │ ├── related_entries.html.twig │ │ ├── related_magazines.html.twig │ │ ├── related_posts.html.twig │ │ ├── report_list.html.twig │ │ ├── tag_actions.html.twig │ │ ├── user_actions.html.twig │ │ ├── user_avatar.html.twig │ │ ├── user_box.html.twig │ │ ├── user_form_actions.html.twig │ │ ├── user_image_component.html.twig │ │ ├── user_inline.html.twig │ │ ├── user_inline_box.html.twig │ │ ├── user_inline_md.html.twig │ │ ├── vote.html.twig │ │ └── voters_inline.html.twig │ ├── content/ │ │ ├── _list.html.twig │ │ └── front.html.twig │ ├── domain/ │ │ ├── _header_nav.html.twig │ │ ├── _list.html.twig │ │ ├── _options.html.twig │ │ ├── comment/ │ │ │ └── front.html.twig │ │ └── front.html.twig │ ├── entry/ │ │ ├── _create_options.html.twig │ │ ├── _form_edit.html.twig │ │ ├── _form_entry.html.twig │ │ ├── _info.html.twig │ │ ├── _list.html.twig │ │ ├── _menu.html.twig │ │ ├── _moderate_panel.html.twig │ │ ├── _options.html.twig │ │ ├── _options_activity.html.twig │ │ ├── comment/ │ │ │ ├── _form_comment.html.twig │ │ │ ├── _list.html.twig │ │ │ ├── _menu.html.twig │ │ │ ├── _moderate_panel.html.twig │ │ │ ├── _no_comments.html.twig │ │ │ ├── _options.html.twig │ │ │ ├── _options_activity.html.twig │ │ │ ├── create.html.twig │ │ │ ├── edit.html.twig │ │ │ ├── favourites.html.twig │ │ │ ├── front.html.twig │ │ │ ├── moderate.html.twig │ │ │ ├── view.html.twig │ │ │ └── voters.html.twig │ │ ├── create_entry.html.twig │ │ ├── edit_entry.html.twig │ │ ├── favourites.html.twig │ │ ├── moderate.html.twig │ │ ├── single.html.twig │ │ └── voters.html.twig │ ├── form/ │ │ └── lang_select.html.twig │ ├── layout/ │ │ ├── _domain_activity_list.html.twig │ │ ├── _flash.html.twig │ │ ├── _form_media.html.twig │ │ ├── _generic_subject_list.html.twig │ │ ├── _header.html.twig │ │ ├── _header_bread.html.twig │ │ ├── _header_nav.html.twig │ │ ├── _magazine_activity_list.html.twig │ │ ├── _options_appearance.html.twig │ │ ├── _options_font_size.html.twig │ │ ├── _options_theme.html.twig │ │ ├── _pagination.html.twig │ │ ├── _sidebar.html.twig │ │ ├── _subject.html.twig │ │ ├── _subject_link.html.twig │ │ ├── _subject_list.html.twig │ │ ├── _topbar.html.twig │ │ ├── _user_activity_list.html.twig │ │ └── sidebar_subscriptions.html.twig │ ├── magazine/ │ │ ├── _federated_info.html.twig │ │ ├── _list.html.twig │ │ ├── _moderators_list.html.twig │ │ ├── _moderators_sidebar.html.twig │ │ ├── _options.html.twig │ │ ├── _restricted_info.html.twig │ │ ├── _visibility_info.html.twig │ │ ├── create.html.twig │ │ ├── list_abandoned.html.twig │ │ ├── list_all.html.twig │ │ ├── moderators.html.twig │ │ └── panel/ │ │ ├── _options.html.twig │ │ ├── _stats_pills.html.twig │ │ ├── badges.html.twig │ │ ├── ban.html.twig │ │ ├── bans.html.twig │ │ ├── general.html.twig │ │ ├── moderator_requests.html.twig │ │ ├── moderators.html.twig │ │ ├── reports.html.twig │ │ ├── stats.html.twig │ │ ├── tags.html.twig │ │ ├── theme.html.twig │ │ └── trash.html.twig │ ├── messages/ │ │ ├── _form_create.html.twig │ │ ├── front.html.twig │ │ └── single.html.twig │ ├── modlog/ │ │ ├── _blocks.html.twig │ │ └── front.html.twig │ ├── notifications/ │ │ ├── _blocks.html.twig │ │ └── front.html.twig │ ├── page/ │ │ ├── about.html.twig │ │ ├── agent.html.twig │ │ ├── contact.html.twig │ │ ├── faq.html.twig │ │ ├── federation.html.twig │ │ ├── privacy_policy.html.twig │ │ └── terms.html.twig │ ├── people/ │ │ └── front.html.twig │ ├── post/ │ │ ├── _form_post.html.twig │ │ ├── _info.html.twig │ │ ├── _list.html.twig │ │ ├── _menu.html.twig │ │ ├── _moderate_panel.html.twig │ │ ├── _options.html.twig │ │ ├── _options_activity.html.twig │ │ ├── comment/ │ │ │ ├── _form_comment.html.twig │ │ │ ├── _list.html.twig │ │ │ ├── _menu.html.twig │ │ │ ├── _moderate_panel.html.twig │ │ │ ├── _no_comments.html.twig │ │ │ ├── _options.html.twig │ │ │ ├── _options_activity.html.twig │ │ │ ├── _preview.html.twig │ │ │ ├── create.html.twig │ │ │ ├── edit.html.twig │ │ │ ├── favourites.html.twig │ │ │ ├── moderate.html.twig │ │ │ └── voters.html.twig │ │ ├── create.html.twig │ │ ├── edit.html.twig │ │ ├── favourites.html.twig │ │ ├── moderate.html.twig │ │ ├── single.html.twig │ │ └── voters.html.twig │ ├── report/ │ │ ├── _form_report.html.twig │ │ └── create.html.twig │ ├── resend_verification_email/ │ │ └── resend.html.twig │ ├── reset_password/ │ │ ├── check_email.html.twig │ │ ├── request.html.twig │ │ └── reset.html.twig │ ├── search/ │ │ ├── _emoji_suggestion.html.twig │ │ ├── _list.html.twig │ │ ├── _user_suggestion.html.twig │ │ ├── form.html.twig │ │ └── front.html.twig │ ├── stats/ │ │ ├── _filters.html.twig │ │ ├── _options.html.twig │ │ ├── _stats_count.html.twig │ │ └── front.html.twig │ ├── styles/ │ │ └── custom.css.twig │ ├── tag/ │ │ ├── _list.html.twig │ │ ├── _options.html.twig │ │ ├── _panel.html.twig │ │ ├── comments.html.twig │ │ ├── front.html.twig │ │ ├── overview.html.twig │ │ ├── people.html.twig │ │ └── posts.html.twig │ └── user/ │ ├── 2fa.html.twig │ ├── _admin_panel.html.twig │ ├── _boost_list.html.twig │ ├── _federated_info.html.twig │ ├── _info.html.twig │ ├── _list.html.twig │ ├── _options.html.twig │ ├── _user_popover.html.twig │ ├── _visibility_info.html.twig │ ├── comments.html.twig │ ├── consent.html.twig │ ├── entries.html.twig │ ├── followers.html.twig │ ├── following.html.twig │ ├── login.html.twig │ ├── message.html.twig │ ├── moderated.html.twig │ ├── overview.html.twig │ ├── posts.html.twig │ ├── register.html.twig │ ├── replies.html.twig │ ├── reputation.html.twig │ ├── settings/ │ │ ├── 2fa.html.twig │ │ ├── 2fa_backup.html.twig │ │ ├── 2fa_secret.html.twig │ │ ├── _2fa_backup.html.twig │ │ ├── _options.html.twig │ │ ├── _stats_pills.html.twig │ │ ├── account_deletion.html.twig │ │ ├── block_domains.html.twig │ │ ├── block_magazines.html.twig │ │ ├── block_pills.html.twig │ │ ├── block_users.html.twig │ │ ├── email.html.twig │ │ ├── filter_lists.html.twig │ │ ├── filter_lists_create.html.twig │ │ ├── filter_lists_edit.html.twig │ │ ├── filter_lists_form.html.twig │ │ ├── general.html.twig │ │ ├── password.html.twig │ │ ├── profile.html.twig │ │ ├── reports.html.twig │ │ ├── stats.html.twig │ │ ├── sub_domains.html.twig │ │ ├── sub_magazines.html.twig │ │ ├── sub_pills.html.twig │ │ └── sub_users.html.twig │ └── subscriptions.html.twig ├── tests/ │ ├── ActivityPubJsonDriver.php │ ├── ActivityPubTestCase.php │ ├── FactoryTrait.php │ ├── Functional/ │ │ ├── ActivityPub/ │ │ │ ├── ActivityPubFunctionalTestCase.php │ │ │ ├── Inbox/ │ │ │ │ ├── AcceptHandlerTest.php │ │ │ │ ├── AddHandlerTest.php │ │ │ │ ├── BlockHandlerTest.php │ │ │ │ ├── CreateHandlerTest.php │ │ │ │ ├── DeleteHandlerTest.php │ │ │ │ ├── DislikeHandlerTest.php │ │ │ │ ├── FlagHandlerTest.php │ │ │ │ ├── FollowHandlerTest.php │ │ │ │ ├── LikeHandlerTest.php │ │ │ │ ├── LockHandlerTest.php │ │ │ │ ├── RemoveHandlerTest.php │ │ │ │ └── UpdateHandlerTest.php │ │ │ ├── MarkdownConverterTest.php │ │ │ └── Outbox/ │ │ │ ├── BlockHandlerTest.php │ │ │ ├── DeleteHandlerTest.php │ │ │ └── LockHandlerTest.php │ │ ├── Command/ │ │ │ ├── AdminCommandTest.php │ │ │ ├── ModeratorCommandTest.php │ │ │ └── UserCommandTest.php │ │ ├── Controller/ │ │ │ ├── ActivityPub/ │ │ │ │ ├── GeneralAPTest.php │ │ │ │ └── UserOutboxControllerTest.php │ │ │ ├── Admin/ │ │ │ │ ├── AdminFederationControllerTest.php │ │ │ │ └── AdminUserControllerTest.php │ │ │ ├── Api/ │ │ │ │ ├── Bookmark/ │ │ │ │ │ ├── BookmarkApiTest.php │ │ │ │ │ └── BookmarkListApiTest.php │ │ │ │ ├── Combined/ │ │ │ │ │ ├── CombinedRetrieveApiCursoredTest.php │ │ │ │ │ └── CombinedRetrieveApiTest.php │ │ │ │ ├── Domain/ │ │ │ │ │ ├── DomainBlockApiTest.php │ │ │ │ │ ├── DomainRetrieveApiTest.php │ │ │ │ │ └── DomainSubscribeApiTest.php │ │ │ │ ├── Entry/ │ │ │ │ │ ├── Admin/ │ │ │ │ │ │ ├── EntryChangeMagazineApiTest.php │ │ │ │ │ │ └── EntryPurgeApiTest.php │ │ │ │ │ ├── Comment/ │ │ │ │ │ │ ├── Admin/ │ │ │ │ │ │ │ └── EntryCommentPurgeApiTest.php │ │ │ │ │ │ ├── DomainEntryCommentRetrieveApiTest.php │ │ │ │ │ │ ├── EntryCommentCreateApiTest.php │ │ │ │ │ │ ├── EntryCommentDeleteApiTest.php │ │ │ │ │ │ ├── EntryCommentReportApiTest.php │ │ │ │ │ │ ├── EntryCommentRetrieveApiTest.php │ │ │ │ │ │ ├── EntryCommentUpdateApiTest.php │ │ │ │ │ │ ├── EntryCommentVoteApiTest.php │ │ │ │ │ │ ├── EntryCommentsActivityApiTest.php │ │ │ │ │ │ ├── Moderate/ │ │ │ │ │ │ │ ├── EntryCommentSetAdultApiTest.php │ │ │ │ │ │ │ ├── EntryCommentSetLanguageApiTest.php │ │ │ │ │ │ │ └── EntryCommentTrashApiTest.php │ │ │ │ │ │ └── UserEntryCommentRetrieveApiTest.php │ │ │ │ │ ├── DomainEntryRetrieveApiTest.php │ │ │ │ │ ├── EntriesActivityApiTest.php │ │ │ │ │ ├── EntryCreateApiNewTest.php │ │ │ │ │ ├── EntryCreateApiTest.php │ │ │ │ │ ├── EntryDeleteApiTest.php │ │ │ │ │ ├── EntryFavouriteApiTest.php │ │ │ │ │ ├── EntryReportApiTest.php │ │ │ │ │ ├── EntryRetrieveApiTest.php │ │ │ │ │ ├── EntryUpdateApiTest.php │ │ │ │ │ ├── EntryVoteApiTest.php │ │ │ │ │ ├── MagazineEntryRetrieveApiTest.php │ │ │ │ │ ├── Moderate/ │ │ │ │ │ │ ├── EntryLockApiTest.php │ │ │ │ │ │ ├── EntryPinApiTest.php │ │ │ │ │ │ ├── EntrySetAdultApiTest.php │ │ │ │ │ │ ├── EntrySetLanguageApiTest.php │ │ │ │ │ │ └── EntryTrashApiTest.php │ │ │ │ │ └── UserEntryRetrieveApiTest.php │ │ │ │ ├── Instance/ │ │ │ │ │ ├── Admin/ │ │ │ │ │ │ ├── InstanceFederationUpdateApiTest.php │ │ │ │ │ │ ├── InstancePagesUpdateApiTest.php │ │ │ │ │ │ ├── InstanceSettingsRetrieveApiTest.php │ │ │ │ │ │ └── InstanceSettingsUpdateApiTest.php │ │ │ │ │ ├── InstanceDetailsApiTest.php │ │ │ │ │ ├── InstanceFederationApiTest.php │ │ │ │ │ ├── InstanceModlogApiTest.php │ │ │ │ │ └── InstanceRetrieveInfoApiTest.php │ │ │ │ ├── Magazine/ │ │ │ │ │ ├── Admin/ │ │ │ │ │ │ ├── MagazineBadgesApiTest.php │ │ │ │ │ │ ├── MagazineCreateApiTest.php │ │ │ │ │ │ ├── MagazineDeleteApiTest.php │ │ │ │ │ │ ├── MagazineDeleteIconApiTest.php │ │ │ │ │ │ ├── MagazineModeratorsApiTest.php │ │ │ │ │ │ ├── MagazinePurgeApiTest.php │ │ │ │ │ │ ├── MagazineRetrieveStatsApiTest.php │ │ │ │ │ │ ├── MagazineTagsApiTest.php │ │ │ │ │ │ ├── MagazineUpdateApiTest.php │ │ │ │ │ │ └── MagazineUpdateThemeApiTest.php │ │ │ │ │ ├── MagazineBlockApiTest.php │ │ │ │ │ ├── MagazineModlogApiTest.php │ │ │ │ │ ├── MagazineRetrieveApiTest.php │ │ │ │ │ ├── MagazineRetrieveThemeApiTest.php │ │ │ │ │ ├── MagazineSubscribeApiTest.php │ │ │ │ │ └── Moderate/ │ │ │ │ │ ├── MagazineActionReportsApiTest.php │ │ │ │ │ ├── MagazineBanApiTest.php │ │ │ │ │ ├── MagazineModOwnerRequestApiTest.php │ │ │ │ │ ├── MagazineRetrieveBansApiTest.php │ │ │ │ │ ├── MagazineRetrieveReportsApiTest.php │ │ │ │ │ └── MagazineRetrieveTrashApiTest.php │ │ │ │ ├── Message/ │ │ │ │ │ ├── MessageReadApiTest.php │ │ │ │ │ ├── MessageRetrieveApiTest.php │ │ │ │ │ ├── MessageThreadCreateApiTest.php │ │ │ │ │ └── MessageThreadReplyApiTest.php │ │ │ │ ├── Notification/ │ │ │ │ │ ├── AdminNotificationRetrieveApiTest.php │ │ │ │ │ ├── NotificationDeleteApiTest.php │ │ │ │ │ ├── NotificationReadApiTest.php │ │ │ │ │ ├── NotificationRetrieveApiTest.php │ │ │ │ │ └── NotificationUpdateApiTest.php │ │ │ │ ├── OAuth2/ │ │ │ │ │ └── OAuth2ClientApiTest.php │ │ │ │ ├── Post/ │ │ │ │ │ ├── Admin/ │ │ │ │ │ │ └── PostPurgeApiTest.php │ │ │ │ │ ├── Comment/ │ │ │ │ │ │ ├── Admin/ │ │ │ │ │ │ │ └── PostCommentPurgeApiTest.php │ │ │ │ │ │ ├── Moderate/ │ │ │ │ │ │ │ ├── PostCommentSetAdultApiTest.php │ │ │ │ │ │ │ ├── PostCommentSetLanguageApiTest.php │ │ │ │ │ │ │ └── PostCommentTrashApiTest.php │ │ │ │ │ │ ├── PostCommentCreateApiTest.php │ │ │ │ │ │ ├── PostCommentDeleteApiTest.php │ │ │ │ │ │ ├── PostCommentReportApiTest.php │ │ │ │ │ │ ├── PostCommentRetrieveApiTest.php │ │ │ │ │ │ ├── PostCommentUpdateApiTest.php │ │ │ │ │ │ ├── PostCommentVoteApiTest.php │ │ │ │ │ │ ├── PostCommentsActivityApiTest.php │ │ │ │ │ │ └── UserPostCommentRetrieveApiTest.php │ │ │ │ │ ├── MagazinePostRetrieveApiTest.php │ │ │ │ │ ├── Moderate/ │ │ │ │ │ │ ├── PostLockApiTest.php │ │ │ │ │ │ ├── PostPinApiTest.php │ │ │ │ │ │ ├── PostSetAdultApiTest.php │ │ │ │ │ │ ├── PostSetLanguageApiTest.php │ │ │ │ │ │ └── PostTrashApiTest.php │ │ │ │ │ ├── PostCreateApiTest.php │ │ │ │ │ ├── PostDeleteApiTest.php │ │ │ │ │ ├── PostFavouriteApiTest.php │ │ │ │ │ ├── PostReportApiTest.php │ │ │ │ │ ├── PostRetrieveApiTest.php │ │ │ │ │ ├── PostUpdateApiTest.php │ │ │ │ │ ├── PostVoteApiTest.php │ │ │ │ │ ├── PostsActivityApiTest.php │ │ │ │ │ └── UserPostRetrieveApiTest.php │ │ │ │ ├── Search/ │ │ │ │ │ └── SearchApiTest.php │ │ │ │ └── User/ │ │ │ │ ├── Admin/ │ │ │ │ │ ├── UserBanApiTest.php │ │ │ │ │ ├── UserDeleteApiTest.php │ │ │ │ │ ├── UserPurgeApiTest.php │ │ │ │ │ ├── UserRetrieveBannedApiTest.php │ │ │ │ │ └── UserVerifyApiTest.php │ │ │ │ ├── UserBlockApiTest.php │ │ │ │ ├── UserContentApiTest.php │ │ │ │ ├── UserFilterListApiTest.php │ │ │ │ ├── UserFollowApiTest.php │ │ │ │ ├── UserModeratesApiTest.php │ │ │ │ ├── UserRetrieveApiTest.php │ │ │ │ ├── UserRetrieveOAuthConsentsApiTest.php │ │ │ │ ├── UserUpdateApiTest.php │ │ │ │ ├── UserUpdateImagesApiTest.php │ │ │ │ └── UserUpdateOAuthConsentsApiTest.php │ │ │ ├── Domain/ │ │ │ │ ├── DomainBlockControllerTest.php │ │ │ │ ├── DomainCommentFrontControllerTest.php │ │ │ │ ├── DomainFrontControllerTest.php │ │ │ │ └── DomainSubControllerTest.php │ │ │ ├── Entry/ │ │ │ │ ├── Comment/ │ │ │ │ │ ├── EntryCommentBoostControllerTest.php │ │ │ │ │ ├── EntryCommentChangeLangControllerTest.php │ │ │ │ │ ├── EntryCommentCreateControllerTest.php │ │ │ │ │ ├── EntryCommentDeleteControllerTest.php │ │ │ │ │ ├── EntryCommentEditControllerTest.php │ │ │ │ │ ├── EntryCommentFrontControllerTest.php │ │ │ │ │ └── EntryCommentModerateControllerTest.php │ │ │ │ ├── EntryBoostControllerTest.php │ │ │ │ ├── EntryChangeAdultControllerTest.php │ │ │ │ ├── EntryChangeLangControllerTest.php │ │ │ │ ├── EntryChangeMagazineControllerTest.php │ │ │ │ ├── EntryCreateControllerTest.php │ │ │ │ ├── EntryDeleteControllerTest.php │ │ │ │ ├── EntryEditControllerTest.php │ │ │ │ ├── EntryFrontControllerTest.php │ │ │ │ ├── EntryLockControllerTest.php │ │ │ │ ├── EntryModerateControllerTest.php │ │ │ │ ├── EntryPinControllerTest.php │ │ │ │ ├── EntrySingleControllerTest.php │ │ │ │ └── EntryVotersControllerTest.php │ │ │ ├── Magazine/ │ │ │ │ ├── MagazineBlockControllerTest.php │ │ │ │ ├── MagazineCreateControllerTest.php │ │ │ │ ├── MagazineListControllerTest.php │ │ │ │ ├── MagazinePeopleControllerTest.php │ │ │ │ ├── MagazineSubControllerTest.php │ │ │ │ └── Panel/ │ │ │ │ ├── MagazineAppearanceControllerTest.php │ │ │ │ ├── MagazineBadgeControllerTest.php │ │ │ │ ├── MagazineBanControllerTest.php │ │ │ │ ├── MagazineEditControllerTest.php │ │ │ │ ├── MagazineModeratorControllerTest.php │ │ │ │ ├── MagazineReportControllerTest.php │ │ │ │ └── MagazineTrashControllerTest.php │ │ │ ├── Moderator/ │ │ │ │ └── ModeratorSignupRequestsControllerTest.php │ │ │ ├── People/ │ │ │ │ └── FrontControllerTest.php │ │ │ ├── Post/ │ │ │ │ ├── Comment/ │ │ │ │ │ ├── PostCommentBoostControllerTest.php │ │ │ │ │ ├── PostCommentChangeLangControllerTest.php │ │ │ │ │ ├── PostCommentCreateControllerTest.php │ │ │ │ │ ├── PostCommentDeleteControllerTest.php │ │ │ │ │ ├── PostCommentEditControllerTest.php │ │ │ │ │ └── PostCommentModerateControllerTest.php │ │ │ │ ├── PostBoostControllerTest.php │ │ │ │ ├── PostChangeAdultControllerTest.php │ │ │ │ ├── PostChangeLangControllerTest.php │ │ │ │ ├── PostChangeMagazineControllerTest.php │ │ │ │ ├── PostCreateControllerTest.php │ │ │ │ ├── PostDeleteControllerTest.php │ │ │ │ ├── PostEditControllerTest.php │ │ │ │ ├── PostFrontControllerTest.php │ │ │ │ ├── PostLockControllerTest.php │ │ │ │ ├── PostModerateControllerTest.php │ │ │ │ ├── PostPinControllerTest.php │ │ │ │ ├── PostSingleControllerTest.php │ │ │ │ └── PostVotersControllerTest.php │ │ │ ├── PrivacyPolicyControllerTest.php │ │ │ ├── ReportControllerControllerTest.php │ │ │ ├── Security/ │ │ │ │ ├── LoginControllerTest.php │ │ │ │ ├── OAuth2ConsentControllerTest.php │ │ │ │ ├── OAuth2TokenControllerTest.php │ │ │ │ └── RegisterControllerTest.php │ │ │ ├── TermsControllerTest.php │ │ │ ├── User/ │ │ │ │ ├── Admin/ │ │ │ │ │ └── UserDeleteControllerTest.php │ │ │ │ ├── Profile/ │ │ │ │ │ ├── UserBlockControllerTest.php │ │ │ │ │ ├── UserEditControllerTest.php │ │ │ │ │ ├── UserNotificationControllerTest.php │ │ │ │ │ └── UserSubControllerTest.php │ │ │ │ ├── UserBlockControllerTest.php │ │ │ │ ├── UserFollowControllerTest.php │ │ │ │ └── UserFrontControllerTest.php │ │ │ ├── VoteControllerTest.php │ │ │ └── WebfingerControllerTest.php │ │ └── Misc/ │ │ └── Entry/ │ │ └── CrosspostDetectionTest.php │ ├── OAuth2FlowTrait.php │ ├── Service/ │ │ ├── TestingApHttpClient.php │ │ └── TestingImageManager.php │ ├── Unit/ │ │ ├── ActivityPub/ │ │ │ ├── ActorHandleTest.php │ │ │ ├── CollectionExtractionTest.php │ │ │ ├── Outbox/ │ │ │ │ ├── AddHandlerTest.php │ │ │ │ ├── AnnounceTest.php │ │ │ │ ├── BlockTest.php │ │ │ │ ├── CreateTest.php │ │ │ │ ├── DeleteTest.php │ │ │ │ ├── FlagTest.php │ │ │ │ ├── FollowTest.php │ │ │ │ ├── JsonSnapshots/ │ │ │ │ │ ├── AddHandlerTest__testAddModerator__1.json │ │ │ │ │ ├── AddHandlerTest__testAddPinnedPost__1.json │ │ │ │ │ ├── AddHandlerTest__testRemoveModerator__1.json │ │ │ │ │ ├── AddHandlerTest__testRemovePinnedPost__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceAddModerator__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceAddPinnedPost__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceBlockUser__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceCreateEntryComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceCreateEntry__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceCreateMessage__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceCreateNestedEntryComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceCreateNestedPostComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceCreatePostComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceCreatePost__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceDeleteEntryByModerator__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceDeleteEntryCommentByModerator__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceDeleteEntryComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceDeleteEntry__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceDeletePostByModerator__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceDeletePostCommentByModerator__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceDeletePostComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceDeletePost__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceDeleteUser__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceLikeEntryComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceLikeEntry__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceLikeNestedEntryComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceLikeNestedPostComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceLikePostComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceLikePost__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceRemoveModerator__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceRemovePinnedPost__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceUndoBlockUser__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceUndoLikeEntryComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceUndoLikeEntry__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceUndoLikeNestedEntryComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceUndoLikeNestedPostComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceUndoLikePostComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceUndoLikePost__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceUpdateEntryComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceUpdateEntry__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceUpdateMagazine__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceUpdatePostComment__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceUpdatePost__1.json │ │ │ │ │ ├── AnnounceTest__testAnnounceUpdateUser__1.json │ │ │ │ │ ├── AnnounceTest__testMagazineBoostEntryComment__1.json │ │ │ │ │ ├── AnnounceTest__testMagazineBoostEntry__1.json │ │ │ │ │ ├── AnnounceTest__testMagazineBoostNestedEntryComment__1.json │ │ │ │ │ ├── AnnounceTest__testMagazineBoostNestedPostComment__1.json │ │ │ │ │ ├── AnnounceTest__testMagazineBoostPostComment__1.json │ │ │ │ │ ├── AnnounceTest__testMagazineBoostPost__1.json │ │ │ │ │ ├── AnnounceTest__testUserBoostEntryComment__1.json │ │ │ │ │ ├── AnnounceTest__testUserBoostEntry__1.json │ │ │ │ │ ├── AnnounceTest__testUserBoostNestedEntryComment__1.json │ │ │ │ │ ├── AnnounceTest__testUserBoostNestedPostComment__1.json │ │ │ │ │ ├── AnnounceTest__testUserBoostPostComment__1.json │ │ │ │ │ ├── AnnounceTest__testUserBoostPost__1.json │ │ │ │ │ ├── BlockTest__testBlockUser__1.json │ │ │ │ │ ├── CreateTest__testCreateEntryComment__1.json │ │ │ │ │ ├── CreateTest__testCreateEntryWithUrlAndImage__1.json │ │ │ │ │ ├── CreateTest__testCreateEntry__1.json │ │ │ │ │ ├── CreateTest__testCreateMessage__1.json │ │ │ │ │ ├── CreateTest__testCreateNestedEntryComment__1.json │ │ │ │ │ ├── CreateTest__testCreateNestedPostComment__1.json │ │ │ │ │ ├── CreateTest__testCreatePostComment__1.json │ │ │ │ │ ├── CreateTest__testCreatePost__1.json │ │ │ │ │ ├── DeleteTest__testDeleteEntryByModerator__1.json │ │ │ │ │ ├── DeleteTest__testDeleteEntryCommentByModerator__1.json │ │ │ │ │ ├── DeleteTest__testDeleteEntryComment__1.json │ │ │ │ │ ├── DeleteTest__testDeleteEntry__1.json │ │ │ │ │ ├── DeleteTest__testDeletePostByModerator__1.json │ │ │ │ │ ├── DeleteTest__testDeletePostCommentByModerator__1.json │ │ │ │ │ ├── DeleteTest__testDeletePostComment__1.json │ │ │ │ │ ├── DeleteTest__testDeletePost__1.json │ │ │ │ │ ├── DeleteTest__testDeleteUser__1.json │ │ │ │ │ ├── FlagTest__testFlagEntryComment__1.json │ │ │ │ │ ├── FlagTest__testFlagEntry__1.json │ │ │ │ │ ├── FlagTest__testFlagNestedEntryComment__1.json │ │ │ │ │ ├── FlagTest__testFlagNestedPostComment__1.json │ │ │ │ │ ├── FlagTest__testFlagPostComment__1.json │ │ │ │ │ ├── FlagTest__testFlagPost__1.json │ │ │ │ │ ├── FollowTest__testAcceptFollowMagazine__1.json │ │ │ │ │ ├── FollowTest__testAcceptFollowUser__1.json │ │ │ │ │ ├── FollowTest__testFollowMagazine__1.json │ │ │ │ │ ├── FollowTest__testFollowUser__1.json │ │ │ │ │ ├── FollowTest__testRejectFollowMagazine__1.json │ │ │ │ │ ├── FollowTest__testRejectFollowUser__1.json │ │ │ │ │ ├── LikeTest__testLikeEntryComment__1.json │ │ │ │ │ ├── LikeTest__testLikeEntry__1.json │ │ │ │ │ ├── LikeTest__testLikeNestedEntryComment__1.json │ │ │ │ │ ├── LikeTest__testLikeNestedPostComment__1.json │ │ │ │ │ ├── LikeTest__testLikePostComment__1.json │ │ │ │ │ ├── LikeTest__testLikePost__1.json │ │ │ │ │ ├── LockTest__testLockEntryByAuthor__1.json │ │ │ │ │ ├── LockTest__testLockEntryByModerator__1.json │ │ │ │ │ ├── LockTest__testLockPostByAuthor__1.json │ │ │ │ │ ├── LockTest__testLockPostByModerator__1.json │ │ │ │ │ ├── UndoTest__testUndoBlockUser__1.json │ │ │ │ │ ├── UndoTest__testUndoFollowMagazine__1.json │ │ │ │ │ ├── UndoTest__testUndoFollowUser__1.json │ │ │ │ │ ├── UndoTest__testUndoLikeEntryComment__1.json │ │ │ │ │ ├── UndoTest__testUndoLikeEntry__1.json │ │ │ │ │ ├── UndoTest__testUndoLikeNestedEntryComment__1.json │ │ │ │ │ ├── UndoTest__testUndoLikeNestedPostComment__1.json │ │ │ │ │ ├── UndoTest__testUndoLikePostComment__1.json │ │ │ │ │ ├── UndoTest__testUndoLikePost__1.json │ │ │ │ │ ├── UpdateTest__testUpdateEntryComment__1.json │ │ │ │ │ ├── UpdateTest__testUpdateEntry__1.json │ │ │ │ │ ├── UpdateTest__testUpdateMagazine__1.json │ │ │ │ │ ├── UpdateTest__testUpdatePostComment__1.json │ │ │ │ │ ├── UpdateTest__testUpdatePost__1.json │ │ │ │ │ └── UpdateTest__testUpdateUser__1.json │ │ │ │ ├── LikeTest.php │ │ │ │ ├── LockTest.php │ │ │ │ ├── UndoTest.php │ │ │ │ └── UpdateTest.php │ │ │ ├── TagMatchTest.php │ │ │ └── Traits/ │ │ │ ├── AddRemoveActivityGeneratorTrait.php │ │ │ ├── AnnounceActivityGeneratorTrait.php │ │ │ ├── BlockActivityGeneratorTrait.php │ │ │ ├── CreateActivityGeneratorTrait.php │ │ │ ├── DeleteActivityGeneratorTrait.php │ │ │ ├── FlagActivityGeneratorTrait.php │ │ │ ├── FollowActivityGeneratorTrait.php │ │ │ ├── LikeActivityGeneratorTrait.php │ │ │ ├── LockActivityGeneratorTrait.php │ │ │ ├── UndoActivityGeneratorTrait.php │ │ │ └── UpdateActivityGeneratorTrait.php │ │ ├── CursorPaginationTest.php │ │ ├── Service/ │ │ │ ├── ActivityPub/ │ │ │ │ └── SignatureValidatorTest.php │ │ │ ├── MentionManagerTest.php │ │ │ ├── MonitoringParameterEncodingTest.php │ │ │ ├── SettingsManagerTest.php │ │ │ └── TagExtractorTest.php │ │ ├── TwigRuntime/ │ │ │ └── FormattingExtensionRuntimeTest.php │ │ └── Utils/ │ │ ├── ArrayUtilTest.php │ │ ├── GeneralUtilTest.php │ │ ├── MarkdownTest.php │ │ └── SluggerTest.php │ ├── ValidationTrait.php │ ├── WebTestCase.php │ └── bootstrap.php ├── tools/ │ └── composer.json ├── translations/ │ ├── .gitignore │ ├── messages.an.yaml │ ├── messages.ast.yaml │ ├── messages.bg.yaml │ ├── messages.ca.yaml │ ├── messages.ca@valencia.yaml │ ├── messages.da.yaml │ ├── messages.de.yaml │ ├── messages.el.yaml │ ├── messages.en.yaml │ ├── messages.eo.yaml │ ├── messages.es.yaml │ ├── messages.et.yaml │ ├── messages.eu.yaml │ ├── messages.fi.yaml │ ├── messages.fil.yaml │ ├── messages.fr.yaml │ ├── messages.gl.yaml │ ├── messages.gsw.yaml │ ├── messages.it.yaml │ ├── messages.ja.yaml │ ├── messages.nb_NO.yaml │ ├── messages.nl.yaml │ ├── messages.pl.yaml │ ├── messages.pt.yaml │ ├── messages.pt_BR.yaml │ ├── messages.ru.yaml │ ├── messages.sv.yaml │ ├── messages.ta.yaml │ ├── messages.tr.yaml │ ├── messages.uk.yaml │ ├── messages.zh_Hans.yaml │ ├── messages.zh_TW.yaml │ └── security.en.yaml └── webpack.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/apache-vhost.conf ================================================ ServerName 127.0.0.1 # The ServerName directive sets the request scheme, hostname and port that # the server uses to identify itself. This is used when creating # redirection URLs. In the context of virtual hosts, the ServerName # specifies what hostname must appear in the request's Host: header to # match this virtual host. For the default virtual host (this file) this # value is not decisive as it is used as a last resort host regardless. # However, you must set it for any further virtual host explicitly. #ServerName www.example.com ServerAdmin webmaster@localhost DocumentRoot /var/www/html/public # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, # error, crit, alert, emerg. # It is also possible to configure the loglevel for particular # modules, e.g. #LogLevel info ssl:warn ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined # For most configuration files from conf-available/, which are # enabled or disabled at a global level, it is possible to # include a line for only one particular virtual host. For example the # following line enables the CGI configuration for this host only # after it has been globally disabled with "a2disconf". #Include conf-available/serve-cgi-bin.conf AllowOverride None Require all granted FallbackResource /index.php DirectoryIndex index.php # Disabling MultiViews prevents unwanted negotiation, e.g. "/index" should not resolve # to the front controller "/index.php" but be rewritten to "/index.php/index". Options -MultiViews RewriteEngine On # This RewriteRule is used to dynamically discover the RewriteBase path. # See https://httpd.apache.org/docs/current/mod/mod_rewrite.html#rewriterule # Here we will compare the stripped per-dir path *relative to the filesystem # path where the .htaccess file is read from* with the URI of the request. # # If a match is found, the prefix path is stored into an ENV var that is later # used to properly prefix the URI of the front controller index.php. # This is what makes it possible to host a Symfony application under a subpath, # such as example.com/subpath # The convoluted rewrite condition means: # 1. Match all current URI in the RewriteRule and backreference it using $0 # 2. Strip the request uri the per-dir path and use ir as REQUEST_URI. # This is documented in https://bit.ly/3zDm3SI ("What is matched?") # 3. Evaluate the RewriteCond, assuming your DocumentRoot is /var/www/html, # this .htaccess is in the /var/www/html/public dir and your request URI # is /public/hello/world: # * strip per-dir prefix: /var/www/html/public/hello/world -> hello/world # * applying pattern '.*' to uri 'hello/world' # * RewriteCond: input='/public/hello/world::hello/world' pattern='^(/.+)/(.*)::\\2$' => matched # 4. Execute the RewriteRule: # * The %1 in the RewriteRule flag E=BASE:%1 refers to the first group captured in the RewriteCond ^(/.+)/(.*) # * setting env variable 'BASE' to '/public' RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$ RewriteRule .* - [E=BASE:%1] # Sets the HTTP_AUTHORIZATION header removed by Apache RewriteCond %{HTTP:Authorization} .+ RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0] # Removes the /index.php/ part from a URL, if present RewriteCond %{ENV:REDIRECT_STATUS} ^$ RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L] # If the requested filename exists, simply serve it. # Otherwise rewrite all other queries to the front controller. RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^ %{ENV:BASE}/index.php [L] # vim: syntax=apache ts=4 sw=4 sts=4 sr noet ================================================ FILE: .devcontainer/devcontainer.json ================================================ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/php { "name": "PHP", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "image": "mcr.microsoft.com/devcontainers/php:3.0.3-8.4-trixie", // Features to add to the dev container. More info: https://containers.dev/features. "features": { "ghcr.io/yassinedoghri/devcontainers/php-extensions-installer:1": { "extensions": "amqp apcu bcmath exif gd intl opcache pcntl pdo_pgsql pgsql redis" }, "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers/features/node:1": { "version": "latest" }, "ghcr.io/itsmechlark/features/postgresql:1": { "version": "18" }, "ghcr.io/itsmechlark/features/rabbitmq-server:1": {}, "ghcr.io/itsmechlark/features/redis-server:1": {} }, // Configure tool-specific properties. "customizations": { // Configure properties specific to VS Code. "vscode": { "extensions": [ "christian-kohler.npm-intellisense", "christian-kohler.path-intellisense", "editorconfig.editorconfig", "ikappas.composer", "junstyle.php-cs-fixer", "marcoroth.stimulus-lsp", "mblode.twig-language", "mikestead.dotenv", "ms-azuretools.vscode-docker", "neilbrayfield.php-docblocker", "recca0120.vscode-phpunit", "redhat.vscode-yaml", "sanderronde.phpstan-vscode" ], "settings": { "javascript.suggest.paths": false, "typescript.suggest.paths": false, "pgsql.connections": [ { "server": "127.0.0.1", "database": "postgres", "user": "postgres", "password": "" } ] } } }, // Use 'forwardPorts' to make a list of ports inside the container available locally. "forwardPorts": [ 8080 ], // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": { "webdir": "sudo chmod a+x \"$(pwd)\" && sudo rm -rf /var/www/html && sudo ln -s \"$(pwd)\" /var/www/html", "deps": "if [ -f composer.json ]; then composer install; fi", "config": "cp .devcontainer/.env.devcontainer .env", "apache": "sudo sed -i 's/Listen 80$//' /etc/apache2/ports.conf && sudo cp .devcontainer/apache-vhost.conf /etc/apache2/sites-enabled/000-default.conf && sudo a2enmod rewrite", "phpconf": "sudo cp .devcontainer/php_config.ini /usr/local/etc/php/conf.d/custom.ini", "symfony": "wget https://get.symfony.com/cli/installer -O - | bash && sudo mv ~/.symfony5/bin/symfony /usr/local/bin/symfony" } // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "root" // Uncomment if you are using Podman //runArgs: [ // "--userns=keep-id", // "--security-opt=label=disable" //], //"updateRemoteUserUID": true } ================================================ FILE: .devcontainer/php_config.ini ================================================ memory_limit = 1G max_execution_time = 60 ================================================ FILE: .dockerignore ================================================ **/*.log **/*.md **/*.php~ **/*.dist.php **/*.dist **/*.cache **/._* **/.dockerignore **/.DS_Store **/.git/ **/.gitattributes **/.gitignore **/.gitmodules **/compose.*.yaml **/compose.yaml **/Dockerfile **/Thumbs.db .github/ storage/ docs/ public/bundles/ tests/ tools/ var/ vendor/ .vs/ .editorconfig .env.*.local .env.local .env.local.php .env.test .env Dockerfile ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space insert_final_newline = true trim_trailing_whitespace = true [*.php] indent_style = space indent_size = 4 [*.twig] indent_style = space indent_size = 4 [*.js] indent_style = space indent_size = 4 [*.{css,scss}] indent_style = space indent_size = 2 [*.json] indent_style = space indent_size = 4 [*.yaml] indent_style = space indent_size = 4 quote_type = single [*.md] trim_trailing_whitespace = false [{compose.yaml,compose.*.yaml}] indent_size = 2 [translations/*.yaml] indent_style = space indent_size = 2 quote_type = single [.github/workflows/*.yaml] indent_style = space indent_size = 2 quote_type = single ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: ['bug'] assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **On which Mbin instance did you find the bug?** [domain.tld] **Which Mbin version was running on the instance?** [e.g. 1.7.4] **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser: [e.g. chrome, safari] - Browser Version: [e.g. 123] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser: [e.g. stock browser, safari] - Browser Version: [e.g. 123] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: ['enhancement'] assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE/pull_request_template.md ================================================ # Summary ## Checklist - [ ] Marked as draft PR while still working on PR - [ ] Marked as "ready for review" once not in progress - [ ] Added tests (for code changes) - [ ] Provided screenshots (for visual changes) # Additional information # Related issues ================================================ FILE: .github/dependabot.yml ================================================ # Inspired by: https://github.com/dependabot/dependabot-core/blob/main/.github/dependabot.yml # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" day: "saturday" time: "14:00" groups: npm: applies-to: security-updates update-types: - "minor" - "patch" - package-ecosystem: "composer" directory: "/" schedule: interval: "weekly" day: "saturday" time: "14:00" groups: php: applies-to: security-updates update-types: - "minor" - "patch" - package-ecosystem: "devcontainers" directory: "/" schedule: interval: "weekly" day: "saturday" time: "14:00" groups: devcontainers: applies-to: security-updates update-types: - "minor" - "patch" ================================================ FILE: .github/workflows/action.yaml ================================================ name: Mbin Workflow on: pull_request: branches: - main - develop - dev/new_features push: branches: - main - dev/new_features tags: - 'v*' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} jobs: build: runs-on: ubuntu-latest container: image: ghcr.io/mbinorg/mbin-pipeline-image:latest steps: - uses: actions/checkout@v4 - name: Get NPM cache directory path id: npm-cache-dir-path run: echo "dir=$(npm get cache)" >> $GITHUB_OUTPUT - name: Add GITHUB_WORKSPACE as a safe directory run: git config --global --add safe.directory $GITHUB_WORKSPACE - name: Get Composer Cache Directory id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - uses: actions/cache@v4 id: npm-cache with: path: ${{ steps.npm-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} - uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-no-dev-${{ hashFiles('**/composer.lock') }} - run: cp .env.example .env - name: Composer install run: > ./ci/skipOnExcluded.sh composer install --no-dev --no-progress - name: Test API dump run: > ./ci/skipOnExcluded.sh php bin/console nelmio:apidoc:dump - name: NPM install run: > ./ci/skipOnExcluded.sh npm ci --include=dev env: NODE_ENV: production - name: Build frontend (production) run: > ./ci/skipOnExcluded.sh npm run build automated-tests: runs-on: ubuntu-latest container: image: ghcr.io/mbinorg/mbin-pipeline-image:latest steps: - uses: actions/checkout@v4 - name: Add GITHUB_WORKSPACE as a safe directory run: git config --global --add safe.directory $GITHUB_WORKSPACE - name: Get Composer Cache Directory id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Get NPM cache directory path id: npm-cache-dir-path run: echo "dir=$(npm get cache)" >> $GITHUB_OUTPUT - uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('*/composer.lock') }} restore-keys: ${{ runner.os }}-composer- - uses: actions/cache@v4 id: npm-cache with: path: ${{ steps.npm-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-npm-${{ hashFiles('*/package-lock.json') }} restore-keys: ${{ runner.os }}-npm- - name: Composer install run: > ./ci/skipOnExcluded.sh composer install --no-scripts --no-progress - run: cp .env.example .env - name: NPM install run: > ./ci/skipOnExcluded.sh npm ci --include=dev env: NODE_ENV: production - name: Build frontend (production) run: > ./ci/skipOnExcluded.sh npm run build - name: Run unit tests env: COMPOSER_CACHE_DIR: ${{ steps.composer-cache.outputs.dir }} SYMFONY_DEPRECATIONS_HELPER: disabled DATABASE_HOST: postgres DATABASE_PORT: 5432 REDIS_HOST: valkey REDIS_PORT: 6379 CREATE_SNAPSHOTS: false run: > ./ci/skipOnExcluded.sh php vendor/bin/paratest tests/Unit - name: Run non thread safe integration tests env: COMPOSER_CACHE_DIR: ${{ steps.composer-cache.outputs.dir }} SYMFONY_DEPRECATIONS_HELPER: disabled DATABASE_HOST: postgres DATABASE_PORT: 5432 REDIS_HOST: valkey REDIS_PORT: 6379 run: > ./ci/skipOnExcluded.sh php vendor/bin/phpunit tests/Functional --group NonThreadSafe - name: Run thread safe integration tests env: COMPOSER_CACHE_DIR: ${{ steps.composer-cache.outputs.dir }} SYMFONY_DEPRECATIONS_HELPER: disabled DATABASE_HOST: postgres DATABASE_PORT: 5432 REDIS_HOST: valkey REDIS_PORT: 6379 run: > ./ci/skipOnExcluded.sh php vendor/bin/paratest tests/Functional --exclude-group NonThreadSafe services: postgres: # Docker Hub image image: postgres:16 # Provide the password for postgres env: POSTGRES_DB: mbin_test POSTGRES_USER: mbin POSTGRES_PASSWORD: ChangeThisPostgresPass # Set health checks to wait until postgres has started options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 valkey: # Docker Hub image image: valkey/valkey # Set health checks to wait until redis has started options: >- --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 audit-check: runs-on: ubuntu-latest container: image: ghcr.io/mbinorg/mbin-pipeline-image:latest continue-on-error: true steps: - uses: actions/checkout@v4 - name: Add GITHUB_WORKSPACE as a safe directory run: git config --global --add safe.directory $GITHUB_WORKSPACE - name: Get Composer Cache Directory id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache vendor directory uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-composer- - run: cp .env.example .env - name: Composer install run: composer install --no-scripts --no-progress - name: Run Npm audit run: npm audit --omit=dev - name: Run Composer audit run: composer audit --no-dev --abandoned=ignore fixer-dry-run: runs-on: ubuntu-latest container: image: ghcr.io/mbinorg/mbin-pipeline-image:latest steps: - uses: actions/checkout@v4 - name: Add GITHUB_WORKSPACE as a safe directory run: git config --global --add safe.directory $GITHUB_WORKSPACE - name: Get Composer Cache Directory id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-tools-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-composer-tools- - name: Composer tools install run: composer -d tools install --no-scripts --no-progress - name: PHP CS Fixer dry-run with diff run: > tools/vendor/bin/php-cs-fixer fix --dry-run --diff --show-progress=none - name: PHP CS Fixer to PR Annotations run: > tools/vendor/bin/php-cs-fixer fix --dry-run --format=checkstyle --show-progress=none | cs2pr twig-lint: runs-on: ubuntu-latest container: image: ghcr.io/mbinorg/mbin-pipeline-image:latest steps: - uses: actions/checkout@v4 - name: Add GITHUB_WORKSPACE as a safe directory run: git config --global --add safe.directory $GITHUB_WORKSPACE - name: Get Composer Cache Directory id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-tools-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-composer-tools- - run: cp .env.example .env - name: Composer tools install run: composer install --no-scripts --no-progress - name: Twig linter run: php bin/console lint:twig templates/ frontend-lint: runs-on: ubuntu-latest container: image: ghcr.io/mbinorg/mbin-pipeline-image:latest steps: - uses: actions/checkout@v4 - name: Add GITHUB_WORKSPACE as a safe directory run: git config --global --add safe.directory $GITHUB_WORKSPACE - name: Get Composer Cache Directory id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Get NPM cache directory path id: npm-cache-dir-path run: echo "dir=$(npm get cache)" >> $GITHUB_OUTPUT - uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('*/composer.lock') }} restore-keys: ${{ runner.os }}-composer- - uses: actions/cache@v4 id: npm-cache with: path: ${{ steps.npm-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-npm-${{ hashFiles('*/package-lock.json') }} restore-keys: ${{ runner.os }}-npm- - name: Composer install run: composer install --no-scripts --no-progress - run: cp .env.example .env - name: NPM install run: npm ci - name: eslint run: npm run lint build-and-publish-docker-image: runs-on: ubuntu-latest # Let's only run this on branches and tagged releases only # Because the Docker build takes quite some time. if: github.event_name != 'pull_request' permissions: contents: write packages: write steps: - uses: actions/checkout@v4 - name: Login to ghcr if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker meta data id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/mbinorg/mbin - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . file: ./docker/Dockerfile push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} # We will also use this job to dispatch an event to the mbin-docs repository to trigger documentation build and publish - name: Trigger mbin-docs workflow dispatch if: github.event_name != 'pull_request' uses: peter-evans/repository-dispatch@v4 with: token: ${{ secrets.MBIN_ACCESS_TOKEN }} repository: MbinOrg/mbin-docs event-type: update-docs ================================================ FILE: .github/workflows/build-and-publish-pipeline-image.yaml ================================================ name: Build and publish Mbin GitHub pipeline image # Trigger either manually or when ci/Dockerfile changes (on the main branch) on: push: branches: ['main'] paths: - 'ci/Dockerfile' workflow_dispatch: jobs: build-and-publish-docker-image: runs-on: ubuntu-latest permissions: contents: write packages: write steps: - name: Checkout code uses: actions/checkout@v4 with: ref: main - name: Login to ghcr uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build image working-directory: ./ci run: | docker build -t ghcr.io/mbinorg/mbin-pipeline-image:latest . - name: Publish run: | docker push ghcr.io/mbinorg/mbin-pipeline-image:latest ================================================ FILE: .github/workflows/build-pipeline-image.yaml ================================================ name: Build Mbin GitHub pipeline image # Only trigger on Pull requests when ci/Dockerfile is changed (do not push the image) on: pull_request: branches: - main paths: - 'ci/Dockerfile' jobs: build-docker-image: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Build test image working-directory: ./ci run: | docker build . ================================================ FILE: .github/workflows/contrib.yaml ================================================ name: Contributor Workflow on: push: branches: - main jobs: contrib-readme: runs-on: ubuntu-latest name: Update contrib in README permissions: contents: write pull-requests: write steps: - name: Contribute List uses: akhilmhdh/contributors-readme-action@v2.3.11 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/psalm.yml ================================================ name: Psalm Security Scan on: push: branches: ["main"] pull_request: # The branches below must be a subset of the branches above branches: ["main"] schedule: - cron: "25 9 * * 0" permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} jobs: php-security-scan: runs-on: ubuntu-latest permissions: contents: read security-events: write # for github/codeql-action/upload-sarif to upload SARIF results actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status steps: - name: Checkout code uses: actions/checkout@v4 - name: Psalm Security Scan by Mbin uses: docker://ghcr.io/mbinorg/psalm-security-scan - name: Import Security Analysis results into GitHub Security Code Scanning uses: github/codeql-action/upload-sarif@v2 with: sarif_file: results.sarif ================================================ FILE: .github/workflows/stale.yml ================================================ name: "Close stale issues and PRs" on: schedule: - cron: "46 1 * * *" jobs: stale: permissions: issues: write pull-requests: write runs-on: ubuntu-latest steps: - uses: actions/stale@v9 with: stale-issue-message: "This issue is stale because it has been open a year with no activity." stale-pr-message: "This PR is stale because it has been open 40 days with no activity." close-issue-message: "This issue was closed because it has been stalled for 6 days with no activity." exempt-issue-labels: "high priority" days-before-issue-stale: 365 days-before-pr-stale: 40 days-before-issue-close: -1 days-before-pr-close: -1 ================================================ FILE: .gitignore ================================================ # IDEA/PhpStorm *.iml .idea/ .DS_Store supervisord.log supervisord.pid reports/ .php-cs-fixer.cache tools/vendor/ # VSCode .vscode/ .vs/ *.session.sql # Keys *.pem # Mbin specific .env /public/media/* /public/media yarn.lock /metal/ /tests/assets/copy # autogenerated files /public/.rnd /config/reference.php # Docker specific /storage/ /compose.override.yaml ###> symfony/framework-bundle ### /.env.local /.env.local.php /.env.*.local /config/secrets/prod/prod.decrypt.private.php /public/bundles/ /public/cache/ /var/ /vendor/ /cache/ ###< symfony/framework-bundle ### ###> symfony/phpunit-bridge ### .phpunit .phpunit.result.cache /phpunit.xml .phpunit.cache/ ###< symfony/phpunit-bridge ### ###> symfony/webpack-encore-bundle ### /node_modules/ /public/build/ npm-debug.log yarn-error.log ###< symfony/webpack-encore-bundle ### ###> liip/imagine-bundle ### /public/media/cache/ ###< liip/imagine-bundle ### ###> league/oauth2-server-bundle ### /config/jwt/*.pem ###< league/oauth2-server-bundle ### ###> phpunit/phpunit ### /phpunit.xml .phpunit.result.cache clover.xml /coverage /.phpunit.cache/ ###< phpunit/phpunit ### ###> phpstan/phpstan ### phpstan.neon ###< phpstan/phpstan ### ================================================ FILE: .php-cs-fixer.dist.php ================================================ in(__DIR__) ->exclude([ 'var', 'node_modules', 'vendor', 'docker', ]) ; return (new PhpCsFixer\Config()) ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) ->setRules([ '@Symfony' => true, # defined as "risky" as they could break code. Since our codebase is passing that's fine 'declare_strict_types' => true, 'strict_comparison' => true, 'native_function_invocation' => true, 'phpdoc_to_comment' => [ 'ignored_tags' => ['var'] ] ]) ->setRiskyAllowed(true) ->setFinder($finder) ; ================================================ FILE: C4.md ================================================ # Collective Code Construction Contract (C4) - Mbin - Status: final - Editor: Melroy van den Berg (melroy at melroy dot org) The Collective Code Construction Contract (C4) is an evolution of the github.com Fork + Pull Model, aimed at providing an optimal collaboration model for free software projects. This is _our_ Mbin revision of the upstream C4 specification, built on the lessons learned from the experience of many other projects and the original C4 specification itself. ## License Copyright (c) 2009-2016 Pieter Hintjens. Copyright (c) 2016-2018 The ZeroMQ developers. Copyright (c) 2023-2024 Melroy van den Berg & Mbin developers. This Specification is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. This Specification 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 General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, see [http://www.gnu.org/licenses](http://www.gnu.org/licenses). ## Abstract C4 provides a standard process for contributing, evaluating and discussing improvements on software projects. It defines specific technical requirements for projects like a style guide, unit tests, `git` and similar platforms. It also establishes different personas for projects, with clear and distinct duties. C4 specifies a process for documenting and discussing issues including seeking consensus and clear descriptions, use of “pull requests” and systematic reviews. ## Language The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in [RFC 2119](http://tools.ietf.org/html/rfc2119). ## 1. Goals C4 is meant to provide a reusable optimal collaboration model for open source software projects. It has these specific goals: 1. To maximize the scale and diversity of the community around a project, by reducing the friction for new Contributors and creating a scaled participation model with strong positive feedbacks; 2. To relieve dependencies on key individuals by separating different skill sets so that there is a larger pool of competence in any required domain; 3. To allow the project to develop faster and more accurately, by increasing the diversity of the decision making process; 4. To support the natural life cycle of project versions from experimental through to stable, by allowing safe experimentation, rapid failure, and isolation of stable code; 5. To reduce the internal complexity of project repositories, thus making it easier for Contributors to participate and reducing the scope for error; 6. To enforce collective ownership of the project, which increases economic incentive to Contributors and reduces the risk of hijack by hostile entities. ## 2. Design ### 2.1. Preliminaries 1. The project SHALL use the git distributed revision control system. 2. The project SHALL be hosted on github.com or equivalent, herein called the “Platform”. 3. The project SHALL use the Platform issue tracker. 4. The project SHOULD have clearly documented guidelines for code style. 5. A code change is refer to as a “patch” or “PR” on the Platform. 6. A “Contributor” is a person who wishes to provide a patch/PR, being a set of commits that solve some clearly identified problem. 7. A “Maintainer” is a person who merges patches/PRs to the project. Maintainers can also be developers / contributors at the same time. 8. Maintainers are owners of the project. There is no single “founder” or “creator” of the project. 9. Contributors SHALL NOT have commit access to the repository unless they are also Maintainers. 10. Maintainers SHALL have commit access to the repository. 11. Administrators SHALL have administration rights on the Platform. 12. Everyone, without distinction or discrimination, SHALL have an equal right to become a Contributor under the terms of this contract. ### 2.2. Licensing and Ownership 1. The project SHALL use the share-alike license: [AGPL](https://github.com/MbinOrg/mbin/blob/main/LICENSE). 2. All contributions (patches/PRs) to the project source code SHALL use the same license as the project. 3. All patches / PRs are owned by their authors. There SHALL NOT be any copyright assignment process. ### 2.3. Patch / PR Requirements 1. A patch / PR SHOULD be a minimal and accurate answer to exactly one identified and agreed problem. 2. A patch / PR MUST adhere to the code style guidelines of the project if these are defined. 3. A patch / PR MUST adhere to the “Evolution of Public Contracts” guidelines defined below. 4. A patch / PR SHALL NOT include non-trivial code from other projects unless the Contributor is the original author of that code. 5. A patch / PR SHALL NOT include libraries that are incompliant with the project license. 6. A patch / PR MUST compile cleanly and pass project self-tests (for example unit tests or linting) before a Maintainer can merge it. Also known as the “All-green policy”. 7. A commit message MUST consist of a single short (less than 100 characters) line stating the problem and/or solution that is being solved. 8. A commit message MAY be prefixed with a addenum “FIX:”, “FEAT:”, “DOCS:”, “TEST:”, “REFACTOR:” or "IMPROVEMENT:" to indicate the type of commit. Also known as “semantic commit messages”. 9. A commit type MAY be part of the PR title as well however using Labels on the Platform PR is usually preferred way of classifying the type of the Patch / PR. 10. A “Correct Patch / PR” is one that satisfies the above requirements. ### 2.4. Development Process 1. Change on the project SHALL be governed by the pattern of accurately identifying problems and applying minimal, accurate solutions to these problems. 2. To request changes, a user SHOULD log an issue on the project Platform issue tracker. 3. The user or Contributor SHOULD write the issue by describing the problem they face or observe. 4. The user or Contributor SHOULD seek consensus on the accuracy of their observation, and the value of solving the problem. 5. Users SHALL NOT log feature requests, ideas, suggestions, or any solutions to problems that are not explicitly documented and provable. 6. Thus, the release history of the project SHALL be a list of meaningful issues logged and solved. 7. To work on an issue, a Contributor SHOULD fork the project repository and then work on their forked repository. Unless the Contributor is also a Maintainer then a fork is NOT required, creating a new git branch SHOULD be sufficient. 8. To submit a patch, a Contributor SHALL create a Platform pull request back to the project. 9. Maintainers or Contributors SHOULD NOT directly push changes to the default branch (main), instead they SHOULD use the Platform Pull requests functionality. (See also branch protection rules of the Platform) 10. Contributors or Maintainers SHALL mark their PRs as “Draft” on the Platform, whenever the patch/PR is not yet ready for review / not finished. 11. If the Platform implements pull requests as issues, a Contributor MAY directly send a pull request without logging a separate issue. 12. To discuss a patch (PR), people SHOULD comment on the Platform pull request, on the commit, or on [Matrix Space (chat)](https://matrix.to/#/#mbin:melroy.org). We have various Matrix Rooms (also a dedicated [Matrix room for Pull Requests/Reviews](https://matrix.to/#/#mbin-pr:melroy.org)). 13. Contributors MAY want to discuss very large / complex changes (PRs) in the [Matrix Space](https://matrix.to/#/#mbin:melroy.org) first, since the effort might be all for nothing if the patch is rejected by the Maintainers in advance. 14. To request changes, accept or reject a patch / PR, a Maintainer SHALL use the Platform interface. 15. Maintainers SHOULD NOT merge patches (PRs), even their own, unless there is at least one (1) other Maintainer approval. Or in exceptional cases, such as non-responsiveness from other Maintainers for an extended period (more than 3-4 days), and the patch / PR has a high criticality level and cannot be waited on for more than 4 days before being merged. 16. Maintainers SHALL merge their own patches (PRs). Maintainers SHALL NOT merge patches from other Maintainers without their consent. 17. Maintainers SHOULD merge patches (PRs) from other Contributors, since Contributors do NOT have the rights to merge Pull Requests. 18. Maintainers SHALL NOT make value judgments on correct patches (PRs). 19. Maintainers SHALL merge correct patches (PRs) from other Contributors rapidly. 20. Maintainers MAY merge incorrect patches (PRs) from other Contributors with the goals of (a) ending fruitless discussions, (b) capturing toxic patches (PRs) in the historical record, (c) engaging with the Contributor on improving their patch (PR) quality. 21. The user who created an issue SHOULD close the issue after checking the patch (PR) is successful. Using “Closing keywords” in the description with a reference to the issue on the Platform will close the issue automatically. For example: “Fixes #251”. 22. Any Contributor who has value judgments on a patch / PR SHOULD express these via their own patches (PRs). Ideally after the correct patch / PR has been merged, avoiding file conflicts. 23. Maintainers SHALL use the “Squash and merge” option on the Platform pull request interface to merge a patch (PR). 24. Stale Platform Action is used to automatically mark an issue or a PR as “stale” and close the issue over time. PRs will NOT be closed automatically. ### 2.5. Branches and Releases 1. The project SHALL have one branch (“main”) that always holds the latest in-progress version and SHOULD always build. 2. The project MAY use topic / feature branches for new functionality. 3. To make a stable release a Maintainer SHALL tag the repository. Stable releases SHALL always be released from the repository main. 4. A Maintainer SHOULD create a release from the Platform Release page. The release description SHOULD contain our template table (“DB migrations”, “Cache clearning”, etc.) as well as releases notes (changes made in the release) in all cases. ### 2.6. Evolution of Public Contracts 1. All Public Contracts (APIs or protocols and their behaviour and side effects) SHALL be documented. 2. All Public Contracts SHOULD have space for extensibility and experimentation. 3. A patch (PR) that modifies a stable Public Contract SHOULD not break existing applications unless there is overriding consensus on the value of doing this. 4. A patch (PR) that introduces new features SHOULD do so using new names (a new contract). 5. New contracts SHOULD be marked as “draft” until they are stable and used by real users. 6. Old contracts SHOULD be deprecated in a systematic fashion by marking them as “deprecated” and replacing them with new contracts as needed. 7. When sufficient time has passed, old deprecated contracts SHOULD be removed. 8. Old names SHALL NOT be reused by new contracts. 9. A new contract marked as “draft” MUST NOT be changed to “stable” until all the following conditions are met: 1. Documentation has been written and is as comprehensive as that of comparable contracts. 2. Self-tests exercising the functionality are passing. 3. No changes in the contract have happened for at least one public release. 4. No changes in the contract have happened for at least 6 months. 5. No veto from the Contributor(s) of the new contract and its implementation on the change of status. 10. A new contract marked as “draft” SHOULD be changed to “stable” when the above conditions are met. 11. The “draft” to “stable” transition status for new contracts SHOULD be tracked using the Platform issue tracker. ### 2.7. Project Administration 1. The project's existing Maintainers SHALL act as Administrators to manage the set of project Maintainers. 2. The Administrators SHALL ensure their own succession over time by promoting the most effective Maintainers. 3. A new Contributor who makes correct patches (PRs), who clearly understands the project goals. After a discussion with existing Maintainers whether we SHOULD be invite a new Contributor, the new Contributor SHOULD be invited to become a Maintainer. But only after the new Contributor has demonstrated the above for a period of time (multiple correct PRs and more than 2-3 months). 4. Administrators MAY remove Maintainers that are long inactive (~1-2 years). Mainly due to security reasons. The Maintainer can always return back, if the person wants to become Maintainer again. 5. Administrators SHOULD remove Maintainers who repeatedly fail to apply this process accurately. 6. Administrators SHOULD block or ban “bad actors” who cause stress and pain to others in the project. This should be done after public discussion, with a chance for all parties to speak. A bad actor is someone who repeatedly ignores the rules and culture of the project, who is needlessly argumentative or hostile, or who is offensive, and who is unable to self-correct their behavior when asked to do so by others. If the majority of the currently active Maintainers agrees (or neutral) on the removal of the “bad actor” (after giving the “bad actor” time to self-improve), it can then be the final agreement on the decision to proceed with removal. ## Further Reading - [Original C4 rev. 3](https://rfc.zeromq.org/spec/44/) - C4 by Pieter Hintjens - [Argyris’ Models 1 and 2](http://en.wikipedia.org/wiki/Chris_Argyris) - the goals of C4 are consistent with Argyris’ Model 2. - [Toyota Kata](http://en.wikipedia.org/wiki/Toyota_Kata) - covering the Improvement Kata (fixing problems one at a time) and the Coaching Kata (helping others to learn the Improvement Kata). ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Mbin For all the details about contributing [go to the following contributing page](docs/03-contributing). ================================================ FILE: LICENSE ================================================ 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. Copyright (C) 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 . ================================================ FILE: LICENSES/Zlib.txt ================================================ This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software. Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: 1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required. 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. 3. This notice may not be removed or altered from any source distribution. ================================================ FILE: README.md ================================================

Mbin logo

GitHub Actions Workflow Psalm Security Scan Translation status Matrix chat License

## Introduction Mbin is a decentralized content aggregator, voting, discussion, and microblogging platform running on the fediverse. It can communicate with various ActivityPub services, including but not limited to: Mastodon, Lemmy, Pixelfed, Pleroma, and PeerTube. Mbin is a fork and continuation of [/kbin](https://codeberg.org/Kbin/kbin-core), but community-focused. Feel free to chat on [Matrix](https://matrix.to/#/#mbin:melroy.org). Pull requests are always welcome. > [!Important] > Mbin is focused on what the community wants. Pull requests can be merged by any repo maintainer with merge rights in GitHub. Discussions take place on [Matrix](https://matrix.to/#/#mbin:melroy.org) then _consensus_ has to be reached by the community. Unique Features of Mbin for server owners & users alike: - Tons of **[GUI improvements](https://github.com/MbinOrg/mbin/pulls?q=is%3Apr+is%3Amerged+label%3Afrontend)** - A lot of **[enhancements](https://github.com/MbinOrg/mbin/pulls?q=is%3Apr+is%3Amerged+label%3Aenhancement)** - Various **[bug fixes](https://github.com/MbinOrg/mbin/pulls?q=is%3Apr+is%3Amerged+label%3Abug)** - Support of **all** ActivityPub Actor Types (including also "Service" account support; thus support for robot accounts) - **Up-to-date** PHP packages and **security/vulnerability** issues fixed - Support for `application/json` Accept request header on all ActivityPub end-points - Introducing a hosted documentation: [docs.joinmbin.org](https://docs.joinmbin.org) See also: [all merged PRs](https://github.com/MbinOrg/mbin/pulls?q=is%3Apr+is%3Amerged) or [our releases](https://github.com/MbinOrg/mbin/releases). For developers: - Improved [bare metal/VM guide](https://docs.joinmbin.org/admin/installation/bare_metal) and [Docker guide](https://docs.joinmbin.org/admin/installation/docker/) - [Improved Docker setup](https://github.com/MbinOrg/mbin/pulls?q=is%3Apr+is%3Amerged+label%3Adocker) - _Developer_ server explained (see [Development Server documentation here](https://docs.joinmbin.org/contributing/development_server) ) - GitHub Security advisories, vulnerability reporting, [Dependabot](https://github.com/features/security) and [Advanced code scanning](https://docs.github.com/en/code-security/code-scanning/introduction-to-code-scanning/about-code-scanning) enabled. And we run `composer audit`. - Improved **code documentation** - **Tight integration** with [Mbin Weblate project](https://hosted.weblate.org/engage/mbin/) for translations (Two way sync) - Last but not least, a **community-focus project embracing the [Collective Code Construction Contract](./C4.md)** (C4). No single maintainer. ## Instances - [List of instances](https://joinmbin.org/servers) - [Alternative list of instances at fedidb.org](https://fedidb.org/software/mbin) - [Alternative list of instances at fediverse.observer](https://mbin.fediverse.observer/list) ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=MbinOrg/mbin&type=Date)](https://star-history.com/#MbinOrg/mbin&Date) ## Contributing - [Official repository on GitHub](https://github.com/MbinOrg/mbin) - [Matrix Space for discussions](https://matrix.to/#/#mbin:melroy.org) - [Translations](https://hosted.weblate.org/engage/mbin/) - [Contribution guidelines](docs/03-contributing) - please read first, including before opening an issue! ## Magazines Unofficial magazines: - [@mbinmeta@gehirneimer.de](https://gehirneimer.de/m/mbinmeta) - [@updates@kbin.melroy.org](https://kbin.melroy.org/m/updates) - [@AskMbin@fedia.io](https://fedia.io/m/AskMbin) ## Contributors
ernestwisniewski
Ernest
melroy89
Melroy van den Berg
BentiGorlich
BentiGorlich
weblate
Weblate (bot)
e-five256
e-five
asdfzdfj
asdfzdfj
SzymonKaminski
SzymonKaminski
cooperaj
Adam Cooper
simonrcodrington
Simon Codrington
blued-gear
blued_gear
kkoyung
Kingsley Yung
TheVillageGuy
TheVillageGuy
danielpervan
Daniel Pervan
garrettw
Garrett W.
jwr1
John Wesley
Ahrotahn
Ahrotahn
GauthierPLM
Gauthier POGAM--LE MONTAGNER
CocoPoops
CocoPoops
thepaperpilot
Anthony Lawn
chall8908
Chris Hall
andrewmoise
andrewmoise
piotr-sikora-v
Piotr Sikora
ryanmonsen
ryanmonsen
drupol
Pol Dellaiera
MakaryGo
Makary
cavebob
cavebob
vpzomtrrfrt
vpzomtrrfrt
lilfade
Bryson
comradekingu
Allan Nordhøy
CSDUMMI
CSDUMMI
e-michalak
e-michalak
MHLoppy
Mark Heath
robertolopezlopez
Roberto López López
grahhnt
grahhnt
olorin99
olorin99
privacyguard
privacyguard
## Getting Started ### Documentation See [docs.joinmbin.org](https://docs.joinmbin.org) ### Requirements [See also Symfony requirements](https://symfony.com/doc/current/setup.html#technical-requirements) - PHP version: 8.2 or higher - GD or Imagemagick PHP extension - NGINX / Apache / Caddy - PostgreSQL - RabbitMQ - Valkey / KeyDB / Redis - Mercure (optional) ## Languages Following languages are currently supported/translated: - Bulgarian - Catalan - Chinese - Danish - Dutch - English - Esperanto - Filipino - French - Galician - German - Greek - Italian - Japanese - Polish - Portuguese - Portuguese (Brazil) - Russian - Spanish - Turkish - Ukrainian ## Credits - [grumpyDev](https://karab.in/u/grumpyDev): icons, kbin-theme - [Emma](https://codeberg.org/LItiGiousemMA/Postmill): Postmill - [Ernest](https://github.com/ernestwisniewski): Kbin ## License [AGPL-3.0 license](LICENSE) ================================================ FILE: UPGRADE.md ================================================ # Upgrade ## Bare Metal / VM Upgrade If you perform a mbin upgrade (eg. `git pull`), be aware to _always_ execute the following Bash script: ```bash ./bin/post-upgrade ``` And when needed also execute: `sudo redis-cli FLUSHDB` to get rid of Redis cache issues. And reload the PHP FPM service if you have OPCache enabled. ## Docker Upgrade > [!Note] > When you're using the [Docker v2 guide](docker/v2/), then the database migration is executed during the Docker container start-up. ```bash $ docker compose exec php bin/console cache:clear $ docker compose exec redis redis-cli > auth REDIS_PASSWORD > FLUSHDB ``` ================================================ FILE: assets/app.js ================================================ import './stimulus_bootstrap.js'; import './styles/app.scss'; import './utils/popover.js'; import '@github/markdown-toolbar-element'; import { Application } from '@hotwired/stimulus'; if ('serviceWorker' in navigator) { window.addEventListener('load', function() { navigator.serviceWorker.register('/sw.js'); }); } // start the Stimulus application Application.start(); ================================================ FILE: assets/controllers/autogrow_controller.js ================================================ import TextareaAutoGrow from 'stimulus-textarea-autogrow'; /* stimulusFetch: 'lazy' */ export default class extends TextareaAutoGrow { connect() { super.connect(); } } ================================================ FILE: assets/controllers/clipboard_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; /* stimulusFetch: 'lazy' */ export default class extends Controller { copy(event) { event.preventDefault(); const url = event.target.href; navigator.clipboard.writeText(url); } } ================================================ FILE: assets/controllers/collapsable_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; import debounce from '../utils/debounce'; // use some buffer-space so that the expand-button won't be included if just a couple of lines would be hidden const MAX_COLLAPSED_HEIGHT_REM = 25; const MAX_FULL_HEIGHT_REM = 28; /* stimulusFetch: 'lazy' */ export default class extends Controller { static targets = ['content', 'button']; maxCollapsedHeightPx = 0; maxFullHeightPx = 0; isActive = false; isExpanded = true; button = null; buttonIcon = null; connect() { const remConvert = parseFloat(getComputedStyle(document.documentElement).fontSize); this.maxCollapsedHeightPx = MAX_COLLAPSED_HEIGHT_REM * remConvert; this.maxFullHeightPx = MAX_FULL_HEIGHT_REM * remConvert; this.setup(); const observerDebounced = debounce(200, () => { this.setup(); }); const observer = new ResizeObserver(observerDebounced); observer.observe(this.contentTarget); } setup() { const activate = this.checkSize(); if (activate === this.isActive) { return; } if (activate) { this.setupButton(); this.setExpanded(false, true); } else { this.contentTarget.style.maxHeight = null; this.button.remove(); } this.isActive = activate; } checkSize() { const elem = this.contentTarget; return elem.scrollHeight - 30 > this.maxFullHeightPx || elem.scrollWidth > elem.clientWidth; } setupButton() { this.buttonIcon = document.createElement('i'); this.buttonIcon.classList.add('fa-solid', 'fa-angles-down'); this.button = document.createElement('div'); this.button.classList.add('more'); this.button.appendChild(this.buttonIcon); this.button.addEventListener('click', () => { this.setExpanded(!this.isExpanded, false); }); this.buttonTarget.appendChild(this.button); } setExpanded(expanded, skipEffects) { if (expanded) { this.contentTarget.style.maxHeight = null; this.buttonIcon.classList.remove('fa-angles-down'); this.buttonIcon.classList.add('fa-angles-up'); } else { this.contentTarget.style.maxHeight = `${MAX_COLLAPSED_HEIGHT_REM}rem`; this.buttonIcon.classList.remove('fa-angles-up'); this.buttonIcon.classList.add('fa-angles-down'); if (!skipEffects) { this.contentTarget.scrollIntoView(); } } this.isExpanded = expanded; } } ================================================ FILE: assets/controllers/comment_collapse_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; import { getLevel } from '../utils/mbin'; const COMMENT_ELEMENT_TAG = 'BLOCKQUOTE'; const COLLAPSIBLE_CLASS = 'collapsible'; const COLLAPSED_CLASS = 'collapsed'; const HIDDEN_CLASS = 'hidden'; /* stimulusFetch: 'lazy' */ export default class extends Controller { static values = { depth: Number, hiddenBy: Number, }; static targets = ['counter']; connect() { // derive depth value if it doesn't exist // or when attached depth is 1 but css depth says otherwise (trying to handle dynamic list) const cssLevel = getLevel(this.element); if (!this.hasDepthValue || (1 === this.depthValue && cssLevel > this.depthValue)) { this.depthValue = cssLevel; } this.element.classList.add(COLLAPSIBLE_CLASS); this.element.collapse = this; } // main function, use this in action toggleCollapse(event) { event.preventDefault(); for ( var nextSibling = this.element.nextElementSibling, collapsed = 0; nextSibling && COMMENT_ELEMENT_TAG === nextSibling.tagName; nextSibling = nextSibling.nextElementSibling ) { const siblingDepth = nextSibling.dataset.commentCollapseDepthValue; if (!siblingDepth || siblingDepth <= this.depthValue) { break; } this.toggleHideSibling(nextSibling, this.depthValue); collapsed += 1; } this.toggleCollapseSelf(); if (0 < collapsed) { this.updateCounter(collapsed); } } // signals sibling comment element to hide itself toggleHideSibling(element, collapserDepth) { if (!element.collapse.hasHiddenByValue) { element.collapse.hiddenByValue = collapserDepth; } else if (collapserDepth === element.collapse.hiddenByValue) { element.collapse.hiddenByValue = undefined; } } // put itself into collapsed state toggleCollapseSelf() { this.element.classList.toggle(COLLAPSED_CLASS); } updateCounter(count) { if (!this.hasCounterTarget) { return; } if (this.element.classList.contains(COLLAPSED_CLASS)) { this.counterTarget.innerText = `(${count})`; } else { this.counterTarget.innerText = ''; } } // using value changed callback to enforce proper state appearance // existence of hidden-by value means this comment is in hidden state // (basically display: none) hiddenByValueChanged() { if (this.hasHiddenByValue) { this.element.classList.add(HIDDEN_CLASS); } else { this.element.classList.remove(HIDDEN_CLASS); } } } ================================================ FILE: assets/controllers/confirmation_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; /* stimulusFetch: 'lazy' */ export default class extends Controller { ask(event) { if (!window.confirm(event.params.message)) { event.preventDefault(); event.stopImmediatePropagation(); } } } ================================================ FILE: assets/controllers/entry_link_create_controller.js ================================================ import { ApplicationController, useThrottle } from 'stimulus-use'; import { fetch, ok } from '../utils/http'; import router from '../utils/routing'; /* stimulusFetch: 'lazy' */ export default class extends ApplicationController { static throttles = ['fetchLink']; static targets = ['title', 'description', 'url', 'loader']; static values = { loading: Boolean, }; timeoutId = null; connect() { useThrottle(this, { wait: 1000, }); const params = new URLSearchParams(window.location.search); const url = params.get('url'); if (url) { this.urlTarget.value = url; this.urlTarget.dispatchEvent(new Event('input')); } } fetchLink(event) { if (!event.target.value) { return; } if (this.timeoutId) { window.clearTimeout(this.timeoutId); this.timeoutId = null; } this.timeoutId = window.setTimeout(() => { this.loadingValue = true; this.fetchTitleAndDescription(event) .then(() => { this.loadingValue = false; this.timeoutId = null; }) .catch(() => { this.loadingValue = false; this.timeoutId = null; }); }, 1000); } loadingValueChanged(val) { this.titleTarget.disabled = val; this.descriptionTarget.disabled = val; if (val) { this.loaderTarget.classList.remove('hide'); } else { this.loaderTarget.classList.add('hide'); } } async fetchTitleAndDescription(event) { if (this.titleTarget.value && false === confirm('Are you sure you want to fetch the title and description? This will overwrite the current values.')) { return; } const url = router().generate('ajax_fetch_title'); let response = await fetch(url, { method: 'POST', body: JSON.stringify({ 'url': event.target.value, }), }); response = await ok(response); response = await response.json(); this.titleTarget.value = response.title; this.descriptionTarget.value = response.description; // required for input length indicator this.titleTarget.dispatchEvent(new Event('input')); this.descriptionTarget.dispatchEvent(new Event('input')); } } ================================================ FILE: assets/controllers/form_collection_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; /* stimulusFetch: 'lazy' */ export default class extends Controller { static targets = ['collectionContainer']; static values = { index : Number, prototype: String, }; addCollectionElement() { const item = document.createElement('div'); item.innerHTML = this.prototypeValue.replace(/__name__/g, this.indexValue); this.collectionContainerTarget.appendChild(item); this.indexValue++; } } ================================================ FILE: assets/controllers/html_refresh_controller.js ================================================ import { fetch, ok } from '../utils/http'; import { Controller } from '@hotwired/stimulus'; /* stimulusFetch: 'lazy' */ export default class extends Controller { /** * Calls the address attached to the nearest link node. Replaces the outer html of the nearest `cssclass` parameter * with the response from the link */ async linkCallback(event) { event.preventDefault(); const { cssclass: cssClass, refreshlink: refreshLink, refreshselector: refreshSelector } = event.params; const a = event.target.closest('a'); const subjectController = this.application.getControllerForElementAndIdentifier(this.element, 'subject'); try { if (subjectController) { subjectController.loadingValue = true; } let response = await fetch(a.href); response = await ok(response); response = await response.json(); event.target.closest(`.${cssClass}`).outerHTML = response.html; const refreshElement = this.element.querySelector(refreshSelector); if (!!refreshLink && '' !== refreshLink && !!refreshElement) { let response = await fetch(refreshLink); response = await ok(response); response = await response.json(); refreshElement.outerHTML = response.html; } } catch (e) { console.error(e); } finally { if (subjectController) { subjectController.loadingValue = false; } } } } ================================================ FILE: assets/controllers/image_upload_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; /* stimulusFetch: 'lazy' */ export default class extends Controller { connect() { const container = this.element; const input = container.querySelector('.image-input'); const preview = container.querySelector('.image-preview'); const clearButton = container.querySelector('.image-preview-clear'); input.addEventListener('change', function(e) { const file = e.target.files[0]; const reader = new FileReader(); reader.onload = function(e) { preview.src = e.target.result; preview.style.display = 'block'; clearButton.setAttribute('style', 'display: inline-block !important'); }; reader.readAsDataURL(file); }); } clearPreview() { const container = this.element; const input = container.querySelector('.image-input'); const preview = container.querySelector('.image-preview'); const clearButton = container.querySelector('.image-preview-clear'); input.value = ''; preview.src = '#'; preview.style.display = 'none'; clearButton.style.display = 'none'; } } ================================================ FILE: assets/controllers/infinite_scroll_controller.js ================================================ import { fetch, ok } from '../utils/http'; import { Controller } from '@hotwired/stimulus'; import { useIntersection } from 'stimulus-use'; /* stimulusFetch: 'lazy' */ export default class extends Controller { static targets = ['loader', 'pagination']; static values = { loading: Boolean, }; connect() { window.infiniteScrollUrls = []; useIntersection(this); } async appear() { if (true === this.loadingValue) { return; } try { this.loadingValue = true; const cursorPaginationElement = this.paginationTarget.getElementsByClassName('cursor-pagination'); let paginationElem = null; if (cursorPaginationElement.length) { const button = cursorPaginationElement[0].getElementsByClassName('next'); if (!button.length) { throw new Error('No more pages'); } paginationElem = button[0]; } else { paginationElem = this.paginationTarget.getElementsByClassName('pagination__item--current-page')[0].nextElementSibling; if (paginationElem.classList.contains('pagination__item--disabled')) { throw new Error('No more pages'); } } if (window.infiniteScrollUrls.includes(paginationElem.href)) { return; } window.infiniteScrollUrls.push(paginationElem.href); this.handleEntries(paginationElem.href); } catch { this.loadingValue = false; this.showPagination(); } } async handleEntries(url) { let response = await fetch(url, { method: 'GET' }); response = await ok(response); try { response = await response.json(); } catch { this.showPagination(); throw new Error('Invalid JSON response'); } const div = document.createElement('div'); div.innerHTML = response.html; const elements = div.querySelectorAll('[data-controller="subject-list"] > *'); for (let i = 0; i < elements.length; i++) { const element = elements[i]; if ((element.id && null === document.getElementById(element.id)) || element.classList.contains('user-box-inline') || element.classList.contains('magazine') || element.classList.contains('post-container')) { this.element.before(element); if (elements[i + 1] && elements[i + 1].classList.contains('post-comments')) { this.element.before(elements[i + 1]); } } } const scroll = div.querySelector('[data-controller="infinite-scroll"]'); if (scroll) { this.element.after(div.querySelector('[data-controller="infinite-scroll"]')); } this.element.remove(); this.application .getControllerForElementAndIdentifier(document.getElementById('main'), 'lightbox') .connect(); this.application .getControllerForElementAndIdentifier(document.getElementById('main'), 'timeago') .connect(); } loadingValueChanged(val) { this.loaderTarget.style.display = true === val ? 'block' : 'none'; } showPagination() { this.loadingValue = false; this.paginationTarget.classList.remove('visually-hidden'); } } ================================================ FILE: assets/controllers/input_length_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; /* stimulusFetch: 'lazy' */ export default class extends Controller { static values = { max: Number, }; /** DOM element that will hold the current/max text */ lengthIndicator; connect() { if (!this.hasMaxValue) { return; } //create a html element to display the current/max text const indicator = document.createElement('div'); indicator.classList.add('length-indicator'); this.element.insertAdjacentElement('afterend', indicator); this.lengthIndicator = indicator; this.updateDisplay(); } updateDisplay() { if (!this.lengthIndicator) { return; } //trim to max length if needed if (this.element.value.length >= this.maxValue) { this.element.value = this.element.value.substring(0, this.maxValue); } //display to user this.lengthIndicator.innerHTML = `${this.element.value.length}/${this.maxValue}`; } } ================================================ FILE: assets/controllers/lightbox_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; import GLightbox from 'glightbox'; /* stimulusFetch: 'lazy' */ export default class extends Controller { connect() { const params = { selector: '.thumb', openEffect: 'none', closeEffect: 'none', touchNavigation: true, }; GLightbox(params); } } ================================================ FILE: assets/controllers/markdown_toolbar_controller.js ================================================ // SPDX-FileCopyrightText: 2023-2024 /kbin & Mbin contributors // // SPDX-License-Identifier: AGPL-3.0-only import 'emoji-picker-element'; import { autoUpdate, computePosition, flip, limitShift, shift } from '@floating-ui/dom'; import { Controller } from '@hotwired/stimulus'; /* stimulusFetch: 'lazy' */ export default class extends Controller { addSpoiler(event) { event.preventDefault(); const input = document.getElementById(this.element.getAttribute('for')); let spoilerBody = 'spoiler body'; let contentAfterCursor; const start = input.selectionStart; const end = input.selectionEnd; const contentBeforeCursor = input.value.substring(0, start); if (start === end) { contentAfterCursor = input.value.substring(start); } else { contentAfterCursor = input.value.substring(end); spoilerBody = input.value.substring(start, end); } const spoiler = ` ::: spoiler spoiler-title ${spoilerBody} :::`; input.value = contentBeforeCursor + spoiler + contentAfterCursor; input.dispatchEvent(new Event('input')); const spoilerTitlePosition = contentBeforeCursor.length + '::: spoiler '.length + 1; input.setSelectionRange(spoilerTitlePosition, spoilerTitlePosition); input.focus(); } toggleEmojiPicker(event) { event.preventDefault(); const button = event.currentTarget; const input = document.getElementById(this.element.getAttribute('for')); const tooltip = document.querySelector('#tooltip'); const emojiPicker = document.querySelector('#emoji-picker'); // Remove any existing event listener if (this.emojiClickHandler) { emojiPicker.removeEventListener('emoji-click', this.emojiClickHandler); } if (!this.cleanupTooltip) { this.cleanupTooltip = autoUpdate(button, tooltip, () => { computePosition(button, tooltip, { placement: 'bottom', middleware: [flip(), shift({ limiter: limitShift() })], }).then(({ x, y }) => { Object.assign(tooltip.style, { left: `${x}px`, top: `${y}px`, }); }); }); } tooltip.classList.toggle('shown'); if (tooltip.classList.contains('shown')) { this.emojiClickHandler = (event) => { const emoji = event.detail.emoji.unicode; const start = input.selectionStart; const end = input.selectionEnd; input.value = input.value.slice(0, start) + emoji + input.value.slice(end); const emojiPosition = start + emoji.length; input.setSelectionRange(emojiPosition, emojiPosition); input.focus(); tooltip.classList.remove('shown'); this.cleanupTooltip(); emojiPicker.removeEventListener('emoji-click', this.emojiClickHandler); this.emojiClickHandler = null; }; emojiPicker.addEventListener('emoji-click', this.emojiClickHandler); } } } ================================================ FILE: assets/controllers/mbin_controller.js ================================================ import { ApplicationController, useDebounce } from 'stimulus-use'; /* stimulusFetch: 'lazy' */ export default class extends ApplicationController { static values = { loading: Boolean, }; static debounces = ['mention']; connect() { useDebounce(this, { wait: 800 }); this.handleDropdowns(); this.handleOptionsBarScroll(); } handleDropdowns() { this.element.querySelectorAll('.dropdown > a').forEach((dropdown) => { dropdown.addEventListener('click', (event) => { event.preventDefault(); }); }); } handleOptionsBarScroll() { const container = document.getElementById('options'); if (container) { const containerWidth = container.clientWidth; const area = container.querySelector('.options__main'); if (null === area) { return; } const areaWidth = area.scrollWidth; if (areaWidth > containerWidth && !area.nextElementSibling) { container.insertAdjacentHTML('beforeend', '
  • '); const scrollLeft = container.querySelector('.scroll-left'); const scrollRight = container.querySelector('.scroll-right'); const scrollArea = container.querySelector('.options__main'); scrollRight.addEventListener('click', () => { scrollArea.scrollLeft += 100; }); scrollLeft.addEventListener('click', () => { scrollArea.scrollLeft -= 100; }); } } } /** * Handles interaction with the mobile nav button, opening the sidebar */ handleNavToggleClick() { const sidebar = document.getElementById('sidebar'); sidebar.classList.toggle('open'); } changeLang(event) { window.location.href = '/settings/theme/mbin_lang/' + event.target.value; } } ================================================ FILE: assets/controllers/mentions_controller.js ================================================ import { fetch, ok } from '../utils/http'; import { Controller } from '@hotwired/stimulus'; import router from '../utils/routing'; /* stimulusFetch: 'lazy' */ export default class extends Controller { /** * Instance of setTimeout to be used for the display of the popup. This is cleared if the user * exits the target before the delay is reached */ userPopupTimeout; /** * Delay to wait until the popup is displayed */ userPopupTimeoutDelay = 1200; /** * Called on mouseover * @param {*} event * @returns */ async userPopup(event) { if (false === event.target.matches(':hover')) { return; } //create a setTimeout callback to be executed when the user has hovered over the target for a set amount of time this.userPopupTimeout = setTimeout(this.triggerUserPopup, this.userPopupTimeoutDelay, event); } /** * Called on mouseout, cancel the UI popup as the user has moved off the element */ async userPopupOut() { clearTimeout(this.userPopupTimeout); } /** * Called when the user popup should open */ async triggerUserPopup(event) { try { let param = event.params.username; if ('@' === param.charAt(0)) { param = param.substring(1); } const username = param.includes('@') ? `@${param}` : param; const url = router().generate('ajax_fetch_user_popup', { username: username }); this.loadingValue = true; let response = await fetch(url); response = await ok(response); response = await response.json(); document.querySelector('.popover').innerHTML = response.html; popover.trigger = event.target; popover.selectedTrigger = event.target; popover.element.dispatchEvent(new Event('openPopover')); } catch { } finally { this.loadingValue = false; } } async navigateUser(event) { event.preventDefault(); window.location = '/u/' + event.params.username; } async navigateMagazine(event) { event.preventDefault(); window.location = '/m/' + event.params.username; } } ================================================ FILE: assets/controllers/notifications_controller.js ================================================ import { ThrowResponseIfNotOk, fetch } from '../utils/http'; import { Controller } from '@hotwired/stimulus'; import Subscribe from '../utils/event-source'; /* stimulusFetch: 'lazy' */ export default class extends Controller { static values = { endpoint: String, user: String, magazine: String, entryId: String, postId: String, }; connect() { if (this.endpointValue) { this.connectEs(this.endpointValue, this.getTopics()); window.addEventListener('pagehide', this.closeEs); } if (this.userValue) { this.fetchAndSetNewNotificationAndMessageCount(); } } disconnect() { this.closeEs(); } connectEs(endpoint, topics) { this.closeEs(); const cb = (e) => { const data = JSON.parse(e.data); this.dispatch(data.op, { detail: data }); this.dispatch('Notification', { detail: data }); // if (data.op.includes('Create')) { // self.dispatch('CreatedNotification', {detail: data}); // } // if (data.op === 'EntryCreatedNotification' || data.op === 'PostCreatedNotification') { // self.dispatch('MainSubjectCreatedNotification', {detail: data}); // } // }; const eventSource = Subscribe(endpoint, topics, cb); if (eventSource) { window.es = eventSource; // firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1803431 if (navigator.userAgent.toLowerCase().includes('firefox')) { const resubscribe = () => { window.es.close(); setTimeout(() => { const eventSource = Subscribe(endpoint, topics, cb); if (eventSource) { window.es = eventSource; window.es.onerror = resubscribe; } }, 10000); }; window.es.onerror = resubscribe; } } } closeEs() { if (window.es instanceof EventSource) { window.es.close(); } } getTopics() { let pub = true; const topics = [ 'count', ]; if (this.userValue) { topics.push(`/api/users/${this.userValue}`); pub = true; } if (this.magazineValue) { topics.push(`/api/magazines/${this.magazineValue}`); pub = false; } if (this.entryIdValue) { topics.push(`/api/entries/${this.entryIdValue}`); pub = false; } if (this.postIdValue) { topics.push(`/api/posts/${this.postIdValue}`); pub = false; } if (pub) { topics.push('pub'); } return topics; } fetchAndSetNewNotificationAndMessageCount() { fetch('/ajax/fetch_user_notifications_count') .then(ThrowResponseIfNotOk) .then((data) => { if ('number' === typeof data.notifications) { this.setNotificationCount(data.notifications); } if ('number' === typeof data.messages) { this.setMessageCount(data.messages); } window.setTimeout(() => this.fetchAndSetNewNotificationAndMessageCount(), 30 * 1000); }); } /** * @param {number} count */ setNotificationCount(count) { const notificationHeader = self.window.document.getElementById('header-notification-count'); notificationHeader.style.display = count ? '' : 'none'; this.setCountInSubBadgeElement(notificationHeader, count); const notificationDropdown = self.window.document.getElementById('dropdown-notifications-count'); this.setCountInSubBadgeElement(notificationDropdown, count); } /** * @param {number} count */ setMessageCount(count) { const messagesHeader = self.window.document.getElementById('header-messages-count'); messagesHeader.style.display = count ? '' : 'none'; this.setCountInSubBadgeElement(messagesHeader, count); const messageDropdown = self.window.document.getElementById('dropdown-messages-count'); this.setCountInSubBadgeElement(messageDropdown, count); } /** * @param {Element} element * @param {number} count */ setCountInSubBadgeElement(element, count) { const badgeElements = element.getElementsByClassName('badge'); for (let i = 0; i < badgeElements.length; i++) { const el = badgeElements.item(i); el.textContent = count.toString(10); el.style.display = count ? '' : 'none'; } } } ================================================ FILE: assets/controllers/options_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; /* stimulusFetch: 'lazy' */ export default class extends Controller { static targets = ['settings', 'actions']; static values = { activeTab: String, }; connect() { const activeTabFragment = window.location.hash; if (!activeTabFragment) { return; } if ('#settings' !== activeTabFragment) { return; } this.actionsTarget.querySelector(`a[href="${activeTabFragment}"]`).classList.add('active'); this.activeTabValue = activeTabFragment.substring(1); } toggleTab(e) { const selectedTab = e.params.tab; this.actionsTarget.querySelectorAll('.active').forEach((el) => el.classList.remove('active')); if (selectedTab === this.activeTabValue) { this.activeTabValue = 'none'; } else { this.activeTabValue = selectedTab; e.currentTarget.classList.add('active'); } } activeTabValueChanged(selectedTab) { if ('none' === selectedTab) { this.settingsTarget.style.display = 'none'; return; } this[`${selectedTab}Target`].style.display = 'block'; // If you were to need to hide another tab: //const otherTab = selectedTab === 'settings' ? 'federation' : 'settings'; // //this[`${otherTab}Target`].style.display = 'none'; } closeMobileSidebar() { document.getElementById('sidebar').classList.remove('open'); } appearanceReloadRequired(event) { event.target.classList.add('spin'); window.location.reload(); } } ================================================ FILE: assets/controllers/password_preview_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; /* stimulusFetch: 'lazy' */ export default class extends Controller { previewButton; previewIcon; input; connect() { this.input = this.element.querySelector('[type="password"]'); //create the preview button this.setupPasswordPreviewButton(); } /** * Create the preview button and bind its event listener */ setupPasswordPreviewButton() { const previewButton = document.createElement('div'); previewButton.classList.add('password-preview-button', 'btn', 'btn__secondary'); this.previewButton = previewButton; const previewIcon = document.createElement('i'); previewIcon.classList.add('fas', 'fa-eye-slash'); this.previewIcon = previewIcon; previewButton.append(previewIcon); this.element.append(previewButton); //setup event listener previewButton.addEventListener('click', () => { this.onPreviewButtonClick(); }); } /** * On press, switch out the input 'type' to show or hide the password */ onPreviewButtonClick() { const inputType = this.input.getAttribute('type'); if ('password' === inputType) { this.input.setAttribute('type', 'text'); this.previewIcon.classList.remove('fa-eye-slash'); this.previewIcon.classList.add('fa-eye'); } else { this.input.setAttribute('type', 'password'); this.previewIcon.classList.remove('fa-eye'); this.previewIcon.classList.add('fa-eye-slash'); } } } ================================================ FILE: assets/controllers/post_controller.js ================================================ import { fetch, ok } from '../utils/http'; import { Controller } from '@hotwired/stimulus'; import getIntIdFromElement from '../utils/mbin'; import router from '../utils/routing'; /* stimulusFetch: 'lazy' */ export default class extends Controller { static targets = ['main', 'loader', 'expand', 'collapse', 'comments']; static values = { loading: Boolean, }; async expandComments(event) { event.preventDefault(); if (true === this.loadingValue) { return; } try { this.loadingValue = true; const url = router().generate('ajax_fetch_post_comments', { 'id': getIntIdFromElement(this.mainTarget) }); let response = await fetch(url, { method: 'GET' }); response = await ok(response); response = await response.json(); this.collapseComments(); this.commentsTarget.innerHTML = response.html; if (this.commentsTarget.children.length && this.commentsTarget.children[0].classList.contains('comments')) { const container = this.commentsTarget.children[0]; const parentDiv = container.parentNode; while (container.firstChild) { parentDiv.insertBefore(container.firstChild, container); } parentDiv.removeChild(container); } this.expandTarget.style.display = 'none'; this.collapseTarget.style.display = 'block'; this.commentsTarget.style.display = 'block'; this.application .getControllerForElementAndIdentifier(document.getElementById('main'), 'lightbox') .connect(); this.application .getControllerForElementAndIdentifier(document.getElementById('main'), 'timeago') .connect(); } catch (e) { console.error(e); } finally { this.loadingValue = false; } } collapseComments(event) { event?.preventDefault(); while (this.commentsTarget.firstChild) { this.commentsTarget.removeChild(this.commentsTarget.firstChild); } this.expandTarget.style.display = 'block'; this.collapseTarget.style.display = 'none'; this.commentsTarget.style.display = 'none'; } async expandVoters(event) { event?.preventDefault(); try { this.loadingValue = true; let response = await fetch(event.target.href, { method: 'GET' }); response = await ok(response); response = await response.json(); event.target.parentNode.innerHTML = response.html; } catch (e) { console.error(e); } finally { this.loadingValue = false; } } loadingValueChanged(val) { const subjectController = this.application.getControllerForElementAndIdentifier(this.mainTarget, 'subject'); if (null !== subjectController) { subjectController.loadingValue = val; } } } ================================================ FILE: assets/controllers/preview_controller.js ================================================ import { fetch, ok } from '../utils/http'; import { Controller } from '@hotwired/stimulus'; import router from '../utils/routing'; import { useThrottle } from 'stimulus-use'; /* stimulusFetch: 'lazy' */ export default class extends Controller { static values = { loading: Boolean, }; static targets = ['container']; static throttles = ['show']; /** memoization of fetched embed response */ fetchedResponse = {}; connect() { useThrottle(this, { wait: 1000 }); // workaround: give itself a container if it couldn't find one // I am not happy with this if (!this.hasContainerTarget && this.element.matches('span.preview')) { const container = this.createContainerTarget('preview-target'); this.element.insertAdjacentElement('beforeend', container); console.warn('unable to find container target, creating one for itself at', this.element.lastChild); } } createContainerTarget(extraClasses) { const classes = [].concat(extraClasses ?? []); const div = document.createElement('div'); div.classList.add(...classes, 'hidden'); div.dataset.previewTarget = 'container'; return div; } async retry(event) { event.preventDefault(); this.containerTarget.replaceChildren(); this.containerTarget.classList.add('hidden'); await this.show(event); } async fetchEmbed(url) { if (this.fetchedResponse[url]) { return this.fetchedResponse[url]; } let response = await fetch(router().generate('ajax_fetch_embed', { url }), { method: 'GET' }); response = await ok(response); response = await response.json(); this.fetchedResponse[url] = response; return response; } async show(event) { event.preventDefault(); if (this.containerTarget.hasChildNodes()) { this.containerTarget.replaceChildren(); this.containerTarget.classList.add('hidden'); return; } try { this.loadingValue = true; const response = await this.fetchEmbed(event.params.url); this.containerTarget.innerHTML = response.html; this.containerTarget.classList.remove('hidden'); if (event.params.ratio) { this.containerTarget .querySelector('.preview') .classList.add('ratio'); } this.loadScripts(response.html); } catch (e) { console.error('preview failed: ', e); const failedHtml = ``; this.containerTarget.innerHTML = failedHtml; this.containerTarget.classList.remove('hidden'); } finally { this.loadingValue = false; } } loadScripts(response) { const tmp = document.createElement('div'); tmp.innerHTML = response; const el = tmp.getElementsByTagName('script'); if (el.length) { const script = document.createElement('script'); script.setAttribute('src', el[0].getAttribute('src')); script.setAttribute('async', 'false'); // let exists = [...document.head.querySelectorAll('script')] // .filter(value => value.getAttribute('src') >= script.getAttribute('src')); // // if (exists.length) { // return; // } const head = document.head; head.insertBefore(script, head.firstElementChild); } } loadingValueChanged(val) { const subject = this.element.closest('.subject'); if (null !== subject) { const subjectController = this.application.getControllerForElementAndIdentifier(subject, 'subject'); subjectController.loadingValue = val; } } } ================================================ FILE: assets/controllers/push_controller.js ================================================ import { ThrowResponseIfNotOk, fetch } from '../utils/http'; import { Controller } from '@hotwired/stimulus'; export default class extends Controller { applicationServerPublicKey; connect() { this.applicationServerPublicKey = this.element.dataset.applicationServerPublicKey; window.navigator.serviceWorker.getRegistration() .then((registration) => { return registration?.pushManager.getSubscription(); }) .then((pushSubscription) => { this.updateButtonVisibility(pushSubscription); }) .catch((error) => { console.error('There was an error in the service worker registration method', error); this.element.style.display = 'none'; }); if (!('serviceWorker' in navigator)) { // Service Worker isn't supported on this browser, disable or hide UI. this.element.style.display = 'none'; } if (!('PushManager' in window)) { // Push isn't supported on this browser, disable or hide UI. this.element.style.display = 'none'; } } updateButtonVisibility(pushSubscription) { const registerBtn = document.getElementById('push-subscription-register-btn'); const unregisterBtn = document.getElementById('push-subscription-unregister-btn'); const testBtn = document.getElementById('push-subscription-test-btn'); if (pushSubscription) { registerBtn.style.display = 'none'; testBtn.style.display = ''; unregisterBtn.style.display = ''; } else { registerBtn.style.display = ''; testBtn.style.display = 'none'; unregisterBtn.style.display = 'none'; } } async retry() { } async show() { } askPermission() { return new Promise(function (resolve, reject) { const permissionResult = Notification.requestPermission(function (result) { resolve(result); }); if (permissionResult) { permissionResult.then(resolve, reject); } }) .then(function (permissionResult) { if ('granted' !== permissionResult) { throw new Error('We weren\'t granted permission.'); } }); } registerPush() { this.askPermission() .then(() => window.navigator.serviceWorker.getRegistration()) .then((registration) => { const subscribeOptions = { userVisibleOnly: true, applicationServerKey: this.applicationServerPublicKey, }; return registration.pushManager.subscribe(subscribeOptions); }) .then((pushSubscription) => { this.updateButtonVisibility(pushSubscription); const jsonSub = pushSubscription.toJSON(); const payload = { endpoint: pushSubscription.endpoint, deviceKey: this.getDeviceKey(), contentPublicKey: jsonSub.keys['p256dh'], serverKey: jsonSub.keys['auth'], }; return fetch('/ajax/register_push', { method: 'post', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' }, }); }) .then((response) => { if (!response.ok) { throw response; } return response.json(); }) .catch((error) => { console.error(error); this.unregisterPush(); }); } unregisterPush() { window.navigator.serviceWorker.getRegistration() .then((registration) => registration?.pushManager.getSubscription()) .then((pushSubscription) => pushSubscription.unsubscribe()) .then((successful) => { if (successful) { this.updateButtonVisibility(null); const payload = { deviceKey: this.getDeviceKey(), }; fetch('/ajax/unregister_push', { method: 'post', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' }, }) .then(ThrowResponseIfNotOk) .then(() => { }) .catch((error) => console.error(error)); } }) .catch((error) => { console.error('There was an error in the service worker registration method, for unsubscribing', error); }); } testPush() { fetch('/ajax/test_push', { method: 'post', body: JSON.stringify({ deviceKey: this.getDeviceKey() }), headers: { 'Content-Type': 'application/json' } }) .then((response) => { if (!response.ok) { throw response; } return response.json(); }) .then(() => { }) .catch((error) => console.error(error)); } storageKeyPushSubscriptionDevice = 'push_subscription_device_key'; getDeviceKey() { if (localStorage.getItem(this.storageKeyPushSubscriptionDevice)) { return localStorage.getItem(this.storageKeyPushSubscriptionDevice); } const subscriptionKey = crypto.randomUUID(); localStorage.setItem(this.storageKeyPushSubscriptionDevice, subscriptionKey); return subscriptionKey; } } ================================================ FILE: assets/controllers/rich_textarea_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; import { fetch } from '../utils/http'; /* stimulusFetch: 'lazy' */ export default class extends Controller { connect() { this.element.addEventListener('keydown', this.handleInput.bind(this)); this.element.addEventListener('blur', this.delayedClearAutocomplete.bind(this)); } // map: allowed enclosure key -> max repeats enclosureKeys = { '`': 1, '"': 1, "'": 1, '*': 2, '_': 2, '~': 2, }; emojiAutocompleteActive = false; mentionAutocompleteActive = false; abortController; requestActive = false; selectedSuggestionIndex = 0; handleInput (event) { const hasSelection = this.element.selectionStart !== this.element.selectionEnd; const key = event.key; if (event.ctrlKey && 'Enter' === key) { // ctrl + enter to submit form this.element.form.submit(); event.preventDefault(); } else if (event.ctrlKey && 'b' === key) { // ctrl + b to toggle bold this.toggleFormattingEnclosure('**'); event.preventDefault(); } else if (event.ctrlKey && 'i' === key) { // ctrl + i to toggle italic this.toggleFormattingEnclosure('_'); event.preventDefault(); } else if (hasSelection && key in this.enclosureKeys) { // toggle/cycle wrapping on selection texts this.toggleFormattingEnclosure(key, this.enclosureKeys[key] ?? 1); event.preventDefault(); } else if (!this.emojiAutocompleteActive && !this.mentionAutocompleteActive && ':' === key) { this.emojiAutocompleteActive = true; } else if (this.emojiAutocompleteActive && ('Escape' === key || ' ' === key)) { this.clearAutocomplete(); } else if (!this.emojiAutocompleteActive && !this.mentionAutocompleteActive && '@' === key) { this.mentionAutocompleteActive = true; } else if (this.mentionAutocompleteActive && ('Escape' === key || ' ' === key)) { this.clearAutocomplete(); } else if (this.mentionAutocompleteActive || this.emojiAutocompleteActive) { if ('ArrowDown' === key || 'ArrowUp' === key) { if ('ArrowDown' === key) { this.selectedSuggestionIndex = Math.min(this.getSuggestionElements().length-1, this.selectedSuggestionIndex + 1); } else if ('ArrowUp' === key) { this.selectedSuggestionIndex = Math.max(0, this.selectedSuggestionIndex - 1); } this.markSelectedSuggestion(); event.preventDefault(); } else if ('Enter' === key) { this.replaceAutocompleteSearchString(this.getSelectedSuggestionReplacement()); event.preventDefault(); } else { this.fetchAutocompleteResults(this.getAutocompleteSearchString(key)); } } } toggleFormattingEnclosure(encl, maxLength = 1) { const start = this.element.selectionStart, end = this.element.selectionEnd; const before = this.element.value.substring(0, start), inner = this.element.value.substring(start, end), after = this.element.value.substring(end); // TODO: find a way to do undo-aware text manipulations that isn't deprecated like execCommand? // it seems like specs never actually replaced it with anything unless i'm missing it // remove enclosure when it's at the max const finalEnclosure = encl.repeat(maxLength); if (before.endsWith(finalEnclosure) && after.startsWith(finalEnclosure)) { const outerStart = start - finalEnclosure.length, outerEnd = end + finalEnclosure.length; this.element.selectionStart = outerStart; this.element.selectionEnd = outerEnd; // no need for delete command as insertText should deletes selection by itself // ref: https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#inserttext document.execCommand('insertText', false, inner); this.element.selectionStart = start - finalEnclosure.length; this.element.selectionEnd = end - finalEnclosure.length; } else { // add a new enclosure document.execCommand('insertText', false, encl + inner + encl); this.element.selectionStart = start + encl.length; this.element.selectionEnd = end + encl.length; } } delayedClearAutocomplete() { window.setTimeout(() => this.clearAutocomplete(), 100); } clearAutocomplete() { this.selectedSuggestionIndex = 0; this.emojiAutocompleteActive = false; this.mentionAutocompleteActive = false; this.abortController.abort(); this.requestActive = false; document.getElementById('user-suggestions')?.remove(); document.getElementById('emoji-suggestions')?.remove(); } getAutocompleteSearchString(key) { const [wordStart, wordEnd] = this.getAutocompleteSearchStringStartAndEnd(); let val = this.element.value.substring(wordStart, wordEnd+1); if (1 === key.length) { val += key; } return val; } getAutocompleteSearchStringStartAndEnd() { const value = this.element.value; const selection = this.element.selectionStart-1; let cursor = selection; const breakCharacters = ' \n\t*#?!'; while (0 < cursor) { cursor--; if (breakCharacters.includes(value[cursor])) { cursor++; break; } } const wordStart = cursor; cursor = selection; while (cursor < value.length) { cursor++; if (breakCharacters.includes(value[cursor])) { cursor--; break; } } const wordEnd = cursor; return [wordStart, wordEnd]; } fetchAutocompleteResults(searchText) { if (this.requestActive) { this.abortController.abort(); } if (this.mentionAutocompleteActive) { this.abortController = new AbortController(); this.requestActive = true; fetch(`/ajax/fetch_users_suggestions/${searchText}`, { signal: this.abortController.signal }) .then((response) => response.json()) .then((data) => { this.fillSuggestions(data.html); this.requestActive = false; }) .catch(() => {}); } else if (this.emojiAutocompleteActive) { this.abortController = new AbortController(); this.requestActive = true; fetch(`/ajax/fetch_emoji_suggestions?query=${searchText}`, { signal: this.abortController.signal }) .then((response) => response.json()) .then((data) => { this.fillSuggestions(data.html); this.requestActive = false; }) .catch(() => {}); } } replaceAutocompleteSearchString(replaceText) { const [wordStart, wordEnd] = this.getAutocompleteSearchStringStartAndEnd(); this.element.selectionStart = wordStart; this.element.selectionEnd = wordEnd+1; document.execCommand('insertText', false, replaceText); this.clearAutocomplete(); const resultCursor = wordStart + replaceText.length; this.element.selectionStart = resultCursor; this.element.selectionEnd = resultCursor; } fillSuggestions (html) { const id = this.mentionAutocompleteActive ? 'user-suggestions' : 'emoji-suggestions'; let element = document.getElementById(id); if (element) { element.outerHTML = html; } else { element = this.element.insertAdjacentElement('afterend', document.createElement('div')); element.outerHTML = html; } for (const suggestion of this.getSuggestionElements()) { suggestion.onclick = (event) => { const value = event.target.getAttribute('data-replace') ?? event.target.outerText; this.element.focus(); this.replaceAutocompleteSearchString(value); }; } this.markSelectedSuggestion(); } markSelectedSuggestion() { let i = 0; for (const suggestion of this.getSuggestionElements()) { if (i === this.selectedSuggestionIndex) { suggestion.classList.add('selected'); } else { suggestion.classList.remove('selected'); } i++; } } getSelectedSuggestionReplacement() { let i = 0; for (const suggestion of this.getSuggestionElements()) { if (i === this.selectedSuggestionIndex) { suggestion.classList.add('selected'); return suggestion.getAttribute('data-replace') ?? suggestion.outerText; } i++; } return null; } getSuggestionElements() { const suggestions = document.getElementById(this.mentionAutocompleteActive ? 'user-suggestions' : 'emoji-suggestions'); return suggestions.querySelectorAll('.suggestion'); } } ================================================ FILE: assets/controllers/scroll_top_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { connect() { const self = this; window.onscroll = function () { self.scroll(); }; } scroll() { if ( 20 < document.body.scrollTop || 20 < document.documentElement.scrollTop ) { this.element.style.display = 'block'; } else { this.element.style.display = 'none'; } } increaseCounter() { const counter = this.element.querySelector('small'); counter.innerHTML = parseInt(counter.innerHTML) + 1; counter.classList.remove('hidden'); } scrollTop() { document.body.scrollTop = 0; document.documentElement.scrollTop = 0; } } ================================================ FILE: assets/controllers/selection_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; /* stimulusFetch: 'lazy' */ export default class extends Controller { changeLocation(event) { window.location = event.currentTarget.value; } } ================================================ FILE: assets/controllers/settings_row_enum_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { /** * Calls the action at the given path when the value changes * @param actionPath {string} - The path to the action to be called * @param reloadRequired {boolean} - Whether the page needs to be reloaded after the action is called */ change({ params: { actionPath, reloadRequired } }) { return fetch(actionPath).then(() => { if (reloadRequired) { document.querySelector('.settings-list').classList.add('reload-required'); } }); } } ================================================ FILE: assets/controllers/settings_row_switch_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { /** * Calls the action at the given path when the toggle is checked or unchecked * @param target {HTMLInputElement} - The checkbox element of the toggle that was clicked * @param truePath {string} - The path to the action to be called when the toggle is checked * @param falsePath {string} - The path to the action to be called when the toggle is unchecked * @param reloadRequired {boolean} - Whether the page needs to be reloaded after the action is called */ toggle({ target, params: { truePath, falsePath, reloadRequired } }) { const path = target.checked ? truePath : falsePath; return fetch(path).then(() => { if (reloadRequired) { document.querySelector('.settings-list').classList.add('reload-required'); } }); } } ================================================ FILE: assets/controllers/subject_controller.js ================================================ import { fetch, ok } from '../utils/http'; import getIntIdFromElement, { getDepth, getLevel, getTypeFromNotification } from '../utils/mbin'; import { Controller } from '@hotwired/stimulus'; import router from '../utils/routing'; import { useIntersection } from 'stimulus-use'; /* stimulusFetch: 'lazy' */ export default class extends Controller { static previewInit = false; static targets = ['loader', 'more', 'container', 'commentsCounter', 'favCounter', 'upvoteCounter', 'downvoteCounter']; static values = { loading: Boolean, isOnCombined: Boolean, }; static sendBtnLabel = null; connect() { this.wireMoreFocusClassAdjustment(); if (this.element.classList.contains('show-preview')) { useIntersection(this); } this.wireTouchEvent(); } async getForm(event) { event.preventDefault(); if ('' !== this.containerTarget.innerHTML.trim()) { if (false === confirm('Do you really want to leave?')) { return; } } try { this.loadingValue = true; let response = await fetch(event.target.href, { method: 'GET' }); response = await ok(response); response = await response.json(); this.containerTarget.style.display = 'block'; this.containerTarget.innerHTML = response.form; const textarea = this.containerTarget.querySelector('textarea'); if (textarea) { if ('' !== textarea.value) { let firstLineEnd = textarea.value.indexOf('\n'); if (-1 === firstLineEnd) { firstLineEnd = textarea.value.length; textarea.value = textarea.value.slice(0, firstLineEnd) + ' ' + textarea.value.slice(firstLineEnd); textarea.selectionStart = firstLineEnd + 1; textarea.selectionEnd = firstLineEnd + 1; } else { textarea.value = textarea.value.slice(0, firstLineEnd) + ' ' + textarea.value.slice(firstLineEnd); textarea.selectionStart = firstLineEnd + 1; textarea.selectionEnd = firstLineEnd + 1; } } textarea.focus(); } } catch { window.location.href = event.target.href; } finally { this.loadingValue = false; popover.togglePopover(false); } } async sendForm(event) { event.preventDefault(); const form = event.target.closest('form'); const url = form.action; try { this.loadingValue = true; self.sendBtnLabel = event.target.innerHTML; event.target.disabled = true; event.target.innerHTML = 'Sending...'; let response = await fetch(url, { method: 'POST', body: new FormData(form), }); response = await ok(response); response = await response.json(); if (response.form) { this.containerTarget.style.display = 'block'; this.containerTarget.innerHTML = response.form; } else if (form.classList.contains('replace')) { const div = document.createElement('div'); div.innerHTML = response.html; div.firstElementChild.className = this.element.className; this.element.innerHTML = div.firstElementChild.innerHTML; } else { const div = document.createElement('div'); div.innerHTML = response.html; const level = getLevel(this.element); const depth = getDepth(this.element); div.firstElementChild.classList.remove('comment-level--1'); div.firstElementChild.classList.add('comment-level--' + (10 <= level ? 10 : level + 1)); div.firstElementChild.dataset.commentCollapseDepthValue = depth + 1; if (this.element.nextElementSibling && this.element.nextElementSibling.classList.contains('comments')) { this.element.nextElementSibling.appendChild(div.firstElementChild); this.element.classList.add('mb-0'); } else { this.element.parentNode.insertBefore(div.firstElementChild, this.element.nextSibling); } this.containerTarget.style.display = 'none'; this.containerTarget.innerHTML = ''; } } catch (e) { console.error(e); // this.containerTarget.innerHTML = ''; } finally { this.application .getControllerForElementAndIdentifier(document.getElementById('main'), 'lightbox') .connect(); this.application .getControllerForElementAndIdentifier(document.getElementById('main'), 'timeago') .connect(); this.loadingValue = false; event.target.disabled = false; event.target.innerHTML = self.sendBtnLabel; } } async favourite(event) { event.preventDefault(); const form = event.target.closest('form'); try { this.loadingValue = true; let response = await fetch(form.action, { method: 'POST', body: new FormData(form), }); response = await ok(response); response = await response.json(); form.innerHTML = response.html; } catch { form.submit(); } finally { this.loadingValue = false; } } async vote(event) { event.preventDefault(); const form = event.target.closest('form'); try { this.loadingValue = true; let response = await fetch(form.action, { method: 'POST', body: new FormData(form), }); response = await ok(response); response = await response.json(); event.target.closest('.vote').outerHTML = response.html; } catch { form.submit(); } finally { this.loadingValue = false; } } loadingValueChanged(val) { const submitButton = this.containerTarget.querySelector('form button[type="submit"]'); if (true === val) { if (submitButton) { submitButton.disabled = true; } this.loaderTarget.style.display = 'block'; } else { if (submitButton) { submitButton.disabled = false; } this.loaderTarget.style.display = 'none'; } } async showModPanel(event) { event.preventDefault(); let container = this.element.querySelector('.moderate-inline'); if (null !== container) { // moderate panel was already added to this post, toggle // hidden on it to show/hide it and exit container.classList.toggle('hidden'); return; } container = document.createElement('div'); container.classList.add('moderate-inline'); this.element.insertAdjacentHTML('beforeend', container.outerHTML); try { this.loadingValue = true; let response = await fetch(event.target.href); response = await ok(response); response = await response.json(); this.element.querySelector('.moderate-inline').insertAdjacentHTML('afterbegin', response.html); } catch { window.location.href = event.target.href; } finally { this.loadingValue = false; } } notification(data) { if (data.detail.parentSubject && this.element.id === data.detail.parentSubject.htmlId) { if (data.detail.op.endsWith('CommentDeletedNotification') || data.detail.op.endsWith('CommentCreatedNotification')) { this.updateCommentCounter(data); } } if (this.element.id !== data.detail.htmlId) { return; } if (data.detail.op.endsWith('EditedNotification')) { this.refresh(data); return; } if (data.detail.op.endsWith('DeletedNotification')) { this.element.remove(); return; } if (data.detail.op.endsWith('Vote')) { this.updateVotes(data); return; } if (data.detail.op.endsWith('Favourite')) { this.updateFavourites(data); return; } } async refresh(data) { try { this.loadingValue = true; const url = router().generate(`ajax_fetch_${getTypeFromNotification(data)}`, { id: getIntIdFromElement(this.element) }); let response = await fetch(url); response = await ok(response); response = await response.json(); const div = document.createElement('div'); div.innerHTML = response.html; div.firstElementChild.className = this.element.className; this.element.outerHTML = div.firstElementChild.outerHTML; } catch { } finally { this.loadingValue = false; } } updateVotes(data) { this.upvoteCounterTarget.innerText = `(${data.detail.up})`; if (0 < data.detail.up) { this.upvoteCounterTarget.classList.remove('hidden'); } else { this.upvoteCounterTarget.classList.add('hidden'); } if (this.hasDownvoteCounterTarget) { this.downvoteCounterTarget.innerText = data.detail.down; } } updateFavourites(data) { if (this.hasFavCounterTarget) { this.favCounterTarget.innerText = data.detail.count; } } updateCommentCounter(data) { if (data.detail.op.endsWith('CommentCreatedNotification') && this.hasCommentsCounterTarget) { this.commentsCounterTarget.innerText = parseInt(this.commentsCounterTarget.innerText) + 1; } if (data.detail.op.endsWith('CommentDeletedNotification') && this.hasCommentsCounterTarget) { this.commentsCounterTarget.innerText = parseInt(this.commentsCounterTarget.innerText) - 1; } } async removeImage(event) { event.preventDefault(); try { this.loadingValue = true; let response = await fetch(event.target.parentNode.formAction, { method: 'POST' }); response = await ok(response); await response.json(); event.target.parentNode.previousElementSibling.remove(); event.target.parentNode.nextElementSibling.classList.remove('hidden'); event.target.parentNode.remove(); } catch { } finally { this.loadingValue = false; } } appear() { if (this.previewInit) { return; } const prev = this.element.querySelectorAll('.show-preview'); prev.forEach((el) => { el.click(); }); this.previewInit = true; } wireMoreFocusClassAdjustment() { const self = this; if (this.hasMoreTarget) { // Add z-5 (higher z-index with !important) to the element when more button is focused (eg. clicked) // Remove z-5 from other elements in the same parent this.moreTarget.addEventListener('focusin', () => { self.element.parentNode .querySelectorAll('.z-5') .forEach((el) => { el.classList.remove('z-5'); }); this.element.classList.add('z-5'); }); // During a mouse hover, remove z-5 from other elements in the same parent // and clear :focus-within from any focused element inside the same parent this.moreTarget.addEventListener('mouseenter', () => { // Remove z-5 from other elements in the same parent const parent = self.element.parentNode; parent .querySelectorAll('.z-5') .forEach((el) => { el.classList.remove('z-5'); }); // Clear keyboard/mouse focus from any element inside the same // parent so that :focus-within is removed from the old // element without assigning focus to the hovered one. const active = document.activeElement; if (active && parent.contains(active) && !self.moreTarget.contains(active)) { try { active.blur(); } catch { // ignore environments where blur may throw } } }); } } wireTouchEvent() { if (this.isOnCombinedValue) { this.wireTouchEventCombined(); } else { this.wireTouchEventRegular(); } } wireTouchEventRegular() { // if in a list and the click is made via touch, open the post if (!this.element.classList.contains('isSingle')) { this.element.querySelector('.content')?.addEventListener('click', (e) => { if (this.filterClickEvent(e)) { return; } if ('touch' === e.pointerType) { const link = this.element.querySelector('header a:not(.user-inline)'); if (link) { const href = link.getAttribute('href'); if (href) { document.location.href = href; } } } }); } } wireTouchEventCombined() { // if on Combined view, open the post via click on card this.element.addEventListener('click', (e) => { if (this.filterClickEvent(e)) { return; } const link = this.element.querySelector('footer span[data-subject-target="commentsCounter"]')?.parentElement; if (link) { let href = link.getAttribute('href'); href = href.substring(0, href.length - '#comments'.length); if (href) { document.location.href = href; } } else { const link = this.element.querySelector('footer span[data-subject-x="subjectLink"]')?.parentElement; if (link) { const href = link.getAttribute('href'); if (href) { document.location.href = href; } } } }); } filterClickEvent(e) { if (e.defaultPrevented) { return true; } const filteredElementTypes = [ 'a', 'button', 'select', 'option', 'input', 'textarea', 'details', 'summary', ]; for (const type of filteredElementTypes) { if (e.target.nodeName?.toLowerCase() === type || e.target.tagName?.toLowerCase() === type) { return true; } } // ignore click on images const figures = this.element.querySelectorAll('figure'); if ( figures.entries().some(([, elem]) => elem.contains(e.target)) ) { return true; } return false; } } ================================================ FILE: assets/controllers/subject_list_controller.js ================================================ import { fetch, ok } from '../utils/http'; import { getDepth, getLevel, getTypeFromNotification } from '../utils/mbin'; import { Controller } from '@hotwired/stimulus'; import router from '../utils/routing'; /* stimulusFetch: 'lazy' */ export default class extends Controller { addComment(data) { if (!document.getElementById(data.detail.parentSubject.htmlId)) { return; } this.addMainSubject(data); } async addMainSubject(data) { try { const url = router().generate(`ajax_fetch_${getTypeFromNotification(data)}`, { id: data.detail.id }); let response = await fetch(url); response = await ok(response); response = await response.json(); if (!data.detail.parent) { if (!document.getElementById(data.detail.htmlId)) { this.element.insertAdjacentHTML('afterbegin', response.html); } return; } const parent = document.getElementById(data.detail.parent.htmlId); if (parent) { const div = document.createElement('div'); div.innerHTML = response.html; const level = getLevel(parent); const depth = getDepth(parent); div.firstElementChild.classList.remove('comment-level--1'); div.firstElementChild.classList.add('comment-level--' + (10 <= level ? 10 : level + 1)); div.firstElementChild.dataset.commentCollapseDepthValue = depth + 1; let current = parent; while (current) { if (!current.nextElementSibling) { break; } if ('undefined' === current.nextElementSibling.dataset.subjectParentValue) { break; } if (current.nextElementSibling.dataset.subjectParentValue !== div.firstElementChild.dataset.subjectParentValue && getLevel(current.nextElementSibling) <= level) { break; } current = current.nextElementSibling; } if (!document.getElementById(div.firstElementChild.id)) { current.insertAdjacentElement('afterend', div.firstElementChild); } } } catch { } finally { this.application .getControllerForElementAndIdentifier(document.getElementById('main'), 'timeago') .connect(); } } async addCommentOverview(data) { try { const parent = document.getElementById(data.detail.parentSubject.htmlId); if (!parent) { return; } const url = router().generate(`ajax_fetch_${getTypeFromNotification(data)}`, { id: data.detail.id }); let response = await fetch(url); response = await ok(response); response = await response.json(); const div = document.createElement('div'); div.innerHTML = response.html; div.firstElementChild.classList.add('comment-level--2'); if (!parent.nextElementSibling || !parent.nextElementSibling.classList.contains('comments')) { const comments = document.createElement('div'); comments.classList.add('comments', 'post-comments', 'comments-tree'); parent.insertAdjacentElement('afterend', comments); } parent.classList.add('mb-0'); if (parent.nextElementSibling.querySelector('#' + data.detail.htmlId)) { return; } parent.nextElementSibling.appendChild(div.firstElementChild); } catch { } finally { this.application .getControllerForElementAndIdentifier(document.getElementById('main'), 'timeago') .connect(); } } increaseCounter() { this.application .getControllerForElementAndIdentifier(document.getElementById('scroll-top'), 'scroll-top') .increaseCounter(); } } ================================================ FILE: assets/controllers/subs_controller.js ================================================ import { fetch, ok } from '../utils/http'; import { Controller } from '@hotwired/stimulus'; /* stimulusFetch: 'lazy' */ export default class extends Controller { static values = { loading: Boolean, }; async send(event) { event.preventDefault(); const form = event.target.closest('form'); try { this.loadingValue = true; let response = await fetch(form.action, { method: 'POST', body: new FormData(form), }); response = await ok(response); response = await response.json(); this.element.outerHTML = response.html; } catch { form.submit(); } finally { this.loadingValue = false; } } } ================================================ FILE: assets/controllers/subs_panel_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; import router from '../utils/routing'; const KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR = 'kbin_subscriptions_in_separate_sidebar'; const KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE = 'kbin_subscriptions_sidebars_same_side'; /* stimulusFetch: 'lazy' */ export default class extends Controller { static values = { sidebarPosition: String, }; generateSettingsRoute(key, value) { return router().generate('theme_settings', { key, value }); } async reattach() { await window.fetch( this.generateSettingsRoute(KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR, 'false'), ); window.location.reload(); } async popLeft() { await window.fetch( this.generateSettingsRoute(KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR, 'true'), ); await window.fetch( this.generateSettingsRoute( KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE, ('left' === this.sidebarPositionValue ? 'true' : 'false'), ), ); window.location.reload(); } async popRight() { await window.fetch( this.generateSettingsRoute(KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR, 'true'), ); await window.fetch( this.generateSettingsRoute( KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE, ('left' !== this.sidebarPositionValue ? 'true' : 'false'), ), ); window.location.reload(); } } ================================================ FILE: assets/controllers/thumb_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; /* stimulusFetch: 'lazy' */ export default class extends Controller { /** * Called on mouseover * @param {*} event * @returns */ async adultImageHover(event) { if (false === event.target.matches(':hover')) { return; } event.target.style.filter = 'none'; } /** * Called on mouseout * @param {*} event */ async adultImageHoverOut(event) { event.target.style.filter = 'blur(8px)'; } } ================================================ FILE: assets/controllers/timeago_controller.js ================================================ import { Controller } from '@hotwired/stimulus'; /* eslint-disable camelcase -- zh_TW is a specific identifier */ // eslint-disable-next-line -- grouping timeago imports here is more readable than properly sorting import * as timeago from 'timeago.js'; import bg from 'timeago.js/lib/lang/bg'; import da from 'timeago.js/lib/lang/da'; import de from 'timeago.js/lib/lang/de'; import el from 'timeago.js/lib/lang/el'; import en from 'timeago.js/lib/lang/en_US'; import es from 'timeago.js/lib/lang/es'; import fr from 'timeago.js/lib/lang/fr'; import gl from 'timeago.js/lib/lang/gl'; import it from 'timeago.js/lib/lang/it'; import ja from 'timeago.js/lib/lang/ja'; import nl from 'timeago.js/lib/lang/nl'; import pl from 'timeago.js/lib/lang/pl'; import pt_BR from 'timeago.js/lib/lang/pt_BR'; import ru from 'timeago.js/lib/lang/ru'; import tr from 'timeago.js/lib/lang/tr'; import uk from 'timeago.js/lib/lang/uk'; import zh_TW from 'timeago.js/lib/lang/zh_TW'; /* stimulusFetch: 'lazy' */ export default class extends Controller { connect() { const elems = document.querySelectorAll('.timeago'); if (!elems.length) { return; } const lang = document.documentElement.lang; const languages = { bg, da, de, el, en, es, fr, gl, it, ja, nl, pl, pt_BR, ru, tr, uk, zh_TW }; if (languages[lang]) { timeago.register(lang, languages[lang]); timeago.render(elems, lang); } else { timeago.render(elems); } } } ================================================ FILE: assets/controllers.json ================================================ { "controllers": { "@symfony/ux-autocomplete": { "autocomplete": { "enabled": true, "fetch": "eager", "autoimport": { "tom-select/dist/css/tom-select.default.css": true, "tom-select/dist/css/tom-select.bootstrap4.css": false, "tom-select/dist/css/tom-select.bootstrap5.css": false } } }, "@symfony/ux-chartjs": { "chart": { "enabled": true, "fetch": "eager" } } }, "entrypoints": [] } ================================================ FILE: assets/email.js ================================================ import './styles/emails.scss'; ================================================ FILE: assets/stimulus_bootstrap.js ================================================ // register any custom, 3rd party controllers here // app.register('some_controller_name', SomeImportedController); import { startStimulusApp } from '@symfony/stimulus-bridge'; // Registers Stimulus controllers from controllers.json and in the controllers/ directory export const app = startStimulusApp(require.context( '@symfony/stimulus-bridge/lazy-controller-loader!./controllers', true, /\.[jt]sx?$/, )); ================================================ FILE: assets/styles/_shared.scss ================================================ // a file for shared CSS styling between multiple components or views .user-badge { border: var(--kbin-section-border); padding: 0.25rem 0.5rem; margin-left: .25rem; border-radius: var(--kbin-rounded-edges-radius); } ================================================ FILE: assets/styles/_variables.scss ================================================ $grid-breakpoints: ( xs: 0, sm: 690px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px ) !default; $aspect-ratios: ( "1x1": 100%, "4x3": calc(3 / 4 * 100%), "16x9": calc(9 / 16 * 100%), "21x9": calc(9 / 21 * 100%) ) !default; :root { // --------------------------------------------------------------------------- // Variables that are common to all themes // --------------------------------------------------------------------------- --kbin-shadow: rgba(0, 0, 0, 0.25) 0px 54px 55px, rgba(0, 0, 0, 0.12) 0px -12px 30px, rgba(0, 0, 0, 0.12) 0px 4px 6px, rgba(0, 0, 0, 0.17) 0px 12px 13px, rgba(0, 0, 0, 0.09) 0px -3px 5px; --kbin-rounded-edges-radius: .5rem; // buttons --kbin-button-danger-bg: #842029; --kbin-button-danger-hover-bg: #921d27; --kbin-button-danger-text-color: #fff; --kbin-button-danger-text-hover-color: #fff; --kbin-button-danger-border: 1px dashed #842029; // topbar --kbin-topbar-link-color: #fff; // alerts --kbin-alert-success-bg: var(--kbin-success-color); --kbin-alert-success-border: 1px solid var(--kbin-success-color); --kbin-alert-success-text-color: #fff; --kbin-alert-success-link-color: #fff; // fontawesome --kbin-font-awesome-font-family: "Font Awesome 6 Free"; // fonts --kbin-body-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; // --------------------------------------------------------------------------- // Default theme variables // --------------------------------------------------------------------------- --kbin-body-font-size: 1rem; --kbin-body-font-weight: 400; --kbin-body-line-height: 1.5; --kbin-body-text-align: left; --kbin-body-bg: #fff; --kbin-bg: #ecf0f1; --kbin-bg-nth: #fafafa; --kbin-text-color: #212529; --kbin-link-color: #37769e; --kbin-link-hover-color: #275878; --kbin-outline: #ff8c00 solid 4px; --kbin-primary-color: #61366b; --kbin-text-muted-color: #95a6a6; --kbin-success-color: #0f5132; --kbin-danger-color: #842029; --kbin-own-color: #0f5132; --kbin-author-color: #842029; // section --kbin-section-bg: #fff; --kbin-section-text-color: var(--kbin-text-color); --kbin-section-title-link-color: var(--kbin-link-color); --kbin-section-link-color: var(--kbin-link-color); --kbin-section-link-hover-color: var(--kbin-link-hover-color); --kbin-section-border: 1px solid #e5eaec; --kbin-author-border: 1px dashed var(--kbin-author-color); --kbin-own-border: 1px dashed var(--kbin-own-color); // meta --kbin-meta-bg: none; --kbin-meta-text-color: #606060; --kbin-meta-link-color: #606060; --kbin-meta-link-hover-color: var(--kbin-link-hover-color); --kbin-meta-border: 1px dashed #e5eaec; --kbin-avatar-border: 3px solid #ecf0f1; --kbin-avatar-border-active: 3px solid #d3d5d6; --kbin-blockquote-color: #0f5132; // options --kbin-options-bg: #fff; --kbin-options-text-color: #95a5a6; --kbin-options-link-color: #95a5a6; --kbin-options-link-hover-color: #32465b; --kbin-options-border: 1px solid #e5eaec; --kbin-options-link-hover-border: 3px solid #32465b; // forms --kbin-input-bg: #fff; --kbin-input-text-color: var(--kbin-text-color); --kbin-input-border-color: #e5eaec; --kbin-input-border: 1px solid var(--kbin-input-border-color); --kbin-input-placeholder-text-color: #929497; // buttons --kbin-button-primary-bg: #4e3a8c; --kbin-button-primary-hover-bg: #3f2e77; --kbin-button-primary-text-color: #fff; --kbin-button-primary-text-hover-color: #fff; --kbin-button-primary-border: 1px solid #3f2e77; --kbin-button-secondary-bg: #fff; --kbin-button-secondary-hover-bg: #f5f5f5; --kbin-button-secondary-text-color: var(--kbin-meta-text-color); --kbin-button-secondary-text-hover-color: var(--kbin-text-color); --kbin-button-secondary-border: 1px dashed #e5eaec; // header --kbin-header-bg: #110045; --kbin-header-text-color: #fff; --kbin-header-link-color: #fff; --kbin-header-link-hover-color: #e8e8e8; --kbin-header-link-active-bg: #0a0026; --kbin-header-border: 1px solid #e5eaec; --kbin-header-hover-border: 3px solid #fff; // topbar --kbin-topbar-bg: #0a0026; --kbin-topbar-active-bg: #150a37; --kbin-topbar-active-link-color: #fff; --kbin-topbar-hover-bg: #150a37; --kbin-topbar-border: 1px solid #150a37; // sidebar --kbin-sidebar-header-text-color: #909ea2; --kbin-sidebar-header-border: 1px solid #e5eaec; --kbin-sidebar-settings-row-bg: #E5EAEC; --kbin-sidebar-settings-switch-on-color: #fff ; --kbin-sidebar-settings-switch-on-bg: var(--kbin-button-primary-bg); --kbin-sidebar-settings-switch-off-color: #fff ; --kbin-sidebar-settings-switch-off-bg: #b5c4c9; --kbin-sidebar-settings-switch-hover-bg: #9992BC; // vote --kbin-vote-bg: #f3f3f3; --kbin-vote-text-color: #b6b6b6; --kbin-vote-text-hover-color: #000; --kbin-upvoted-color: #0f5132; --kbin-downvoted-color: #842029; // boost --kbin-boosted-color: var(--kbin-upvoted-color); // alerts --kbin-alert-info-bg: #fff3cd; --kbin-alert-info-border: 1px solid #ffe69c; --kbin-alert-info-text-color: #997404; --kbin-alert-info-link-color: #997404; --kbin-alert-danger-bg: #f8d7da; --kbin-alert-danger-border: 1px solid #f5c2c7; --kbin-alert-danger-text-color: var(--kbin-danger-color); --kbin-alert-danger-link-color: var(--kbin-danger-color); // entry --kbin-entry-link-visited-color: #7e8f99; // details --mbin-details-border: var(--kbin-section-border); --mbin-details-separator-border: var(--kbin-meta-border); --mbin-details-detail-color: var(--kbin-link-hover-color); --mbin-details-spoiler-color: var(--kbin-danger-color); --mbin-details-detail-label: "Details"; --mbin-details-spoiler-label: "Spoiler"; } ================================================ FILE: assets/styles/app.scss ================================================ @use '@fortawesome/fontawesome-free/scss/fontawesome'; @use '@fortawesome/fontawesome-free/scss/solid'; @use '@fortawesome/fontawesome-free/scss/regular'; @use '@fortawesome/fontawesome-free/scss/brands'; @use 'simple-icons-font/font/simple-icons'; @use 'variables'; @use 'shared'; @use 'layout/breakpoints'; @use 'layout/typo'; @use 'layout/layout'; @use 'layout/section'; @use 'layout/options'; @use 'layout/meta'; @use 'layout/tools'; @use 'layout/alerts'; @use 'layout/forms'; @use 'layout/images'; @use 'layout/icons'; @use 'components/announcement'; @use 'components/topbar'; @use 'components/header'; @use 'components/sidebar'; @use 'components/magazine'; @use 'components/domain'; @use 'components/user'; @use 'components/main'; @use 'components/vote'; @use 'components/entry'; @use 'components/comment'; @use 'components/figure_image'; @use 'components/figure_lightbox'; @use 'components/post'; @use 'components/search'; @use 'components/subject'; @use 'components/suggestions'; @use 'components/login'; @use 'components/modlog'; @use 'components/monitoring'; @use 'components/notification_switch'; @use 'components/notifications'; @use 'components/messages'; @use 'components/dropdown'; @use 'components/pagination'; @use 'components/media'; @use 'components/preview'; @use 'components/popover'; @use 'components/stats'; @use 'components/infinite_scroll'; @use 'components/sidebar-subscriptions'; @use 'components/settings_row'; @use 'components/inline_md'; @use 'components/emoji_picker'; @use 'components/filter_list'; @use 'pages/post_single'; @use 'pages/post_front'; @use 'pages/page_bookmarks'; @use 'pages/page_modlog'; @use 'pages/page_profile'; @use 'pages/page_filter_lists'; @use 'themes/kbin'; @use 'themes/default'; @use 'themes/solarized'; @use 'themes/tokyo-night'; @use 'components/tag'; @import 'glightbox/dist/css/glightbox.min.css'; ================================================ FILE: assets/styles/components/_announcement.scss ================================================ .announcement { padding: 0.75rem; position: relative; p { margin: 0; text-align: center; } a{ font-weight: bold; } &__info { background: var(--kbin-alert-info-bg); border: var(--kbin-alert-info-border); color: var(--kbin-alert-info-text-color); a { color: var(--kbin-alert-info-link-color); } } } ================================================ FILE: assets/styles/components/_comment.scss ================================================ @use "sass:list"; @use "sass:string"; @use '../layout/breakpoints' as b; @use '../mixins/animations' as ani; @use '../mixins/mbin'; $levels: ('#ac5353', '#71ac53', '#ffa500', '#538eac', '#6253ac', '#ac53ac', '#ac5353', '#2b7070ff', '#b9ab52', '#808080ff'); $comment-margin-xl: 1rem; $comment-margin-lg: .5rem; $comment-margin-sm: .3rem; .comment-add { .row { margin-bottom: 0; } @include b.media-breakpoint-down(sm) { .params { display: block; div { margin-bottom: 1rem; } > div:last-of-type { margin-bottom: 0; } } } } .comment { display: grid; font-size: .9rem; grid-gap: .5rem; grid-template-areas: "avatar header aside" "avatar body body" "avatar footer footer" "moderate moderate moderate"; grid-template-columns: min-content auto min-content; margin: .5rem 0; padding: 0.5rem 0.75rem; position: relative; z-index: 2; @include b.media-breakpoint-down(sm) { grid-template-areas: "avatar header aside" "body body body" "footer footer footer" "moderate moderate moderate"; } &:hover, &:focus-visible { z-index: 3; } header { color: var(--kbin-meta-text-color); font-size: .8rem; grid-area: header; opacity: .75; a { color: var(--kbin-meta-text-color); font-weight: bold; time { font-weight: normal; } } } .content { p:last-child { margin-bottom: 0; } } .aside { grid-area: aside; display: flex; gap: .5rem; } .comment-collapse { cursor: pointer; display: none; white-space: nowrap; > a { padding: 0 .25rem; } } .expand-label { display: none; } div { grid-area: body; p { margin-top: 0; margin-bottom: 0.5rem; } } > figure { grid-area: avatar; display: none; margin: 0; img { display: block; width: 30px; height: 30px; } @include b.media-breakpoint-up(sm) { img { border: var(--kbin-avatar-border); width: 40px; height: 40px; } } } .vote { display: flex; gap: .5rem; justify-content: flex-end; button { height: 1.2rem; width: 4rem; } } footer { color: var(--kbin-meta-text-color); font-size: .75rem; font-weight: 300; grid-area: footer; menu { column-gap: 1rem; display: flex; grid-area: meta; list-style: none; opacity: .75; position: relative; z-index: 4; & > a.active, & > li button.active { text-decoration: underline; } button, a { font-size: .8rem; @include mbin.btn-link; } > li { width: max-content; } li:first-child a { padding-left: 0; } } menu, .boosts { opacity: .75; } a { @include mbin.btn-link; } figure { display: block; margin: .5rem 0; } } .loader { height: 20px; position: absolute; width: 20px; } &.collapsible { .comment-collapse { display: revert; } } &.collapsed { border-style: dashed; grid-template-areas: "avatar header aside"; grid-template-columns: min-content auto min-content; > :not(header, figure, .aside) { display: none; } .aside > :not(.comment-collapse) { display: none; } header a { color: var(--kbin-text-muted-color); } > figure img { filter: grayscale(.25) opacity(.75); } .comment-collapse { opacity: .75; } .collapse-label { display: none; } .expand-label { display: revert; } } &.hidden { display: none; } &:hover, &:focus-within { header, footer menu, footer .boosts { @include ani.fade-in(.5s, .75); } } } .post-comments { blockquote { margin: 0; } } .comments-view-style--tree, .comments-view-style--classic { .comment-level--1:not(:first-child) { margin-top: 0.5rem; } } .comments-tree { position: relative; blockquote { margin-top: 0; } .comment { @for $i from 2 to 11 { &-line--#{$i} { border-left: 1px dashed string.unquote(list.nth($levels, $i)); bottom: 0; height: 100%; left: $comment-margin-lg * ($i - 1); opacity: .4; position: absolute; z-index: 1; @include b.media-breakpoint-up(xl) { left: $comment-margin-xl * ($i - 1); } @include b.media-breakpoint-down(sm) { left: $comment-margin-sm * ($i - 1); } } } @for $i from 2 to 11 { &-level--#{$i} { border-left: 1px solid string.unquote(list.nth($levels, $i)); margin-left: $comment-margin-lg * ($i - 1) !important; @include b.media-breakpoint-up(xl) { margin-left: $comment-margin-xl * ($i - 1) !important; } @include b.media-breakpoint-down(sm) { margin-left: $comment-margin-sm * ($i - 1) !important; } } } } } .show-comment-avatar { .comment>figure { display: block; } } aside.comments { position: relative; } .entry-comment { margin-bottom: 0; } #comment-add { margin: .5rem 0; padding: 0.75rem; } ================================================ FILE: assets/styles/components/_domain.scss ================================================ .domain { header { text-align: center; h4 { font-size: 1rem; margin-bottom: 1rem; margin-top: .5rem; } } &__name { margin-top: 0; } &__subscribe { display: flex; flex-direction: row; font-size: .9rem; justify-content: center; margin-bottom: 2.5rem; div { align-items: center; background: var(--kbin-button-secondary-bg); border: var(--kbin-button-secondary-border); color: var(--kbin-button-secondary-text-color); display: flex; flex-direction: row; left: 1px; padding: .3rem .5rem; position: relative; .rounded-edges & { border-radius: .5rem; } i { padding-right: .5rem; } } button { height: 100%; padding-bottom: .5rem; padding-top: .5rem; } form:last-of-type { position: relative; right: 1px; } } } td { .domain__subscribe { margin: 0; } } ================================================ FILE: assets/styles/components/_dropdown.scss ================================================ // Learn about how this was made: // @link https://moderncss.dev/css-only-accessible-dropdown-navigation-menu/ $transition: 180ms all 120ms ease-out; .dropdown { position: relative; &__menu { background-color: var(--kbin-section-bg); border: var(--kbin-section-border); box-shadow: var(--kbin-shadow); left: 50%; margin-bottom: 0; margin-top: 0; min-width: 15rem; opacity: 0; position: absolute; transform: rotateX(-90deg) translateX(-50%); transform-origin: top center; visibility: hidden; z-index: 100; top: 100%; padding: 0em; overflow: clip; li { list-style: none; padding: 0; } a { color: var(--kbin-meta-link-color) !important; font-weight: normal !important; border: 0 !important; display: block !important; padding: .5rem 1rem !important; text-decoration: none; width: 100%; text-align: left; border-radius: 0 !important; &:hover { color: var(--kbin-meta-link-hover-color) !important; background: var(--kbin-bg) !important; } &.active { font-weight: bold !important; } } button { color: var(--kbin-button-secondary-text-color); background: var(--kbin-button-secondary-bg); font-weight: normal; display: block; padding: .5rem 1rem; width: 100%; &:hover { color: var(--kbin-button-secondary-text-hover-color); background: var(--kbin-button-secondary-hover-bg); } &.active { font-weight: bold; } } } .dropdown__menu > li button { padding: .5rem 1rem; text-align: left; border-radius: 0 !important; &:hover { color: var(--kbin-meta-link-hover-color); background: var(--kbin-bg); } } &:hover, &:focus-within { .dropdown__menu { transform: rotateX(0) translateX(-50%); visibility: visible; transition: visibility 0s, opacity .2s; opacity: 1; } } &:hover { z-index: 101; } &:focus-within > .btn__secondary { color: var(--kbin-button-secondary-text-hover-color) !important; background: var(--kbin-button-secondary-hover-bg); } .dropdown__separator { border: var(--kbin-section-border); height: 0px; margin: 2px 5px; } } ================================================ FILE: assets/styles/components/_emoji_picker.scss ================================================ emoji-picker { --background: var(--kbin-bg); --input-font-color: var(--kbin-text-color); --button-active-background: var(--kbin-button-primary-bg); --button-hover-background: var(--kbin-button-primary-hover-bg); --border-color: var(--kbin-input-border-color); } .rounded-edges emoji-picker { --border-radius: var(--kbin-rounded-edges-radius); } ================================================ FILE: assets/styles/components/_entry.scss ================================================ @use '../layout/breakpoints' as b; @use '../mixins/animations' as ani; @use '../mixins/mbin'; :root { --kbin-entry-element-spacing: 10px; } .entry { display: grid; grid-template-areas: "vote image title" "vote image shortDesc" "vote image meta" "vote image footer" "moderate moderate moderate" "preview preview preview" "body body body"; grid-template-columns: min-content min-content 1fr; grid-template-rows: 1fr min-content; padding: 0; position: relative; z-index: 2; &.no-image { grid-template-areas: "vote title" "vote shortDesc" "vote meta" "vote footer" "moderate moderate" "preview preview" "body body"; grid-template-columns: min-content 1fr; } header, .vote, figure, .no-image-placeholder, .short-desc, footer, &__meta { margin-left: var(--kbin-entry-element-spacing); } @include b.media-breakpoint-down(sm) { grid-template-areas: "image image" "vote title" "shortDesc shortDesc" "meta meta" "footer footer" "moderate moderate" "preview preview" "body body"; grid-template-columns: min-content 1fr; header, .vote, .short-desc, footer, &__meta { margin-left: var(--kbin-entry-element-spacing); } &.no-image { grid-template-areas: "vote title" "shortDesc shortDesc" "meta meta" "footer footer" "moderate moderate" "preview preview" "body body"; grid-template-columns: min-content 1fr; } .view-compact & { grid-template-areas: "title title vote" "meta meta image" "footer footer image" "moderate moderate moderate" "preview preview preview" "body body body"; grid-template-columns: 1fr min-content min-content; .vote { justify-content: right; margin-right: var(--kbin-entry-element-spacing); margin-left: 0; } .short-desc { display: none; } } .view-compact &.no-meta { grid-template-areas: "title vote" "shortDesc shortDesc" "meta meta" "footer footer" "moderate moderate" "preview preview" "body body"; grid-template-columns: 1fr min-content; } } @include b.media-breakpoint-up(sm) { .view-compact & { grid-template-areas: "vote title image" "vote meta image" "vote footer image" "moderate moderate moderate" "preview preview preview" "body body body"; grid-template-columns: min-content 1fr min-content; .short-desc { display: none; } } } &:hover, &:focus-visible { z-index: 3; } .vote { grid-area: vote; margin-top: var(--kbin-entry-element-spacing); margin-bottom: var(--kbin-entry-element-spacing); } figure, .no-image-placeholder { position: relative; grid-area: image; margin: var(--kbin-entry-element-spacing) 0 var(--kbin-entry-element-spacing) var(--kbin-entry-element-spacing); width: 170px; height: calc(170px / 1.5); // 3:2 ratio justify-self: right; overflow: hidden; img { position: absolute; top: 0; height: 100%; width: 100%; object-fit: contain; -o-object-fit: contain; } .image-filler { background: var(--kbin-vote-bg); position: absolute; width: 100%; height: 100%; img { object-fit: cover; filter: brightness(85%); } } .rounded-edges &, .rounded-edges & .image-filler { border-radius: var(--kbin-rounded-edges-radius); } .view-compact & { width: 170px; height: 100%; margin: 0 0 0 var(--kbin-entry-element-spacing); } .rounded-edges .view-compact & { border-top-left-radius: 0 !important; border-bottom-left-radius: 0 !important; } .figure-badge { bottom: .25rem; right: .25rem; gap: .25rem; } .sensitive-button-label { line-height: 1rem; } @include b.media-breakpoint-down(lg) { width: 140px; height: calc(140px / 1.5); // 3:2 ratio } @include b.media-breakpoint-down(sm) { margin: 0; height: 110px; width: 100%; .view-compact & { margin: 0 10px 10px 10px; height: calc(100% - 10px); width: calc(100% - 10px); .sensitive-button-hide { display: none; } .figure-badge { display: none; } } .rounded-edges & { border-bottom-left-radius: 0 !important; border-bottom-right-radius: 0 !important; } .rounded-edges .view-compact & { border-radius: var(--kbin-rounded-edges-radius) !important; } } } .no-image-placeholder { background: var(--kbin-vote-bg); font-size: 2.5rem; a { display: flex; height: 100%; align-items: center; justify-content: center; } i { color: var(--kbin-vote-text-color); opacity: .5; } .view-compact & { display: none; } @include b.media-breakpoint-down(sm) { display: none; } } &.no-image { figure { display: none; } } header { align-items: flex-start; display: flex; flex-wrap: wrap; grid-area: title; margin: var(--kbin-entry-element-spacing); overflow-wrap: anywhere; h2, h1 { font-size: 1.0rem; font-weight: 600; line-height: 1.2; margin: 0; a:visited { color: var(--kbin-entry-link-visited-color); } a:hover { color: var(--kbin-link-hover-color); } } h1 { font-size: 1.3rem; } } .short-desc { grid-area: shortDesc; p { font-size: .85rem; margin: 0 var(--kbin-entry-element-spacing) 1rem 0; } } &__preview { grid-area: preview; margin: 0.5rem; } &__body { grid-area: body; margin-top: 1.5rem; } &__meta { grid-area: meta; align-self: flex-end; justify-content: flex-start; align-items: center; column-gap: 0.25rem; .edited { font-style: italic; } } footer { grid-area: footer; align-self: flex-end; margin-bottom: var(--kbin-entry-element-spacing); menu { column-gap: 1rem; display: grid; grid-auto-columns: max-content; grid-auto-flow: column; list-style: none; opacity: .75; & > li { line-height: 1rem; } & > a.active, & > li button.active { text-decoration: underline; } button, input[type='submit'], a:not(.notification-setting) { @include mbin.btn-link; } } .view-compact & { margin-bottom: 0.3rem; } } &__domain { color: var(--kbin-meta-text-color); font-size: .7rem; white-space: nowrap; a { color: var(--kbin-meta-text-color); } i { font-size: .6rem; } } .loader { height: 20px; position: absolute; width: 20px; } &:hover, &:focus-within { footer menu, .entry__meta { @include ani.fade-in(.5s, .75); } } &--single { border-top: 0; margin-top: 0; .entry__body { margin: 0 var(--kbin-entry-element-spacing) var(--kbin-entry-element-spacing) var(--kbin-entry-element-spacing); padding: 3px; .content *:last-child { margin-bottom: 0; } .more { width: inherit; margin: var(--kbin-entry-element-spacing); margin-top: 1rem; } h1, h2, h3, h4, h5, h6 { margin: 1rem auto; } h1 { font-size: 1.25rem; } h2 { font-size: 1.20rem; } h3 { font-size: 1.15rem; } h4 { font-size: 1.10rem; } h5 { font-size: 1.05rem; } h6 { font-size: 1rem; } } .no-image-placeholder { display: none; } .rounded-edges .view-compact & figure { border-bottom-right-radius: 0; } @include b.media-breakpoint-down(sm) { .rounded-edges .view-compact & figure { border-bottom-right-radius: var(--kbin-rounded-edges-radius); } } } small { font-size: .75rem; } .badge { display: inline-block; position: relative; top: -2px; padding: .25rem; } } .entries-cross-2, .entries-cross-3 { display: grid; margin: 0; padding: 0; @include b.media-breakpoint-down(lg) { display: block; } } .entries-cross-2 { grid-template-columns: repeat(2, 1fr); } .entries-cross-3 { grid-template-columns: repeat(3, 1fr); } .entry-cross { grid-template-areas: "vote meta" "preview preview" "moderate moderate" !important; grid-template-columns: min-content 1fr !important; margin-top: -.5rem; header { p { font-size: .9rem; } } .vote span { font-size: .7rem; } .vote button { height: 1.5rem } .vote { margin-right: 0 !important; } @include b.media-breakpoint-down(sm) { .vote { margin-left: var(--kbin-entry-element-spacing) !important; } } footer { margin-bottom: 0; } .meta { align-self: center; @include b.media-breakpoint-down(lg) { & div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 270px; } } @include b.media-breakpoint-up(lg) { .entries-cross-2 & div, .entries-cross-3 & div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .entries-cross-2 & div { max-width: 370px; } .entries-cross-3 & div { max-width: 270px; } } } footer { align-self: center; margin-left: 0; margin-bottom: 0 !important; } .entry__preview { opacity: 1.0; } } .page-entry-create { .container { margin: 0 auto; max-width: 30rem; .params { margin-bottom: 2.5rem !important; } } } .page-entry-single { .entry-comments { margin-bottom: 0.5rem; } } ================================================ FILE: assets/styles/components/_figure_image.scss ================================================ // main wrapper .figure-container { position: relative; width: fit-content; height: fit-content; } // main image thumbnail .figure-thumb { .thumb { display: block; } img { display: block; width: 100%; height: 100%; max-width: 600px; max-height: 500px; } } // blurhash image obscuring main image .figure-blur { position: absolute; top: 0; left: 0; width: 100%; height: 100%; img { width: 100%; height: 100%; overflow: hidden; object-fit: cover; } } // checkbox to store sensitive state input.sensitive-state { position: absolute; top: 0; left: 0; width: 0; height: 0; opacity: 0; z-index: -1; } .figure-badge { position: absolute; pointer-events: none; bottom: .5rem; right: .5rem; display: flex; gap: .5rem; &-label { padding: .2rem .4rem; background: var(--kbin-button-secondary-bg); opacity: .5; font-weight: 500; font-size: .75rem; line-height: 1rem; text-align: center; i { font-size: 1rem; } .rounded-edges & { border-radius: var(--kbin-rounded-edges-radius); } } } // button to toggle sensitive .sensitive-button { position: absolute; &-label { background: var(--kbin-button-secondary-bg); padding: .5rem; font-weight: normal; font-size: .8rem; text-align: center; opacity: .8; i { font-size: 1rem; } .rounded-edges & { border-radius: var(--kbin-rounded-edges-radius); } &:hover, &:active { opacity: 1; } } &-show { top: 0; left: 0; width: 100%; height: 100%; .sensitive-button-label { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); } } &-hide { top: .5rem; right: .5rem; .sensitive-button-label { opacity: .5; line-height: 1rem; i { font-size: .9rem; } &:hover, &:active { opacity: .7; } } } } // the magic part: toggle visibility depending on sensitive state .sensitive-state { ~ .sensitive-checked--hide { display: initial; } ~ .sensitive-checked--show { display: none; } } .sensitive-state:checked { ~ .sensitive-checked--hide { display: none; } ~ .sensitive-checked--show { display: revert; } } ================================================ FILE: assets/styles/components/_figure_lightbox.scss ================================================ .glightbox-container { .goverlay { background: rgba(0, 0, 0, 0.7); } .gslide-description { font-family: var(--kbin-body-font-family); background: var(--kbin-body-bg); } .gdesc-inner { padding: 1rem; } .gslide-title, .gslide-desc { font-family: var(--kbin-body-font-family); font-size: 0.9rem; color: var(--kbin-text-color); max-height: 12rem; overflow-wrap: break-word; overflow-x: hidden; overflow-y: scroll; } .gslide-image img { max-height: 95vh; } } .glightbox-mobile .glightbox-container { .goverlay { background: rgba(0, 0, 0, 0.7); } .gslide-description { font-family: var(--kbin-body-font-family); background: color-mix(in srgb, var(--kbin-body-bg) 85%, transparent); } .gslide-title, .gslide-desc { font-family: var(--kbin-body-font-family); font-size: 0.9rem; color: var(--kbin-text-color); overflow: unset; max-height: unset; } .gslide-image img { max-width: 95vw; } } .gdesc-open { .gslide-media { filter: brightness(.7); opacity: 1; -webkit-transition: filter .5s ease; transition: filter .5s ease; } &.glightbox-mobile { .gslide-description { background: var(--kbin-body-bg); } } } .gdesc-closed .gslide-media { filter: brightness(1); opacity: 1; -webkit-transition: filter .5s ease; transition: filter .5s ease; } ================================================ FILE: assets/styles/components/_filter_list.scss ================================================ .filter-list { h3 { margin-top: 0; margin-bottom: 1rem; } .flex { gap: 1rem; } } ================================================ FILE: assets/styles/components/_header.scss ================================================ @use '../layout/breakpoints' as b; #header { align-items: end; background: var(--kbin-header-bg); color: var(--kbin-header-text-color); font-size: .85rem; position: relative; z-index: 10; height: 3.25rem; line-height: normal; #logo { height: 1.75rem; } .dropdown__menu { display: none; } .dropdown:focus-within, .dropdown:hover { .dropdown__menu { display: block; @include b.media-breakpoint-down(sm) { left: auto; top: 100%; transform: none; right: 0; min-width: 10rem; } } } menu { .dropdown__menu { left: -3.75rem; opacity: 1; } .dropdown:last-of-type .dropdown__menu { left: auto; } } .mbin-container { display: grid; grid-template-areas: 'sr-nav brand magazine nav menu'; grid-template-columns: min-content max-content max-content auto max-content; position: relative; height: 100%; @include b.media-breakpoint-down(lg) { max-width: 100vw; } & > menu { @include b.media-breakpoint-down(lg) { margin-right: .5rem; } } } .fixed-navbar & { position: sticky; top: 0; } .topbar & { padding-top: 1.25rem; height: 4.5rem; } .login, .counter a { font-weight: normal; } .user-name { height: 1.25rem; } .login { @include b.media-breakpoint-down(sm) { white-space: nowrap; .user-name { text-overflow: ellipsis; overflow: hidden; max-width: 12vw; padding: 0 .25rem } } } .login.has-avatar { display: flex; align-items: center; gap: .3rem; .user-avatar { border-radius: 50%; height: 1.5625rem; width: 1.5625rem; } @include b.media-breakpoint-down(sm) { .user-name { display: none; } } } .counter a { min-width: unset; } .badge { display: inline-flex; justify-content: center; align-items: center; height: 1.5625rem; min-width: 1.5625rem; } a { color: var(--kbin-header-link-color); &:hover { color: var(--kbin-header-link-hover-color); } } nav { display: flex; grid-area: nav; } menu { grid-area: menu; display: flex; align-items: center; list-style: none; .sidebar-link { display: none; } .icon i { font-size: .85rem; } li { display: flex; align-items: center; height: 100%; } li a { border-bottom: 3px solid transparent; padding: 3px 1rem 0; display: flex; align-items: center; justify-content: center; min-width: 3rem; height: 100%; @include b.media-breakpoint-down(sm) { padding: 3px 0 0; min-width: 2.5rem; } } li a:hover { border-bottom: var(--kbin-header-hover-border); } li .active { border-bottom: var(--kbin-header-hover-border); } .magazine { align-self: center; margin-left: 1rem; padding-top: .2rem; span { color: var(--kbin-header-text-color); font-weight: 100; opacity: .75; } } } .sr-nav { grid-area: sr-nav; z-index: 100; a { background-color: white; border: 0; clip: rect(0, 0, 0, 0); font-size: 1.3rem; font-weight: bold; height: 1px; left: 0; overflow: hidden; padding: .5rem 1rem; position: absolute; top: 0; white-space: nowrap; width: 1px; &:focus { clip: auto; color: black; height: auto; outline: solid 4px darkorange; overflow: visible; position: absolute; white-space: normal; width: auto; } } } .brand { display: flex; font-weight: 400; text-decoration: none; height: 100%; #nav-toggle { display: none; font-size: .9rem; cursor: pointer; } a { display: flex; align-items: center; justify-content: center; padding: 0 1rem; height: 100%; span { font-size: clamp(.6875rem, 3.5vw, 1.5rem); } } @include b.media-breakpoint-down(sm) { #nav-toggle { min-width: 2.5rem; } } @include b.media-breakpoint-down(lg) { a { gap: .5rem; padding: 0; span { line-height: normal; } } #logo { height: 1.5rem; } #nav-toggle { display: flex; align-items: center; justify-content: center; min-width: 3rem; height: 100%; } } } .head-title { align-items: center; display: flex; height: 100%; span { opacity: 0.5; } a { padding-left: 0; &:hover { border-bottom-color: transparent; } } @include b.media-breakpoint-down(lg) { color: var(--kbin-meta-text-color); span { padding-left: .5rem; width: max-content; } a { padding-left: 0; font-weight: bold; } } } .head-nav { @include b.media-breakpoint-down(lg) { overflow: hidden; &__menu { display: none; } } @include b.media-breakpoint-up(lg) { li a.active { background: var(--kbin-header-link-active-bg); } &__mobile-menu { display: none; } } } } ================================================ FILE: assets/styles/components/_infinite_scroll.scss ================================================ .infinite-scroll { text-align: center; .loader { margin: 2rem 0; } } ================================================ FILE: assets/styles/components/_inline_md.scss ================================================ .entry-inline, .entry-comment-inline, .post-inline, .post-comment-inline { display: inline-block; font-weight: bold; border: var(--kbin-section-border); padding: .25em; background: var(--kbin-bg); a .fa-photo-film { margin-right: .25em; } } .rounded-edges { .entry-inline, .entry-comment-inline, .post-inline, .post-comment-inline { border-radius: var(--kbin-rounded-edges-radius); } } ================================================ FILE: assets/styles/components/_login.scss ================================================ @use '../layout/breakpoints' as b; .page-login, .page-register, .page-reset-password, .page-reset-password-email-sent, .page-resend-activation-email { #content .container { margin: 0 auto; max-width: 30rem; p { margin-bottom: .5rem; } a { font-weight: bold; } .separator { display: none; height: 0; } .separator:has(+ div p), .separator.separator-show { display: block; border: var(--kbin-section-border); height: 0; margin: 20px 30px; } .actions { margin-top: 1rem; display: block; } .social { grid-template-columns: repeat(1, 1fr); display: grid; gap: 0.5rem; justify-content: center; @include b.media-breakpoint-up(lg) { &:has(a + a) { grid-template-columns: repeat(2, 1fr); } } a { text-align: center; } } } } .page-2fa { #content .container { margin: 0 auto; max-width: 30rem; .actions { display: flex; gap: 1rem; justify-content: flex-end; align-items: center; } } } .page-reset-password-email-sent { p:last-of-type { margin-top: 2rem; text-align: right; } } ================================================ FILE: assets/styles/components/_magazine.scss ================================================ @use '../layout/breakpoints' as b; .magazine { .panel { margin-bottom: 1rem; text-align: center; a, button { display: block; width: 100%; } } header { text-align: center; h4, h4 a { font-size: 1.2rem; margin-bottom: 0; margin-top: .5rem; } } figure { text-align: center; } &__name { margin-top: 0; i { font-size: 0.7rem; } } &__description, &__rules { ul { padding-left: 1.5rem; li { margin-bottom: .5rem; } } ol { padding-left: 1.5rem; li { margin-left: 2px; margin-bottom: 0.5rem; } } } &__description { margin-top: 2.5rem; } &__subscribe { display: flex; flex-direction: row; justify-content: center; flex-wrap: wrap; div { align-items: center; background: var(--kbin-button-secondary-bg); border: var(--kbin-button-secondary-border); color: var(--kbin-button-secondary-text-color); display: flex; flex-direction: row; font-size: .9rem; left: 1px; padding: .3rem .5rem; position: relative; .rounded-edges & { border-radius: .5rem; } } .action{ span{ margin-left: 0.5rem; } } button { height: 100%; padding-bottom: .5rem; padding-top: .5rem; } form:last-of-type { position: relative; right: 1px; } } } .magazine-inline { img { border-radius: 50%; vertical-align: middle; margin-right: 0.25rem; @include b.media-breakpoint-down(sm){ width: 25px; height: 25px; } } } .magazines-cards { display: grid; gap: 1rem; grid-template-columns: repeat(2, 1fr); @include b.media-breakpoint-down(sm) { grid-template-columns: repeat(1, 1fr); } .magazine { align-content: start; align-items: center; display: grid; grid-template-rows: auto; } h3 { border-bottom: var(--kbin-sidebar-header-border); color: var(--kbin-sidebar-header-text-color); font-size: .8rem; margin: 0 0 1rem; text-transform: uppercase; } .content { font-size: 0.85rem; } } .magazines-columns, .domains-columns { font-size: .9rem; ul { display: grid; grid-gap: 1rem; grid-template-columns: repeat(3, 1fr); margin: 0; padding: 0; @include b.media-breakpoint-down(lg) { grid-template-columns: repeat(2, 1fr); } @include b.media-breakpoint-down(sm) { grid-template-columns: repeat(1, 1fr); } } ul figure { margin: 0 .5rem 0 0; } ul li { align-items: center; display: flex; list-style: none; position: relative; } ul li a { display: block } ul li small { color: var(--kbin-meta-text-color); font-size: .85rem; } .stretched-link { small { &.badge.danger { color: var(--kbin-danger-color); } } } } td { .magazine__subscribe { margin: 0; } } .page-magazine-panel { .container { margin: 0 auto; max-width: 30rem; } .report { div { margin-bottom: .5rem; } } .actions { margin-bottom: 0 !important; @include b.media-breakpoint-down(sm) { display: flex; flex-wrap: wrap; } } .users-columns, .columns { .actions { margin-left: 1rem; .btn { padding: .5rem; } } } } .related-magazines { h3 { margin: 0 0 .5rem !important; } ul.meta li:first-child { border-top: 0 !important; margin-top: 0 !important; } } .magazines.table-responsive{ display: none; @include b.media-breakpoint-up(md){ display: block; } td:first-of-type { max-width: 220px; } } .magazine-list-mobile{ display: none; .magazines__sortby{ display: flex; column-gap: 0.5rem; row-gap: 0.5rem; align-items: center; margin-bottom: 1rem; span{ display: flex; } } .magazine{ position: relative; border-bottom: var(--kbin-section-border); padding: 1rem; font-size: 0.9rem; &:nth-of-type(even){ background-color: var(--kbin-bg-nth); } &__top{ display: flex; justify-content: flex-start; align-items: center; column-gap: 1rem; row-gap: 0.5rem; flex-wrap: wrap; } &__inline{ width: 100%; position: relative; } &__sub{ } &__information{ justify-content: space-evenly; display: flex; column-gap: 1rem; width: 100%; > span{ border-left: var(--kbin-section-border); padding-left: 1rem; margin-right: auto; &:first-child{ padding-left: 0px; border:0px; } } } &__info{ display: flex; flex-direction: column; justify-content: center; align-items: center; .value{ font-weight: bold; } } span{ position: relative; } &__subscribe{ margin-bottom: 0px; } } @include b.media-breakpoint-up(md){ border: solid 1px red; } @include b.media-breakpoint-down(md){ display: block; } } .new-magazine-icon { color: green; } ================================================ FILE: assets/styles/components/_main.scss ================================================ #main { padding-bottom: 1rem; } ================================================ FILE: assets/styles/components/_media.scss ================================================ @use '../layout/breakpoints' as b; .media { text-align: left; align-items: center; display: flex; gap: 1rem; .actions { display: flex; } > div { display: flex; flex-flow: row wrap; gap: 1rem; flex: 1; max-width: 25rem; &:first-child { height: 12.5rem; width: 12.5rem; @include b.media-breakpoint-down(md) { height: 10rem; } } > div { width: 100% } @include b.media-breakpoint-down(md) { flex: 100%; } .image-input { max-width: 100%; } } @include b.media-breakpoint-down(md) { flex-flow: row wrap; justify-content: center; } .image-preview-container { display: flex; justify-content: center; align-items: center; position: relative; background: rgba(0, 0, 0, 0.75); img { display: none; max-width: 100%; max-height: 100%; } .image-preview-clear { color: var(--kbin-button-secondary-text-color); background: var(--kbin-button-secondary-bg); border: var(--kbin-button-secondary-border); position: absolute; top: 0.25rem; right: 0.25rem; width: 1.75rem; height: 1.75rem; padding: 0; text-align: center; font-weight: bold; display: none; cursor: pointer; &:hover { background: var(--kbin-button-secondary-hover-bg); color: var(--kbin-button-secondary-text-hover-color); } } } .image-form { width: 100%; } } .comment-add, .post-add, .comment-edit, .post-edit, .page-entry-create { .dropdown { @include b.media-breakpoint-down(lg) { position: static; } } .dropdown__menu { padding: 1.5rem; left: 50%; top: auto; bottom: calc(100% + 1rem); z-index: 5; @include b.media-breakpoint-down(md) { padding: 1rem; width: 100%; max-width: 20rem; } } .media { color: var(--kbin-meta-text-color); font-size: .9rem; list-style: none; } } .page-post-front, .page-post-create { .post-add { .dropdown__menu { margin-top: .5rem; bottom: auto; } } } ================================================ FILE: assets/styles/components/_messages.scss ================================================ .page-messages { #main .thread { display: flex; gap: .5rem; justify-content: space-between; } .message-view { max-height: calc(100vh - 11em); overflow: auto; position: relative; } .thread-participants { position: absolute; } .section--top { padding: .25em; } .message { max-width: 75%; width: fit-content; min-width: 20%; padding: .25em .5em; p { margin-bottom: .25em; } } .message-self { margin-left: auto; } .message-other { } .col { flex: 1 1 auto; margin-bottom: 0; } .col-auto { flex: 0 0 auto; margin-bottom: 0; } .message-form { margin: 0; display: flex; position: relative; bottom: 0; form div { margin-bottom: 0; } .message-input { height: 3em; padding: .75em; } } .message-view-container { position:absolute; width:calc(100% - 1em); height: calc(100vh - 4em); } } ================================================ FILE: assets/styles/components/_modlog.scss ================================================ .page-modlog { #main .log { display: flex; gap: 1rem; justify-content: space-between; } .log span { min-width: fit-content; } } ================================================ FILE: assets/styles/components/_monitoring.scss ================================================ .page-admin-monitoring { h1, h2, h3, h4, h5, h6 { margin-top: 0; } .monitoring-twig-render { .children { margin-left: 2rem; } } .more { background: var(--kbin-bg); cursor: pointer; text-align: center; width: 100%; margin-top: 1rem; // bigger button for touch devices @media (pointer:none), (pointer:coarse) { margin-top: 2rem; padding: 0.5rem; } i { padding: .35rem; pointer-events: none; } .rounded-edges & { border-radius: var(--kbin-rounded-edges-radius); } } input[type=text], input[type=datetime-local], select { padding: 0.65rem; width: 100%; } form div { margin-bottom: .25rem; } table tr { vertical-align: top; td.query * { margin: 0; overflow: hidden; } } .row { display: flex; flex-direction: row; .col { flex: 1 1; margin-bottom: 0; padding: 0 .25rem; } .col-auto { flex: 0 0 auto; margin-bottom: 0; } .btn-col { text-align: right; margin: auto; } } } ================================================ FILE: assets/styles/components/_notification_switch.scss ================================================ .notification-switch-container .notification-switch { align-items: center; justify-content: center; } .entry-info, .user-main { .notification-switch > * { opacity: .75; } } footer .notification-switch { padding: 0.25em 0; margin-top: 0; line-height: 1.25em; } .notification-switch { display: flex; flex-direction: row; margin-top: .25em; line-height: 1.5; >* { cursor: pointer; padding: .25em .375em; border: var(--kbin-button-secondary-border); background: var(--kbin-button-secondary-bg); color: var(--kbin-button-secondary-text-color); &:hover:not(.active) { background: var(--kbin-button-secondary-hover-bg); color: var(--kbin-button-secondary-text-hover-color); } &.active { cursor: unset; background: var(--kbin-button-primary-bg); color: var(--kbin-button-primary-text-color); &:hover { background: var(--kbin-button-primary-hover-bg); color: var(--kbin-button-primary-text-hover-color); } } &:last-child { border-radius: 0 1em 1em 0; padding-right: .75em; } &:first-child { border-radius: 1em 0 0 1em; padding-left: .75em; } } } ================================================ FILE: assets/styles/components/_notifications.scss ================================================ .page-notifications { #main .notification { display: flex; gap: .5rem; justify-content: space-between; } } ================================================ FILE: assets/styles/components/_pagination.scss ================================================ @use '../layout/breakpoints' as b; .pagination { color: var(--kbin-meta-text-color); display: flex; gap: 1rem; justify-content: center; margin: .5rem 0; position: relative; z-index: 2; padding: .5rem; flex-flow: row wrap; a, span { padding: .6rem 1rem; } a { font-weight: bold; } &__item--current-page { background-color: var(--kbin-bg); } @include b.media-breakpoint-down(sm) { justify-content: space-between; gap: 0rem; a, span { padding: 1rem .5rem; } } } ================================================ FILE: assets/styles/components/_popover.scss ================================================ :root { --popover-width: 250px; --popover-control-gap: 4px; // ⚠️ use px units - vertical gap between the popover and its control --popover-viewport-gap: 20px; // ⚠️ use px units - vertical gap between the popover and the viewport - visible if popover height > viewport height --popover-transition-duration: 0.2s; } .popover { box-shadow: var(--kbin-shadow); margin-bottom: var(--popover-control-gap); // top/left position set in JS margin-top: var(--popover-control-gap); opacity: 0; overflow: auto; -webkit-overflow-scrolling: touch; position: fixed; transition: visibility 0s var(--popover-transition-duration), opacity var(--popover-transition-duration); visibility: hidden; //width: var(--popover-width); z-index: var(--z-index-popover, 25); a { color: var(--kbin-meta-link-color); line-height: normal; display: inline-block; &:hover { color: var(--kbin-meta-link-color-hover); } } } .popover--is-visible { opacity: 1; outline: none; transition: visibility 0s, opacity var(--popover-transition-duration); visibility: visible; } .popover-control--active { outline: none; // class added to the trigger when popover is visible } .user-popover { min-width: 26rem; header { display: flex; gap: 1rem; h3 { font-size: 1.2rem; margin: 0; } p { font-size: .9rem; margin: 0; } ul { font-size: .9rem; list-style: none; padding: 0; li { div { i { padding-right: .5rem; } } } } .user__actions { justify-content: left; margin-bottom: 1rem; } } footer { menu { display: flex; font-size: .9rem; font-weight: 800; gap: 1rem; justify-content: space-around; list-style: none; position: relative; li { text-align: center; } } } } ================================================ FILE: assets/styles/components/_post.scss ================================================ @use '../layout/breakpoints' as b; @use '../mixins/animations' as ani; @use '../mixins/mbin'; .post-add { .ts-control { min-width: 18rem; } .row { flex-wrap: wrap-reverse; margin-bottom: 0; @include b.media-breakpoint-down(sm) { display: block; > div { margin-bottom: 1rem; } } } div { margin-bottom: 0; } } .post-container { margin: 0 0 1rem; } blockquote.post { display: grid; font-size: .9rem; grid-gap: .5rem; grid-template-areas: "vote header header" "vote body body" "vote meta meta" "vote footer footer" "moderate moderate moderate"; grid-template-columns: min-content auto min-content; margin: 0 0 .5rem; padding: var(--kbin-entry-element-spacing); position: relative; z-index: 2; @include b.media-breakpoint-down(sm) { grid-template-areas: "vote header header" "body body body" "meta meta meta" "footer footer footer" "moderate moderate moderate"; } &:hover, &:focus-visible { z-index: 3; } header { grid-area: header; color: var(--kbin-meta-text-color); font-size: .8rem; margin-bottom: 0; opacity: .75; a:not(.notification-setting) { color: var(--kbin-meta-link-color); font-weight: bold; time { font-weight: normal; } } } .content { p:last-child { margin-bottom: 0; } } aside:not(.notification-switch) { grid-area: vote; } div { grid-area: body; p { margin-top: 0 } } > figure { grid-area: avatar; margin: 0; display: none; img { border: var(--kbin-avatar-border); } } .vote { display: flex; gap: .5rem; justify-content: flex-end; } footer { color: var(--kbin-meta-text-color); font-weight: 300; grid-area: footer; .boosts { font-size: .75rem; opacity: .75; } menu { column-gap: 1rem; display: grid; grid-area: meta; grid-auto-columns: max-content; grid-auto-flow: column; list-style: none; font-size: .8rem; opacity: .75; position: relative; z-index: 4; & > li { line-height: 1rem; } & > a:not(.notification-setting).active, & > li button.active { text-decoration: underline; } button, a:not(.notification-setting) { font-size: .8rem; @include mbin.btn-link; } li:first-child a { padding-left: 0; } } a:not(.notification-setting) { @include mbin.btn-link; } figure { display: block; margin: .5rem 0; } button { position: relative; } } .loader { height: 20px; position: absolute; width: 20px; } &:hover, &:focus-within { header, footer menu, footer .boosts { @include ani.fade-in(.5s, .75); } } &--single { border-top: 0; margin-top: 0; padding-bottom: 2rem; padding-top: 2rem; .entry__body { padding: 0 2rem; } } } article.post { display: grid; grid-template-areas: "vote image header" "vote image shortDesc" "vote image meta" "vote image footer" "moderate moderate moderate" "preview preview preview"; grid-template-columns: min-content min-content 1fr; grid-template-rows: 1fr min-content; padding: 0; position: relative; z-index: 2; &.no-image { grid-template-areas: "vote shortDesc" "header meta" "header footer" "moderate moderate" "preview preview"; grid-template-columns: min-content 1fr; } header, .vote, figure, .no-image-placeholder, .short-desc, footer, &__meta { margin-left: var(--kbin-entry-element-spacing); } @include b.media-breakpoint-down(sm) { grid-template-areas: "image image" "vote header" "vote shortDesc" "meta meta" "footer footer" "moderate moderate" "preview preview"; grid-template-columns: min-content 1fr; header, .vote, .short-desc, footer, &__meta { margin-left: var(--kbin-entry-element-spacing); } &.no-image { grid-template-areas: "vote header" "vote shortDesc" "meta meta" "footer footer" "moderate moderate" "preview preview"; grid-template-columns: min-content 1fr; } .view-compact & { grid-template-areas: "shortDesc shortDesc shortDesc vote" "header meta meta image" "footer footer footer image" "moderate moderate moderate moderate" "preview preview preview preview"; grid-template-columns: max-content 1fr min-content min-content; .vote { justify-content: right; margin-right: var(--kbin-entry-element-spacing); margin-left: 0; } header { margin-top: 0.2rem; margin-bottom: 0; flex-flow: row-reverse; } .short-desc { margin-top: 0.2rem; margin-bottom: 0.2rem; p { max-height: 3lh; margin-bottom: 0; } } } .view-compact &.no-meta { grid-template-areas: "shortDesc vote" "header header" "meta meta" "footer footer" "moderate moderate" "preview preview"; grid-template-columns: 1fr min-content; } } @include b.media-breakpoint-up(sm) { .view-compact & { grid-template-areas: "vote header image" "vote shortDesc image" "vote meta image" "vote footer image" "moderate moderate moderate" "preview preview preview"; grid-template-columns: min-content 1fr min-content; header { margin-top: 0.2rem; margin-bottom: 0; } .short-desc { margin-top: 0.2rem; margin-bottom: 0.2rem; p { max-height: 3lh; margin-bottom: 0; } } } } &:hover, &:focus-visible { z-index: 3; } .vote { grid-area: vote; margin-top: var(--kbin-entry-element-spacing); margin-bottom: var(--kbin-entry-element-spacing); } figure, .no-image-placeholder { position: relative; grid-area: image; margin: var(--kbin-entry-element-spacing) 0 var(--kbin-entry-element-spacing) var(--kbin-entry-element-spacing); width: 170px; height: calc(170px / 1.5); // 3:2 ratio justify-self: right; overflow: hidden; img { position: absolute; top: 0; height: 100%; width: 100%; object-fit: contain; -o-object-fit: contain; } .image-filler { background: var(--kbin-vote-bg); position: absolute; width: 100%; height: 100%; img { object-fit: cover; filter: brightness(85%); } } .rounded-edges &, .rounded-edges & .image-filler { border-radius: var(--kbin-rounded-edges-radius); } .view-compact & { width: 170px; height: 100%; margin: 0 0 0 var(--kbin-entry-element-spacing); } .rounded-edges .view-compact & { border-top-left-radius: 0 !important; border-bottom-left-radius: 0 !important; } .figure-badge { bottom: .25rem; right: .25rem; gap: .25rem; } .sensitive-button-label { line-height: 1rem; } @include b.media-breakpoint-down(lg) { width: 140px; height: calc(140px / 1.5); // 3:2 ratio } @include b.media-breakpoint-down(sm) { margin: 0; height: 110px; width: 100%; .view-compact & { margin: 0 10px 10px 10px; height: calc(100% - 10px); width: calc(100% - 10px); .sensitive-button-hide { display: none; } .figure-badge { display: none; } } .rounded-edges & { border-bottom-left-radius: 0 !important; border-bottom-right-radius: 0 !important; } .rounded-edges .view-compact & { border-radius: var(--kbin-rounded-edges-radius) !important; } } } .no-image-placeholder { background: var(--kbin-vote-bg); font-size: 2.5rem; a { display: flex; height: 100%; align-items: center; justify-content: center; } i { color: var(--kbin-vote-text-color); opacity: .5; } .view-compact & { display: none; } @include b.media-breakpoint-down(sm) { display: none; } } &.no-image { figure { display: none; } .short-desc { @include b.media-breakpoint-up(sm) { max-height: 1lh; } } } header { grid-area: header; align-items: flex-start; display: flex; flex-wrap: wrap; margin: var(--kbin-entry-element-spacing); overflow-wrap: anywhere; h2, h1 { font-size: 1.0rem; font-weight: 600; line-height: 1.2; margin: 0; a:visited { color: var(--kbin-entry-link-visited-color); } a:hover { color: var(--kbin-link-hover-color); } } h1 { font-size: 1.3rem; } } .short-desc { grid-area: shortDesc; p { font-size: .85rem; margin: 0 var(--kbin-entry-element-spacing) 1rem 0; } } &__preview { grid-area: preview; margin: 0.5rem; } &__meta { grid-area: meta; align-self: flex-end; justify-content: flex-start; align-items: center; column-gap: 0.25rem; .edited { font-style: italic; } } footer { grid-area: footer; align-self: flex-end; margin-bottom: var(--kbin-entry-element-spacing); menu { column-gap: 1rem; display: grid; grid-auto-columns: max-content; grid-auto-flow: column; list-style: none; opacity: .75; & > li { line-height: 1rem; } & > a.active, & > li button.active { text-decoration: underline; } button, input[type='submit'], a:not(.notification-setting) { @include mbin.btn-link; } } .view-compact & { margin-bottom: 0.3rem; } } .loader { height: 20px; position: absolute; width: 20px; } &:hover, &:focus-within { footer menu, .entry__meta { @include ani.fade-in(.5s, .75); } } small { font-size: .75rem; } .badge { display: inline-block; position: relative; top: -2px; padding: .25rem; } } .show-comment-avatar { .comment>figure { display: block; } } .show-post-avatar { .post>figure { display: block; } } .post-comments-preview { margin-top: -.5rem; margin-bottom: .5rem; } ================================================ FILE: assets/styles/components/_preview.scss ================================================ @use '../variables' as v; .preview { text-align:center; //display: inline-flex; button { margin: 0 .25rem; padding: 0; } img { max-width: 100%; } .show-preview { background: none; border: 0; color: var(--kbin-meta-text-color); display: inline-flex; margin-top: .2rem; padding-left: 0; } video, iframe { max-width: 100%; } } .preview-target { margin: 0.5rem 0; &:not(.hidden) { display: block; } } // Credit: Nicolas Gallagher and SUIT CSS. .ratio { --aspect-ratio: 16 / 9; aspect-ratio: 16 / 9; position: relative; width: 100%; img { max-width: 100%; } &::before { content: ""; display: block; padding-top: 56.25%; } > * { height: 100%; left: 0; position: absolute; top: 0; width: 100%; } } @each $key, $ratio in v.$aspect-ratios { .ratio-#{$key} { aspect-ratio: #{$ratio}; } } ================================================ FILE: assets/styles/components/_search.scss ================================================ .search-container { background: var(--kbin-input-bg); border: var(--kbin-input-border); border-radius: var(--kbin-rounded-edges-radius) !important; input.form-control { border-radius: 0 !important; border: none; background: transparent; margin: 0 .5em; padding: .5rem .25rem; } button { border-radius: 0 var(--kbin-rounded-edges-radius) var(--kbin-rounded-edges-radius) 0 !important; border: 0; &:not(.small) { padding: 1rem 0.5rem; } &:not(:hover) { background: var(--kbin-input-bg); color: var(--kbin-input-text-color) !important; } } } ================================================ FILE: assets/styles/components/_settings_row.scss ================================================ .settings-row { display: grid; grid-template-areas: "label value"; grid-template-columns: auto; align-items: center; width: 100%; background: var(--kbin-sidebar-settings-row-bg); .rounded-edges & { &:first-child { border-top-left-radius: .375rem; border-top-right-radius: .375rem; overflow: clip; } &:last-child { border-bottom-left-radius: .375rem; border-bottom-right-radius: .375rem; overflow: clip; } } &[data-controller="settings-row-enum"] { grid-template-areas: "label value"; } .label { grid-area: label; line-height: normal; align-items: center; display: flex; margin-left: .375rem; } .value-container { display: flex; justify-content: end; width: 100%; padding: 4px 6px; line-height: normal; flex-grow: 1; grid-area: value; .link-muted.active { color: var(--kbin-primary); font-weight: 800 !important; } /** Enum Settings row **/ .enum { display: flex; align-items: center; text-align: center; background-color: var(--kbin-sidebar-settings-switch-off-bg); overflow: clip; font-size: .8em; box-shadow: 0 .0625rem hsla(0,0%,100%,.08); .rounded-edges & { border-radius: var(--kbin-rounded-edges-radius); } input { display: none; } .value { cursor: pointer; span { min-width: 3rem; height: 100%; display: block; padding: .25rem .25rem; font-weight: 400; color: var(--kbin-button-secondary-text-color); transition: color .25s, background-color .25s, font-weight .15s; } &:hover { input:checked + span { background: var(--kbin-sidebar-settings-switch-hover-bg); color: var(--kbin-button-primary-hover-text-color); } span { background: var(--kbin-sidebar-settings-switch-hover-bg); color: var(--kbin-button-secondary-text-hover-color); } } } input:checked + span { background: var(--kbin-sidebar-settings-switch-on-bg); color: var(--kbin-sidebar-settings-switch-on-color); font-weight: 800; } } /** Button Settings row **/ button { background: var(--kbin-button-primary-bg); color: var(--kbin-button-primary-text-color); border: var(--kbin-button-primary-border); cursor: pointer; font-size: 0.8em; .rounded-edges & { border-radius: var(--kbin-rounded-edges-radius); } &:hover { background: var(--kbin-button-primary-hover-bg); color: var(--kbin-button-primary-hover-text-color); } } /** Switch Settings row **/ .switch { .rounded-edges & { border-radius: .75rem; } input { display: none; } } .slider { cursor: pointer; background-color: var(--kbin-sidebar-settings-switch-off-bg); transition: .25s; display: block; height: 1.25rem; width: 2rem; border: .125rem solid var(--kbin-sidebar-settings-switch-off-bg); box-shadow: 0px .0625rem hsla(0, 0%, 100%, .08); .rounded-edges & { border-radius: .75rem; } &:hover { background-color: var(--kbin-sidebar-settings-switch-hover-bg); border-color: var(--kbin-sidebar-settings-switch-hover-bg); &::before { background-color: var(--kbin-sidebar-settings-switch-on-color); } } &:before { position: absolute; content: ""; height: 1rem; width: 1rem; background-color: var(--kbin-sidebar-settings-switch-off-color); transition: .25s; .rounded-edges & { border-radius: 50%; } } } input:checked + .slider { background-color: var(--kbin-sidebar-settings-switch-on-bg); border: .125rem solid var(--kbin-sidebar-settings-switch-on-bg); box-shadow: 0px -1px hsla(0, 0%, 0%, .1); } input:checked + .slider:before { transform: translateX(.75rem); background: var(--kbin-sidebar-settings-switch-on-color); box-shadow: inset 0 -.0625rem hsl(0, 0%, 0%, .1); } } } ================================================ FILE: assets/styles/components/_sidebar-subscriptions.scss ================================================ @use '../layout/breakpoints' as b; .rounded-edges .sidebar-subscriptions .active { border-radius: 0.5rem; } .mbin-container.width--fixed { .sidebar-subscriptions:not(.inline) .subscription-list .subscription a { max-width: calc(max(305px, 1360px / 6) - 3rem); } .sidebar-subscriptions.inline .subscription-list .subscription a { max-width: calc(max(305px, 1360px / 4) - 3rem); } } .mbin-container.width--auto { .sidebar-subscriptions:not(.inline) .subscription-list .subscription a { max-width: calc(max(305px, 85vw / 6) - 3rem); } .sidebar-subscriptions.inline .subscription-list .subscription a { max-width: calc(max(305px, 85vw / 4) - 3rem); } } .mbin-container.width--max { .sidebar-subscriptions:not(.inline) .subscription-list .subscription a { max-width: calc(max(305px, 100vw / 6) - 3rem); } .sidebar-subscriptions.inline .subscription-list .subscription a { max-width: calc(max(305px, 100vw / 4) - 3rem); } } #sidebar .sidebar-subscriptions, .sidebar-subscriptions { height: fit-content; &:not(.inline) { padding: 0 0.5rem; } &.inline { padding-bottom: unset; } .sidebar-subscriptions-icons { float: right; @include b.media-breakpoint-down(lg) { display: none; } } .inline .subscription-list { max-height: 20em; overflow: auto; &.lg { max-height: 40em; } } .inline .magazine-subscription-avatar-placeholder { display: inline-block; } :not(.inline) .magazine-subscription-avatar-placeholder { display: none; } .section { padding: 0.5rem 0.5rem 0; @include b.media-breakpoint-down(lg) { padding: 0.5rem; } } h3 { margin: 0 0 .5rem; @include b.media-breakpoint-down(lg) { margin: 0; } } .subscription-list { &.meta :first-child { border-top: unset; } @include b.media-breakpoint-down(lg) { display: flex; flex-direction: row; overflow-y: auto; max-width: calc(-2rem + 100vw); } .subscription { padding: 0.5rem; @include b.media-breakpoint-down(lg) { min-width: max-content; } &:not(:last-child) { @include b.media-breakpoint-up(lg) { border-bottom: var(--kbin-meta-border); } @include b.media-breakpoint-down(lg) { border-right: var(--kbin-meta-border); } } &:last-child { border-bottom: unset; } .magazine-subscription-avatar { margin: 0; height: 1.5rem; max-height: 1.5rem; width: 1.5rem; max-width: 1.5rem; object-fit: scale-down; border-radius: 50%; vertical-align: middle; &.onlyMobile { @include b.media-breakpoint-up(lg) { display: none; } } } .magazine-subscription-avatar-placeholder{ width: 1.5rem; @include b.media-breakpoint-down(lg) { display: none; } &.onlyMobile { @include b.media-breakpoint-up(lg) { display: none; } } } &.active { background-color: var(--kbin-bg); } a { display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; .magazine-name { font-weight: bold; display: inline; &.has-image { margin-left: .25rem; &.onlyMobile { @include b.media-breakpoint-up(lg) { margin-left: 0; } } @include b.media-breakpoint-down(lg) { display: none; } } } } } } } ================================================ FILE: assets/styles/components/_sidebar.scss ================================================ @use '../layout/breakpoints' as b; @use '../mixins/mbin'; #sidebar, .sidebar-subscriptions { font-size: .85rem; opacity: 1; padding-bottom: 1rem; h3, h5 { border-bottom: var(--kbin-sidebar-header-border); color: var(--kbin-sidebar-header-text-color); font-size: .8rem; margin: 0 0 1rem; text-transform: uppercase; } figure, blockquote { margin: 0; } .options { grid-template-columns: 1fr; menu { display: flex; li { flex-grow: 1; flex-shrink: 0; text-align: center; } } } .section { padding: .5rem; } .sidebar-options { .top-options.section { padding: 0px; overflow: hidden; menu { display: flex; overflow: hidden; ul { list-style-type: none; display: flex; flex-direction: row; column-gap: 0.5rem; padding: 0px; margin: 0px; width: 100%; } li { display: flex; justify-content: center; a { flex-grow: 1; } } li.close-button, li.home-button { display: none; } } @include b.media-breakpoint-down(lg) { menu { justify-content: flex-start; li.close-button, li.home-button { display: flex; } li.close-button { margin-left: auto; } } } } } .posts, .entries { color: var(--kbin-meta-text-color); .container { @include b.media-breakpoint-down(lg) { max-width: 100%; } @include b.media-breakpoint-only(md) { display: grid; gap: 2rem; grid-template-columns: repeat(2, 1fr); } } figure { border-bottom: var(--kbin-meta-border); margin-bottom: 1rem; padding-bottom: 1rem; &:last-child { margin-bottom: 0; } .row img { height: 100px; margin-bottom: .5rem; -o-object-fit: cover; object-fit: cover; width: 100%; } blockquote { border: 0; padding: 0; a { @include mbin.btn-link; } p:last-of-type { margin: 0em; } div { margin-top: 0.5em; } } .more { opacity: 0; } figcaption { color: var(--kbin-meta-text-color); text-align: right; } } } .entries blockquote { font-weight: bold; } .meta, .info { color: var(--kbin-meta-text-color); list-style: none; margin: 0; padding: 0; li { align-items: center; border-bottom: var(--kbin-meta-border); display: flex; flex-direction: row; justify-content: space-between; min-height: 3rem; padding: .5rem; position: relative; &:first-child { border-top: var(--kbin-meta-border); } a { font-weight: bold; padding: 0; } div { i { padding-right: .5rem; } } } } .user-list { ul { margin: 0; padding: 0; } li.moderator-item { height: calc(30px + 1rem); } ul li { align-items: center; border-top: var(--kbin-meta-border); display: flex; list-style: none; position: relative; &:first-child { border-top: 0; padding-top: 0; } &:last-child { border-bottom: var(--kbin-meta-border); } a { padding: 0 .5rem; } img { margin: .5rem 0; border-right: 0.25rem; } } footer { opacity: .85; padding: .5rem 0; position: relative; text-align: center; a { color: var(--kbin-meta-text-color); } } } .entry-info, .user-info { .row { text-align: center; h4 { font-size: 1rem; margin-bottom: 0; margin-top: .5rem; } } a:not(.notification-setting) { color: var(--kbin-meta-link-color); } &_name { margin-top: 0; } figure { text-align: center; } } .entry-info ul.info { margin-top: 2.5rem; } .settings { display: flex; gap: 1rem; justify-content: center; margin-bottom: 1.5rem; align-items: flex-end; flex-flow: row wrap; & + .settings { margin-bottom: .5rem; } &:last-of-type { margin-bottom: 0; } .theme { filter: drop-shadow(1px 2px 3px hsl(0, 0%, 0%, .25)); height: 2rem; width: 2rem; &.light { background: url("/assets/images/light.svg") no-repeat; background-size: 2rem; background-position: center; .theme--light & { outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg); } .theme--default & { @media (prefers-color-scheme: light) { outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg); } } } &.dark { background: url("/assets/images/dark.svg") no-repeat; background-size: 2rem; background-position: center; .theme--dark & { outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg); } .theme--default & { @media (prefers-color-scheme: dark) { outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg); } } } &.kbin { background: url("/assets/images/kbin.svg") no-repeat; background-size: 2rem; background-position: center; .theme--kbin & { outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg); } } &.solarized-light { background: url("/assets/images/solarized.svg") no-repeat; background-size: 2rem; background-position: center; .theme--solarized-light & { outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg); } .theme--solarized & { @media (prefers-color-scheme: light) { outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg); } } } &.solarized-dark { background: url("/assets/images/solarized-dark.svg") no-repeat; background-size: 2rem; background-position: center; .theme--solarized-dark & { outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg); } .theme--solarized & { @media (prefers-color-scheme: dark) { outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg); } } } &.tokyo-night { background: url("/assets/images/tokyo-night.svg") no-repeat; background-size: 2rem; background-position: center; .theme--tokyo-night & { outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg); } } } .font-size { align-items: center; border: 3px solid transparent; box-sizing: content-box; display: flex; height: 30px; justify-content: center; padding: 3px; width: 30px; &.active { border: var(--kbin-avatar-border); } } } .settings-list { display: flex; flex-flow: row wrap; gap: 1px; align-items: center; color: var(--kbin-meta-text-color); &.reload-required .reload-required-section { display: block; } .reload-required-section { z-index: 1; background: var(--kbin-section-bg); position: sticky; top: 0; display: none; text-align: center; width: 100%; animation: showReloadRequired .25s ease-in-out forwards; @keyframes showReloadRequired { 0% { opacity: 0; transform: translateY(-.5em); } 75% { opacity: 1; transform: translateY(.25em); } 100% { transform: translateY(0); } } .rounded-edges & { border-radius: var(--kbin-rounded-edges-radius); } .btn { width: 100%; display: flex; gap: .5rem; justify-content: center; align-items: center; width: 100%; &:hover { color: var(--kbin-button-secondary-text-hover-color); } } &:hover { background: var(--kbin-button-secondary-hover-bg); color: var(--kbin-button-secondary-text-hover-color); } /** Faster spin animation than fa-spin */ button.spin i { animation: spin .5s linear infinite; @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } } } strong { margin-top: .5rem; display: block; flex: 100%; font-weight: 700; font-variant: all-small-caps; margin-left: .3125rem; color: var(--kbin-meta-text-color); opacity: .5; } .settings-section { display: flex; flex: auto; flex-flow: column wrap; gap: 1px; align-items: center; @include b.media-breakpoint-down(lg) { & .width-setting { display: none; } } } .row { display: flex; justify-content: space-between; align-items: center; flex: 100%; min-height: 1.5rem; div { display: flex; align-items: center; font-size: 0; color: var(--kbin-meta-text-color); border: var(--kbin-button-secondary-border); background: var(--kbin-button-secondary-bg); height: 1.5rem; overflow: clip; a { font-weight: 400; height: 100%; font-size: .85rem; padding: 0 .375rem; white-space: nowrap; &.active { font-weight: 700; background: var(--kbin-button-secondary-hover-bg); color: var(--kbin-button-secondary-text-hover-color); } &.link-muted:not(.active):hover { color: var(--kbin-link-hover-color); } } } span { line-height: normal; } } } #settings, #sidebarcontent { display: none; } #settings:target, #sidebarcontent:target { display: block; } .active-users { > div { display: grid; gap: .2rem; grid-template-columns: repeat(4, 1fr); text-align: center; } img { border: var(--kbin-avatar-border); } } .intro { .container { background: url('/assets/images/intro-bg.png') no-repeat center 20%; background-size: cover; color: #ffffff !important; font-size: .85rem; margin: -.5rem; padding: 1rem; .rounded-edges & { border-radius: .5rem .5rem 0 0; } h3 { border: 0; color: white; font-size: 1rem; font-weight: 600; margin: 1rem 0 1rem 0; text-transform: none; } } .btn:first-of-type { margin-bottom: 1rem; margin-top: 2rem; } .btn { display: block; text-align: center; width: 100% !important; .rounded-edges & { border-radius: .5rem; } } } .about { ul { padding: 0; opacity: 0.8; &:last-child { margin-bottom: 0; } li { padding: 0.1rem; list-style: none; display: inline; } &.about-mbin { text-align: center; } } .about-options { li { display: flex; justify-content: center; } select { padding: 0.4rem; background: var(--kbin-section-bg); color: var(--kbin-button-secondary-text-color); } } .about-seperator { border: var(--kbin-section-border); height: 0; margin: 2px 5px; } } .kbin-promo { display: flex; gap: 1rem; padding: 1rem; position: relative; h4 { font-size: 1.0rem; font-weight: 600; margin: 0; } p { font-size: .8rem; margin: 0; } a { color: var(--kbin-text-color); } } .mobile-close { //display: none; } .mobile-nav { display: none; } @include b.media-breakpoint-down(lg) { .mobile-nav { display: block; position: relative; li { font-size: 1.1rem; list-style: none; position: relative; padding: .85rem 0; } a { color: var(--kbin-meta-link-color); &::after { bottom: 0; content: ''; left: 0; position: absolute; right: 0; top: 0; z-index: 1; } } a.active { font-weight: bold; } .head-title { border-bottom: 1px solid var(--kbin-options-text-color); font-weight: bold; padding-bottom: .5rem; } .head-title span { display: none; } } } } // Use white background for light themes .theme--light #sidebar .intro .container, .theme--solarized-light #sidebar .intro .container { background: url('/assets/images/intro-bg-white.png') no-repeat center 50%; color: rgb(39, 39, 39) !important; font-weight: 400; h3 { color: #202020; } } #sidebar { @include b.media-breakpoint-down(lg) { &.open { background: var(--kbin-bg); height: 100%; left: 0; overflow: auto; padding-bottom: 100px !important; position: fixed; top: 3.25rem; width: 100%; z-index: 98; .topbar & { top: 4.5rem; } .mobile-close { display: flex; font-size: 1.5rem; justify-content: space-between; padding: 0.5em; button { height: auto; } } } &:not(.open) { display: none; } } } ================================================ FILE: assets/styles/components/_stats.scss ================================================ @use '../layout/breakpoints' as b; .stats-count { display: grid; grid-template-columns: repeat(3, 1fr); row-gap: 2rem; @include b.media-breakpoint-only(xs) { grid-template-columns: repeat(1, 1fr); } div { display: table; text-align: center; h3 { font-size: .9rem; font-weight: bold; margin: 0; } p { font-size: 1.8rem; font-weight: bold; } } } ================================================ FILE: assets/styles/components/_subject.scss ================================================ .subjects { .post, .entry { margin-top: .5rem; &:first-child { margin-top: 0 } } .comment { margin-bottom: 0; margin-top: 0.5em; } .post-comment { margin-left: 1rem; } } .subject { .more { background: var(--kbin-bg); cursor: pointer; text-align: center; width: 100%; margin-top: 1rem; // bigger button for touch devices @media (pointer:none), (pointer:coarse) { margin-top: 2rem; padding: 0.5rem; } i { padding: .35rem; pointer-events: none; } .rounded-edges & { border-radius: var(--kbin-rounded-edges-radius); } } .show-preview { cursor: pointer; } &:nth-of-type(odd) { } .js-container { display: none; font-size: 1rem; margin-top: 1rem; } &.author { border-left: var(--kbin-author-border); } &.own { border-left: var(--kbin-own-border); } } div.moderate-inline { grid-area: moderate !important; // this is to appear below the more menu z-index: -1; } .moderate-panel { position: relative; z-index: 2; menu { align-items: center; column-gap: 1rem; display: flex; flex-wrap: wrap; grid-auto-columns: max-content; grid-auto-flow: column; justify-content: space-around; list-style: none; select { padding: .25rem; } input { width: 10rem; } .actions form { display: flex; } input, select, input[type=checkbox] { margin-right: .25rem; } & > a.active, & > li button.active, { text-decoration: underline; } } } .overview .comments-tree { margin-top: -.5rem; } ================================================ FILE: assets/styles/components/_suggestions.scss ================================================ .suggestions { position: absolute; z-index: 10; border: var(--kbin-input-border); background: var(--kbin-input-bg); padding: 0 .5rem; .suggestion { color: var(--kbin-input-text-color); cursor: pointer; margin: 0; padding: .5rem 0; &:hover, &.selected { background: var(--kbin-input-hover-background); color: var(--kbin-meta-link-hover-color); } &:not(:last-child):not(:only-child) { border-bottom: var(--kbin-input-border); } } } .rounded-edges { .suggestions { border-radius: var(--kbin-rounded-edges-radius) !important; } } ================================================ FILE: assets/styles/components/_tag.scss ================================================ .section.tag { header { text-align: center; h4 { font-size: 1.2rem; margin-bottom: 0; margin-top: .5rem; } } .tag__actions { display: flex; flex-direction: row; flex-wrap: wrap; justify-content: center; margin-top: 1rem; margin-bottom: 2.5rem; gap: .25rem; } } ================================================ FILE: assets/styles/components/_topbar.scss ================================================ #topbar { background: var(--kbin-topbar-bg); border-bottom: var(--kbin-topbar-border); display: none; grid-template-areas: 'left middle right'; grid-template-columns: min-content auto max-content; position: absolute; top: 0; width: 100%; z-index: 20; height: 1.25rem; .topbar & { display: grid; position: fixed; } .fixed-navbar & { position: fixed; } menu:nth-child(1) { grid-area: left; } menu:nth-child(2) { grid-area: middle; } a { color: var(--kbin-topbar-link-color); white-space: nowrap; } menu:nth-child(3) { background: var(--kbin-topbar-bg); position: absolute; right: 0; z-index: 10; } menu { display: flex; font-size: .75rem; list-style: none; li { padding: 0 .5rem; position: relative; &:hover { background: var(--kbin-topbar-hover-bg); } } li.active { background: var(--kbin-topbar-active-bg); a { color: var(--kbin-topbar-active-link-color); font-weight: bold; } } } } ================================================ FILE: assets/styles/components/_user.scss ================================================ @use '../layout/breakpoints' as b; .user { &__actions { display: flex; flex-direction: row; justify-content: center; opacity: .75; div { align-items: center; background: var(--kbin-button-secondary-bg); border: var(--kbin-button-secondary-border); color: var(--kbin-button-secondary-text-color); display: flex; flex-direction: row; font-size: .9rem; left: 1px; padding: .3rem .5rem; position: relative; .rounded-edges & { border-radius: .5rem; } i { padding-right: .5rem; } } button { height: 100%; padding-bottom: .5rem; padding-top: .5rem; } form:last-of-type { position: relative; right: 1px; } } &__name { i { font-size: 0.7rem; } } } .user-inline { img { border-radius: 50%; vertical-align: middle; margin-right: 0.25rem; @include b.media-breakpoint-down(sm) { width: 25px; height: 25px; } } } .page-user-overview { .section--top { padding: 0; overflow: clip; } } .user-box, .user-box-inline { figure { margin: 0; padding: 0; } img { -o-object-fit: cover; object-fit: cover; } h1 { font-size: 1.2rem; } .user-main { margin-left: 1.5rem; padding-top: 2rem; text-align: center; width: max-content; @include b.media-breakpoint-down(lg) { justify-content: space-around; margin-left: 0; } h1 { margin-bottom: 0; } h1 > code { background: var(--kbin-button-secondary-bg); border: var(--kbin-button-secondary-border); color: var(--kbin-button-secondary-text-color); font-size: .8rem; padding: .3rem .5rem; left: 2px; top: -2px; position: relative; } small { display: block; margin-bottom: 1rem; } img { border: var(--kbin-avatar-border); } } .about { margin-bottom: 2.5rem; padding: 0 1.5rem; @include b.media-breakpoint-down(sm) { padding: 0 .5rem; } } .with-cover.with-avatar { figure { position: relative; top: -60px; height: 40px; } } } .user-box { .with-cover.with-avatar { .user-main { margin-top: 0; padding-top: 0; .user__actions { margin: 0; } } .about { margin-bottom: 1.5rem; margin-top: 1em; } } } .users-cards { display: grid; gap: 1rem; grid-template-columns: repeat(2, 1fr); @include b.media-breakpoint-down(sm) { grid-template-columns: repeat(1, 1fr); } .magazine { align-content: center; align-items: center; display: grid; grid-template-rows: auto; } } .users-columns, .columns { font-size: .9rem; ul { display: grid; grid-gap: 1rem; grid-template-columns: repeat(3, 1fr); margin: 0; padding: 0; @include b.media-breakpoint-down(lg) { grid-template-columns: repeat(2, 1fr); } @include b.media-breakpoint-down(sm) { grid-template-columns: repeat(1, 1fr); } } ul figure { margin: 0 .5rem 0 0; } ul li { align-items: center; display: flex; flex-wrap: wrap; list-style: none; position: relative; form { margin-bottom: 0 !important; } } ul li a { display: block } ul li small { color: var(--kbin-meta-text-color); font-size: .85rem; } } .page-user-overview { #sidebar { .user-info { ul li:first-of-type { border-top: 0; } } } } .page-people { .users-cards.section { padding: 0; } .users-cards { .section { padding: 0; overflow: clip; } } .user-box { .cover { height: 150px; } .user-main { justify-content: center; padding-top: 12.6rem; position: relative; .row { position: relative; .stretched-link { color: var(--kbin-text-color) !important; font-weight: bold !important; } } } .about { font-size: .9rem; } } .with-cover { .user-main { padding-top: 2.87rem } } .with-avatar { .user-main { padding-top: 6rem; } } .with-cover.with-avatar { padding-top: 0; position: inherit; } } .page-settings { .container { margin: 0 auto; max-width: 32.7rem; h2:first-of-type { margin-top: 0; } } } td .user__actions { margin: 0; } .page-settings-password { a.btn { display: inline-block; } } .page-settings-2fa { .twofa-qrcode { display: block; text-align: center; margin-bottom: 2rem; img { width: 250px; height: 250px; } } .twofa-backup-codes, .twofa-secret{ display: flex; gap: 0.2rem 1rem; flex-wrap: wrap; justify-content: start; font-family: monospace; background: var(--kbin-input-bg); border: var(--kbin-section-border); padding: 0.5rem; li { list-style: none; padding: 0.1rem 0.34rem; } } .actions { gap: 1rem; justify-content: flex-end; align-items: center; margin-bottom: 1rem; } } .new-user-icon { color: green; } .user-box-inline { padding: 0; overflow: clip; .user-box-info { display: flex; } .with-cover.with-avatar { .user-main { margin-top: 0; padding-top: 0; .user__actions { margin: 0; } } } .about { padding-top: 2em; margin-bottom: 1.5rem; } } ================================================ FILE: assets/styles/components/_vote.scss ================================================ .vote { display: grid; gap: .5rem; grid-template-rows: min-content min-content; .active.vote__up button { color: var(--kbin-upvoted-color); } .active.vote__down button { color: var(--kbin-downvoted-color); } button { background-color: var(--kbin-vote-bg); border: 0; color: var(--kbin-vote-text-color); cursor: pointer; font-weight: bold; height: 1.9rem; margin: 0; padding: 0; width: 4rem; &:hover, &:focus-visible { background-color: var(--kbin-vote-bg-hover-bg); color: var(--kbin-vote-text-hover-color); } span { font-size: .85rem; font-weight: normal; } } } ================================================ FILE: assets/styles/emails.scss ================================================ body{ background-color: #fff; color: #212529; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-size: 1em; font-weight: 400; line-height: 1.5; background-color: #fff; margin: 0px; } .container{ padding: 0.5em; } h1,h2,h3,h4,h5,h6{ margin: 0.5em auto; line-height: 100%; } h1{ } h2{ } h3{ } a { color: #37769e; text-decoration: none; &:hover { color: #275878; } } .btn { height: 100%; padding: 0.7em; font-size: 0.85em; cursor: pointer; &__danger{ background: #842029; border: 1px dashed #842029; color: #fff; font-weight: bold; &:hover, &:focus-visible { background: #921d27; color: #fff; } a, a:hover { color: #fff; } } &__primary { background: #4e3a8c; border: 1px solid #3f2e77; color: #fff; font-weight: bold; &:hover, &:focus-visible { background: #3f2e77; color: #fff; } a, a:hover { color: #fff; } } &__secondry { background: #fff; border: 1px dashed #e5eaec; color: #606060; &:hover, &:focus-visible { background: #f5f5f5; color: #212529; } } } .footer{ background-color: #1e1f22; color: #fff; a{ color: #fff; &:hover, &:active{ color: #e8e8e8; } } } .logo{ max-width: 100px; } .header{ background-color:#1e1f22; color: #fff; } ================================================ FILE: assets/styles/layout/_alerts.scss ================================================ .alert { margin: .5rem 0; padding: 1rem; position: relative; z-index: 2; p { margin: 0; } a { font-weight: bold; } i { font-size: 0.8rem; vertical-align: middle; } &__info { background: var(--kbin-alert-info-bg); border: var(--kbin-alert-info-border); color: var(--kbin-alert-info-text-color); a { color: var(--kbin-alert-info-link-color); } } &__danger { background: var(--kbin-alert-danger-bg); border: var(--kbin-alert-danger-border); color: var(--kbin-alert-danger-text-color); a { color: var(--kbin-alert-danger-link-color); } } &__success { background: var(--kbin-alert-success-bg); border: var(--kbin-alert-success-border); color: var(--kbin-alert-success-text-color); a { color: var(--kbin-alert-success-link-color); } } } ================================================ FILE: assets/styles/layout/_breakpoints.scss ================================================ @use "sass:list"; @use "sass:map"; // https://github.com/twbs/bootstrap/blob/main/scss/mixins/_breakpoints.scss // // Breakpoint viewport sizes and media queries. // // Breakpoints are defined as a map of (name: minimum width), order from small to large: // // (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px) // // The map defined in the `$grid-breakpoints` variable is used as the `$breakpoints` argument by default. @use '../variables' as v; // Name of the next breakpoint, or null for the last breakpoint. // // >> breakpoint-next(sm) // md // >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)) // md // >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl xxl)) // md @function breakpoint-next($name, $breakpoints: v.$grid-breakpoints, $breakpoint-names: map.keys($breakpoints)) { $n: list.index($breakpoint-names, $name); @if not $n { @error "breakpoint `#{$name}` not found in `#{$breakpoints}`"; } @return if($n < list.length($breakpoint-names), list.nth($breakpoint-names, $n + 1), null); } // Minimum breakpoint width. Null for the smallest (first) breakpoint. // // >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)) // 576px @function breakpoint-min($name, $breakpoints: v.$grid-breakpoints) { $min: map.get($breakpoints, $name); @return if($min != 0, $min, null); } // Maximum breakpoint width. // The maximum value is reduced by 0.02px to work around the limitations of // `min-` and `max-` prefixes and viewports with fractional widths. // See https://www.w3.org/TR/mediaqueries-4/#mq-min-max // Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari. // See https://bugs.webkit.org/show_bug.cgi?id=178261 // // >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)) // 767.98px @function breakpoint-max($name, $breakpoints: v.$grid-breakpoints) { $max: map.get($breakpoints, $name); @return if($max and $max > 0, $max - .02, null); } // Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front. // Useful for making responsive utilities. // // >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)) // "" (Returns a blank string) // >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)) // "-sm" @function breakpoint-infix($name, $breakpoints: v.$grid-breakpoints) { @return if(breakpoint-min($name, $breakpoints) == null, "", "-#{$name}"); } // Media of at least the minimum breakpoint width. No query for the smallest breakpoint. // Makes the @content apply to the given breakpoint and wider. @mixin media-breakpoint-up($name, $breakpoints: v.$grid-breakpoints) { $min: breakpoint-min($name, $breakpoints); @if $min { @media (min-width: $min) { @content; } } @else { @content; } } // Media of at most the maximum breakpoint width. No query for the largest breakpoint. // Makes the @content apply to the given breakpoint and narrower. @mixin media-breakpoint-down($name, $breakpoints: v.$grid-breakpoints) { $max: breakpoint-max($name, $breakpoints); @if $max { @media (max-width: $max) { @content; } } @else { @content; } } // Media that spans multiple breakpoint widths. // Makes the @content apply between the min and max breakpoints @mixin media-breakpoint-between($lower, $upper, $breakpoints: v.$grid-breakpoints) { $min: breakpoint-min($lower, $breakpoints); $max: breakpoint-max($upper, $breakpoints); @if $min != null and $max != null { @media (min-width: $min) and (max-width: $max) { @content; } } @else if $max == null { @include media-breakpoint-up($lower, $breakpoints) { @content; } } @else if $min == null { @include media-breakpoint-down($upper, $breakpoints) { @content; } } } // Media between the breakpoint's minimum and maximum widths. // No minimum for the smallest breakpoint, and no maximum for the largest one. // Makes the @content apply only to the given breakpoint, not viewports any wider or narrower. @mixin media-breakpoint-only($name, $breakpoints: v.$grid-breakpoints) { $min: breakpoint-min($name, $breakpoints); $next: breakpoint-next($name, $breakpoints); $max: breakpoint-max($next, $breakpoints); @if $min != null and $max != null { @media (min-width: $min) and (max-width: $max) { @content; } } @else if $max == null { @include media-breakpoint-up($name, $breakpoints) { @content; } } @else if $min == null { @include media-breakpoint-down($next, $breakpoints) { @content; } } } // Last ditch function; just "don't show on small screens" class .hide-on-mobile { @include media-breakpoint-down(lg) { display: none; } } ================================================ FILE: assets/styles/layout/_forms.scss ================================================ @use 'breakpoints' as b; @use '../mixins/mbin'; @use '@fortawesome/fontawesome-free/scss/fontawesome' as fa; // needed for the checkmark do render correctly even though it is not directly used @use '@fortawesome/fontawesome-free/scss/solid' as faS; .btn { font-size: .85rem; height: 100%; padding: .7rem; cursor: pointer; span { margin-left: .5rem; } &__danger { background: var(--kbin-button-danger-bg); border: var(--kbin-button-danger-border); color: var(--kbin-button-danger-text-color) !important; font-weight: bold; &:hover, &:focus-visible { background: var(--kbin-button-danger-hover-bg); color: var(--kbin-button-danger-text-hover-color) !important; } a, a:hover { color: var(--kbin-button-danger-text-hover-color) !important; } } &__primary { background: var(--kbin-button-primary-bg); border: var(--kbin-button-primary-border); color: var(--kbin-button-primary-text-color) !important; font-weight: bold; &:hover, &:focus-visible { background: var(--kbin-button-primary-hover-bg); color: var(--kbin-button-primary-text-hover-color) !important; } a, a:hover { color: var(--kbin-button-primary-text-hover-color) !important; } } &__secondary { background: var(--kbin-button-secondary-bg); border: var(--kbin-button-secondary-border); color: var(--kbin-button-secondary-text-color) !important; &:hover, &:focus-visible { background: var(--kbin-button-secondary-hover-bg); color: var(--kbin-button-secondary-text-hover-color) !important; } } &__secondry { background: var(--kbin-button-secondary-bg); border: var(--kbin-button-secondary-border); color: var(--kbin-button-secondary-text-color) !important; &:hover, &:focus-visible { background: var(--kbin-button-secondary-hover-bg); color: var(--kbin-button-secondary-text-hover-color) !important; } } &__emoji { cursor: pointer; float: right; position: absolute; top: 5px; right: 5px; height: fit-content; background-color: transparent; border: 0; &:hover { background-color: var(--kbin-button-primary-bg); } &.active { background-color: var(--kbin-button-primary-hover-bg); } } } select { -webkit-appearance: menulist-button; } input, textarea { background: var(--kbin-input-bg); border: var(--kbin-input-border); color: var(--kbin-input-text-color); font-family: var(--kbin-body-font-family); font-size: .9rem; &::placeholder { color: var(--kbin-input-placeholder-text-color) !important; } } input, textarea, select, button { &[disabled] { opacity: 0.5; cursor: not-allowed; } } textarea { box-sizing: border-box; height: 5rem; padding: 1rem .5rem; resize: vertical; width: 100%; } input[type=radio] { border-radius: 50%; } input[type=checkbox], input[type=radio] { -webkit-appearance: none; appearance: none; font-size: 0.9rem; display: grid; margin: 0px; width: 1.5rem; height: 1.5rem; justify-content: center; align-items: center; align-content: center; cursor: pointer; @include fa.fa-icon-solid(fa.$fa-var-check); &::before { transform: scale(0); transition: 100ms transform ease-in; } &:checked::before { transform: scale(1); } &[disabled] { opacity: 0.5; cursor: not-allowed; } } label { display: block; } input[type=text], input[type=email], input[type=password], input[type=select-one] { display: block; padding: 1rem .5rem; width: 100%; text-indent: .1rem !important; } .password-preview { display: grid; grid-template-areas: "label label" "password preview"; justify-items: start; align-items: end; grid-template-columns: 2fr 0fr; label { grid-area: label; } input[type=password], input[type=text] { grid-area: password; width: 100%; .rounded-edges & { border-top-right-radius: 0px !important; border-bottom-right-radius: 0px !important; } } .password-preview-button { display: flex; justify-content: center; align-items: center; grid-area: preview; width: 40px; cursor: pointer; height: 100%; margin: 0; .rounded-edges & { border-top-left-radius: 0px !important; border-bottom-left-radius: 0px !important; } } } form { div { margin-bottom: 1rem; ul { color: var(--kbin-danger-color); font-weight: bold; list-style: none; margin: 1rem 0; padding: 0; } ul li { padding: 0; } } .help-text { font-size: 0.8rem; &.checkbox { margin-top: -.75rem; margin-left: 2rem; margin-bottom: 0; } } .length-indicator { font-size: 0.8rem; } } .checkbox { display: flex; flex-direction: row-reverse; justify-content: flex-end; input[type=checkbox] { margin-right: .5rem; } } .ts-wrapper { div { margin-bottom: 0; } &.single .ts-control { background: var(--kbin-meta-bg); box-shadow: none; } .clear-button { color: var(--kbin-meta-text-color); font-size: 1.5rem; } .ts-control { background: var(--kbin-input-bg) !important; border: var(--kbin-input-border) !important; border-radius: 0; box-shadow: none !important; color: var(--kbin-input-text-color); display: flex; flex-flow: row wrap; gap: .5rem; padding: 1rem .5rem; width: 100%; line-height: normal; input { color: var(--kbin-input-text-color); width: auto; min-width: 8rem; border-radius: 0 !important; } & > * { font-size: .85rem; } } &.multi { .ts-control { > [data-value].item, > [data-value].active { background-image: none; background: var(--kbin-button-primary-bg); color: var(--kbin-button-primary-text-color); border: var(--kbin-button-primary-border); border-radius: 0; text-shadow: none; box-shadow: none; padding: 0 .5rem; height: 2rem; max-width: fit-content; margin: 0; overflow: clip; } } &.plugin-remove_button:not(.rtl) .item > .remove { display: inline-flex; align-items: center; justify-content: center; font-size: 1rem; border-left: var(--kbin-button-primary-border); margin-left: .5rem; padding: 0 .5rem 0 .4375rem; height: 100%; &:hover { background-color: var(--kbin-button-primary-hover-bg); color: var(--kbin-button-primary-text-hover-color); } } } &.single.input-active .ts-control input { color: var(--kbin-meta-text-color) //display: none !important; } &.single .ts-control, .ts-dropdown.single { border: var(--kbin-input-border); } .ts-dropdown { font-size: .85rem; line-height: normal; margin: -1px 0 0; border: var(--kbin-input-border); border-top: 0; background: var(--kbin-input-bg); color: var(--kbin-meta-text-color); box-shadow: var(--kbin-shadow); overflow: clip; .active { color: var(--kbin-meta-text-color); } &.single .active { background: var(--kbin-options-bg); } &.multi .active { background-color: var(--kbin-input-bg); } } &.multi.has-items .ts-control { padding: .5rem; } } .actions ul { margin: 0; > img { height: max-content; order: -2; flex: calc(100% - 2.5rem); width: 100%; max-height: 15rem; margin-bottom: .75rem; object-fit: contain; background-color: hsla(0, 0%, 0%, 0.75); .rounded-edges & { border-radius: var(--kbin-rounded-edges-radius); } + .btn-link { order: -1; width: 2rem; height: 2rem; margin-left: -2.25rem; color: var(--kbin-button-secondary-text-color); background: var(--kbin-button-secondary-bg); border: var(--kbin-button-secondary-border); &:hover { color: var(--kbin-button-secondary-text-hover-color); background: var(--kbin-button-secondary-hover-bg); } } } } .actions, .actions ul, .params { display: flex; gap: .25rem; justify-content: flex-end; align-items: center; .btn-link i { position: relative; } div { margin-bottom: 0; } div button { height: 100%; white-space: nowrap; } .ts-control { padding: .5rem; } .ts-wrapper { &.single .ts-control, .ts-dropdown.single { background: var(--kbin-input-bg) !important; border: var(--kbin-meta-border) !important; box-shadow: none; } } } .row.actions ul { flex-flow: row wrap; width: 100%; } select { background: var(--kbin-button-secondary-bg); border: var(--kbin-button-secondary-border); color: var(--kbin-button-secondary-text-color) !important; padding: 0.65rem; border-radius: 0; cursor: pointer; font-size: .85rem; } .select-flex { select { width: 100%; } } .button-flex-hf { text-align: center; button { width: 50%; @include b.media-breakpoint-down(lg) { width: 100%; } } } .actions { @include b.media-breakpoint-down(sm) { text-align: right; > * { margin-bottom: .5rem !important; } } } .params { color: var(--kbin-meta-text-color); font-size: .813rem; gap: 1rem; margin-bottom: .5rem !important; overflow: visible; > div { align-items: center; display: flex; flex-direction: row-reverse; } &__left { margin-bottom: 1rem; } } .radios { > div { display: flex; flex-wrap: wrap; label { margin-right: 1rem; } } } .actions select { max-width: 6.4rem; } markdown-toolbar { > * { @include mbin.cursor-pointer; @include mbin.simple-transition; &:focus, &:hover { color: var(--kbin-section-link-hover-color); } } } .tooltip { width: max-content; position: absolute; top: 0; left: 0; z-index: 9999; } .tooltip:not(.shown) { display: none; } div.input-box { background: var(--kbin-input-bg); border: var(--kbin-input-border); color: var(--kbin-input-text-color); font-family: var(--kbin-body-font-family); font-size: .9rem; display: block; padding: 1rem 0.5rem; width: 100%; line-height: normal; &.disabled { opacity: 0.5; cursor: not-allowed; } .rounded-edges & { border-radius: var(--kbin-rounded-edges-radius) !important; } } .form-control { display: block; width: 100%; } ================================================ FILE: assets/styles/layout/_icons.scss ================================================ i.active { color: var(--kbin-color-icon-active, orange); } ================================================ FILE: assets/styles/layout/_images.scss ================================================ .image-inline { display: inline-block; overflow: hidden; // inline icons/avatars that are nsfw // likely are used with .stretched-link, // so this is to get the mouse events above the link &.image-adult { position: relative; z-index: 2; } } .image-adult { filter: blur(8px); } ================================================ FILE: assets/styles/layout/_layout.scss ================================================ @use 'breakpoints' as b; body { background: var(--kbin-bg); position: relative; } #logo path { fill: red } .mbin-container { margin: 0 auto; max-width: 1360px; &.width--max { max-width: 100%; } @include b.media-breakpoint-up(lg) { &.width--auto { max-width: 85%; } } } #middle { background: var(--kbin-bg); z-index: 5; position: relative; .mbin-container { display: grid; grid-template-areas: 'main sidebar'; grid-template-columns: 3fr 1fr; @include b.media-breakpoint-up(lg) { .subs-show & { grid-template-areas: 'subs main sidebar'; grid-template-columns: minmax(305px, 1fr) 4fr minmax(305px, 1fr); } .sidebar-left & { grid-template-areas: 'sidebar main'; grid-template-columns: 1fr 3fr; } .sidebar-left.subs-show & { grid-template-areas: 'sidebar main subs'; grid-template-columns: minmax(305px, 1fr) 4fr minmax(305px, 1fr); } .sidebars-same-side.subs-show & { grid-template-areas: 'main sidebar subs '; grid-template-columns: 4fr minmax(305px, 1fr) minmax(305px, 1fr); } .sidebars-same-side.sidebar-left.subs-show & { grid-template-areas: 'subs sidebar main'; grid-template-columns: minmax(305px, 1fr) minmax(305px, 1fr) 4fr; } } @include b.media-breakpoint-down(lg) { grid-template-areas: 'subs subs' 'main main' 'sidebar sidebar'; grid-template-columns: 1fr; margin: 0 auto; } } //a:focus-visible, //input:focus-visible, //button:focus-visible, //textarea:focus-visible { // outline-color: darkorange; //} #main { grid-area: main; padding: 0 .5rem; position: relative; @include b.media-breakpoint-down(md) { overflow-x: clip; } } #sidebar { grid-area: sidebar; padding: 0 .5rem; } } html { box-sizing: border-box; } *, *:before, *:after { box-sizing: inherit; } menu { margin: 0; padding: 0; } .content { // margin: -3px !important; overflow: hidden !important; // padding: 3px !important; blockquote { border-left: 2px solid var(--kbin-blockquote-color); margin: 0 0 1rem 1rem !important; padding-left: 1rem; } } main { .content { a { color: var(--kbin-section-link-color) !important; } } } .row, .content { position: relative; word-break: break-word; } hr { border: 1px solid var(--kbin-bg); } .float-end { text-align: right; } table { border-collapse: collapse; font-family: sans-serif; font-size: .9em; -webkit-overflow-scrolling: touch; overflow-x: auto; width: 100%; border: var(--kbin-section-border); } table thead tr { font-weight: bold; text-align: left; } table th{ background-color: var(--kbin-bg); } table th a{ overflow-wrap: normal !important; } table th, table td { padding: 0.5rem 0.25rem; position: relative; border: var(--kbin-section-border); button.btn { padding: .25rem; } } table tbody tr { border-bottom: var(--kbin-section-border); } table tbody tr:nth-of-type(even) { background-color: var(--kbin-bg-nth); } .icon { font-size: 0; i { font-size: initial; } } figure { margin: 0; } .options--top, .section--top { margin-top: 0.5rem !important; } .rounded-edges { .section, .options, .alert, .btn, figure img, input:not([type='radio']), textarea, select, button, details, .preview img, .preview iframe, .dropdown__menu, #sidebar .theme, #sidebar .font-size, #sidebar .row div, #sidebar .user-list img, .no-image-placeholder, .pagination__item, .no-avatar, code, .ts-control > [data-value].item, .image-preview-container { &:not(.ignore-edges) { border-radius: var(--kbin-rounded-edges-radius) !important; } } .ts-wrapper { .ts-control { border-radius: .5rem; } &.dropdown-active .ts-control { border-radius: .5rem .5rem 0 0; } } .ts-dropdown { border-radius: 0 0 .5rem .5rem } .options { button { border-radius: 0; } menu { border-radius: 0 0 0 .5rem; } } .options--top, .section--top { border-radius: 0 0 .5rem .5rem !important; } .magazine__subscribe, .user__actions, .domain__subscribe { gap: 0.25rem } } .dot { background: var(--kbin-primary-color); border-radius: 50%; display: inline-block; height: 15px; width: 15px; } .opacity-50 { opacity: .5; } .ms-1 { margin-left: .5rem; } .me-1 { margin-right: .5rem; } .text-right { text-align: right !important; } .z-5 { z-index: 5 !important; } .visually-hidden { visibility: hidden; } .loader { animation: rotation 1s linear infinite; border: 5px solid var(--kbin-meta-text-color); border-bottom-color: transparent; border-radius: 50%; box-sizing: border-box; display: inline-block; height: 28px; text-align: center; width: 28px; line-height: 1; margin: auto; span { visibility: hidden; } &.hide{ display: none; } &.small{ width: 14px; height: 14px; border-width: 3px; } } .danger, .danger i { color: var(--kbin-danger-color); } .danger-bg { background: var(--kbin-danger-color); } .success, .success i { color: var(--kbin-success-color); } .secondary-bg { background: var(--kbin-section-bg); color: var(--kbin-meta-text-color); } .kbin-bg { background: var(--kbin-bg); } @keyframes rotation { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .hidden { display: none; } .select div { height: 100%; select { height: 100%; } } .flex { display: flex; gap: .25rem; &.flex-reverse { flex-direction: row-reverse; } } .flex-item { flex-grow: 1; &.flex-item-auto { flex-grow: 0; } } @include b.media-breakpoint-down(lg) { .flex.mobile { display: block; } } .flex-wrap { flex-wrap: wrap; } pre, code { white-space: pre-wrap; word-wrap: break-word; } pre > code { display: inline-block; color: var(--kbin-text-color); background: var(--kbin-bg); padding: 1rem; font-size: .85rem; max-height: 16rem; overflow: auto; } p > code { color: var(--kbin-text-color); background: var(--kbin-bg); padding: 0.2rem .4rem; font-size: .85rem; } details { border: var(--mbin-details-border); border-left: 2px solid var(--mbin-details-detail-color); padding: .5rem; margin: .5rem 0; summary { padding-left: .5rem; cursor: pointer; > * { display: inline; } &:empty::after { content: var(--mbin-details-detail-label); } } > .content { margin-top: .5rem; padding-top: .5rem; padding-left: .5rem; } &.spoiler { border-left: 2px solid var(--mbin-details-spoiler-color); summary:empty::after { content: var(--mbin-details-spoiler-label); } } &[open] > .content { border-top: var(--mbin-details-separator-border); } @include b.media-breakpoint-down(sm) { summary, > .content { padding-left: .25rem; } } #sidebar & { summary, > .content { padding-left: .25rem; } } } .markdown { display: flex; flex-wrap: wrap; gap: 1rem; padding: .5rem; } #scroll-top { background-color: var(--kbin-section-bg); border-radius: 5px; bottom: 20px; cursor: pointer; //display: none; font-size: 18px; outline: none; padding: 15px 20px; position: fixed; right: 30px; z-index: 99; } .js-container { margin-bottom: 0; } .bold { font-weight: bold; } .no-avatar { display: block; width: 30px; height: 30px; border: var(--kbin-avatar-border); @include b.media-breakpoint-up(sm) { width: 40px; height: 40px; } } :target { scroll-margin-top: 8rem; } .boost-link { &.active{ color: var(--kbin-boosted-color); } } .magazine-banner { margin-top: .5rem; text-align: center; img.cover { width: 100%; max-height: 300px; object-fit: cover; } } .rounded-edges { .magazine-banner { border-radius: var(--kbin-rounded-edges-radius); img.cover { border-radius: var(--kbin-rounded-edges-radius); } } } ================================================ FILE: assets/styles/layout/_meta.scss ================================================ .meta { color: var(--kbin-meta-text-color); font-size: .8rem; opacity: .75; a { color: var(--kbin-meta-link-color); font-weight: bold; padding: .5rem 0; img { margin-left: 0.25rem; } &:hover { color: var(--kbin-meta-link-hover-color); } &:first-of-type { margin-left: 0rem; } } } .meta-link { color: var(--kbin-meta-link-color); } ================================================ FILE: assets/styles/layout/_normalize.scss ================================================ /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ /* Document ========================================================================== */ /** * 1. Correct the line height in all browsers. * 2. Prevent adjustments of font size after orientation changes in iOS. */ html { line-height: 1.15; /* 1 */ -webkit-text-size-adjust: 100%; /* 2 */ } /* Sections ========================================================================== */ /** * Remove the margin in all browsers. */ body { margin: 0; } /** * Render the `main` element consistently in IE. */ main { display: block; } /** * Correct the font size and margin on `h1` elements within `section` and * `article` contexts in Chrome, Firefox, and Safari. */ h1 { font-size: 2em; margin: 0.67em 0; } /* Grouping content ========================================================================== */ /** * 1. Add the correct box sizing in Firefox. * 2. Show the overflow in Edge and IE. */ hr { box-sizing: content-box; /* 1 */ height: 0; /* 1 */ overflow: visible; /* 2 */ } /** * 1. Correct the inheritance and scaling of font size in all browsers. * 2. Correct the odd `em` font sizing in all browsers. */ pre { font-family: monospace, monospace; /* 1 */ font-size: 1em; /* 2 */ } /* Text-level semantics ========================================================================== */ /** * Remove the gray background on active links in IE 10. */ a { background-color: transparent; } /** * 1. Remove the bottom border in Chrome 57- * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. */ abbr[title] { border-bottom: none; /* 1 */ text-decoration: underline; /* 2 */ text-decoration: underline dotted; /* 2 */ } /** * Add the correct font weight in Chrome, Edge, and Safari. */ b, strong { font-weight: bolder; } /** * 1. Correct the inheritance and scaling of font size in all browsers. * 2. Correct the odd `em` font sizing in all browsers. */ code, kbd, samp { font-family: monospace, monospace; /* 1 */ font-size: 1em; /* 2 */ } /** * Add the correct font size in all browsers. */ small { font-size: 80%; } /** * Prevent `sub` and `sup` elements from affecting the line height in * all browsers. */ sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } /* Embedded content ========================================================================== */ /** * Remove the border on images inside links in IE 10. */ img { border-style: none; } /* Forms ========================================================================== */ /** * 1. Change the font styles in all browsers. * 2. Remove the margin in Firefox and Safari. */ button, input, optgroup, select, textarea { font-family: inherit; /* 1 */ font-size: 100%; /* 1 */ line-height: 1.15; /* 1 */ margin: 0; /* 2 */ } /** * Show the overflow in IE. * 1. Show the overflow in Edge. */ button, input { /* 1 */ overflow: visible; } /** * Remove the inheritance of text transform in Edge, Firefox, and IE. * 1. Remove the inheritance of text transform in Firefox. */ button, select { /* 1 */ text-transform: none; } /** * Correct the inability to style clickable types in iOS and Safari. */ button, [type="button"], [type="reset"], [type="submit"] { -webkit-appearance: button; } /** * Remove the inner border and padding in Firefox. */ button::-moz-focus-inner, [type="button"]::-moz-focus-inner, [type="reset"]::-moz-focus-inner, [type="submit"]::-moz-focus-inner { border-style: none; padding: 0; } /** * Restore the focus styles unset by the previous rule. */ button:-moz-focusring, [type="button"]:-moz-focusring, [type="reset"]:-moz-focusring, [type="submit"]:-moz-focusring { outline: 1px dotted ButtonText; } /** * Correct the padding in Firefox. */ fieldset { padding: 0.35em 0.75em 0.625em; } /** * 1. Correct the text wrapping in Edge and IE. * 2. Correct the color inheritance from `fieldset` elements in IE. * 3. Remove the padding so developers are not caught out when they zero out * `fieldset` elements in all browsers. */ legend { box-sizing: border-box; /* 1 */ color: inherit; /* 2 */ display: table; /* 1 */ max-width: 100%; /* 1 */ padding: 0; /* 3 */ white-space: normal; /* 1 */ } /** * Add the correct vertical alignment in Chrome, Firefox, and Opera. */ progress { vertical-align: baseline; } /** * Remove the default vertical scrollbar in IE 10+. */ textarea { overflow: auto; } /** * 1. Add the correct box sizing in IE 10. * 2. Remove the padding in IE 10. */ [type="checkbox"], [type="radio"] { box-sizing: border-box; /* 1 */ padding: 0; /* 2 */ } /** * Correct the cursor style of increment and decrement buttons in Chrome. */ [type="number"]::-webkit-inner-spin-button, [type="number"]::-webkit-outer-spin-button { height: auto; } /** * 1. Correct the odd appearance in Chrome and Safari. * 2. Correct the outline style in Safari. */ [type="search"] { -webkit-appearance: textfield; /* 1 */ outline-offset: -2px; /* 2 */ } /** * Remove the inner padding in Chrome and Safari on macOS. */ [type="search"]::-webkit-search-decoration { -webkit-appearance: none; } /** * 1. Correct the inability to style clickable types in iOS and Safari. * 2. Change font properties to `inherit` in Safari. */ ::-webkit-file-upload-button { -webkit-appearance: button; /* 1 */ font: inherit; /* 2 */ } /* Interactive ========================================================================== */ /* * Add the correct display in Edge, IE 10+, and Firefox. */ details { display: block; } /* * Add the correct display in all browsers. */ summary { display: list-item; } /* Misc ========================================================================== */ /** * Add the correct display in IE 10+. */ template { display: none; } /** * Add the correct display in IE 10. */ [hidden] { display: none; } ================================================ FILE: assets/styles/layout/_options.scss ================================================ @use 'breakpoints' as b; .options { background: var(--kbin-options-bg); border: var(--kbin-options-border); color: var(--kbin-options-text-color); display: grid; font-size: .85rem; grid-template-areas: "start middle beforeEnd end"; grid-template-columns: max-content auto max-content max-content; height: 2.5rem; margin-bottom: .5rem; z-index: 5; .dropdown__menu { opacity: 1; } .dropdown:hover, .dropdown:focus-within{ .dropdown__menu { @include b.media-breakpoint-down(lg){ left: auto; top: 100%; transform: none; right: 0; min-width: 10rem; } } } .options__filter .dropdown__menu, .options__filter .dropdown:hover .dropdown__menu, .options__filter .dropdown:focus-within .dropdown__menu { /* Positioning for dropdown menus inside .options__main */ left: 0; right: auto; /* Reset the right property */ top: 100%; /* Position it below the trigger element */ transform: none; min-width: 10rem; } .scroll { position: static; align-self: center; border-left: var(--kbin-options-border); border-radius: 0; height: 100%; padding: 0px; .scroll-left, .scroll-right{ padding: 0.5rem; cursor: pointer; color: var(--kbin-button-secondary-text-color); &:hover, &:active{ color: var(--kbin-button-secondary-text-hover-color); } } } &__view{ li:not(:last-of-type){ button{ border-bottom-left-radius: 0px!important; border-bottom-right-radius: 0px!important; } } li:last-of-type{ button{ border-bottom-left-radius: 0px!important; } } } &__filter { li:first-of-type { button { border-bottom-right-radius: 0px!important; } } li:not(:first-of-type) { button { border-bottom-right-radius: 0px!important; border-bottom-left-radius: 0px!important; } } button { font-size: 0; i { font-size: .85rem; } span { font-size: .85rem; margin-left: 0.5rem; } } } &--top { border-top: 0; } h1, h2, h3 { font-size: .85rem; font-weight: bold; margin: 0; border-bottom: 3px solid transparent; } & > * { align-items: center; align-self: self-end; display: grid; grid-auto-columns: max-content; grid-auto-flow: column; justify-content: end; list-style: none; margin: 0; padding: 0; .options__nolink { background: none; border: 0; border-bottom: 3px solid transparent; display: block; padding: .5rem 1rem; text-decoration: none; } a, button { background: none; border: 0; border-bottom: 3px solid transparent; color: var(--kbin-options-link-color); display: block; padding: .5rem 1rem; text-decoration: none; &.active, &:focus-visible, &:hover { border-bottom: var(--kbin-options-link-hover-border); color: var(--kbin-options-link-hover-color); } } } &__main { justify-content: start; overflow: hidden; -ms-overflow-style: none; overflow-x: auto; scrollbar-width: none; } &__main::-webkit-scrollbar { display: none; } &__title { align-self: center; margin: 0 .5rem; text-transform: uppercase; } &__filter { justify-content: start; } &__view button { font-size: 0; i { font-size: .85rem; } span { font-size: .85rem; margin-left: 0.5rem; @include b.media-breakpoint-down(lg) { display: none; } } } } .pills { margin-bottom: .5rem; padding: 1rem 0; menu, div { display: flex; flex-wrap: wrap; gap: .5rem; list-style: none; a { color: var(--kbin-meta-link-color); font-weight: bold; padding: 1rem; } a:hover, .active { color: var(--kbin-meta-link-hover-color); } } } ================================================ FILE: assets/styles/layout/_section.scss ================================================ .section { background-color: var(--kbin-section-bg); border: var(--kbin-section-border); color: var(--kbin-section-text-color); margin-bottom: .5rem; padding: 2rem 1rem; &.section-sm { padding: .5em; } a:not(.notification-setting) { color: var(--kbin-section-title-link-color); overflow-wrap: anywhere; &:hover { color: var(--kbin-section-link-hover-color); } } menu { font-size: .8rem; > li:first-child a { padding-left: 0; } > li { padding: .5rem 0; position: relative; } } &--small { padding: 1rem 1rem; } &--top { border-top: 0 !important; } &--muted { font-size: 1.3rem !important; font-weight: bold; text-align: center; p { color: var(--kbin-text-muted-color); margin: 0; } small { color: var(--kbin-text-muted-color); display: block; font-size: .8rem; font-weight: normal; } } &--no-bg { background: none; } &--no-border { border: 0; } &__danger { border: var(--kbin-alert-danger-border); background-color: var(--kbin-alert-danger-bg); color: var(--kbin-alert-danger-text-color); } } .cursor-pagination .section { text-align: center; padding: .25rem; .container { display: flex; flex-direction: row; > .col { display: block; flex-grow: 1; &.col-auto { flex-grow: 0; } } } a { display: inline-block; width: 150px; &.small { width: 50px } } } ================================================ FILE: assets/styles/layout/_tools.scss ================================================ .stretched-link { &::after { bottom: 0; content: ''; left: 0; position: absolute; right: 0; top: 0; z-index: 1; } } a.link-muted, .text-muted { color: var(--kbin-meta-text-color); font-weight: normal; } a.link-muted:hover { color: var(--kbin-link-hover-color); } .mb-0 { margin-bottom: 0 !important; } .mb-1 { margin-bottom: .5rem !important; } .mb-2 { margin-bottom: 1rem !important; } .mt-0 { margin-bottom: 0 !important; } .mt-1 { margin-top: .5rem !important; } .mt-2 { margin-top: 1rem !important; } .mt-3 { margin-top: 1.5rem !important; } .mt-4 { margin-top: 2rem !important; } .table-responsive { -webkit-overflow-scrolling: touch; overflow-x: auto; } .user-content-table-responsive { display: block; overflow-x: auto; white-space: pre-wrap; } .badge { border-radius: 20px; padding: .5rem; } .badge-lang { border-radius: 20px; padding: .3rem; } ================================================ FILE: assets/styles/layout/_typo.scss ================================================ @use '../mixins/mbin'; body { background-color: var(--kbin-body-bg); color: var(--kbin-text-color); font-family: var(--kbin-body-font-family); font-size: var(--kbin-body-font-size); font-weight: var(--kbin-body-font-weight); line-height: var(--kbin-body-line-height); margin: 0; -webkit-tap-highlight-color: transparent; text-align: var(--kbin-body-text-align); -webkit-text-size-adjust: 100%; } a { color: var(--kbin-link-color); text-decoration: none; &:hover { color: var(--kbin-link-hover-color); } } p { margin-bottom: 1rem; margin-top: 0; } h1 { font-size: 2.5rem; } h2 { font-size: 2rem; } h3 { font-size: 1.75rem; } h4 { font-size: 1.5rem; } h5 { font-size: 1.25rem; } h6 { font-size: 1rem; } .content:not(.formatted) { h1, h2, h3, h4, h5 { font-size: 1rem; font-weight: bold; } } .btn-link { @include mbin.btn-link; } ================================================ FILE: assets/styles/mixins/animations.scss ================================================ @use '../layout/breakpoints' as b; @mixin fade-in($waitTime, $from) { animation: fadein #{$waitTime} linear 1 normal forwards; -webkit-animation: fadein #{$waitTime} linear 1 normal forwards; @include b.media-breakpoint-down(sm) { animation: fadein 0s linear 1 normal forwards; -webkit-animation: fadein 0s linear 1 normal forwards; } @keyframes fadein { from { opacity: $from; } to { opacity: 1; } } @-webkit-keyframes fadein { from { opacity: $from; } to { opacity: 1; } } } ================================================ FILE: assets/styles/mixins/mbin.scss ================================================ @mixin btn-link { background: none; border: 0; display: inline; font-weight: normal; margin: 0; padding: 0; color: var(--kbin-meta-link-color); font-size: 1em; &:hover, &:focus-within { background: none; cursor: pointer; } &:hover { color: var(--kbin-meta-link-hover-color); } } @mixin cursor-pointer{ cursor: pointer; } @mixin simple-transition{ transition: all 150ms ease-in; } ================================================ FILE: assets/styles/mixins/theme-dark.scss ================================================ // loaded under .theme--dark // or .theme--default with prefers-color-scheme: dark @mixin theme { --kbin-body-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; --kbin-body-font-size: 1rem; --kbin-body-font-weight: 400; --kbin-body-line-height: 1.5; --kbin-body-text-align: left; --kbin-body-bg: #1c1c1c; --kbin-bg: #1c1c1c; --kbin-bg-nth: #232323; --kbin-text-color: #cacece; --kbin-link-color: #cdd5de; --kbin-link-hover-color: #fafafa; --kbin-outline: #ff8c00 solid 4px; --kbin-primary-color: #3c3c3c; --kbin-text-muted-color: #95a6a6; --kbin-success-color: #24b270; --kbin-danger-color: #dc2f3f; --kbin-own-color: #24b270; --kbin-author-color: #dc2f3f; // section --kbin-section-bg: #2c2c2c; --kbin-section-text-color: var(--kbin-text-color); --kbin-section-title-link-color: var(--kbin-link-color); --kbin-section-link-color: #85a1c0; --kbin-section-link-hover-color: var(--kbin-link-hover-color); --kbin-section-border: 1px solid #373737; --kbin-author-border: 1px dashed var(--kbin-author-color); --kbin-own-border: 1px dashed var(--kbin-own-color); // meta --kbin-meta-bg: none; --kbin-meta-text-color: #cecece; --kbin-meta-link-color: #d9dde5; --kbin-meta-link-hover-color: var(--kbin-link-hover-color); --kbin-meta-border: 1px dashed #4d5052; --kbin-avatar-border: 3px solid #373737; --kbin-avatar-border-active: 3px solid #3c3c3c; --kbin-blockquote-color: #24b270; // options --kbin-options-bg: #2c2c2c; --kbin-options-text-color: #95a5a6; --kbin-options-link-color: #cdd5de; --kbin-options-link-hover-color: #e1e5ec; --kbin-options-border: 1px solid #373737; --kbin-options-link-hover-border: 3px solid #e1e5ec; // forms --kbin-input-bg: #1c1c1c; --kbin-input-text-color: var(--kbin-text-color); --kbin-input-border-color: #373737; --kbin-input-border: 1px solid var(--kbin-input-border-color); --kbin-input-placeholder-text-color: #878787; // buttons --kbin-button-primary-bg: var(--kbin-primary-color); --kbin-button-primary-hover-bg: #333333; --kbin-button-primary-text-color: #fff; --kbin-button-primary-text-hover-color: #fff; --kbin-button-primary-border: 1px solid #4a4a4a; --kbin-button-secondary-bg: #1c1c1c; --kbin-button-secondary-hover-bg: #282828; --kbin-button-secondary-text-color: var(--kbin-meta-text-color); --kbin-button-secondary-text-hover-color: var(--kbin-text-color); --kbin-button-secondary-border: 1px dashed #373737; // header --kbin-header-bg: var(--kbin-primary-color); --kbin-header-text-color: #fff; --kbin-header-link-color: #fff; --kbin-header-link-hover-color: #e8e8e8; --kbin-header-link-active-bg: var(--kbin-options-bg); --kbin-header-border: 1px solid #e5eaec; --kbin-header-hover-border: 3px solid #fff; // topbar --kbin-topbar-bg: var(--kbin-section-bg); --kbin-topbar-active-bg: #fff; --kbin-topbar-active-link-color: #000; --kbin-topbar-hover-bg: #282828; --kbin-topbar-border: 1px solid #4d5052; //sidebar --kbin-sidebar-header-text-color: #909ea2; --kbin-sidebar-header-border: 1px solid #4a4a4a; --kbin-sidebar-settings-row-bg: #3c3c3c; --kbin-sidebar-settings-switch-on-color: #FFFFFF; --kbin-sidebar-settings-switch-on-bg: #B3B3B3; --kbin-sidebar-settings-switch-off-color: #989898; --kbin-sidebar-settings-switch-off-bg: #1C1C1C; --kbin-sidebar-settings-switch-hover-bg: #666666; //vote --kbin-vote-bg: #1c1c1c; --kbin-vote-bg-hover-bg: #161616; --kbin-vote-text-color: #b6b6b6; --kbin-vote-text-hover-color: #fafafa; --kbin-upvoted-color: #24b270; --kbin-downvoted-color: #dc2f3f; //boost --kbin-boosted-color: var(--kbin-upvoted-color); // alerts --kbin-alert-info-bg: rgba(153,116,4,0.15); --kbin-alert-info-border: 1px solid rgba(153,116,4, 0.04); --kbin-alert-info-text-color: #997404; --kbin-alert-info-link-color: #997404; --kbin-alert-danger-bg: rgba(171, 28, 40, 0.9); --kbin-alert-danger-border: 1px solid rgba(171, 28, 40, 1); --kbin-alert-danger-text-color: var(--kbin-danger-color); --kbin-alert-danger-link-color: var(--kbin-danger-color); //entry --kbin-entry-link-visited-color: #8e939b; // details --mbin-details-border: var(--kbin-section-border); --mbin-details-separator-border: var(--kbin-meta-border); --mbin-details-detail-color: var(--kbin-link-hover-color); --mbin-details-spoiler-color: var(--kbin-danger-color); } ================================================ FILE: assets/styles/mixins/theme-light.scss ================================================ // loaded under .theme--light // or .theme--default with prefers-color-scheme: light @mixin theme { --kbin-body-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; --kbin-body-font-size: 1rem; --kbin-body-font-weight: 400; --kbin-body-line-height: 1.5; --kbin-body-text-align: left; --kbin-body-bg: #fff; --kbin-bg: #ecf0f1; --kbin-bg-nth: #fafafa; --kbin-text-color: #1c1e20; --kbin-link-color: #245e83; --kbin-link-hover-color: #153b57; --kbin-outline: #ff8c00 solid 4px; --kbin-primary-color: #61366b; --kbin-text-muted-color: #95a6a6; --kbin-success-color: #0f5132; --kbin-danger-color: #842029; --kbin-own-color: #0f5132; --kbin-author-color: #842029; // section --kbin-section-bg: #fff; --kbin-section-text-color: var(--kbin-text-color); --kbin-section-title-link-color: var(--kbin-link-color); --kbin-section-link-color: var(--kbin-link-color); --kbin-section-link-hover-color: var(--kbin-link-hover-color); --kbin-section-border: 1px solid #e5eaec; --kbin-author-border: 1px dashed var(--kbin-author-color); --kbin-own-border: 1px dashed var(--kbin-own-color); // meta --kbin-meta-bg: none; --kbin-meta-text-color: #222; --kbin-meta-link-color: #222; --kbin-meta-link-hover-color: var(--kbin-link-hover-color); --kbin-meta-border: 1px dashed #e5eaec; --kbin-avatar-border: 3px solid #ecf0f1; --kbin-avatar-border-active: 3px solid #d3d5d6; --kbin-blockquote-color: #0f5132; // options --kbin-options-bg: #fff; --kbin-options-text-color: #6f7575; --kbin-options-link-color: #6f7575; --kbin-options-link-hover-color: #32465b; --kbin-options-border: 1px solid #e5eaec; --kbin-options-link-hover-border: 3px solid #32465b; // forms --kbin-input-bg: #f3f3f3; --kbin-input-text-color: var(--kbin-text-color); --kbin-input-border-color: #c9cecf; --kbin-input-border: 1px solid var(--kbin-input-border-color); --kbin-input-placeholder-text-color: #929497; // buttons --kbin-button-primary-bg: #7951a7; --kbin-button-primary-hover-bg: #66438f; --kbin-button-primary-text-color: #fff; --kbin-button-primary-text-hover-color: #fff; --kbin-button-primary-border: 1px solid #66438f; --kbin-button-secondary-bg: #fff; --kbin-button-secondary-hover-bg: #f5f5f5; --kbin-button-secondary-text-color: var(--kbin-meta-text-color); --kbin-button-secondary-text-hover-color: var(--kbin-text-color); --kbin-button-secondary-border: 1px dashed #e5eaec; // header --kbin-header-bg: #2c074b; --kbin-header-text-color: #fff; --kbin-header-link-color: #fff; --kbin-header-link-hover-color: #e8e8e8; --kbin-header-link-active-bg: #0a0026; --kbin-header-border: 1px solid #e5eaec; --kbin-header-hover-border: 3px solid #fff; // topbar --kbin-topbar-bg: #0a0026; --kbin-topbar-active-bg: #150a37; --kbin-topbar-active-link-color: #fff; --kbin-topbar-hover-bg: #150a37; --kbin-topbar-border: 1px solid #150a37; //sidebar --kbin-sidebar-header-text-color: #595d5e; --kbin-sidebar-header-border: 1px solid #e5eaec; --kbin-sidebar-settings-row-bg: #E5EAEC; --kbin-sidebar-settings-switch-on-color: #fff ; --kbin-sidebar-settings-switch-on-bg: var(--kbin-button-primary-bg); --kbin-sidebar-settings-switch-off-color: #fff ; --kbin-sidebar-settings-switch-off-bg: #b5c4c9; --kbin-sidebar-settings-switch-hover-bg: #9992BC; //vote --kbin-vote-bg: #e7e7e7; --kbin-vote-bg-hover-bg: #dfdfdf; --kbin-vote-text-color: #222222; --kbin-vote-text-hover-color: #0d0014; --kbin-upvoted-color: #0f663d; --kbin-downvoted-color: #961822; //boost --kbin-boosted-color: var(--kbin-upvoted-color); // alerts --kbin-alert-info-bg: #fff3cd; --kbin-alert-info-border: 1px solid #ffe69c; --kbin-alert-info-text-color: #997404; --kbin-alert-info-link-color: #997404; --kbin-alert-danger-bg: #f8d7da; --kbin-alert-danger-border: 1px solid #f5c2c7; --kbin-alert-danger-text-color: var(--kbin-danger-color); --kbin-alert-danger-link-color: var(--kbin-danger-color); //entry --kbin-entry-link-visited-color: #60707a; // details --mbin-details-border: var(--kbin-section-border); --mbin-details-separator-border: var(--kbin-meta-border); --mbin-details-detail-color: var(--kbin-link-hover-color); --mbin-details-spoiler-color: var(--kbin-danger-color); } ================================================ FILE: assets/styles/mixins/theme-solarized-dark.scss ================================================ $base03: #002b36; $base02: #073642; $base01: #586e75; $base00: #657b83; $base0: #839496; $base1: #93a1a1; $base2: #eee8d5; $base3: #fdf6e3; $yellow: #b58900; $orange: #cb4b16; $red: #dc322f; $magenta: #d33682; $violet: #6c71c4; $blue: #268bd2; $cyan: #2aa198; $green: #859900; // loaded under .theme--solarized-dark // or .theme--solarized with prefers-color-scheme: dark @mixin theme { --kbin-body-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; --kbin-body-font-size: 1rem; --kbin-body-font-weight: 400; --kbin-body-line-height: 1.5; --kbin-body-text-align: left; --kbin-body-bg: #{$base02}; --kbin-bg: #{$base02}; --kbin-bg-nth: #{$base03}; --kbin-text-color: #809293; --kbin-link-color: #b2c9cc; --kbin-link-hover-color: #dcf5f8; --kbin-outline: #ff8c00 solid 4px; --kbin-primary-color: #{$base02}; --kbin-text-muted-color: #{$base01}; --kbin-success-color: #{$green}; --kbin-danger-color: #{$red}; --kbin-own-color: #{$green}; --kbin-author-color: #{$red}; // section --kbin-section-bg: #{$base03}; --kbin-section-text-color: var(--kbin-text-color); --kbin-section-title-link-color: var(--kbin-link-color); --kbin-section-link-color: #{$yellow}; --kbin-section-link-hover-color: var(--kbin-link-hover-color); --kbin-section-border-color: #214852; --kbin-section-border: 1px solid var(--kbin-section-border-color); --kbin-author-border: 1px dashed var(--kbin-author-color); --kbin-own-border: 1px dashed var(--kbin-own-color); // meta --kbin-meta-bg: none; --kbin-meta-text-color: #{$base1}; --kbin-meta-link-color: #{$base01}; --kbin-meta-link-hover-color: #a9bbbb; --kbin-meta-border: 1px dashed #001a21; --kbin-avatar-border: 3px solid #373737; --kbin-avatar-border-active: 3px solid #555555; --kbin-blockquote-color: #{$green}; // options --kbin-options-bg: #{$base03}; --kbin-options-text-color: #95a5a6; --kbin-options-link-color: #{$base00}; --kbin-options-link-hover-color: #e1e5ec; --kbin-options-border: 1px solid #214852; --kbin-options-link-hover-border: 3px solid #{$magenta}; // forms --kbin-input-bg: #00242c; --kbin-input-text-color: var(--kbin-text-color); --kbin-input-border-color: var(--kbin-section-border-color); --kbin-input-border: 1px solid var(--kbin-input-border-color); --kbin-input-placeholder-text-color: #5d6a6b; // buttons --kbin-button-primary-bg: var(--kbin-primary-color); --kbin-button-primary-hover-bg: #0b5467; --kbin-button-primary-text-color: #fff; --kbin-button-primary-text-hover-color: #fff; --kbin-button-primary-border: 1px solid #04242c; --kbin-button-secondary-bg: #00242c; --kbin-button-secondary-hover-bg: #01313b; --kbin-button-secondary-text-color: var(--kbin-meta-text-color); --kbin-button-secondary-text-hover-color: var(--kbin-text-color); --kbin-button-secondary-border: 1px dashed #001a21; // header --kbin-header-bg: var(--kbin-primary-color); --kbin-header-text-color: #{$base0}; --kbin-header-link-color: #{$base0}; --kbin-header-link-hover-color: #e8e8e8; --kbin-header-link-active-bg: var(--kbin-options-bg); --kbin-header-border: 1px solid #e5eaec; --kbin-header-hover-border: 3px solid #{$base01}; // topbar --kbin-topbar-bg: #{$base03}; --kbin-topbar-active-bg: var(--kbin-primary-color); --kbin-topbar-link-color: var(--kbin-meta-text-color); --kbin-topbar-active-link-color: #{$base2}; --kbin-topbar-hover-bg: var(--kbin-primary-color); --kbin-topbar-border: 1px solid #{$base02}; //sidebar --kbin-sidebar-header-text-color: #909ea2; --kbin-sidebar-header-border: var(--kbin-section-border); --kbin-sidebar-settings-row-bg: var(--kbin-body-bg); --kbin-sidebar-settings-switch-on-color: #40a4bf; --kbin-sidebar-settings-switch-on-bg: var(--kbin-button-primary-hover-bg); --kbin-sidebar-settings-switch-off-color: var(--kbin-body-bg); --kbin-sidebar-settings-switch-off-bg: var(--kbin-button-secondary-bg); --kbin-sidebar-settings-switch-hover-bg: #084554; //vote --kbin-vote-bg: #00242c; --kbin-vote-bg-hover-bg: #001f25; --kbin-vote-text-color: #b6b6b6; --kbin-vote-text-hover-color: #fafafa; --kbin-upvoted-color: #{$green}; --kbin-downvoted-color: #{$red}; //boost --kbin-boosted-color: var(--kbin-upvoted-color); // alerts --kbin-alert-info-bg: rgba(153,116,4,0.15); --kbin-alert-info-border: 1px solid rgba(153,116,4,0.4); --kbin-alert-info-text-color: #997404; --kbin-alert-info-link-color: #997404; --kbin-alert-danger-bg: rgba(171, 28, 40, 0.9); --kbin-alert-danger-border: 1px solid rgba(171, 28, 40, 1); --kbin-alert-danger-text-color: var(--kbin-danger-color); --kbin-alert-danger-link-color: var(--kbin-danger-color); //entry --kbin-entry-link-visited-color: #586e75; // details --mbin-details-border: var(--kbin-section-border); --mbin-details-separator-border: var(--kbin-meta-border); --mbin-details-detail-color: #{$blue}; --mbin-details-spoiler-color: var(--kbin-danger-color); .options--top, .section--top { border-top: var(--kbin-options-border) !important; } &.rounded-edges { .section--top, .options--top { border-radius: .5rem !important; } .head-nav__menu li a { border-radius: 0 0 .5rem .5rem; } } .entry__domain, .entry__domain a { color: $yellow !important; } } ================================================ FILE: assets/styles/mixins/theme-solarized-light.scss ================================================ $base03: #002b36; $base02: #073642; $base01: #405358; $base00: #5b6f77; $base0: #839496; $base1: #606e6e; $base2: #eee8d5; $base3: #fdf6e3; $yellow: #b58900; $orange: #cb4b16; $red: #dc322f; $magenta: #d33682; $violet: #c0ae73; $blue: #268bd2; $cyan: #2aa198; $green: #859900; // loaded under .theme--solarized-light // or .theme--solarized with prefers-color-scheme: light @mixin theme { --kbin-body-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; --kbin-body-font-size: 1rem; --kbin-body-font-weight: 400; --kbin-body-line-height: 1.5; --kbin-body-text-align: left; --kbin-body-bg: #eee8d5; --kbin-bg: #eee8d5; --kbin-bg-nth: #{$base3}; --kbin-text-color: #5b6f75; --kbin-link-color: #4d6369; --kbin-link-hover-color: #3a4c52; --kbin-outline: #ff8c00 solid 4px; --kbin-primary-color: #{$base2}; --kbin-text-muted-color: #bebfbf; --kbin-success-color: #{$green}; --kbin-danger-color: #{$red}; --kbin-own-color: #{$green}; --kbin-author-color: #{$red}; // section --kbin-section-bg: #{$base3}; --kbin-section-text-color: var(--kbin-text-color); --kbin-section-title-link-color: var(--kbin-link-color); --kbin-section-link-color: #{$blue}; --kbin-section-link-hover-color: var(--kbin-link-hover-color); --kbin-section-border-color: #c8cac0; --kbin-section-border: 1px solid var(--kbin-section-border-color); --kbin-author-border: 1px dashed var(--kbin-author-color); --kbin-own-border: 1px dashed var(--kbin-own-color); // meta --kbin-meta-bg: none; --kbin-meta-text-color: #{$base1}; --kbin-meta-link-color: #{$base01}; --kbin-meta-link-hover-color: #{$base1}; --kbin-meta-border: 1px dashed #e7dfc6; --kbin-avatar-border: 3px solid #d8cda9; --kbin-avatar-border-active: 3px solid #c2b691; --kbin-blockquote-color: #{$green}; // options --kbin-options-bg: #{$base3}; --kbin-options-text-color: #95a5a6; --kbin-options-link-color: #{$base01}; --kbin-options-link-hover-color: #41545b; --kbin-options-border: 1px solid #c8cac0; --kbin-options-link-hover-border: 3px solid #{$violet}; // forms --kbin-input-bg: #f6edd4; --kbin-input-text-color: var(--kbin-text-color); --kbin-input-border-color: var(--kbin-section-border-color); --kbin-input-border: 1px solid var(--kbin-input-border-color); --kbin-input-placeholder-text-color: #4f6166; // buttons --kbin-button-primary-bg: var(--kbin-primary-color); --kbin-button-primary-hover-bg: #f5eedb; --kbin-button-primary-text-color: #{$base0}; --kbin-button-primary-text-hover-color: #{$base0}; --kbin-button-primary-border: 1px solid #d8cda9; --kbin-button-secondary-bg: #fdf6e3; --kbin-button-secondary-hover-bg: #fff6e1; --kbin-button-secondary-text-color: var(--kbin-meta-text-color); --kbin-button-secondary-text-hover-color: var(--kbin-text-color); --kbin-button-secondary-border: 1px dashed #d8cda9; // header --kbin-header-bg: var(--kbin-primary-color); --kbin-header-text-color: #{$base01}; --kbin-header-link-color: #{$base01}; --kbin-header-link-hover-color: #41545b; --kbin-header-link-active-bg: var(--kbin-options-bg); --kbin-header-border: 1px solid #d8cda9; --kbin-header-hover-border: 3px solid #{$base01}; // topbar --kbin-topbar-bg: #{$base3}; --kbin-topbar-active-bg: var(--kbin-primary-color); --kbin-topbar-link-color: var(--kbin-meta-text-color); --kbin-topbar-active-link-color: #{$base01}; --kbin-topbar-hover-bg: var(--kbin-primary-color); --kbin-topbar-border: 1px solid #{$base2}; //sidebar --kbin-sidebar-header-text-color: #677479; --kbin-sidebar-header-border: var(--kbin-section-border); --kbin-sidebar-settings-row-bg: var(--kbin-body-bg); --kbin-sidebar-settings-switch-on-color: var(--kbin-section-bg); --kbin-sidebar-settings-switch-on-bg: var(--kbin-button-primary-text-color); --kbin-sidebar-settings-switch-off-color: #d7d6c7; --kbin-sidebar-settings-switch-off-bg: var(--kbin-button-secondary-bg); --kbin-sidebar-settings-switch-hover-bg: #b8bdb5; //vote --kbin-vote-bg: #f5eacd; --kbin-vote-bg-hover-bg: #e7ddc3; --kbin-vote-text-color: #{$base01}; --kbin-vote-text-hover-color: #{$base1}; --kbin-upvoted-color: #{$green}; --kbin-downvoted-color: #{$red}; //boost --kbin-boosted-color: var(--kbin-upvoted-color); // alerts --kbin-alert-info-bg: #fff3cd; --kbin-alert-info-border: 1px solid #ffe69c; --kbin-alert-info-text-color: #997404; --kbin-alert-info-link-color: #997404; --kbin-alert-danger-bg: #f8d7da; --kbin-alert-danger-border: 1px solid #f5c2c7; --kbin-alert-danger-text-color: var(--kbin-danger-color); --kbin-alert-danger-link-color: var(--kbin-danger-color); //entry --kbin-entry-link-visited-color: #586e75; // details --mbin-details-border: var(--kbin-section-border); --mbin-details-separator-border: var(--kbin-meta-border); --mbin-details-detail-color: #{$blue}; --mbin-details-spoiler-color: var(--kbin-danger-color); .options--top, .section--top { border-top: var(--kbin-options-border) !important; } &.rounded-edges { .section--top, .options--top { border-radius: .5rem !important; } .head-nav__menu li a { border-radius: 0 0 .5rem .5rem; } } .entry__domain, .entry__domain a { color: $yellow !important; } } ================================================ FILE: assets/styles/pages/page_bookmarks.scss ================================================ .page-bookmarks { .entry, .entry-comment, .post, .post-comment, .comment { margin-top: 0!important; margin-bottom: .5em!important; } } ================================================ FILE: assets/styles/pages/page_filter_lists.scss ================================================ .page-settings.page-settings-filter-lists { .existing-words > div > label { display:none } .existing-words > div > div, .words-container > div > div { display: flex; gap: .5rem; > * { flex-grow: 1; margin: auto; &.checkbox { flex-grow: 0; } } } } ================================================ FILE: assets/styles/pages/page_modlog.scss ================================================ .page-modlog { .ts-wrapper .ts-control input { min-width: unset; } .ts-wrapper { margin-bottom: 0; } } ================================================ FILE: assets/styles/pages/page_profile.scss ================================================ .page-user:not(.page-user-replies) { .subjects { .entry-comment, .post-comment, .comment { margin-left: 1.5rem; } } } .page-user { .entry-comment, .post-comment { margin-bottom: .5rem; } } ================================================ FILE: assets/styles/pages/post_front.scss ================================================ .page-post-front .section--top .dropdown__menu { z-index: 10; } ================================================ FILE: assets/styles/pages/post_single.scss ================================================ .page-post-single { .options__view { grid-area: end; } .post-comments { margin-bottom: .5rem; } } ================================================ FILE: assets/styles/themes/_default.scss ================================================ @use '../mixins/theme-dark' as dark; @use '../mixins/theme-light' as light; .theme--default { @media (prefers-color-scheme: dark) { @include dark.theme; } @media (prefers-color-scheme: light) { @include light.theme; } } .theme--dark { @include dark.theme; } .theme--light { @include light.theme; } ================================================ FILE: assets/styles/themes/_kbin.scss ================================================ .theme--kbin { --kbin-body-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; --kbin-body-font-size: 1rem; --kbin-body-font-weight: 400; --kbin-body-line-height: 1.5; --kbin-body-text-align: left; --kbin-body-bg: #2B2D31; --kbin-bg: #2B2D31; --kbin-bg-nth: #34363b; --kbin-text-color: #DBDEE2; --kbin-link-color: #F2F3F6; --kbin-link-hover-color: #8e939b; --kbin-outline: #ff8c00 solid 4px; --kbin-primary-color: #3c3c3c; --kbin-text-muted-color: #95a6a6; --kbin-success-color: #24b270; --kbin-danger-color: #dc2f3f; --kbin-own-color: #24b270; --kbin-author-color: #dc2f3f; // section --kbin-section-bg: #313338; --kbin-section-text-color: var(--kbin-text-color); --kbin-section-title-link-color: var(--kbin-link-color); --kbin-section-link-color: #97a7ce; --kbin-section-link-hover-color: #fff; --kbin-section-border: 1px solid #24262A; --kbin-author-border: 1px dashed var(--kbin-author-color); --kbin-own-border: 1px dashed var(--kbin-own-color); // meta --kbin-meta-bg: none; --kbin-meta-text-color: #DBDEE2; --kbin-meta-link-color: var(--kbin-link-color); --kbin-meta-link-hover-color: var(--kbin-link-hover-color); --kbin-meta-border: 1px dashed #24262A; --kbin-avatar-border: 3px solid #373737; --kbin-avatar-border-active: 3px solid #282727; --kbin-blockquote-color: #24b270; // options --kbin-options-bg: #313338; --kbin-options-text-color: #95a5a6; --kbin-options-link-color: var(--kbin-link-color); --kbin-options-link-hover-color: var(--kbin-link-hover-color); --kbin-options-border: 1px solid #24262A; --kbin-options-link-hover-border: 3px solid #e1e5ec; // forms --kbin-input-bg: #2C2D31; --kbin-input-text-color: var(--kbin-text-color); --kbin-input-border-color: #24262A; --kbin-input-border: 1px solid var(--kbin-input-border-color); --kbin-input-placeholder-text-color: #9ea0a3; // buttons --kbin-button-primary-bg: #6166EF; --kbin-button-primary-hover-bg: #5257d5; --kbin-button-primary-text-color: #fff; --kbin-button-primary-text-hover-color: #fff; --kbin-button-primary-border: 1px solid transparent; --kbin-button-secondary-bg: #2f3033; --kbin-button-secondary-hover-bg: #26272a; --kbin-button-secondary-text-color: var(--kbin-meta-text-color); --kbin-button-secondary-text-hover-color: var(--kbin-text-color); --kbin-button-secondary-border: 1px dashed #24262A; // header --kbin-header-bg: #1E1F22; --kbin-header-text-color: #fff; --kbin-header-link-color: #fff; --kbin-header-link-hover-color: #e8e8e8; --kbin-header-link-active-bg: var(--kbin-options-bg); --kbin-header-border: 1px solid #1E1F22; --kbin-header-hover-border: 3px solid #fff; // topbar --kbin-topbar-bg: var(--kbin-section-bg); --kbin-topbar-active-bg: #fff; --kbin-topbar-active-link-color: #000; --kbin-topbar-hover-bg: #282828; --kbin-topbar-border: 1px solid #4d5052; //sidebar --kbin-sidebar-header-text-color: #909ea2; --kbin-sidebar-header-border: 1px solid #4a4a4a; --kbin-sidebar-settings-row-bg: #404349; --kbin-sidebar-settings-switch-on-color: #cfd5e2; --kbin-sidebar-settings-switch-on-bg: var(--kbin-button-primary-bg); --kbin-sidebar-settings-switch-off-color: #404349; --kbin-sidebar-settings-switch-off-bg: var(--kbin-button-secondary-bg); --kbin-sidebar-settings-switch-hover-bg: #7d8bd4; //vote --kbin-vote-bg: #2B2D31; --kbin-vote-bg-hover-bg: #222427; --kbin-vote-text-color: #B6BAC2; --kbin-vote-text-hover-color: #fafafa; --kbin-upvoted-color: #24b270; --kbin-downvoted-color: #dc2f3f; //boost --kbin-boosted-color: var(--kbin-upvoted-color); // alerts --kbin-alert-info-bg: rgba(153,116,4,0.15); --kbin-alert-info-border: 1px solid rgba(153,116,4,0.4); --kbin-alert-info-text-color: #997404; --kbin-alert-info-link-color: #997404; --kbin-alert-danger-bg: rgba(171, 28, 40, 0.9); --kbin-alert-danger-border: 1px solid rgba(171, 28, 40, 1); --kbin-alert-danger-text-color: var(--kbin-danger-color); --kbin-alert-danger-link-color: var(--kbin-danger-color); //entry --kbin-entry-link-visited-color: #8e939b; // details --mbin-details-border: var(--kbin-section-border); --mbin-details-separator-border: var(--kbin-meta-border); --mbin-details-detail-color: var(--kbin-link-hover-color); --mbin-details-spoiler-color: var(--kbin-danger-color); .section--top, .options--top { border-top: var(--kbin-options-border) !important; } &.rounded-edges { .options--top, .section--top { border-radius: .5rem !important; } } } ================================================ FILE: assets/styles/themes/_solarized.scss ================================================ @use '../mixins/theme-solarized-dark' as sol-dark; @use '../mixins/theme-solarized-light' as sol-light; .theme--solarized { @media (prefers-color-scheme: dark) { @include sol-dark.theme; } @media (prefers-color-scheme: light) { @include sol-light.theme; } } .theme--solarized-dark { @include sol-dark.theme; } .theme--solarized-light { @include sol-light.theme; } ================================================ FILE: assets/styles/themes/_tokyo-night.scss ================================================ .theme--tokyo-night { --kbin-body-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; --kbin-body-font-size: 1rem; --kbin-body-font-weight: 400; --kbin-body-line-height: 1.5; --kbin-body-text-align: left; --kbin-body-bg: #1f202e; --kbin-bg: #1f202e; --kbin-bg-nth: #262636; --kbin-text-color: #e4e4e7; --kbin-link-color: #d6d6ff; --kbin-link-hover-color: #9999ff; --kbin-outline: #9999ff solid 4px; --kbin-primary-color: #363649; --kbin-text-muted-color: #9191a1; --kbin-success-color: #a1e87d; --kbin-danger-color: #ff6675; --kbin-own-color: var(--kbin-success-color); --kbin-author-color: var(--kbin-danger-color); // section --kbin-section-bg: #2a2a3c; --kbin-section-text-color: var(--kbin-text-color); --kbin-section-title-link-color: var(--kbin-link-color); --kbin-section-link-color: #9c9fc9; --kbin-section-link-hover-color: var(--kbin-link-hover-color); --kbin-section-border: 1px solid #414358; --kbin-author-border: 1px dashed var(--kbin-author-color); --kbin-own-border: 1px dashed var(--kbin-own-color); // meta --kbin-meta-bg: none; --kbin-meta-text-color: #d5d6dd; --kbin-meta-link-color: #d6ddff; --kbin-meta-link-hover-color: var(--kbin-link-hover-color); --kbin-meta-border: 1px dashed #4f5064; --kbin-avatar-border: 3px solid #414358; --kbin-avatar-border-active: 3px solid #54576e; --kbin-blockquote-color: var(--kbin-success-color); // options --kbin-options-bg: #2a2a3c; --kbin-options-text-color: #a5a5c0; --kbin-options-link-color: #a9b1d6; --kbin-options-link-hover-color: #e1e5ec; --kbin-options-border: 1px solid #414358; --kbin-options-link-hover-border: 3px solid #e1e5ec; // forms --kbin-input-bg: #1b1c27; --kbin-input-text-color: #d6d6ff; --kbin-input-border-color: #414358; --kbin-input-border: 1px solid var(--kbin-input-border-color); --kbin-input-placeholder-text-color: #9292b1; // buttons --kbin-button-primary-bg: #9eadfa; --kbin-button-primary-hover-bg: #6e79f7; --kbin-button-primary-text-color: #1c1c22; --kbin-button-primary-text-hover-color: #0b0b0e; --kbin-button-primary-border: 1px solid transparent; --kbin-button-secondary-bg: #16161d; --kbin-button-secondary-hover-bg: #9eadfa; --kbin-button-secondary-text-color: var(--kbin-meta-text-color); --kbin-button-secondary-text-hover-color: #1c1c22; --kbin-button-secondary-border: 1px solid #414358; // header --kbin-header-bg: #16161d; --kbin-header-text-color: #e8e8ee; --kbin-header-link-color: #e8e8ee; --kbin-header-link-hover-color: #e8e8e8; --kbin-header-link-active-bg: var(--kbin-options-bg); --kbin-header-border: 1px solid #e5eaec; --kbin-header-hover-border: 3px solid #e8e8ee; // topbar --kbin-topbar-bg: #292b3d; --kbin-topbar-active-bg: #b3b3ff; --kbin-topbar-active-link-color: #000; --kbin-topbar-hover-bg: #535679; --kbin-topbar-border: 1px solid #575775; // sidebar --kbin-sidebar-header-text-color: #9191aa; --kbin-sidebar-header-border: 1px solid #575975; --kbin-sidebar-settings-row-bg: #37384e; --kbin-sidebar-settings-switch-on-color: #1c1c22 ; --kbin-sidebar-settings-switch-on-bg: var(--kbin-button-primary-bg); --kbin-sidebar-settings-switch-off-color: var(--kbin-body-bg); --kbin-sidebar-settings-switch-off-bg: #16161d; --kbin-sidebar-settings-switch-hover-bg: #7575a3; // vote --kbin-vote-bg: #1c1d26; --kbin-vote-bg-hover-bg: #13141b; --kbin-vote-text-color: #d6d6ff; --kbin-vote-text-hover-color: #9999ff; --kbin-upvoted-color: var(--kbin-success-color); --kbin-downvoted-color: var(--kbin-danger-color); //boost --kbin-boosted-color: var(--kbin-upvoted-color); // alerts --kbin-alert-info-bg: #fff3cc; --kbin-alert-info-border: 1px solid #fff3cc; --kbin-alert-info-text-color: #261f0d; --kbin-alert-info-link-color: #664b00; --kbin-alert-danger-bg: #16161d; --kbin-alert-danger-border: 1px solid var(--kbin-alert-danger-text-color); --kbin-alert-danger-text-color: var(--kbin-danger-color); --kbin-alert-danger-link-color: var(--kbin-danger-color); // entry --kbin-entry-link-visited-color: #a4a4c1; // details --mbin-details-border: var(--kbin-section-border); --mbin-details-separator-border: var(--kbin-meta-border); --mbin-details-detail-color: var(--kbin-link-hover-color); --mbin-details-spoiler-color: var(--kbin-danger-color); .options--top, .section--top { border-top: var(--kbin-options-border) !important; } &.rounded-edges { .options--top, .section--top { border-radius: 0.5rem !important; } } } ================================================ FILE: assets/utils/debounce.js ================================================ export default function debounce(delay, handler) { let timer = 0; return function() { clearTimeout(timer); timer = setTimeout(handler, delay); }; } ================================================ FILE: assets/utils/event-source.js ================================================ export default function subscribe(endpoint, topics, cb) { if (!endpoint) { return null; } const url = new URL(endpoint); topics.forEach((topic) => { url.searchParams.append('topic', topic); }); const eventSource = new EventSource(url); eventSource.onmessage = (e) => cb(e); return eventSource; } ================================================ FILE: assets/utils/http.js ================================================ /** * @param {RequestInfo} url * @param {RequestInit} options * @returns {Promise} */ export async function fetch(url = '', options = {}) { if ('object' === typeof url && null !== url) { options = url; url = options.url; } options = { ...options }; options.credentials = options.credentials || 'same-origin'; options.redirect = options.redirect || 'error'; options.headers = { ...options.headers, 'X-Requested-With': 'XMLHttpRequest', }; return window.fetch(url, options); } export async function ok(response) { if (!response.ok) { const e = new Error(response.statusText); e.response = response; throw e; } return response; } /** * Throws the response if not ok, otherwise, call .json() * @param {Response} response * @return {Promise} */ export function ThrowResponseIfNotOk(response) { if (!response.ok) { throw response; } return response.json(); } ================================================ FILE: assets/utils/mbin.js ================================================ export default function getIntIdFromElement(element) { return element.id.substring(element.id.lastIndexOf('-') + 1); } export function getIdPrefixFromNotification(data) { switch (data.type) { case 'Entry': return 'entry-'; case 'EntryComment': return 'entry-comment-'; case 'Post': return 'post-'; case 'PostComment': return 'post-comment-'; } } export function getTypeFromNotification(data) { switch (data.detail.op) { case 'EntryEditedNotification': case 'EntryCreatedNotification': return 'entry'; case 'EntryCommentEditedNotification': case 'EntryCommentCreatedNotification': return 'entry_comment'; case 'PostEditedNotification': case 'PostCreatedNotification': return 'post'; case 'PostCommentEditedNotification': case 'PostCommentCreatedNotification': return 'post_comment'; } } export function getLevel(element) { const level = parseInt(element.className.replace('comment-level--1', '').split('--')[1]); return isNaN(level) ? 1 : level; } export function getDepth(element) { const depth = parseInt(element.dataset.commentCollapseDepthValue); return isNaN(depth) ? 1 : depth; } ================================================ FILE: assets/utils/popover.js ================================================ /* eslint-disable no-undef */ Util = function() {}; Util.hasClass = function(el, className) { return el.classList.contains(className); }; Util.addClass = function(el, className) { var classList = className.split(' '); el.classList.add(classList[0]); if (1 < classList.length) { Util.addClass(el, classList.slice(1).join(' ')); } }; Util.removeClass = function(el, className) { var classList = className.split(' '); el.classList.remove(classList[0]); if (1 < classList.length) { Util.removeClass(el, classList.slice(1).join(' ')); } }; Util.toggleClass = function(el, className, bool) { if (bool) { Util.addClass(el, className); } else { Util.removeClass(el, className); } }; Util.setAttributes = function(el, attrs) { for (var key in attrs) { el.setAttribute(key, attrs[key]); } }; Util.moveFocus = function (element) { if (!element) { element = document.getElementsByTagName('body')[0]; } element.focus(); if (document.activeElement !== element) { element.setAttribute('tabindex', '-1'); element.focus(); } }; // Usage: codyhouse.co/license (function() { var Popover = function(element) { this.element = element; this.elementId = this.element.getAttribute('id'); this.trigger = document.querySelectorAll('[aria-controls="'+this.elementId+'"]'); this.selectedTrigger = false; this.popoverVisibleClass = 'popover--is-visible'; this.selectedTriggerClass = 'popover-control--active'; this.popoverIsOpen = false; // focusable elements this.firstFocusable = false; this.lastFocusable = false; // position target - position tooltip relative to a specified element this.positionTarget = getPositionTarget(this); // gap between element and viewport - if there's max-height this.viewportGap = parseInt(getComputedStyle(this.element).getPropertyValue('--popover-viewport-gap')) || 20; initPopover(this); initPopoverEvents(this); }; // public methods Popover.prototype.togglePopover = function(bool, moveFocus) { togglePopover(this, bool, moveFocus); }; Popover.prototype.checkPopoverClick = function(target) { checkPopoverClick(this, target); }; Popover.prototype.checkPopoverFocus = function() { checkPopoverFocus(this); }; // private methods function getPositionTarget(popover) { // position tooltip relative to a specified element - if provided var positionTargetSelector = popover.element.getAttribute('data-position-target'); if (!positionTargetSelector) { return false; } var positionTarget = document.querySelector(positionTargetSelector); return positionTarget; } function initPopover(popover) { // reset popover position initPopoverPosition(popover); // init aria-labels for (var i = 0; i < popover.trigger.length; i++) { Util.setAttributes(popover.trigger[i], { 'aria-expanded': 'false', 'aria-haspopup': 'true' }); } } function initPopoverEvents(popover) { for (var i = 0; i < popover.trigger.length; i++) { (function(i) { popover.trigger[i].addEventListener('click', function(event) { event.preventDefault(); // if the popover had been previously opened by another trigger element -> close it first and reopen in the right position if (Util.hasClass(popover.element, popover.popoverVisibleClass) && popover.s !== popover.trigger[i]) { togglePopover(popover, false, false); // close menu } // toggle popover popover.selectedTrigger = popover.trigger[i]; togglePopover(popover, !Util.hasClass(popover.element, popover.popoverVisibleClass), true); }); })(i); } // trap focus popover.element.addEventListener('keydown', function(event) { if (event.keyCode && 9 === event.keyCode || event.key && 'Tab' === event.key) { //trap focus inside popover trapFocus(popover, event); } }); // custom events -> open/close popover popover.element.addEventListener('openPopover', function() { togglePopover(popover, true); }); popover.element.addEventListener('closePopover', function(event) { togglePopover(popover, false, event.detail); }); } function togglePopover(popover, bool, moveFocus) { // toggle popover visibility Util.toggleClass(popover.element, popover.popoverVisibleClass, bool); popover.popoverIsOpen = bool; if (bool) { popover.selectedTrigger.setAttribute('aria-expanded', 'true'); getFocusableElements(popover); // move focus focusPopover(popover); popover.element.addEventListener('transitionend', function() { focusPopover(popover); }, { once: true }); // position the popover element positionPopover(popover); // add class to popover trigger Util.addClass(popover.selectedTrigger, popover.selectedTriggerClass); } else if (popover.selectedTrigger) { popover.selectedTrigger.setAttribute('aria-expanded', 'false'); if (moveFocus) { Util.moveFocus(popover.selectedTrigger); } // remove class from menu trigger Util.removeClass(popover.selectedTrigger, popover.selectedTriggerClass); popover.selectedTrigger = false; } } function focusPopover(popover) { if (popover.firstFocusable) { popover.firstFocusable.focus(); } else { Util.moveFocus(popover.element); } } function positionPopover(popover) { // reset popover position resetPopoverStyle(popover); var selectedTriggerPosition = (popover.positionTarget) ? popover.positionTarget.getBoundingClientRect() : popover.selectedTrigger.getBoundingClientRect(); var menuOnTop = (window.innerHeight - selectedTriggerPosition.bottom) < selectedTriggerPosition.top; var left = selectedTriggerPosition.left, right = (window.innerWidth - selectedTriggerPosition.right), isRight = (window.innerWidth < selectedTriggerPosition.left + popover.element.offsetWidth); var horizontal = isRight ? 'right: '+right+'px;' : 'left: '+left+'px;', vertical = menuOnTop ? 'bottom: '+(window.innerHeight - selectedTriggerPosition.top)+'px;' : 'top: '+selectedTriggerPosition.bottom+'px;'; // check right position is correct -> otherwise set left to 0 if (isRight && (right + popover.element.offsetWidth) > window.innerWidth) { horizontal = 'left: '+ parseInt((window.innerWidth - popover.element.offsetWidth)/2)+'px;'; } // check if popover needs a max-height (user will scroll inside the popover) var maxHeight = menuOnTop ? selectedTriggerPosition.top - popover.viewportGap : window.innerHeight - selectedTriggerPosition.bottom - popover.viewportGap; var initialStyle = popover.element.getAttribute('style'); if (!initialStyle) { initialStyle = ''; } popover.element.setAttribute('style', initialStyle + horizontal + vertical +'max-height:'+Math.floor(maxHeight)+'px;'); } function resetPopoverStyle(popover) { // remove popover inline style before applying new style popover.element.style.maxHeight = ''; popover.element.style.top = ''; popover.element.style.bottom = ''; popover.element.style.left = ''; popover.element.style.right = ''; } function initPopoverPosition(popover) { // make sure the popover does not create any scrollbar popover.element.style.top = '0px'; popover.element.style.left = '0px'; } function checkPopoverClick(popover, target) { // close popover when clicking outside it if (!popover.popoverIsOpen) { return; } if (!popover.element.contains(target) && !target.closest('[aria-controls="'+popover.elementId+'"]')) { togglePopover(popover, false); } } function checkPopoverFocus(popover) { // on Esc key -> close popover if open and move focus (if focus was inside popover) if (!popover.popoverIsOpen) { return; } var popoverParent = document.activeElement.closest('.js-popover'); togglePopover(popover, false, popoverParent); } function getFocusableElements(popover) { //get all focusable elements inside the popover var allFocusable = popover.element.querySelectorAll(focusableElString); getFirstVisible(popover, allFocusable); getLastVisible(popover, allFocusable); } function getFirstVisible(popover, elements) { //get first visible focusable element inside the popover for (var i = 0; i < elements.length; i++) { if (isVisible(elements[i])) { popover.firstFocusable = elements[i]; break; } } } function getLastVisible(popover, elements) { //get last visible focusable element inside the popover for (var i = elements.length - 1; 0 <= i; i--) { if (isVisible(elements[i])) { popover.lastFocusable = elements[i]; break; } } } function trapFocus(popover, event) { if (popover.firstFocusable === document.activeElement && event.shiftKey) { //on Shift+Tab -> focus last focusable element when focus moves out of popover event.preventDefault(); popover.lastFocusable.focus(); } if (popover.lastFocusable === document.activeElement && !event.shiftKey) { //on Tab -> focus first focusable element when focus moves out of popover event.preventDefault(); popover.firstFocusable.focus(); } } function isVisible(element) { // check if element is visible return element.offsetWidth || element.offsetHeight || element.getClientRects().length; } window.Popover = Popover; //initialize the Popover objects var popovers = document.getElementsByClassName('js-popover'); // generic focusable elements string selector var focusableElString = '[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable], audio[controls], video[controls], summary'; if (0 < popovers.length) { var popoversArray = []; var scrollingContainers = []; for (var i = 0; i < popovers.length; i++) { (function(i) { popoversArray.push(new Popover(popovers[i])); var scrollableElement = popovers[i].getAttribute('data-scrollable-element'); if (scrollableElement && !scrollingContainers.includes(scrollableElement)) { scrollingContainers.push(scrollableElement); } })(i); } // listen for key events window.addEventListener('keyup', function(event) { if (event.keyCode && 27 === event.keyCode || event.key && 'escape' === event.key.toLowerCase()) { // close popover on 'Esc' popoversArray.forEach(function(element) { element.checkPopoverFocus(); }); } }); // close popover when clicking outside it window.addEventListener('click', function(event) { popoversArray.forEach(function(element) { element.checkPopoverClick(event.target); }); }); // on resize -> close all popover elements window.addEventListener('resize', function() { popoversArray.forEach(function(element) { element.togglePopover(false, false); }); }); // on scroll -> close all popover elements window.addEventListener('scroll', function() { popoversArray.forEach(function(element) { if (element.popoverIsOpen) { element.togglePopover(false, false); } }); }); // take additional scrollable containers into account for (var j = 0; j < scrollingContainers.length; j++) { var scrollingContainer = document.querySelector(scrollingContainers[j]); if (scrollingContainer) { scrollingContainer.addEventListener('scroll', function() { popoversArray.forEach(function(element) { if (element.popoverIsOpen) { element.togglePopover(false, false); } }); }); } } } window.popover = popoversArray[0]; }()); ================================================ FILE: assets/utils/routing.js ================================================ import Routing from '../../vendor/friendsofsymfony/jsrouting-bundle/Resources/public/js/router.min.js'; const routes = require('../../public/js/fos_js_routes.json'); export default function router() { Routing.setRoutingData(routes); return Routing; } ================================================ FILE: bin/console ================================================ #!/usr/bin/env php /dev/null && pwd ) printf 'Do you want to proceed with the upgrade? (y/N)? ' read -r answer if [ "$answer" != "${answer#[Yy]}" ]; then # Retrieve prod or dev from .env.local.php file ENV=$(php -r "\$env = require '$BIN_DIR/../.env.local.php'; echo \$env['APP_ENV'];") if [[ "$ENV" == "dev" ]]; then # Development echo -e "INFO: Environment detected: Development\n" cd "$BIN_DIR/.." echo "INFO: Install/update PHP packages" composer install echo "INFO: Dump env file" composer dump-env dev echo "INFO: Clear application cache" APP_ENV=dev APP_DEBUG=1 php "${BIN_DIR}"/console cache:clear -n echo "INFO: Perform database migration" APP_ENV=dev php "${BIN_DIR}"/console doctrine:migrations:migrate -n echo "INFO: Clear composer cache" composer clear-cache echo "INFO: Install/update JS packages" NODE_ENV=development npm ci echo "INFO: Build frontend (development)" NODE_ENV=development npm run dev else # Production echo -e "INFO: Environment detected: Production\n" cd "$BIN_DIR/.." echo "INFO: Install/update PHP packages" composer install --no-dev echo "INFO: Dump env file" composer dump-env prod echo "INFO: Clear application cache" APP_ENV=prod APP_DEBUG=0 php "${BIN_DIR}"/console cache:clear -n echo "INFO: Perform database migration" APP_ENV=prod php "${BIN_DIR}"/console doctrine:migrations:migrate -n echo "INFO: Clear composer cache" composer clear-cache echo "INFO: Install/update JS packages" # Note: npm install also require dev dependencies for the build step npm ci --include=dev echo "INFO: Build frontend (production)" NODE_ENV=production npm run build fi echo -e "INFO: Upgrade successfully completed!\n" echo "INFO: You might want to clear your Redis/Valkey cache (redis-cli FLUSHDB). If you have OPCache enabled also reload your PHP FPM service, to clear the PHP cache." fi ================================================ FILE: ci/Dockerfile ================================================ # Using latest Debian Stable FROM debian:13-slim COPY --from=composer/composer:latest-bin /composer /usr/bin/composer ARG DEBIAN_FRONTEND=noninteractive RUN apt update RUN apt upgrade -y RUN apt install -y lsb-release ca-certificates curl wget unzip gnupg apt-transport-https acl nodejs npm # Install PHP RUN apt install -y php8.4 \ php8.4-common \ php8.4-fpm \ php8.4-cli \ php8.4-amqp \ php8.4-bcmath \ php8.4-pgsql \ php8.4-gd \ php8.4-curl \ php8.4-xml \ php8.4-redis \ php8.4-mbstring \ php8.4-zip \ php8.4-bz2 \ php8.4-intl # Unlimited memory RUN echo "memory_limit = -1" >>/etc/php/8.4/cli/conf.d/docker-php-memlimit.ini RUN echo "memory_limit = -1" >>/etc/php/8.4/fpm/conf.d/docker-php-memlimit.ini # Add cs2pr binary using wget RUN wget https://raw.githubusercontent.com/staabm/annotate-pull-request-from-checkstyle/refs/heads/master/cs2pr -O /usr/local/bin/cs2pr RUN chmod +x /usr/local/bin/cs2pr ================================================ FILE: ci/ignoredPaths.txt ================================================ # Dev container is not relevant to the workflow .devcontainer/** # Issue and pull request templates are not relevant to the workflow .github/ISSUE_TEMPLATE/** .github/PULL_REQUEST_TEMPLATE/** # A separate workflow is used to build and publish the pipeline image ci/** # Documentation changes are not relevant to the workflow and are taking care of by mbin-docs repository docs/** # Other files are not relevant to the workflow LICENSES/** *.md public/robots.txt ================================================ FILE: ci/skipOnExcluded.sh ================================================ #!/usr/bin/env bash set -eu # Necessary in the GitHub Action environment git config --global --add safe.directory "$(realpath "$GITHUB_WORKSPACE")" ignoredPatterns="$(cat "$GITHUB_WORKSPACE"/ci/ignoredPaths.txt)" if [[ "${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" == 'main' ]]; then git fetch origin main --depth 2 changedFiles="$(git diff --name-only main^ HEAD)" else git fetch origin main:main --depth 1 changedFiles="$(git diff --name-only main)" fi doSkip=1 while read -r path; do while read -r pattern; do [[ "$pattern" == '' ]] && continue [[ "$pattern" == \#* ]] && continue # shellcheck disable=SC2053 if [[ "$path" == $pattern ]]; then continue 2 fi done <<< "$ignoredPatterns" doSkip=0 break done <<< "$changedFiles" if [[ "$doSkip" == 1 ]]; then echo "Skipping actions because diff only affects ignored paths" exit 0 else echo "Running actions" echo exec "$@" fi ================================================ FILE: compose.dev.yaml ================================================ # Development environment override services: php: pull_policy: build build: dockerfile: docker/Dockerfile context: . target: dev volumes: - ./:/app - ./docker/Caddyfile:/etc/caddy/Caddyfile:ro - ./docker/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro # If you develop on Mac or Windows you can remove the vendor/ directory # from the bind-mount for better performance by enabling the next line: #- /app/vendor environment: # See https://xdebug.org/docs/all_settings#mode XDEBUG_MODE: '${XDEBUG_MODE:-off}' FRANKENPHP_WORKER_CONFIG: watch MERCURE_EXTRA_DIRECTIVES: demo extra_hosts: # Ensure that host.docker.internal is correctly defined on Linux - host.docker.internal:host-gateway tty: true node: image: node:24-trixie-slim user: ${MBIN_USER} volumes: - ./:/app working_dir: /app command: ['sh', '-c', 'npm install && npm run watch'] messenger: pull_policy: build build: dockerfile: docker/Dockerfile context: . target: dev volumes: - ./:/app - ./docker/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro # If you develop on Mac or Windows you can remove the vendor/ directory # from the bind-mount for better performance by enabling the next line: #- /app/vendor environment: # See https://xdebug.org/docs/all_settings#mode XDEBUG_MODE: '${XDEBUG_MODE:-off}' extra_hosts: # Ensure that host.docker.internal is correctly defined on Linux - host.docker.internal:host-gateway tty: true deploy: mode: replicated # Change to 1 to enable federation replicas: 0 amqproxy: deploy: mode: replicated # Change to 1 to enable federation replicas: 0 rabbitmq: deploy: mode: replicated # Change to 1 to enable federation replicas: 0 ================================================ FILE: compose.yaml ================================================ services: php: image: ghcr.io/mbinorg/mbin:latest build: dockerfile: docker/Dockerfile context: . target: prod restart: unless-stopped env_file: .env environment: MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET} MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET} volumes: - ./storage/caddy_config:/config - ./storage/caddy_data:/data - ./storage/media:/app/public/media - ./storage/oauth:/app/config/oauth2 - ./storage/php_logs:/app/var/log ports: - 80:80 - 443:443 - 443:443/udp depends_on: - amqproxy - postgres - valkey messenger: image: ghcr.io/mbinorg/mbin:latest build: dockerfile: docker/Dockerfile context: . target: prod restart: unless-stopped command: bin/console messenger:consume scheduler_default old async outbox deliver inbox resolve receive failed --time-limit=3600 healthcheck: test: ['CMD-SHELL', "ps aux | grep 'messenger[:]consume' || exit 1"] env_file: .env volumes: - ./storage/media:/app/public/media - ./storage/messenger_logs:/app/var/log depends_on: - amqproxy - postgres - valkey deploy: mode: replicated replicas: 6 amqproxy: image: cloudamqp/amqproxy restart: unless-stopped user: ${MBIN_USER} command: amqp://rabbitmq:5672 depends_on: rabbitmq: condition: service_healthy rabbitmq: image: rabbitmq:3-management-alpine restart: unless-stopped user: ${MBIN_USER} healthcheck: test: ['CMD', 'rabbitmq-diagnostics', 'check_port_connectivity'] env_file: .env volumes: - ./storage/rabbitmq_data:/var/lib/rabbitmq - ./storage/rabbitmq_logs:/var/log/rabbitmq ports: - 15672:15672 # Hostname specified to ensure persistent data: https://stackoverflow.com/questions/41330514 hostname: rabbitmq postgres: image: postgres:${POSTGRES_VERSION}-trixie restart: unless-stopped user: ${MBIN_USER} shm_size: '2gb' healthcheck: test: ['CMD', 'pg_isready'] env_file: .env volumes: - ./storage/postgres:/var/lib/postgresql/data valkey: image: valkey/valkey:trixie restart: unless-stopped user: ${MBIN_USER} command: valkey-server /valkey.conf --requirepass ${VALKEY_PASSWORD} healthcheck: test: ['CMD', 'valkey-cli', 'ping'] volumes: - ./docker/valkey.conf:/valkey.conf ================================================ FILE: composer.json ================================================ { "name": "mbinorg/mbin", "description": "Mbin is a decentralized content aggregator and microblogging platform running on the Fediverse network", "type": "project", "license": "AGPL-3.0-or-later", "minimum-stability": "stable", "prefer-stable": true, "require": { "php": ">=8.3", "ext-amqp": "*", "ext-bcmath": "*", "ext-ctype": "*", "ext-curl": "*", "ext-fileinfo": "*", "ext-gd": "*", "ext-iconv": "*", "ext-intl": "*", "ext-openssl": "*", "ext-redis": "*", "aws/aws-sdk-php": "^3.317.0", "babdev/pagerfanta-bundle": "^v4.4.0", "debril/rss-atom-bundle": "^5.2.0", "doctrine/doctrine-bundle": "^2.18.2", "doctrine/doctrine-migrations-bundle": "^3.7.0", "doctrine/instantiator": "2.0.0", "doctrine/orm": "^3.6.2", "embed/embed": "^4.4.12", "endroid/qr-code": "^6.0.3", "firebase/php-jwt": "7.0.2 as 6.11.1", "friendsofsymfony/jsrouting-bundle": "^3.5.0", "furqansiddiqui/bip39-mnemonic-php": "^0.1.7", "gumlet/php-image-resize": "^2.0.4", "imagine/imagine": "^1.5", "knplabs/knp-time-bundle": "^2.4.0", "knpuniversity/oauth2-client-bundle": "^2.18.1", "kornrunner/blurhash": "^1.2.2", "league/commonmark": "^2.5.1", "league/flysystem-aws-s3-v3": "^3.28.0", "league/html-to-markdown": "^5.1.1", "league/oauth2-facebook": "^2.2.0", "league/oauth2-github": "^3.1.0", "league/oauth2-google": "^4.0.1", "league/oauth2-server-bundle": "^1.0.0", "liip/imagine-bundle": "^2.13.1", "meteo-concept/hcaptcha-bundle": "^4.1.0", "minishlink/web-push": "^10.0.3", "neitanod/forceutf8": "^2.0.4", "nelmio/api-doc-bundle": "^5.7.0", "nelmio/cors-bundle": "^2.5.0", "nyholm/psr7": "^1.8.1", "omines/antispam-bundle": "^0.1.8", "oneup/flysystem-bundle": "^4.12.2", "pagerfanta/core": "^4.7.0", "pagerfanta/doctrine-collections-adapter": "^4.6.0", "pagerfanta/doctrine-dbal-adapter": "^4.6.0", "pagerfanta/doctrine-orm-adapter": "^4.6.0", "pagerfanta/twig": "^4.6.0", "phpdocumentor/reflection-docblock": "^5.4.1", "phpseclib/phpseclib": "^3.0.42", "phpstan/phpdoc-parser": "^2.0.0", "predis/predis": "^3.0.1", "privacyportal/oauth2-privacyportal": "^0.1.1", "runtime/frankenphp-symfony": "^0.2.0", "scheb/2fa-backup-code": "^7.5.0", "scheb/2fa-bundle": "^7.5.0", "scheb/2fa-totp": "^7.5.0", "scienta/doctrine-json-functions": "^6.1.0", "stevenmaguire/oauth2-keycloak": "^5.1.0", "symfony/amqp-messenger": "*", "symfony/asset": "*", "symfony/cache": "*", "symfony/console": "*", "symfony/css-selector": "*", "symfony/doctrine-messenger": "*", "symfony/dotenv": "*", "symfony/emoji": "*", "symfony/expression-language": "*", "symfony/flex": "^2.4.5", "symfony/form": "*", "symfony/framework-bundle": "*", "symfony/http-client": "*", "symfony/lock": "*", "symfony/mailer": "*", "symfony/mailgun-mailer": "*", "symfony/mercure-bundle": "0.3.*", "symfony/messenger": "*", "symfony/mime": "*", "symfony/monolog-bundle": "^4.0.1", "symfony/property-access": "*", "symfony/property-info": "*", "symfony/rate-limiter": "*", "symfony/redis-messenger": "*", "symfony/runtime": "*", "symfony/scheduler": "*", "symfony/security-bundle": "*", "symfony/security-csrf": "*", "symfony/serializer": "*", "symfony/string": "*", "symfony/translation": "*", "symfony/twig-bundle": "*", "symfony/type-info": "*", "symfony/uid": "*", "symfony/ux-autocomplete": "^2.18.0", "symfony/ux-chartjs": "^2.18.0", "symfony/ux-twig-component": "^2.18.1", "symfony/validator": "*", "symfony/webpack-encore-bundle": "^2.1.1", "symfony/workflow": "*", "symfony/yaml": "*", "symfonycasts/reset-password-bundle": "^1.22.0", "symfonycasts/verify-email-bundle": "^1.17.0", "thenetworg/oauth2-azure": "^2.2.2", "twig/cssinliner-extra": "^3.10.0", "twig/extra-bundle": "^3.10.0", "twig/html-extra": "^3.10.0", "twig/intl-extra": "^3.10.0", "twig/twig": "^3.15.0", "webmozart/assert": "^1.11.0", "wohali/oauth2-discord-new": "^1.2.1" }, "require-dev": { "brianium/paratest": "^7.10.1", "dama/doctrine-test-bundle": "^8.2.0", "doctrine/doctrine-fixtures-bundle": "^4.3.1", "fakerphp/faker": "^1.23.1", "justinrainbow/json-schema": "^6.0.0", "phpstan/phpstan": "^2.0.2", "phpunit/phpunit": "12.1.*", "spatie/phpunit-snapshot-assertions": "^5.2", "symfony/browser-kit": "*", "symfony/debug-bundle": "*", "symfony/maker-bundle": "1.67.0", "symfony/phpunit-bridge": "*", "symfony/stopwatch": "*", "symfony/web-profiler-bundle": "*" }, "replace": { "symfony/polyfill-ctype": "*", "symfony/polyfill-iconv": "*", "symfony/polyfill-php72": "*" }, "conflict": { "symfony/symfony": "*" }, "autoload": { "psr-4": { "App\\": "src/" } }, "autoload-dev": { "psr-4": { "App\\Tests\\": "tests/" } }, "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, "symfony/flex": true, "symfony/runtime": true, "ergebnis/composer-normalize": true }, "optimize-autoloader": true, "classmap-authoritative": true, "preferred-install": { "*": "dist" }, "sort-packages": true }, "extra": { "symfony": { "allow-contrib": false, "require": "7.4.*" } }, "scripts": { "auto-scripts": { "cache:clear": "symfony-cmd", "assets:install %PUBLIC_DIR%": "symfony-cmd" }, "post-install-cmd": [ "@auto-scripts" ], "post-update-cmd": [ "@auto-scripts" ], "codestyle:fix": [ "tools/vendor/bin/php-cs-fixer fix" ] } } ================================================ FILE: config/bundles.php ================================================ ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], Knp\Bundle\TimeBundle\KnpTimeBundle::class => ['all' => true], BabDev\PagerfantaBundle\BabDevPagerfantaBundle::class => ['all' => true], Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true], Oneup\FlysystemBundle\OneupFlysystemBundle::class => ['all' => true], FOS\JsRoutingBundle\FOSJsRoutingBundle::class => ['all' => true], SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true], Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], Debril\RssAtomBundle\DebrilRssAtomBundle::class => ['all' => true], KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true], SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true], Symfony\UX\Chartjs\ChartjsBundle::class => ['all' => true], Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], Symfony\UX\Autocomplete\AutocompleteBundle::class => ['all' => true], MeteoConcept\HCaptchaBundle\MeteoConceptHCaptchaBundle::class => ['all' => true], Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true], League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true], Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], Omines\AntiSpamBundle\AntiSpamBundle::class => ['all' => true], ]; ================================================ FILE: config/mbin_routes/activity_pub.yaml ================================================ ap_webfinger: controller: App\Controller\ActivityPub\WebFingerController path: '/.well-known/webfinger' methods: [GET] ap_hostmeta: controller: App\Controller\ActivityPub\HostMetaController path: '/.well-known/host-meta' methods: [GET] ap_node_info: controller: App\Controller\ActivityPub\NodeInfoController::nodeInfo path: '/.well-known/nodeinfo' methods: [GET] ap_node_info_v2: controller: App\Controller\ActivityPub\NodeInfoController::nodeInfoV2 path: '/nodeinfo/{version}.{_format}' methods: [GET] requirements: version: '2.0|2.1' format: json ap_instance: controller: App\Controller\ActivityPub\InstanceController path: '/i/actor' methods: [GET] ap_instance_front: controller: App\Controller\ActivityPub\InstanceController path: '/' methods: [GET] condition: '%kbin_ap_route_condition%' ap_instance_inbox: controller: App\Controller\ActivityPub\SharedInboxController path: /i/inbox methods: [POST] ap_instance_outbox: controller: App\Controller\ActivityPub\InstanceOutboxController path: /i/outbox methods: [POST] ap_shared_inbox: controller: App\Controller\ActivityPub\SharedInboxController path: /f/inbox methods: [POST] ap_object: controller: App\Controller\ActivityPub\ObjectController path: /f/object/{id} methods: [GET] condition: '%kbin_ap_route_condition%' ap_user: controller: App\Controller\ActivityPub\User\UserController path: /u/{username} methods: [GET] condition: '%kbin_ap_route_condition%' ap_user_inbox: controller: App\Controller\ActivityPub\User\UserInboxController path: /u/{username}/inbox methods: [POST] ap_user_outbox: controller: App\Controller\ActivityPub\User\UserOutboxController path: /u/{username}/outbox methods: [GET] condition: '%kbin_ap_route_condition%' ap_user_followers: controller: App\Controller\ActivityPub\User\UserFollowersController::followers path: /u/{username}/followers methods: [GET] condition: '%kbin_ap_route_condition%' ap_user_following: controller: App\Controller\ActivityPub\User\UserFollowersController::following path: /u/{username}/following methods: [GET] condition: '%kbin_ap_route_condition%' ap_magazine: controller: App\Controller\ActivityPub\Magazine\MagazineController path: /m/{name} methods: [GET] condition: '%kbin_ap_route_condition%' ap_magazine_inbox: controller: App\Controller\ActivityPub\Magazine\MagazineInboxController path: /m/{name}/inbox methods: [POST] ap_magazine_outbox: controller: App\Controller\ActivityPub\Magazine\MagazineOutboxController path: /m/{name}/outbox methods: [GET] condition: '%kbin_ap_route_condition%' ap_magazine_followers: controller: App\Controller\ActivityPub\Magazine\MagazineFollowersController path: /m/{name}/followers methods: [GET] condition: '%kbin_ap_route_condition%' ap_magazine_moderators: controller: App\Controller\ActivityPub\Magazine\MagazineModeratorsController path: /m/{name}/moderators methods: [GET] condition: '%kbin_ap_route_condition%' ap_magazine_pinned: controller: App\Controller\ActivityPub\Magazine\MagazinePinnedController path: /m/{name}/pinned methods: [GET] condition: '%kbin_ap_route_condition%' ap_entry: controller: App\Controller\ActivityPub\EntryController defaults: { slug: -, sortBy: hot } path: /m/{magazine_name}/t/{entry_id}/{slug}/{sortBy} methods: [GET] condition: '%kbin_ap_route_condition%' ap_entry_comment: controller: App\Controller\ActivityPub\EntryCommentController defaults: { slug: - } path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/{comment_id} methods: [GET] condition: '%kbin_ap_route_condition%' ap_post: controller: App\Controller\ActivityPub\PostController defaults: { slug: - } path: /m/{magazine_name}/p/{post_id}/{slug} methods: [GET] condition: '%kbin_ap_route_condition%' ap_post_comment: controller: App\Controller\ActivityPub\PostCommentController defaults: { slug: - } path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id} methods: [GET] condition: '%kbin_ap_route_condition%' ap_report: controller: App\Controller\ActivityPub\ReportController path: /reports/{report_id} methods: [GET] condition: '%kbin_ap_route_condition%' ap_message: controller: App\Controller\ActivityPub\MessageController path: /message/{uuid} methods: [GET] condition: '%kbin_ap_route_condition%' ap_contexts: controller: App\Controller\ActivityPub\ContextsController path: /contexts.{_format} methods: [GET] format: jsonld ================================================ FILE: config/mbin_routes/admin.yaml ================================================ admin_users_active: controller: App\Controller\Admin\AdminUsersController::active defaults: { withFederated: false } path: /admin/users/active/{withFederated} methods: [GET] admin_users_inactive: controller: App\Controller\Admin\AdminUsersController::inactive path: /admin/users/inactive methods: [GET] admin_users_suspended: controller: App\Controller\Admin\AdminUsersController::suspended defaults: { withFederated: false } path: /admin/users/suspended/{withFederated} methods: [GET] admin_users_banned: controller: App\Controller\Admin\AdminUsersController::banned defaults: { withFederated: false } path: /admin/users/banned/{withFederated} methods: [GET] admin_reports: controller: App\Controller\Admin\AdminReportController path: /admin/reports/{status} defaults: { status: !php/const \App\Entity\Report::STATUS_ANY } methods: [GET] admin_settings: controller: App\Controller\Admin\AdminSettingsController path: /admin/settings methods: [GET, POST] admin_federation: controller: App\Controller\Admin\AdminFederationController path: /admin/federation methods: [GET, POST] admin_federation_ban_instance: controller: App\Controller\Admin\AdminFederationController::banInstance path: /admin/federation/ban methods: [GET, POST] admin_federation_unban_instance: controller: App\Controller\Admin\AdminFederationController::unbanInstance path: /admin/federation/unban methods: [GET] admin_federation_allow_instance: controller: App\Controller\Admin\AdminFederationController::allowInstance path: /admin/federation/allow methods: [GET] admin_federation_deny_instance: controller: App\Controller\Admin\AdminFederationController::denyInstance path: /admin/federation/deny methods: [GET, POST] admin_pages: controller: App\Controller\Admin\AdminPagesController path: /admin/pages/{page} methods: [GET, POST] admin_deletion_users: controller: App\Controller\Admin\AdminDeletionController::users path: /admin/deletion/users methods: [GET] admin_deletion_magazines: controller: App\Controller\Admin\AdminDeletionController::magazines path: /admin/deletion/magazines methods: [GET] admin_moderators: controller: App\Controller\Admin\AdminModeratorController::moderators path: /admin/moderators methods: [GET, POST] admin_moderator_purge: controller: App\Controller\Admin\AdminModeratorController::removeModerator path: /admin/moderators/purge/{username} methods: [POST] admin_magazine_ownership_requests: controller: App\Controller\Admin\AdminMagazineOwnershipRequestController::requests path: /admin/magazine_ownership methods: [GET] admin_magazine_ownership_requests_accept: controller: App\Controller\Admin\AdminMagazineOwnershipRequestController::accept path: /admin/magazine_ownership/{name}/{username}/accept methods: [POST] admin_magazine_ownership_requests_reject: controller: App\Controller\Admin\AdminMagazineOwnershipRequestController::reject path: /admin/magazine_ownership/{name}/{username}/reject methods: [POST] admin_signup_requests: controller: App\Controller\Admin\AdminSignupRequestsController::requests path: /admin/signup_requests methods: [ GET ] admin_signup_requests_approve: controller: App\Controller\Admin\AdminSignupRequestsController::approve path: /admin/signup_requests/{id}/approve methods: [ POST ] admin_signup_requests_reject: controller: App\Controller\Admin\AdminSignupRequestsController::reject path: /admin/signup_requests/{id}/reject methods: [ POST ] admin_cc: controller: App\Controller\Admin\AdminClearCacheController path: /admin/cc methods: [GET] admin_monitoring: controller: App\Controller\Admin\AdminMonitoringController::overview path: /admin/monitoring methods: [GET] admin_monitoring_single_context: controller: App\Controller\Admin\AdminMonitoringController::single defaults: { page: overview } path: /admin/monitoring/{id}/{page} methods: [GET] admin_dashboard: controller: App\Controller\Admin\AdminDashboardController path: /admin/{statsPeriod}/{withFederated} defaults: { statsType: content, statsPeriod: -1, withFederated: false } methods: [GET] ================================================ FILE: config/mbin_routes/admin_api.yaml ================================================ api_admin_entry_purge: controller: App\Controller\Api\Entry\Admin\EntriesPurgeApi path: /api/admin/entry/{entry_id}/purge methods: [ DELETE ] format: json api_admin_entry_change_magazine: controller: App\Controller\Api\Entry\Admin\EntriesChangeMagazineApi path: /api/admin/entry/{entry_id}/change-magazine/{target_id} methods: [ PUT ] format: json api_admin_comment_purge: controller: App\Controller\Api\Entry\Comments\Admin\EntryCommentsPurgeApi path: /api/admin/comment/{comment_id}/purge methods: [ DELETE ] format: json api_admin_post_purge: controller: App\Controller\Api\Post\Admin\PostsPurgeApi path: /api/admin/post/{post_id}/purge methods: [ DELETE ] format: json api_admin_post_comment_purge: controller: App\Controller\Api\Post\Comments\Admin\PostCommentsPurgeApi path: /api/admin/post-comment/{comment_id}/purge methods: [ DELETE ] format: json api_admin_user_retrieve_banned: controller: App\Controller\Api\User\Admin\UserRetrieveBannedApi::collection path: /api/admin/users/banned methods: [ GET ] format: json api_admin_user_ban: controller: App\Controller\Api\User\Admin\UserBanApi::ban path: /api/admin/users/{user_id}/ban methods: [ POST ] format: json api_admin_user_unban: controller: App\Controller\Api\User\Admin\UserBanApi::unban path: /api/admin/users/{user_id}/unban methods: [ POST ] format: json api_admin_user_delete_account: controller: App\Controller\Api\User\Admin\UserDeleteApi path: /api/admin/users/{user_id}/delete_account methods: [ DELETE ] format: json api_admin_user_purge: controller: App\Controller\Api\User\Admin\UserPurgeApi path: /api/admin/users/{user_id}/purge_account methods: [ DELETE ] format: json api_admin_user_verify: controller: App\Controller\Api\User\Admin\UserVerifyApi path: /api/admin/users/{user_id}/verify methods: [ PUT ] format: json api_admin_retrieve_settings: controller: App\Controller\Api\Instance\Admin\InstanceRetrieveSettingsApi path: /api/instance/settings methods: [ GET ] format: json api_admin_update_settings: controller: App\Controller\Api\Instance\Admin\InstanceUpdateSettingsApi path: /api/instance/settings methods: [ PUT ] format: json api_admin_ban_instance: controller: App\Controller\Api\Instance\Admin\InstanceUpdateFederationApi::banInstance path: /api/instance/ban/{domain} methods: [ PUT ] format: json api_admin_unban_instance: controller: App\Controller\Api\Instance\Admin\InstanceUpdateFederationApi::unbanInstance path: /api/instance/unban/{domain} methods: [ PUT ] format: json api_admin_allow_instance: controller: App\Controller\Api\Instance\Admin\InstanceUpdateFederationApi::allowInstance path: /api/instance/allow/{domain} methods: [ PUT ] format: json api_admin_deny_instance: controller: App\Controller\Api\Instance\Admin\InstanceUpdateFederationApi::denyInstance path: /api/instance/deny/{domain} methods: [ PUT ] format: json api_admin_update_pages: controller: App\Controller\Api\Instance\Admin\InstanceUpdatePagesApi path: /api/instance/{page} methods: [ PUT ] format: json api_admin_retrieve_client_stats: controller: App\Controller\Api\OAuth2\Admin\RetrieveClientStatsApi path: /api/clients/stats methods: [ GET ] format: json api_admin_retrieve_client: controller: App\Controller\Api\OAuth2\Admin\RetrieveClientApi path: /api/clients/{client_identifier} methods: [ GET ] format: json api_admin_retrieve_client_collection: controller: App\Controller\Api\OAuth2\Admin\RetrieveClientApi::collection path: /api/clients methods: [ GET ] format: json api_admin_update_defederated_instances: controller: App\Controller\Api\Instance\Admin\InstanceUpdateFederationApi path: /api/defederated methods: [ PUT ] format: json api_admin_purge_magazine: controller: App\Controller\Api\Magazine\Admin\MagazinePurgeApi path: /api/admin/magazine/{magazine_id}/purge methods: [ DELETE ] format: json api_admin_view_user_applications: controller: App\Controller\Api\User\Admin\UserApplicationApi::retrieve path: /api/admin/users/applications methods: [ GET ] format: json api_admin_view_user_application_approve: controller: App\Controller\Api\User\Admin\UserApplicationApi::approve path: /api/admin/users/applications/{user_id}/approve methods: [ GET ] format: json api_admin_view_user_application_reject: controller: App\Controller\Api\User\Admin\UserApplicationApi::reject path: /api/admin/users/applications/{user_id}/reject methods: [ GET ] format: json ================================================ FILE: config/mbin_routes/ajax.yaml ================================================ ajax_fetch_title: controller: App\Controller\AjaxController::fetchTitle defaults: { _format: json } path: /ajax/fetch_title methods: [POST] ajax_fetch_duplicates: controller: App\Controller\AjaxController::fetchDuplicates defaults: { _format: json } path: /ajax/fetch_duplicates methods: [POST] ajax_fetch_embed: controller: App\Controller\AjaxController::fetchEmbed defaults: { _format: json } path: /ajax/fetch_embed methods: [GET] ajax_fetch_post_comments: controller: App\Controller\AjaxController::fetchPostComments defaults: { _format: json } path: /ajax/fetch_post_comments/{id} methods: [GET] requirements: id: \d+ ajax_fetch_entry: controller: App\Controller\AjaxController::fetchEntry defaults: { _format: json } path: /ajax/fetch_entry/{id} methods: [GET] requirements: id: \d+ ajax_fetch_entry_comment: controller: App\Controller\AjaxController::fetchEntryComment defaults: { _format: json } path: /ajax/fetch_entry_comment/{id} methods: [GET] requirements: id: \d+ ajax_fetch_post: controller: App\Controller\AjaxController::fetchPost defaults: { _format: json } path: /ajax/fetch_post/{id} methods: [GET] requirements: id: \d+ ajax_fetch_post_comment: controller: App\Controller\AjaxController::fetchPostComment defaults: { _format: json } path: /ajax/fetch_post_comment/{id} methods: [GET] requirements: id: \d+ ajax_fetch_online: controller: App\Controller\AjaxController::fetchOnline defaults: { _format: json } path: /ajax/fetch_online/{topic} methods: [ GET ] ajax_fetch_user_popup: controller: App\Controller\AjaxController::fetchUserPopup defaults: { _format: json } path: /ajax/fetch_user_popup/{username} methods: [ GET ] ajax_fetch_user_notifications_count: controller: App\Controller\AjaxController::fetchNotificationsCount defaults: { _format: json } path: /ajax/fetch_user_notifications_count methods: [ GET ] ajax_register_notification_push: controller: App\Controller\AjaxController::registerPushNotifications path: /ajax/register_push methods: [ POST ] ajax_unregister_notification_push: controller: App\Controller\AjaxController::unregisterPushNotifications path: /ajax/unregister_push methods: [ POST ] ajax_test_notification_push: controller: App\Controller\AjaxController::testPushNotification path: /ajax/test_push methods: [ POST ] ajax_fetch_users_suggestions: controller: App\Controller\AjaxController::fetchUsersSuggestions defaults: { _format: json } path: /ajax/fetch_users_suggestions/{username} methods: [ GET ] ajax_fetch_emoji_suggestions: controller: App\Controller\AjaxController::fetchEmojiSuggestions defaults: { _format: json } path: /ajax/fetch_emoji_suggestions methods: [ GET ] ================================================ FILE: config/mbin_routes/api.yaml ================================================ app.swagger_ui: path: /api/docs methods: GET defaults: { _controller: nelmio_api_doc.controller.swagger_ui } ================================================ FILE: config/mbin_routes/bookmark.yaml ================================================ bookmark_front: controller: App\Controller\BookmarkListController::front defaults: { sortBy: hot, time: '∞', federation: all } path: /bookmark-lists/show/{list}/{sortBy}/{time}/{federation} methods: [GET] requirements: &front_requirement sortBy: "%default_sort_options%" time: "%default_time_options%" federation: "%default_federation_options%" bookmark_lists: controller: App\Controller\BookmarkListController::list path: /bookmark-lists methods: [GET, POST] bookmark_lists_menu_refresh_status: controller: App\Controller\BookmarkListController::subjectBookmarkMenuListRefresh path: /blr/{subject_id}/{subject_type} requirements: subject_type: "%default_subject_type_options%" methods: [ GET ] bookmark_lists_make_default: controller: App\Controller\BookmarkListController::makeDefault path: /bookmark-lists/makeDefault methods: [GET] bookmark_lists_edit_list: controller: App\Controller\BookmarkListController::editList path: /bookmark-lists/editList/{list} methods: [GET, POST] bookmark_lists_delete_list: controller: App\Controller\BookmarkListController::deleteList path: /bookmark-lists/deleteList/{list} methods: [GET] subject_bookmark_standard: controller: App\Controller\BookmarkController::subjectBookmarkStandard path: /bos/{subject_id}/{subject_type} requirements: subject_type: "%default_subject_type_options%" methods: [ GET ] subject_bookmark_refresh_status: controller: App\Controller\BookmarkController::subjectBookmarkRefresh path: /bor/{subject_id}/{subject_type} requirements: subject_type: "%default_subject_type_options%" methods: [ GET ] subject_bookmark_to_list: controller: App\Controller\BookmarkController::subjectBookmarkToList path: /bol/{subject_id}/{subject_type}/{list} requirements: subject_type: "%default_subject_type_options%" methods: [ GET ] subject_remove_bookmarks: controller: App\Controller\BookmarkController::subjectRemoveBookmarks path: /rbo/{subject_id}/{subject_type} requirements: subject_type: "%default_subject_type_options%" methods: [ GET ] subject_remove_bookmark_from_list: controller: App\Controller\BookmarkController::subjectRemoveBookmarkFromList path: /rbol/{subject_id}/{subject_type}/{list} requirements: subject_type: "%default_subject_type_options%" methods: [ GET ] ================================================ FILE: config/mbin_routes/bookmark_api.yaml ================================================ api_bookmark_front: controller: App\Controller\Api\Bookmark\BookmarkListApiController::front path: /api/bookmark-lists/show methods: [GET] format: json api_bookmark_lists: controller: App\Controller\Api\Bookmark\BookmarkListApiController::list path: /api/bookmark-lists methods: [GET] format: json api_bookmark_lists_make_default: controller: App\Controller\Api\Bookmark\BookmarkListApiController::makeDefault path: /api/bookmark-lists/{list_name}/makeDefault methods: [PUT] format: json api_bookmark_lists_edit_list: controller: App\Controller\Api\Bookmark\BookmarkListApiController::editList path: /api/bookmark-lists/{list_name} methods: [PUT] format: json api_bookmark_lists_add_list: controller: App\Controller\Api\Bookmark\BookmarkListApiController::createList path: /api/bookmark-lists/{list_name} methods: [POST] format: json api_bookmark_lists_delete_list: controller: App\Controller\Api\Bookmark\BookmarkListApiController::deleteList path: /api/bookmark-lists/{list_name} methods: [DELETE] format: json api_subject_bookmark_standard: controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectBookmarkStandard path: /api/bos/{subject_id}/{subject_type} requirements: subject_type: "%default_subject_type_options%" methods: [ PUT ] format: json api_subject_bookmark_to_list: controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectBookmarkToList path: /api/bol/{subject_id}/{subject_type}/{list_name} requirements: subject_type: "%default_subject_type_options%" methods: [ PUT ] format: json api_subject_remove_bookmarks: controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectRemoveBookmarks path: /api/rbo/{subject_id}/{subject_type} requirements: subject_type: "%default_subject_type_options%" methods: [ DELETE ] format: json api_subject_remove_bookmark_from_list: controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectRemoveBookmarkFromList path: /api/rbol/{subject_id}/{subject_type}/{list_name} requirements: subject_type: "%default_subject_type_options%" methods: [ DELETE ] format: json ================================================ FILE: config/mbin_routes/combined_api.yaml ================================================ api_combined_cursor: controller: App\Controller\Api\Combined\CombinedRetrieveApi::cursorCollection path: /api/combined/v2 methods: [ GET ] format: json api_combined_user_cursor: controller: App\Controller\Api\Combined\CombinedRetrieveApi::cursorUserCollection path: /api/combined/v2/{collectionType} requirements: collectionType: subscribed|moderated|favourited methods: [ GET ] format: json api_combined: controller: App\Controller\Api\Combined\CombinedRetrieveApi::collection path: /api/combined methods: [ GET ] format: json api_combined_user: controller: App\Controller\Api\Combined\CombinedRetrieveApi::userCollection path: /api/combined/{collectionType} requirements: collectionType: subscribed|moderated|favourited methods: [ GET ] format: json ================================================ FILE: config/mbin_routes/custom_style.yaml ================================================ custom_style: controller: App\Controller\CustomStyleController path: /custom-style methods: [ GET ] ================================================ FILE: config/mbin_routes/domain.yaml ================================================ domain_entries: controller: App\Controller\Domain\DomainFrontController defaults: { sortBy: hot, time: '∞'} path: /d/{name}/{sortBy}/{time} methods: [ GET ] requirements: sortBy: "%default_sort_options%" time: "%default_time_options%" domain_comments: controller: App\Controller\Domain\DomainCommentFrontController defaults: { sortBy: hot, time: ~ } path: /d/{name}/comments/{sortBy}/{time} methods: [GET] requirements: sortBy: "%comment_sort_options%" time: "%default_time_options%" domain_subscribe: controller: App\Controller\Domain\DomainSubController::subscribe path: /d/{name}/subscribe methods: [ POST ] domain_unsubscribe: controller: App\Controller\Domain\DomainSubController::unsubscribe path: /d/{name}/unsubscribe methods: [ POST ] domain_block: controller: App\Controller\Domain\DomainBlockController::block path: /d/{name}/block methods: [ POST ] domain_unblock: controller: App\Controller\Domain\DomainBlockController::unblock path: /d/{name}/unblock methods: [ POST ] ================================================ FILE: config/mbin_routes/domain_api.yaml ================================================ # Get a list of threads from specific domain api_domain_entries_retrieve: controller: App\Controller\Api\Entry\DomainEntriesRetrieveApi path: /api/domain/{domain_id}/entries methods: [ GET ] format: json # Get a list of comments from specific domain api_domain_entry_comments_retrieve: controller: App\Controller\Api\Entry\Comments\DomainEntryCommentsRetrieveApi path: /api/domain/{domain_id}/comments methods: [ GET ] format: json # Get list of domains in instance api_domains_retrieve: controller: App\Controller\Api\Domain\DomainRetrieveApi::collection path: /api/domains methods: [ GET ] format: json # Get domain info api_domain_retrieve: controller: App\Controller\Api\Domain\DomainRetrieveApi path: /api/domain/{domain_id} methods: [ GET ] format: json # Get subscribed domains for the current user api_domains_retrieve_subscribed: controller: App\Controller\Api\Domain\DomainRetrieveApi::subscribed path: /api/domains/subscribed methods: [ GET ] format: json # Get blocked domains for the current user api_domains_retrieve_blocked: controller: App\Controller\Api\Domain\DomainRetrieveApi::blocked path: /api/domains/blocked methods: [ GET ] format: json api_domain_block: controller: App\Controller\Api\Domain\DomainBlockApi::block path: /api/domain/{domain_id}/block methods: [ PUT ] format: json api_domain_unblock: controller: App\Controller\Api\Domain\DomainBlockApi::unblock path: /api/domain/{domain_id}/unblock methods: [ PUT ] format: json api_domain_subscribe: controller: App\Controller\Api\Domain\DomainSubscribeApi::subscribe path: /api/domain/{domain_id}/subscribe methods: [ PUT ] format: json api_domain_unsubscribe: controller: App\Controller\Api\Domain\DomainSubscribeApi::unsubscribe path: /api/domain/{domain_id}/unsubscribe methods: [ PUT ] format: json ================================================ FILE: config/mbin_routes/entry.yaml ================================================ entry_comment_create: controller: App\Controller\Entry\Comment\EntryCommentCreateController defaults: { slug: -, parent_comment_id: null } path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/create/{parent_comment_id} methods: [ GET, POST ] requirements: entry_id: \d+ parent_comment_id: \d+ entry_comment_view: controller: App\Controller\Entry\Comment\EntryCommentViewController defaults: { slug: -, comment_id: null } path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/{comment_id} methods: [ GET ] requirements: entry_id: \d+ comment_id: \d+ entry_comment_edit: controller: App\Controller\Entry\Comment\EntryCommentEditController defaults: { slug: -, } path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/{comment_id}/edit methods: [ GET, POST ] requirements: entry_id: \d+ comment_id: \d+ entry_comment_delete: controller: App\Controller\Entry\Comment\EntryCommentDeleteController::delete defaults: { slug: -, } path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{comment_id}/delete methods: [ POST ] requirements: entry_id: \d+ comment_id: \d+ entry_comment_restore: controller: App\Controller\Entry\Comment\EntryCommentDeleteController::restore defaults: { slug: -, } path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{comment_id}/restore methods: [ POST ] requirements: entry_id: \d+ comment_id: \d+ entry_comment_purge: controller: App\Controller\Entry\Comment\EntryCommentDeleteController::purge defaults: { slug: -, } path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{comment_id}/purge methods: [ POST ] requirements: entry_id: \d+ comment_id: \d+ entry_comment_change_lang: controller: App\Controller\Entry\Comment\EntryCommentChangeLangController defaults: { slug: - } path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{comment_id}/change_lang methods: [ POST ] requirements: entry_id: \d+ comment_id: \d+ entry_comment_change_adult: controller: App\Controller\Entry\Comment\EntryCommentChangeAdultController defaults: { slug: - } path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{comment_id}/change_adult methods: [ POST ] requirements: entry_id: \d+ comment_id: \d+ entry_comment_image_delete: controller: App\Controller\Entry\Comment\EntryCommentDeleteImageController defaults: { slug: -, } path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{comment_id}/delete_image methods: [ POST ] requirements: entry_id: \d+ comment_id: \d+ entry_comment_voters: controller: App\Controller\Entry\Comment\EntryCommentVotersController defaults: { slug: -, } path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/{comment_id}/votes/{type} methods: [ GET ] requirements: type: 'up' entry_id: \d+ comment_id: \d+ entry_comment_favourites: controller: App\Controller\Entry\Comment\EntryCommentFavouriteController defaults: { slug: -, } path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/{comment_id}/favourites methods: [ GET ] requirements: entry_id: \d+ comment_id: \d+ entry_comment_moderate: controller: App\Controller\Entry\Comment\EntryCommentModerateController defaults: { slug: -, } path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/{comment_id}/moderate methods: [ GET ] requirements: entry_id: \d+ comment_id: \d+ entry_comments_front: controller: App\Controller\Entry\Comment\EntryCommentFrontController::front defaults: { sortBy: default, time: ~ } path: /comments/{sortBy}/{time} methods: [ GET ] requirements: sortBy: "%comment_sort_options%" time: "%default_time_options%" entry_comments_subscribed: controller: App\Controller\Entry\Comment\EntryCommentFrontController::subscribed defaults: { sortBy: default, time: ~ } path: /sub/comments/{sortBy}/{time} methods: [ GET ] requirements: sortBy: "%comment_sort_options%" time: "%default_time_options%" entry_comments_moderated: controller: App\Controller\Entry\Comment\EntryCommentFrontController::moderated defaults: { sortBy: default, time: ~ } path: /mod/comments/{sortBy}/{time} methods: [ GET ] requirements: sortBy: "%comment_sort_options%" time: "%default_time_options%" entry_comments_favourite: controller: App\Controller\Entry\Comment\EntryCommentFrontController::favourite defaults: { sortBy: default, time: ~ } path: /fav/comments/{sortBy}/{time} methods: [ GET ] requirements: sortBy: "%comment_sort_options%" time: "%default_time_options%" magazine_entry_comments: controller: App\Controller\Entry\Comment\EntryCommentFrontController::front defaults: { sortBy: default, time: ~ } path: /m/{name}/comments/{sortBy}/{time} methods: [ GET ] requirements: sortBy: "%comment_sort_options%" time: "%default_time_options%" entry_comment_vote: controller: App\Controller\VoteController defaults: { entityClass: App\Entity\EntryComment } path: /ecv/{id}/{choice} methods: [ POST ] requirements: id: \d+ entry_comment_report: controller: App\Controller\ReportController defaults: { entityClass: App\Entity\EntryComment } path: /ecr/{id} methods: [ GET, POST ] requirements: id: \d+ entry_comment_favourite: controller: App\Controller\FavouriteController defaults: { entityClass: App\Entity\EntryComment } path: /ecf/{id} methods: [ POST ] requirements: id: \d+ entry_comment_boost: controller: App\Controller\BoostController defaults: { entityClass: App\Entity\EntryComment } path: /ecb/{id} methods: [ POST ] requirements: id: \d+ entry_create: controller: App\Controller\Entry\EntryCreateController path: /new_entry methods: [ GET, POST ] magazine_entry_create: controller: App\Controller\Entry\EntryCreateController path: /m/{name}/new_entry methods: [ GET, POST ] entry_edit: controller: App\Controller\Entry\EntryEditController defaults: { slug: -, sortBy: default } path: /m/{magazine_name}/t/{entry_id}/{slug}/edit methods: [ GET, POST ] requirements: entry_id: \d+ entry_moderate: controller: App\Controller\Entry\EntryModerateController defaults: { slug: -, sortBy: default } path: /m/{magazine_name}/t/{entry_id}/{slug}/moderate methods: [ GET ] requirements: entry_id: \d+ entry_delete: controller: App\Controller\Entry\EntryDeleteController::delete defaults: { slug: -, sortBy: default } path: /m/{magazine_name}/t/{entry_id}/{slug}/delete methods: [ POST ] requirements: entry_id: \d+ entry_restore: controller: App\Controller\Entry\EntryDeleteController::restore defaults: { slug: -, sortBy: default } path: /m/{magazine_name}/t/{entry_id}/{slug}/restore methods: [ POST ] requirements: entry_id: \d+ entry_purge: controller: App\Controller\Entry\EntryDeleteController::purge defaults: { slug: -, sortBy: default } path: /m/{magazine_name}/t/{entry_id}/{slug}/purge methods: [ POST ] requirements: entry_id: \d+ entry_image_delete: controller: App\Controller\Entry\EntryDeleteImageController defaults: { slug: -, } path: /m/{magazine_name}/e/{entry_id}/{slug}/delete_image methods: [ POST ] requirements: entry_id: \d+ entry_change_magazine: controller: App\Controller\Entry\EntryChangeMagazineController defaults: { slug: - } path: /m/{magazine_name}/e/{entry_id}/{slug}/change_magazine methods: [ POST ] requirements: entry_id: \d+ entry_change_lang: controller: App\Controller\Entry\EntryChangeLangController defaults: { slug: - } path: /m/{magazine_name}/e/{entry_id}/{slug}/change_lang methods: [ POST ] requirements: entry_id: \d+ entry_change_adult: controller: App\Controller\Entry\EntryChangeAdultController defaults: { slug: - } path: /m/{magazine_name}/e/{entry_id}/{slug}/change_adult methods: [ POST ] requirements: entry_id: \d+ entry_pin: controller: App\Controller\Entry\EntryPinController defaults: { slug: -, sortBy: default } path: /m/{magazine_name}/t/{entry_id}/{slug}/pin methods: [ POST ] requirements: entry_id: \d+ entry_lock: controller: App\Controller\Entry\EntryLockController defaults: { slug: -, sortBy: default } path: /m/{magazine_name}/t/{entry_id}/{slug}/lock methods: [ POST ] requirements: entry_id: \d+ entry_voters: controller: App\Controller\Entry\EntryVotersController defaults: { slug: -, sortBy: default } path: /m/{magazine_name}/t/{entry_id}/{slug}/votes/{type} methods: [ GET ] requirements: type: 'up' entry_id: \d+ entry_fav: controller: App\Controller\Entry\EntryFavouriteController defaults: { slug: -, sortBy: default } path: /m/{magazine_name}/t/{entry_id}/{slug}/favourites methods: [ GET ] requirements: entry_id: \d+ entry_tips: controller: App\Controller\Entry\EntryTipController defaults: { slug: -, sortBy: default } path: /m/{magazine_name}/t/{entry_id}/{slug}/tips methods: [ GET ] requirements: entry_id: \d+ entry_single: controller: App\Controller\Entry\EntrySingleController defaults: { slug: -, sortBy: default } path: /m/{magazine_name}/t/{entry_id}/{slug}/{sortBy} methods: [ GET ] requirements: sortBy: "%comment_sort_options%" entry_id: \d+ entry_single_comments: controller: App\Controller\Entry\EntrySingleController defaults: { slug: -, sortBy: default } path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{sortBy} methods: [ GET ] requirements: sortBy: "%comment_sort_options%" entry_id: \d+ entry_vote: controller: App\Controller\VoteController defaults: { entityClass: App\Entity\Entry } path: /ev/{id}/{choice} methods: [ POST ] requirements: id: \d+ entry_report: controller: App\Controller\ReportController defaults: { entityClass: App\Entity\Entry } path: /er/{id} methods: [ GET, POST ] requirements: id: \d+ entry_favourite: controller: App\Controller\FavouriteController defaults: { entityClass: App\Entity\Entry } path: /ef/{id} methods: [ POST ] requirements: id: \d+ entry_boost: controller: App\Controller\BoostController defaults: { entityClass: App\Entity\Entry } path: /eb/{id} methods: [ POST ] requirements: id: \d+ entry_crosspost: controller: App\Controller\CrosspostController defaults: { entityClass: App\Entity\Entry } path: /crosspost/{id} methods: [ GET ] requirements: id: \d+ ================================================ FILE: config/mbin_routes/entry_api.yaml ================================================ # Get information about a thread api_entry_retrieve: controller: App\Controller\Api\Entry\EntriesRetrieveApi path: /api/entry/{entry_id} methods: [ GET ] format: json api_entry_update: controller: App\Controller\Api\Entry\EntriesUpdateApi path: /api/entry/{entry_id} methods: [ PUT ] format: json api_entry_delete: controller: App\Controller\Api\Entry\EntriesDeleteApi path: /api/entry/{entry_id} methods: [ DELETE ] format: json api_entry_report: controller: App\Controller\Api\Entry\EntriesReportApi path: /api/entry/{entry_id}/report methods: [ POST ] format: json api_entry_vote: controller: App\Controller\Api\Entry\EntriesVoteApi path: /api/entry/{entry_id}/vote/{choice} methods: [ PUT ] format: json api_entry_favourite: controller: App\Controller\Api\Entry\EntriesFavouriteApi path: /api/entry/{entry_id}/favourite methods: [ PUT ] format: json # Get a list of threads from subscribed magazines api_entries_subscribed: controller: App\Controller\Api\Entry\EntriesRetrieveApi::subscribed path: /api/entries/subscribed methods: [ GET ] format: json # Get a list of threads from moderated magazines api_entries_moderated: controller: App\Controller\Api\Entry\EntriesRetrieveApi::moderated path: /api/entries/moderated methods: [ GET ] format: json # Get a list of favourited threads api_entries_favourited: controller: App\Controller\Api\Entry\EntriesRetrieveApi::favourited path: /api/entries/favourited methods: [ GET ] format: json # Get a list of threads from all magazines api_entries_collection: controller: App\Controller\Api\Entry\EntriesRetrieveApi::collection path: /api/entries methods: [ GET ] format: json # Get comments for a specific thread api_entry_comments: controller: App\Controller\Api\Entry\Comments\EntryCommentsRetrieveApi path: /api/entry/{entry_id}/comments methods: [ GET ] format: json # Create a top level comment on a thread api_entry_comment_new: controller: App\Controller\Api\Entry\Comments\EntryCommentsCreateApi path: /api/entry/{entry_id}/comments methods: [ POST ] format: json # Create a top level comment with uploaded image on a thread api_entry_comment_new_image: controller: App\Controller\Api\Entry\Comments\EntryCommentsCreateApi::uploadImage path: /api/entry/{entry_id}/comments/image methods: [ POST ] format: json # Create a comment reply on a thread api_entry_comment_reply: controller: App\Controller\Api\Entry\Comments\EntryCommentsCreateApi path: /api/entry/{entry_id}/comments/{comment_id}/reply methods: [ POST ] format: json # Create a comment reply with uploaded image on a thread api_entry_comment_reply_image: controller: App\Controller\Api\Entry\Comments\EntryCommentsCreateApi::uploadImage path: /api/entry/{entry_id}/comments/{comment_id}/reply/image methods: [ POST ] format: json # Retrieve a comment api_comment_retrieve: controller: App\Controller\Api\Entry\Comments\EntryCommentsRetrieveApi::single path: /api/comments/{comment_id} methods: [ GET ] format: json # Update a comment api_comment_update: controller: App\Controller\Api\Entry\Comments\EntryCommentsUpdateApi path: /api/comments/{comment_id} methods: [ PUT ] format: json # Delete a comment api_comment_delete: controller: App\Controller\Api\Entry\Comments\EntryCommentsDeleteApi path: /api/comments/{comment_id} methods: [ DELETE ] format: json api_comment_report: controller: App\Controller\Api\Entry\Comments\EntryCommentsReportApi path: /api/comments/{comment_id}/report methods: [ POST ] format: json # Vote on a comment api_comment_vote: controller: App\Controller\Api\Entry\Comments\EntryCommentsVoteApi path: /api/comments/{comment_id}/vote/{choice} methods: [ PUT ] format: json # Favourite a comment api_comment_favourite: controller: App\Controller\Api\Entry\Comments\EntryCommentsFavouriteApi path: /api/comments/{comment_id}/favourite methods: [ PUT ] format: json # boosts and upvotes for entries api_entry_activity: controller: App\Controller\Api\Entry\EntriesActivityApi path: /api/entry/{entry_id}/activity methods: [ GET ] format: json # boosts and upvotes entry comments api_entry_comment_activity: controller: App\Controller\Api\Entry\Comments\EntryCommentsActivityApi path: /api/comments/{comment_id}/activity methods: [ GET ] format: json ================================================ FILE: config/mbin_routes/front.yaml ================================================ front: controller: App\Controller\Entry\EntryFrontController::front defaults: &front_defaults { subscription: home, content: default, sortBy: default, time: '∞', federation: all } path: /{subscription}/{content}/{sortBy}/{time}/{federation} methods: [GET] requirements: &front_requirement subscription: "%default_subscription_options%" sortBy: "%default_sort_options%" time: "%default_time_options%" federation: "%default_federation_options%" content: "%default_content_options%" front_sub: controller: App\Controller\Entry\EntryFrontController::front defaults: *front_defaults path: /{subscription}/{sortBy}/{time}/{federation} methods: [GET] requirements: *front_requirement front_content: controller: App\Controller\Entry\EntryFrontController::front defaults: *front_defaults path: /{content}/{sortBy}/{time}/{federation} methods: [GET] requirements: *front_requirement front_short: controller: App\Controller\Entry\EntryFrontController::front defaults: *front_defaults path: /{sortBy}/{time}/{federation} methods: [GET] requirements: *front_requirement front_magazine: controller: App\Controller\Entry\EntryFrontController::magazine defaults: &front_magazine_defaults { content: default, sortBy: default, time: '∞', federation: all } path: /m/{name}/{content}/{sortBy}/{time}/{federation} methods: [GET] requirements: *front_requirement front_magazine_short: controller: App\Controller\Entry\EntryFrontController::magazine defaults: *front_magazine_defaults path: /m/{name}/{sortBy}/{time}/{federation} methods: [GET] requirements: *front_requirement # Microblog compatibility stuff, redirects from the old routes' URLs posts_front: controller: App\Controller\Entry\EntryFrontController::frontRedirect defaults: { sortBy: default, time: '∞', federation: all, content: microblog } path: /microblog/{sortBy}/{time} methods: [ GET ] requirements: sortBy: "%default_sort_options%" time: "%default_time_options%" posts_subscribed: controller: App\Controller\Entry\EntryFrontController::frontRedirect defaults: { sortBy: default, time: '∞', federation: all, content: microblog, subscription: 'sub' } path: /sub/microblog/{sortBy}/{time} methods: [ GET ] requirements: sortBy: "%default_sort_options%" time: "%default_time_options%" posts_moderated: controller: App\Controller\Entry\EntryFrontController::frontRedirect defaults: { sortBy: default, time: '∞', federation: all, content: microblog, subscription: 'mod' } path: /mod/microblog/{sortBy}/{time} methods: [ GET ] requirements: sortBy: "%default_sort_options%" time: "%default_time_options%" posts_favourite: controller: App\Controller\Entry\EntryFrontController::frontRedirect defaults: { sortBy: default, time: '∞', federation: all, content: microblog, subscription: 'fav' } path: /fav/microblog/{sortBy}/{time} methods: [ GET ] requirements: sortBy: "%default_sort_options%" time: "%default_time_options%" magazine_posts: controller: App\Controller\Entry\EntryFrontController::magazineRedirect defaults: { sortBy: default, time: '∞', federation: all, content: microblog } path: /m/{name}/microblog/{sortBy}/{time}/{federation} methods: [ GET ] requirements: sortBy: "%default_sort_options%" time: "%default_time_options%" federation: "%default_federation_options%" ================================================ FILE: config/mbin_routes/instance_api.yaml ================================================ api_instance_details_retrieve: controller: App\Controller\Api\Instance\InstanceDetailsApi path: /api/instance methods: [ GET ] format: json api_remote_instance_details_retrieve: controller: App\Controller\Api\Instance\InstanceDetailsApi::retrieveRemoteInstanceDetails path: /api/remoteInstance/{domain} methods: [ GET ] format: json api_instance_modlog_retrieve: controller: App\Controller\Api\Instance\InstanceModLogApi::collection path: /api/modlog methods: [ GET ] format: json api_instance_retrieve_votes: controller: App\Controller\Api\Instance\InstanceRetrieveStatsApi::votes path: /api/stats/votes methods: [ GET ] format: json api_instance_retrieve_content: controller: App\Controller\Api\Instance\InstanceRetrieveStatsApi::content path: /api/stats/content methods: [ GET ] format: json api_instance_retrieve_defederated_instances: controller: App\Controller\Api\Instance\InstanceRetrieveFederationApi::getDeFederated path: /api/defederated methods: [ GET ] format: json api_instance_retrieve_defederated_instances_v2: controller: App\Controller\Api\Instance\InstanceRetrieveFederationApi::getDeFederatedV2 path: /api/defederated/v2 methods: [ GET ] format: json api_instance_retrieve_federated_instances: controller: App\Controller\Api\Instance\InstanceRetrieveFederationApi::getFederated path: /api/federated methods: [ GET ] format: json api_instance_retrieve_dead_instances: controller: App\Controller\Api\Instance\InstanceRetrieveFederationApi::getDead path: /api/dead methods: [ GET ] format: json api_instance_retrieve_info: controller: App\Controller\Api\Instance\InstanceRetrieveInfoApi path: /api/info methods: [ GET ] format: json ================================================ FILE: config/mbin_routes/landing.yaml ================================================ about: controller: App\Controller\AboutController path: /about methods: [ GET ] agent: controller: App\Controller\AgentController path: /agent methods: [ GET ] ================================================ FILE: config/mbin_routes/magazine.yaml ================================================ magazine_create: controller: App\Controller\Magazine\MagazineCreateController path: /newMagazine methods: [GET, POST] magazine_delete: controller: App\Controller\Magazine\MagazineDeleteController::delete path: /m/{name}/delete methods: [POST] magazine_restore: controller: App\Controller\Magazine\MagazineDeleteController::restore path: /m/{name}/restore methods: [POST] magazine_purge: controller: App\Controller\Magazine\MagazineDeleteController::purge path: /m/{name}/purge methods: [POST] magazine_abandoned: controller: App\Controller\Magazine\MagazineAbandonedController path: /magazines/abandoned methods: [GET] magazine_purge_content: controller: App\Controller\Magazine\MagazineDeleteController::purgeContent path: /m/{name}/purge_content methods: [POST] magazine_list_all: controller: App\Controller\Magazine\MagazineListController defaults: { sortBy: default, view: table } path: /magazines/{sortBy}/{view} methods: [GET] magazine_moderators: controller: App\Controller\Magazine\MagazineModController path: /m/{name}/moderators methods: [GET] magazine_modlog: controller: App\Controller\ModlogController::magazine path: /m/{name}/modlog methods: [GET] magazine_people: controller: App\Controller\Magazine\MagazinePeopleFrontController path: /m/{name}/people methods: [GET] magazine_subscribe: controller: App\Controller\Magazine\MagazineSubController::subscribe path: /m/{name}/subscribe methods: [POST] magazine_unsubscribe: controller: App\Controller\Magazine\MagazineSubController::unsubscribe path: /m/{name}/unsubscribe methods: [POST] magazine_block: controller: App\Controller\Magazine\MagazineBlockController::block path: /m/{name}/block methods: [POST] magazine_unblock: controller: App\Controller\Magazine\MagazineBlockController::unblock path: /m/{name}/unblock methods: [POST] magazine_remove_subscriptions: controller: App\Controller\Magazine\MagazineRemoveSubscriptionsController path: /m/{name}/remove_subscriptions methods: [POST] magazine_moderator_request: controller: App\Controller\Magazine\MagazineModeratorRequestController path: /m/{name}/moderator_request methods: [POST] magazine_ownership_request: controller: App\Controller\Magazine\MagazineOwnershipRequestController::toggle path: /m/{name}/ownership_request methods: [POST] ================================================ FILE: config/mbin_routes/magazine_api.yaml ================================================ # Create an article entry in a magazine api_magazine_entry_create_article: controller: App\Controller\Api\Entry\MagazineEntryCreateApi::article path: /api/magazine/{magazine_id}/article methods: [ POST ] format: json # Create a link entry in a magazine api_magazine_entry_create_link: controller: App\Controller\Api\Entry\MagazineEntryCreateApi::link path: /api/magazine/{magazine_id}/link methods: [ POST ] format: json # Create an image entry in a magazine api_magazine_entry_create_image: controller: App\Controller\Api\Entry\MagazineEntryCreateApi::uploadImage path: /api/magazine/{magazine_id}/image methods: [ POST ] format: json # Create an image entry in a magazine api_magazine_entry_create: controller: App\Controller\Api\Entry\MagazineEntryCreateApi::entry path: /api/magazine/{magazine_id}/entries methods: [ POST ] format: json # # Create a video entry in a magazine (videos not yet implemented) # api_magazine_entry_create_video: # controller: App\Controller\Api\Entry\MagazineEntryCreateApi::video # path: /api/magazine/{magazine_id}/entry/new/video # methods: [ POST ] # format: json # Create post in magazine api_magazine_posts_create: controller: App\Controller\Api\Post\PostsCreateApi path: /api/magazine/{magazine_id}/posts methods: [ POST ] format: json # Create post with image in magazine api_magazine_posts_create_image: controller: App\Controller\Api\Post\PostsCreateApi::uploadImage path: /api/magazine/{magazine_id}/posts/image methods: [ POST ] format: json # Get a list of threads from specific magazine api_magazine_entries_retrieve: controller: App\Controller\Api\Entry\MagazineEntriesRetrieveApi path: /api/magazine/{magazine_id}/entries methods: [ GET ] format: json # Get list of posts in a magazine api_magazine_posts_retrieve: controller: App\Controller\Api\Post\PostsRetrieveApi::byMagazine path: /api/magazine/{magazine_id}/posts methods: [ GET ] format: json # Get list of magazines in instance api_magazines_retrieve: controller: App\Controller\Api\Magazine\MagazineRetrieveApi::collection path: /api/magazines methods: [ GET ] format: json # Get subscribed magazines for the current user api_magazines_retrieve_subscribed: controller: App\Controller\Api\Magazine\MagazineRetrieveApi::subscribed path: /api/magazines/subscribed methods: [ GET ] format: json # Get moderated magazines for the current user api_magazines_retrieve_moderated: controller: App\Controller\Api\Magazine\MagazineRetrieveApi::moderated path: /api/magazines/moderated methods: [ GET ] format: json # Get blocked magazines for the current user api_magazines_retrieve_blocked: controller: App\Controller\Api\Magazine\MagazineRetrieveApi::blocked path: /api/magazines/blocked methods: [ GET ] format: json # Get magazine info api_magazine_retrieve: controller: App\Controller\Api\Magazine\MagazineRetrieveApi path: /api/magazine/{magazine_id} methods: [ GET ] format: json # Get magazine info by name api_magazine_retrieve_by_name: controller: App\Controller\Api\Magazine\MagazineRetrieveApi::byName path: /api/magazine/name/{magazine_name} methods: [ GET ] format: json api_magazine_block: controller: App\Controller\Api\Magazine\MagazineBlockApi::block path: /api/magazine/{magazine_id}/block methods: [ PUT ] format: json api_magazine_unblock: controller: App\Controller\Api\Magazine\MagazineBlockApi::unblock path: /api/magazine/{magazine_id}/unblock methods: [ PUT ] format: json api_magazine_subscribe: controller: App\Controller\Api\Magazine\MagazineSubscribeApi::subscribe path: /api/magazine/{magazine_id}/subscribe methods: [ PUT ] format: json api_magazine_unsubscribe: controller: App\Controller\Api\Magazine\MagazineSubscribeApi::unsubscribe path: /api/magazine/{magazine_id}/unsubscribe methods: [ PUT ] format: json api_magazine_create: controller: App\Controller\Api\Magazine\Admin\MagazineCreateApi path: /api/moderate/magazine/new methods: [ POST ] format: json api_magazine_update: controller: App\Controller\Api\Magazine\Admin\MagazineUpdateApi path: /api/moderate/magazine/{magazine_id} methods: [ PUT ] format: json api_magazine_delete: controller: App\Controller\Api\Magazine\Admin\MagazineDeleteApi path: /api/moderate/magazine/{magazine_id} methods: [ DELETE ] format: json api_magazine_theme: controller: App\Controller\Api\Magazine\MagazineRetrieveThemeApi path: /api/magazine/{magazine_id}/theme methods: [ GET ] format: json api_magazine_modlog: controller: App\Controller\Api\Magazine\MagazineModLogApi::collection path: /api/magazine/{magazine_id}/log methods: [ GET ] format: json ================================================ FILE: config/mbin_routes/magazine_mod_request_api.yaml ================================================ api_magazine_modrequest_toggle: controller: App\Controller\Api\Magazine\Moderate\MagazineModOwnerRequestApi::toggleModRequest path: /api/moderate/magazine/{magazine_id}/modRequest/toggle methods: [ PUT ] format: json api_magazine_modrequest_accept: controller: App\Controller\Api\Magazine\Moderate\MagazineModOwnerRequestApi::acceptModRequest path: /api/moderate/magazine/{magazine_id}/modRequest/accept/{user_id} methods: [ PUT ] format: json api_magazine_modrequest_reject: controller: App\Controller\Api\Magazine\Moderate\MagazineModOwnerRequestApi::rejectModRequest path: /api/moderate/magazine/{magazine_id}/modRequest/reject/{user_id} methods: [ PUT ] format: json api_magazine_modrequest_list: controller: App\Controller\Api\Magazine\Moderate\MagazineModOwnerRequestApi::getModRequests path: /api/moderate/modRequest/list methods: [ GET ] format: json api_magazine_ownerrequest_toggle: controller: App\Controller\Api\Magazine\Moderate\MagazineModOwnerRequestApi::toggleOwnerRequest path: /api/moderate/magazine/{magazine_id}/ownerRequest/toggle methods: [ PUT ] format: json api_magazine_ownerrequest_accept: controller: App\Controller\Api\Magazine\Moderate\MagazineModOwnerRequestApi::acceptOwnerRequest path: /api/moderate/magazine/{magazine_id}/ownerRequest/accept/{user_id} methods: [ PUT ] format: json api_magazine_ownerrequest_reject: controller: App\Controller\Api\Magazine\Moderate\MagazineModOwnerRequestApi::rejectOwnerRequest path: /api/moderate/magazine/{magazine_id}/ownerRequest/reject/{user_id} methods: [ PUT ] format: json api_magazine_ownerrequest_list: controller: App\Controller\Api\Magazine\Moderate\MagazineModOwnerRequestApi::getOwnerRequests path: /api/moderate/ownerRequest/list methods: [ GET ] format: json ================================================ FILE: config/mbin_routes/magazine_panel.yaml ================================================ magazine_panel_bans: controller: App\Controller\Magazine\Panel\MagazineBanController::bans path: /m/{name}/panel/bans methods: [ GET, POST ] magazine_panel_ban: controller: App\Controller\Magazine\Panel\MagazineBanController::ban defaults: { username: ~ } path: /m/{name}/panel/ban/{username} methods: [ GET, POST ] magazine_panel_unban: controller: App\Controller\Magazine\Panel\MagazineBanController::unban path: /m/{name}/panel/unban/{username} methods: [ POST ] magazine_panel_general: controller: App\Controller\Magazine\Panel\MagazineEditController path: /m/{name}/panel/general methods: [ GET, POST ] magazine_panel_moderators: controller: App\Controller\Magazine\Panel\MagazineModeratorController::moderators path: /m/{name}/panel/moderators methods: [ GET, POST ] magazine_panel_moderator_purge: controller: App\Controller\Magazine\Panel\MagazineModeratorController::remove path: /m/{magazine_name}/panel/{moderator_id}/purge methods: [ POST ] magazine_panel_reports: controller: App\Controller\Magazine\Panel\MagazineReportController::reports path: /m/{name}/panel/reports/{status} defaults: { status: !php/const \App\Entity\Report::STATUS_ANY } methods: [ GET ] magazine_panel_report_approve: controller: App\Controller\Magazine\Panel\MagazineReportController::reportApprove path: /m/{magazine_name}/panel/reports/{report_id}/approve methods: [ POST ] magazine_panel_report_reject: controller: App\Controller\Magazine\Panel\MagazineReportController::reportReject path: /m/{magazine_name}/panel/reports/{report_id}/reject methods: [ POST ] magazine_panel_theme: controller: App\Controller\Magazine\Panel\MagazineThemeController path: /m/{name}/panel/appearance methods: [ GET, POST ] magazine_panel_theme_detach_icon: controller: App\Controller\Magazine\Panel\MagazineThemeController::detachIcon path: /m/{name}/panel/appearance/detachIcon methods: [ POST ] magazine_panel_theme_detach_banner: controller: App\Controller\Magazine\Panel\MagazineThemeController::detachBanner path: /m/{name}/panel/appearance/detachBanner methods: [ POST ] magazine_panel_badges: controller: App\Controller\Magazine\Panel\MagazineBadgeController::badges path: /m/{name}/panel/badges methods: [ GET, POST ] magazine_panel_badge_remove: controller: App\Controller\Magazine\Panel\MagazineBadgeController::remove path: /m/{magazine_name}/panel/badges/{badge_id}/purge methods: [ POST ] magazine_panel_tags: controller: App\Controller\Magazine\Panel\MagazineTagController path: /m/{name}/panel/tags methods: [ GET, POST ] magazine_panel_trash: controller: App\Controller\Magazine\Panel\MagazineTrashController path: /m/{name}/panel/trash methods: [ GET ] magazine_panel_stats: controller: App\Controller\Magazine\Panel\MagazineStatsController defaults: { statsType: content, statsPeriod: 31, withFederated: false } path: /m/{name}/panel/stats/{statsType}/{statsPeriod}/{withFederated} methods: [ GET ] magazine_panel_moderator_requests: controller: App\Controller\Magazine\Panel\MagazineModeratorRequestsController::requests path: /m/{name}/panel/moderator_requests methods: [ GET ] magazine_panel_moderator_request_accept: controller: App\Controller\Magazine\Panel\MagazineModeratorRequestsController::accept path: /m/{name}/moderator_requests/{username}/accept methods: [ POST ] magazine_panel_moderator_request_reject: controller: App\Controller\Magazine\Panel\MagazineModeratorRequestsController::reject path: /m/{name}/panel/moderator_requests/{username}/reject methods: [ POST ] ================================================ FILE: config/mbin_routes/message.yaml ================================================ messages_front: controller: App\Controller\Message\MessageThreadListController path: /profile/messages methods: [ GET ] messages_single: controller: App\Controller\Message\MessageThreadController path: /profile/messages/{id} methods: [ GET, POST ] requirements: id: \d+ messages_create: controller: App\Controller\Message\MessageCreateThreadController path: /u/{username}/message methods: [ GET, POST ] ================================================ FILE: config/mbin_routes/message_api.yaml ================================================ # Get a specific message api_message_retrieve: controller: App\Controller\Api\Message\MessageRetrieveApi path: /api/messages/{message_id} methods: [ GET ] format: json # Mark message as read api_message_read: controller: App\Controller\Api\Message\MessageReadApi::read path: /api/messages/{message_id}/read methods: [ PUT ] format: json # Mark message as not read api_message_unread: controller: App\Controller\Api\Message\MessageReadApi::unread path: /api/messages/{message_id}/unread methods: [ PUT ] format: json # Retrieve current user's message threads api_message_retrieve_threads: controller: App\Controller\Api\Message\MessageRetrieveApi::collection path: /api/messages methods: [ GET ] format: json # Create a reply to a thread api_message_create_reply: controller: App\Controller\Api\Message\MessageThreadReplyApi path: /api/messages/thread/{thread_id}/reply methods: [ POST ] format: json # Retrieve messages from a thread api_message_retrieve_thread: controller: App\Controller\Api\Message\MessageRetrieveApi::thread defaults: { sort: newest } path: /api/messages/thread/{thread_id}/{sort} methods: [ GET ] format: json # Create a thread with a user api_message_create_thread: controller: App\Controller\Api\Message\MessageThreadCreateApi path: /api/users/{user_id}/message methods: [ POST ] format: json ================================================ FILE: config/mbin_routes/moderation_api.yaml ================================================ api_moderate_entry_toggle_pin: controller: App\Controller\Api\Entry\Moderate\EntriesPinApi path: /api/moderate/entry/{entry_id}/pin methods: [ PUT ] format: json api_moderate_entry_toggle_lock: controller: App\Controller\Api\Entry\Moderate\EntriesLockApi path: /api/moderate/entry/{entry_id}/lock methods: [ PUT ] format: json api_moderate_entry_trash: controller: App\Controller\Api\Entry\Moderate\EntriesTrashApi::trash path: /api/moderate/entry/{entry_id}/trash methods: [ PUT ] format: json api_moderate_entry_restore: controller: App\Controller\Api\Entry\Moderate\EntriesTrashApi::restore path: /api/moderate/entry/{entry_id}/restore methods: [ PUT ] format: json api_moderate_entry_set_adult: controller: App\Controller\Api\Entry\Moderate\EntriesSetAdultApi defaults: { adult: true } path: /api/moderate/entry/{entry_id}/adult/{adult} methods: [ PUT ] format: json api_moderate_entry_set_lang: controller: App\Controller\Api\Entry\Moderate\EntriesSetLanguageApi path: /api/moderate/entry/{entry_id}/{lang} methods: [ PUT ] format: json api_moderate_comment_trash: controller: App\Controller\Api\Entry\Comments\Moderate\EntryCommentsTrashApi::trash path: /api/moderate/comment/{comment_id}/trash methods: [ PUT ] format: json api_moderate_comment_restore: controller: App\Controller\Api\Entry\Comments\Moderate\EntryCommentsTrashApi::restore path: /api/moderate/comment/{comment_id}/restore methods: [ PUT ] format: json api_moderate_comment_set_adult: controller: App\Controller\Api\Entry\Comments\Moderate\EntryCommentsSetAdultApi defaults: { adult: true } path: /api/moderate/comment/{comment_id}/adult/{adult} methods: [ PUT ] format: json api_moderate_comment_set_lang: controller: App\Controller\Api\Entry\Comments\Moderate\EntryCommentsSetLanguageApi path: /api/moderate/comment/{comment_id}/{lang} methods: [ PUT ] format: json api_moderate_post_toggle_pin: controller: App\Controller\Api\Post\Moderate\PostsPinApi path: /api/moderate/post/{post_id}/pin methods: [ PUT ] format: json api_moderate_post_toggle_lock: controller: App\Controller\Api\Post\Moderate\PostsLockApi path: /api/moderate/post/{post_id}/lock methods: [ PUT ] format: json api_moderate_post_trash: controller: App\Controller\Api\Post\Moderate\PostsTrashApi::trash path: /api/moderate/post/{post_id}/trash methods: [ PUT ] format: json api_moderate_post_restore: controller: App\Controller\Api\Post\Moderate\PostsTrashApi::restore path: /api/moderate/post/{post_id}/restore methods: [ PUT ] format: json api_moderate_post_set_adult: controller: App\Controller\Api\Post\Moderate\PostsSetAdultApi path: /api/moderate/post/{post_id}/adult/{adult} methods: [ PUT ] format: json api_moderate_post_set_lang: controller: App\Controller\Api\Post\Moderate\PostsSetLanguageApi path: /api/moderate/post/{post_id}/{lang} methods: [ PUT ] format: json api_moderate_post_comment_trash: controller: App\Controller\Api\Post\Comments\Moderate\PostCommentsTrashApi::trash path: /api/moderate/post-comment/{comment_id}/trash methods: [ PUT ] format: json api_moderate_post_comment_restore: controller: App\Controller\Api\Post\Comments\Moderate\PostCommentsTrashApi::restore path: /api/moderate/post-comment/{comment_id}/restore methods: [ PUT ] format: json api_moderate_post_comment_set_adult: controller: App\Controller\Api\Post\Comments\Moderate\PostCommentsSetAdultApi path: /api/moderate/post-comment/{comment_id}/adult/{adult} methods: [ PUT ] format: json api_moderate_post_comment_set_lang: controller: App\Controller\Api\Post\Comments\Moderate\PostCommentsSetLanguageApi path: /api/moderate/post-comment/{comment_id}/{lang} methods: [ PUT ] format: json api_moderate_magazine_ban_user: controller: App\Controller\Api\Magazine\Moderate\MagazineUserBanApi::ban path: /api/moderate/magazine/{magazine_id}/ban/{user_id} methods: [ POST ] format: json api_moderate_magazine_unban_user: controller: App\Controller\Api\Magazine\Moderate\MagazineUserBanApi::unban path: /api/moderate/magazine/{magazine_id}/ban/{user_id} methods: [ DELETE ] format: json api_moderate_magazine_mod_user: controller: App\Controller\Api\Magazine\Admin\MagazineAddModeratorsApi path: /api/moderate/magazine/{magazine_id}/mod/{user_id} methods: [ POST ] format: json api_moderate_magazine_unmod_user: controller: App\Controller\Api\Magazine\Admin\MagazineRemoveModeratorsApi path: /api/moderate/magazine/{magazine_id}/mod/{user_id} methods: [ DELETE ] format: json api_moderate_magazine_add_badge: controller: App\Controller\Api\Magazine\Admin\MagazineAddBadgesApi path: /api/moderate/magazine/{magazine_id}/badge methods: [ POST ] format: json api_moderate_magazine_remove_badge: controller: App\Controller\Api\Magazine\Admin\MagazineRemoveBadgesApi path: /api/moderate/magazine/{magazine_id}/badge/{badge_id} methods: [ DELETE ] format: json api_moderate_magazine_add_tag: controller: App\Controller\Api\Magazine\Admin\MagazineAddTagsApi path: /api/moderate/magazine/{magazine_id}/tag/{tag} methods: [ POST ] format: json api_moderate_magazine_remove_tag: controller: App\Controller\Api\Magazine\Admin\MagazineRemoveTagsApi path: /api/moderate/magazine/{magazine_id}/tag/{tag} methods: [ DELETE ] format: json api_moderate_magazine_retrieve_report: controller: App\Controller\Api\Magazine\Moderate\MagazineReportsRetrieveApi path: /api/moderate/magazine/{magazine_id}/reports/{report_id} methods: [ GET ] format: json api_moderate_magazine_retrieve_reports: controller: App\Controller\Api\Magazine\Moderate\MagazineReportsRetrieveApi::collection path: /api/moderate/magazine/{magazine_id}/reports methods: [ GET ] format: json api_moderate_magazine_accept_report: controller: App\Controller\Api\Magazine\Moderate\MagazineReportsAcceptApi path: /api/moderate/magazine/{magazine_id}/reports/{report_id}/accept methods: [ POST ] format: json api_moderate_magazine_reject_report: controller: App\Controller\Api\Magazine\Moderate\MagazineReportsRejectApi path: /api/moderate/magazine/{magazine_id}/reports/{report_id}/reject methods: [ POST ] format: json api_moderate_magazine_retrieve_bans: controller: App\Controller\Api\Magazine\Moderate\MagazineBansRetrieveApi::collection path: /api/moderate/magazine/{magazine_id}/bans methods: [ GET ] format: json api_moderate_magazine_retrieve_trash: controller: App\Controller\Api\Magazine\Moderate\MagazineTrashedRetrieveApi::collection path: /api/moderate/magazine/{magazine_id}/trash methods: [ GET ] format: json api_moderate_magazine_set_theme: controller: App\Controller\Api\Magazine\Admin\MagazineUpdateThemeApi path: /api/moderate/magazine/{magazine_id}/theme methods: [ POST ] format: json api_moderate_magazine_set_banner: controller: App\Controller\Api\Magazine\Admin\MagazineUpdateThemeApi::banner path: /api/moderate/magazine/{magazine_id}/banner methods: [ PUT ] format: json api_moderate_magazine_delete_icon: controller: App\Controller\Api\Magazine\Admin\MagazineDeleteIconApi path: /api/moderate/magazine/{magazine_id}/icon methods: [ DELETE ] format: json api_moderate_magazine_retrieve_votes: controller: App\Controller\Api\Magazine\Admin\MagazineRetrieveStatsApi::votes path: /api/stats/magazine/{magazine_id}/votes methods: [ GET ] format: json api_moderate_magazine_retrieve_submissions: controller: App\Controller\Api\Magazine\Admin\MagazineRetrieveStatsApi::content path: /api/stats/magazine/{magazine_id}/content methods: [ GET ] format: json ================================================ FILE: config/mbin_routes/modlog.yaml ================================================ modlog: controller: App\Controller\ModlogController::instance path: /modlog methods: [ GET ] ================================================ FILE: config/mbin_routes/notification_api.yaml ================================================ api_notification_read: controller: App\Controller\Api\Notification\NotificationReadApi::read path: /api/notifications/{notification_id}/read methods: [ PUT ] format: json api_notification_read_all: controller: App\Controller\Api\Notification\NotificationReadApi::readAll path: /api/notifications/read methods: [ PUT ] format: json api_notification_unread: controller: App\Controller\Api\Notification\NotificationReadApi::unread path: /api/notifications/{notification_id}/unread methods: [ PUT ] format: json api_notification_delete: controller: App\Controller\Api\Notification\NotificationPurgeApi::purge path: /api/notifications/{notification_id} methods: [ DELETE ] format: json api_notification_delete_all: controller: App\Controller\Api\Notification\NotificationPurgeApi::purgeAll path: /api/notifications methods: [ DELETE ] format: json api_notification_count: controller: App\Controller\Api\Notification\NotificationRetrieveApi::count path: /api/notifications/count methods: [ GET ] format: json api_notification_collection: controller: App\Controller\Api\Notification\NotificationRetrieveApi::collection defaults: { status: all } path: /api/notifications/{status} methods: [ GET ] format: json api_notification_retrieve: controller: App\Controller\Api\Notification\NotificationRetrieveApi path: /api/notification/{notification_id} methods: [ GET ] format: json api_notification_push_register: controller: App\Controller\Api\Notification\NotificationPushApi::createSubscription path: /api/notification/push methods: [ POST ] format: json api_notification_push_unregister: controller: App\Controller\Api\Notification\NotificationPushApi::deleteSubscription path: /api/notification/push methods: [ DELETE ] format: json api_notification_push_test: controller: App\Controller\Api\Notification\NotificationPushApi::testSubscription path: /api/notification/push/test methods: [ POST ] format: json ================================================ FILE: config/mbin_routes/notification_settings.yaml ================================================ change_notification_setting: controller: App\Controller\NotificationSettingsController::changeSetting path: /cns/{subject_type}/{subject_id}/{status} requirements: subject_type: user|magazine|entry|post status: Default|Loud|Muted ================================================ FILE: config/mbin_routes/notification_settings_api.yaml ================================================ api_notification_settings_update: controller: App\Controller\Api\Notification\NotificationSettingApi::update path: /api/notification/update/{targetType}/{targetId}/{setting} requirements: targetType: entry|post|magazine|user setting: Default|Loud|Muted methods: [ PUT ] format: json ================================================ FILE: config/mbin_routes/page.yaml ================================================ page_contact: controller: App\Controller\ContactController path: /contact methods: [ GET, POST ] page_faq: controller: App\Controller\FaqController path: /faq methods: [ GET ] page_privacy_policy: controller: App\Controller\PrivacyPolicyController path: /privacy-policy methods: [ GET ] page_terms: controller: App\Controller\TermsController path: /terms methods: [ GET ] stats: controller: App\Controller\StatsController defaults: { statsType: general, statsPeriod: -1, withFederated: false } path: /stats/{statsType}/{statsPeriod}/{withFederated} methods: [ GET ] page_federation: controller: App\Controller\FederationController path: /federation methods: [ GET ] redirect_instances: controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController path: /instances defaults: route: page_federation permanent: true ================================================ FILE: config/mbin_routes/people.yaml ================================================ people_front: controller: App\Controller\People\PeopleFrontController path: /people methods: [ GET ] ================================================ FILE: config/mbin_routes/post.yaml ================================================ post_comment_create: controller: App\Controller\Post\Comment\PostCommentCreateController defaults: { slug: -, parent_comment_id: null } path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{parent_comment_id} methods: [ GET, POST ] requirements: post_id: \d+ parent_comment_id: \d+ post_comment_edit: controller: App\Controller\Post\Comment\PostCommentEditController defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/edit methods: [ GET, POST ] requirements: post_id: \d+ comment_id: \d+ post_comment_moderate: controller: App\Controller\Post\Comment\PostCommentModerateController defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/moderate methods: [ GET, POST ] requirements: post_id: \d+ comment_id: \d+ post_comment_delete: controller: App\Controller\Post\Comment\PostCommentDeleteController::delete defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/delete methods: [ POST ] requirements: post_id: \d+ comment_id: \d+ post_comment_restore: controller: App\Controller\Post\Comment\PostCommentDeleteController::restore defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/restore methods: [ POST ] requirements: post_id: \d+ comment_id: \d+ post_comment_purge: controller: App\Controller\Post\Comment\PostCommentDeleteController::purge defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/purge methods: [ POST ] requirements: post_id: \d+ comment_id: \d+ post_comment_change_lang: controller: App\Controller\Post\Comment\PostCommentChangeLangController defaults: { slug: - } path: /m/{magazine_name}/p/{post_id}/{slug}/comments/{comment_id}/change_lang methods: [ POST ] requirements: post_id: \d+ comment_id: \d+ post_comment_change_adult: controller: App\Controller\Post\Comment\PostCommentChangeAdultController defaults: { slug: - } path: /m/{magazine_name}/p/{post_id}/{slug}/comments/{comment_id}/change_adult methods: [ POST ] requirements: post_id: \d+ comment_id: \d+ post_comment_image_delete: controller: App\Controller\Post\Comment\PostCommentDeleteImageController defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/delete_image methods: [ POST ] requirements: post_id: \d+ comment_id: \d+ post_comment_voters: controller: App\Controller\Post\Comment\PostCommentVotersController defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/votes methods: [ GET ] requirements: post_id: \d+ comment_id: \d+ post_comment_favourites: controller: App\Controller\Post\Comment\PostCommentFavouriteController defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/favourites methods: [ GET ] requirements: post_id: \d+ comment_id: \d+ post_comment_vote: controller: App\Controller\VoteController defaults: { entityClass: App\Entity\PostComment } path: /pcv/{id}/{choice} methods: [ POST ] requirements: id: \d+ post_comment_report: controller: App\Controller\ReportController defaults: { entityClass: App\Entity\PostComment } path: /pcr/{id} methods: [ GET, POST ] requirements: id: \d+ post_comment_favourite: controller: App\Controller\FavouriteController defaults: { entityClass: App\Entity\PostComment } path: /pcf/{id} methods: [ POST ] requirements: id: \d+ post_comment_boost: controller: App\Controller\BoostController defaults: { entityClass: App\Entity\PostComment } path: /pcb/{id} methods: [ POST ] requirements: id: \d+ post_pin: controller: App\Controller\Post\PostPinController defaults: { slug: - } path: /m/{magazine_name}/p/{post_id}/{slug}/pin methods: [ POST ] requirements: post_id: \d+ post_lock: controller: App\Controller\Post\PostLockController defaults: { slug: - } path: /m/{magazine_name}/p/{post_id}/{slug}/lock methods: [ POST ] requirements: post_id: \d+ post_voters: controller: App\Controller\Post\PostVotersController defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/votes methods: [ GET ] requirements: post_id: \d+ post_favourites: controller: App\Controller\Post\PostFavouriteController defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/favourites methods: [ GET ] requirements: post_id: \d+ post_create: controller: App\Controller\Post\PostCreateController path: /microblog/create methods: [ GET, POST ] post_edit: controller: App\Controller\Post\PostEditController defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/edit methods: [ GET, POST ] requirements: post_id: \d+ post_moderate: controller: App\Controller\Post\PostModerateController defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/moderate methods: [ GET, POST ] requirements: post_id: \d+ post_delete: controller: App\Controller\Post\PostDeleteController::delete defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/delete methods: [ POST ] requirements: post_id: \d+ post_restore: controller: App\Controller\Post\PostDeleteController::restore defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/restore methods: [ POST ] requirements: post_id: \d+ post_purge: controller: App\Controller\Post\PostDeleteController::purge defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/purge methods: [ POST ] requirements: post_id: \d+ post_image_delete: controller: App\Controller\Post\PostDeleteImageController defaults: { slug: -, } path: /m/{magazine_name}/p/{post_id}/{slug}/delete_image methods: [ POST ] requirements: post_id: \d+ post_change_magazine: controller: App\Controller\Post\PostChangeMagazineController defaults: { slug: - } path: /m/{magazine_name}/p/{post_id}/{slug}/change_magazine methods: [ POST ] requirements: post_id: \d+ post_change_lang: controller: App\Controller\Post\PostChangeLangController defaults: { slug: - } path: /m/{magazine_name}/p/{post_id}/{slug}/change_lang methods: [ POST ] requirements: post_id: \d+ post_change_adult: controller: App\Controller\Post\PostChangeAdultController defaults: { slug: - } path: /m/{magazine_name}/p/{post_id}/{slug}/change_adult methods: [ POST ] requirements: post_id: \d+ post_single: controller: App\Controller\Post\PostSingleController defaults: { slug: -, sortBy: default } path: /m/{magazine_name}/p/{post_id}/{slug}/{sortBy} methods: [ GET ] requirements: sortBy: "%comment_sort_options%" post_id: \d+ post_vote: controller: App\Controller\VoteController defaults: { entityClass: App\Entity\Post } path: /pv/{id}/{choice} methods: [ POST ] requirements: id: \d+ post_report: controller: App\Controller\ReportController defaults: { entityClass: App\Entity\Post } path: /pr/{id} methods: [ GET, POST ] requirements: id: \d+ post_favourite: controller: App\Controller\FavouriteController defaults: { entityClass: App\Entity\Post } path: /pf/{id} methods: [ POST ] requirements: id: \d+ post_boost: controller: App\Controller\BoostController defaults: { entityClass: App\Entity\Post } path: /pb/{id} methods: [ POST ] requirements: id: \d+ ================================================ FILE: config/mbin_routes/post_api.yaml ================================================ api_posts_subscribed: controller: App\Controller\Api\Post\PostsRetrieveApi::subscribed path: /api/posts/subscribed methods: [ GET ] format: json api_posts_subscribed_with_boost: controller: App\Controller\Api\Post\PostsRetrieveApi::subscribedWithBoosts path: /api/posts/subscribedWithBoosts methods: [ GET ] format: json api_posts_moderated: controller: App\Controller\Api\Post\PostsRetrieveApi::moderated path: /api/posts/moderated methods: [ GET ] format: json api_posts_favourited: controller: App\Controller\Api\Post\PostsRetrieveApi::favourited path: /api/posts/favourited methods: [ GET ] format: json api_posts_collection: controller: App\Controller\Api\Post\PostsRetrieveApi::collection path: /api/posts methods: [ GET ] format: json # Get information about a post api_post_retrieve: controller: App\Controller\Api\Post\PostsRetrieveApi path: /api/post/{post_id} methods: [ GET ] format: json api_posts_update: controller: App\Controller\Api\Post\PostsUpdateApi path: /api/post/{post_id} methods: [ PUT ] format: json api_posts_delete: controller: App\Controller\Api\Post\PostsDeleteApi path: /api/post/{post_id} methods: [ DELETE ] format: json api_posts_report: controller: App\Controller\Api\Post\PostsReportApi path: /api/post/{post_id}/report methods: [ POST ] format: json api_posts_vote: controller: App\Controller\Api\Post\PostsVoteApi defaults: { choice: 1 } path: /api/post/{post_id}/vote/{choice} methods: [ PUT ] format: json api_posts_favourite: controller: App\Controller\Api\Post\PostsFavouriteApi path: /api/post/{post_id}/favourite methods: [ PUT ] format: json # Get information about a post comment api_post_comment_retrieve: controller: App\Controller\Api\Post\Comments\PostCommentsRetrieveApi path: /api/post-comments/{comment_id} methods: [ GET ] format: json # Get comments from a post api_post_comments_retrieve: controller: App\Controller\Api\Post\Comments\PostCommentsRetrieveApi::collection path: /api/posts/{post_id}/comments methods: [ GET ] format: json # Add comment to a post api_post_comments_create: controller: App\Controller\Api\Post\Comments\PostCommentsCreateApi path: /api/posts/{post_id}/comments methods: [ POST ] format: json api_post_comments_create_image: controller: App\Controller\Api\Post\Comments\PostCommentsCreateApi::uploadImage path: /api/posts/{post_id}/comments/image methods: [ POST ] format: json # Add reply to a post's comment api_post_comments_create_reply: controller: App\Controller\Api\Post\Comments\PostCommentsCreateApi path: /api/posts/{post_id}/comments/{comment_id}/reply methods: [ POST ] format: json api_post_comments_create_image_reply: controller: App\Controller\Api\Post\Comments\PostCommentsCreateApi::uploadImage path: /api/posts/{post_id}/comments/{comment_id}/reply/image methods: [ POST ] format: json # Update post comment api_post_comments_update: controller: App\Controller\Api\Post\Comments\PostCommentsUpdateApi path: /api/post-comments/{comment_id} methods: [ PUT ] format: json # Delete post comment api_post_comments_delete: controller: App\Controller\Api\Post\Comments\PostCommentsDeleteApi path: /api/post-comments/{comment_id} methods: [ DELETE ] format: json api_post_comments_report: controller: App\Controller\Api\Post\Comments\PostCommentsReportApi path: /api/post-comments/{comment_id}/report methods: [ POST ] format: json # Favourite post comment api_post_comments_favourite: controller: App\Controller\Api\Post\Comments\PostCommentsFavouriteApi path: /api/post-comments/{comment_id}/favourite methods: [ PUT ] format: json # Vote on post comment api_post_comments_vote: controller: App\Controller\Api\Post\Comments\PostCommentsVoteApi defaults: { choice: 1 } path: /api/post-comments/{comment_id}/vote/{choice} methods: [ PUT ] format: json # boosts and upvotes for posts api_post_activity: controller: App\Controller\Api\Post\PostsActivityApi path: /api/post/{post_id}/activity methods: [ GET ] format: json # boosts and upvotes entry post comments api_post_comment_activity: controller: App\Controller\Api\Post\Comments\PostCommentsActivityApi path: /api/post-comments/{comment_id}/activity methods: [ GET ] format: json ================================================ FILE: config/mbin_routes/search.yaml ================================================ search: controller: App\Controller\SearchController defaults: { val: ~, } path: /search methods: [GET] ================================================ FILE: config/mbin_routes/search_api.yaml ================================================ api_search: controller: App\Controller\Api\Search\SearchRetrieveApi::searchV1 path: /api/search methods: [ GET ] format: json api_search_v2: controller: App\Controller\Api\Search\SearchRetrieveApi::searchV2 path: /api/search/v2 methods: [ GET ] format: json ================================================ FILE: config/mbin_routes/security.yaml ================================================ app_register: controller: App\Controller\Security\RegisterController path: /register methods: [ GET, POST ] app_verify_email: controller: App\Controller\Security\VerifyEmailController path: /verify/email methods: [ GET ] app_forgot_password_request: controller: App\Controller\Security\ResetPasswordController::request path: /reset-password methods: [ GET, POST ] app_check_email: controller: App\Controller\Security\ResetPasswordController::checkEmail path: /reset-password/check-email methods: [ GET, POST ] app_reset_password: controller: App\Controller\Security\ResetPasswordController::reset defaults: { token: ~ } path: /reset-password/reset/{token} methods: [ GET, POST ] app_login: controller: App\Controller\Security\LoginController path: /login methods: [ GET, POST ] app_resend_email_activation: controller: App\Controller\Security\ResendActivationEmailController::resend path: /resend-email-activation/ app_consent: controller: App\Controller\Security\LoginController::consent path: /consent methods: [ GET, POST ] app_logout: controller: App\Controller\Security\LogoutController path: /logout methods: [ GET ] oauth_azure_connect: controller: App\Controller\Security\AzureController::connect path: /oauth/azure/connect methods: [ GET ] oauth_azure_verify: controller: App\Controller\Security\AzureController::verify path: /oauth/azure/verify methods: [ GET ] oauth_facebook_connect: controller: App\Controller\Security\FacebookController::connect path: /oauth/facebook/connect methods: [ GET ] oauth_facebook_verify: controller: App\Controller\Security\FacebookController::verify path: /oauth/facebook/verify methods: [ GET ] oauth_google_connect: controller: App\Controller\Security\GoogleController::connect path: /oauth/google/connect methods: [ GET ] oauth_google_verify: controller: App\Controller\Security\GoogleController::verify path: /oauth/google/verify methods: [ GET ] oauth_discord_connect: controller: App\Controller\Security\DiscordController::connect path: /oauth/discord/connect methods: [ GET ] oauth_discord_verify: controller: App\Controller\Security\DiscordController::verify path: /oauth/discord/verify methods: [ GET ] oauth_github_connect: controller: App\Controller\Security\GithubController::connect path: /oauth/github/connect methods: [ GET ] oauth_github_verify: controller: App\Controller\Security\GithubController::verify path: /oauth/github/verify methods: [ GET ] oauth_privacyportal_connect: controller: App\Controller\Security\PrivacyPortalController::connect path: /oauth/privacyportal/connect methods: [ GET ] oauth_privacyportal_verify: controller: App\Controller\Security\PrivacyPortalController::verify path: /oauth/privacyportal/verify methods: [ GET ] oauth_keycloak_connect: controller: App\Controller\Security\KeycloakController::connect path: /oauth/keycloak/connect methods: [ GET ] oauth_keycloak_verify: controller: App\Controller\Security\KeycloakController::verify path: /oauth/keycloak/verify methods: [ GET ] oauth_simplelogin_connect: controller: App\Controller\Security\SimpleLoginController::connect path: /oauth/simplelogin/connect methods: [ GET ] oauth_simplelogin_verify: controller: App\Controller\Security\SimpleLoginController::verify path: /oauth/simplelogin/verify methods: [ GET ] oauth_zitadel_connect: controller: App\Controller\Security\ZitadelController::connect path: /oauth/zitadel/connect methods: [ GET ] oauth_zitadel_verify: controller: App\Controller\Security\ZitadelController::verify path: /oauth/zitadel/verify methods: [ GET ] oauth_authentik_connect: controller: App\Controller\Security\AuthentikController::connect path: /oauth/authentik/connect methods: [ GET ] oauth_authentik_verify: controller: App\Controller\Security\AuthentikController::verify path: /oauth/authentik/verify methods: [ GET ] oauth_create_client: controller: App\Controller\Api\OAuth2\CreateClientApi path: /api/client methods: [ POST ] format: json oauth_create_client_image: controller: App\Controller\Api\OAuth2\CreateClientApi::uploadImage path: /api/client-with-logo methods: [ POST ] format: json oauth_revoke_token: controller: App\Controller\Api\OAuth2\RevokeTokenApi path: /api/revoke methods: [ POST ] format: json oauth_delete_client: controller: App\Controller\Api\OAuth2\DeleteClientApi path: /api/client methods: [ DELETE ] format: json ================================================ FILE: config/mbin_routes/tag.yaml ================================================ tag_overview: controller: App\Controller\Tag\TagOverviewController path: /tag/{name} methods: [GET] tag_entries: controller: App\Controller\Tag\TagEntryFrontController defaults: { sortBy: hot, time: ~ } path: tag/{name}/threads/{sortBy}/{time} methods: [ GET ] requirements: { sortBy: "%front_sort_options%" } tag_comments: controller: App\Controller\Tag\TagCommentFrontController defaults: { sortBy: hot, time: ~ } path: tag/{name}/comments/{sortBy}/{time} methods: [GET] requirements: { sortBy: "%front_sort_options%" } tag_posts: controller: App\Controller\Tag\TagPostFrontController defaults: { sortBy: hot, time: ~ } path: tag/{name}/posts/{sortBy}/{time} methods: [GET] requirements: { sortBy: "%front_sort_options%" } tag_people: controller: App\Controller\Tag\TagPeopleFrontController defaults: { sortBy: hot, time: ~ } path: tag/{name}/people methods: [GET] requirements: { sortBy: "%front_sort_options%" } tag_ban: path: /tag/{name}/ban methods: [POST] controller: App\Controller\Tag\TagBanController::ban tag_unban: path: /tag/{name}/unban methods: [POST] controller: App\Controller\Tag\TagBanController::unban ================================================ FILE: config/mbin_routes/user.yaml ================================================ user_overview: controller: App\Controller\User\UserFrontController::front path: /u/{username} methods: [GET] user_entries: controller: App\Controller\User\UserFrontController::entries path: /u/{username}/threads methods: [GET] user_comments: controller: App\Controller\User\UserFrontController::comments path: /u/{username}/comments methods: [GET] user_posts: controller: App\Controller\User\UserFrontController::posts path: /u/{username}/posts methods: [GET] user_replies: controller: App\Controller\User\UserFrontController::replies path: /u/{username}/replies methods: [GET] user_boosts: controller: App\Controller\User\UserFrontController::boosts path: /u/{username}/boosts methods: [GET] user_moderated: controller: App\Controller\User\UserFrontController::moderated path: /u/{username}/moderated methods: [GET] user_subscriptions: controller: App\Controller\User\UserFrontController::subscriptions path: /u/{username}/subscriptions methods: [GET] user_followers: controller: App\Controller\User\UserFrontController::followers path: /u/{username}/followers methods: [GET] user_following: controller: App\Controller\User\UserFrontController::following path: /u/{username}/following methods: [GET] user_follow: controller: App\Controller\User\UserFollowController::follow path: /u/{username}/follow methods: [POST] user_reputation: controller: App\Controller\User\UserReputationController defaults: { reputationType: ~ } path: /u/{username}/reputation/{reputationType} methods: [GET] user_unfollow: controller: App\Controller\User\UserFollowController::unfollow path: /u/{username}/unfollow methods: [POST] user_block: controller: App\Controller\User\UserBlockController::block path: /u/{username}/block methods: [POST] user_unblock: controller: App\Controller\User\UserBlockController::unblock path: /u/{username}/unblock methods: [POST] user_delete_account: controller: App\Controller\User\UserDeleteController::deleteAccount path: /u/{username}/delete_account methods: [POST] schedule_user_delete_account: controller: App\Controller\User\UserDeleteController::scheduleDeleteAccount path: /u/{username}/schedule_delete_account methods: [POST] remove_schedule_user_delete_account: controller: App\Controller\User\UserDeleteController::removeScheduleDeleteAccount path: /u/{username}/remove_schedule_delete_account methods: [POST] user_suspend: controller: App\Controller\User\UserSuspendController::suspend path: /u/{username}/suspend methods: [POST] user_unsuspend: controller: App\Controller\User\UserSuspendController::unsuspend path: /u/{username}/unsuspend methods: [POST] user_ban: controller: App\Controller\User\UserBanController::ban path: /u/{username}/ban methods: [POST] user_unban: controller: App\Controller\User\UserBanController::unban path: /u/{username}/unban methods: [POST] user_2fa_remove: controller: App\Controller\User\Profile\User2FAController::remove path: /u/{username}/remove methods: [POST] user_note: controller: App\Controller\User\UserNoteController path: /u/{username}/note methods: [POST] user_verify: controller: App\Controller\User\Profile\UserVerifyController path: /u/{username}/verify methods: [POST] user_remove_following: controller: App\Controller\User\UserRemoveFollowing path: /u/{username}/remove_following methods: [POST] notifications_front: controller: App\Controller\User\Profile\UserNotificationController::notifications path: /settings/notifications methods: [GET] notifications_read: controller: App\Controller\User\Profile\UserNotificationController::read path: /settings/notifications/read methods: [POST] notifications_clear: controller: App\Controller\User\Profile\UserNotificationController::clear path: /settings/notifications/clear methods: [POST] user_settings_reports: controller: App\Controller\User\Profile\UserReportsController path: /settings/reports/{status} defaults: { status: !php/const \App\Entity\Report::STATUS_ANY } methods: [GET] user_settings_magazine_blocks: controller: App\Controller\User\Profile\UserBlockController::magazines path: /settings/blocked/magazines methods: [GET] user_settings_domain_blocks: controller: App\Controller\User\Profile\UserBlockController::domains path: /settings/blocked/domains methods: [GET] user_settings_user_blocks: controller: App\Controller\User\Profile\UserBlockController::users path: /settings/blocked/people methods: [GET] user_settings_magazine_subscriptions: controller: App\Controller\User\Profile\UserSubController::magazines path: /settings/subscriptions/magazines methods: [GET] user_settings_domain_subscriptions: controller: App\Controller\User\Profile\UserSubController::domains path: /settings/subscriptions/domains methods: [GET] user_settings_user_subscriptions: controller: App\Controller\User\Profile\UserSubController::users path: /settings/subscriptions/people methods: [GET] user_settings_tips: controller: App\Controller\User\Profile\UserTipController path: /settings/ada methods: [GET, POST] user_settings_general: controller: App\Controller\User\Profile\UserSettingController path: /settings/general methods: [GET, POST] user_settings_profile: controller: App\Controller\User\Profile\UserEditController::profile path: /settings/profile methods: [GET, POST] user_settings_email: controller: App\Controller\User\Profile\UserEditController::email path: /settings/email methods: [GET, POST] user_settings_password: controller: App\Controller\User\Profile\UserEditController::password path: /settings/password methods: [GET, POST] user_settings_2fa: controller: App\Controller\User\Profile\User2FAController::enable path: /settings/2fa methods: [GET, POST] user_settings_2fa_disable: controller: App\Controller\User\Profile\User2FAController::disable path: /settings/2fa/disable methods: [POST] user_settings_2fa_qrcode: controller: App\Controller\User\Profile\User2FAController::qrCode path: /settings/2fa/qrcode methods: [GET] user_settings_2fa_backup: controller: App\Controller\User\Profile\User2FAController::backup path: /settings/2fa/backup methods: [POST] user_settings_account_deletion: controller: App\Controller\User\AccountDeletionController path: /settings/account_deletion methods: [GET, POST] user_settings_filter_lists: controller: App\Controller\User\FilterListsController path: /settings/filter_lists methods: [GET, POST] user_settings_filter_lists_create: controller: App\Controller\User\FilterListsController::create path: /settings/filter_lists/create methods: [GET, POST] user_settings_filter_lists_edit: controller: App\Controller\User\FilterListsController::edit path: /settings/filter_lists/edit/{id} methods: [GET, POST] user_settings_filter_lists_delete: controller: App\Controller\User\FilterListsController::delete path: /settings/filter_lists/delete/{id} methods: [GET, POST] user_settings_avatar_delete: controller: App\Controller\User\UserAvatarDeleteController path: /settings/edit/delete_avatar methods: [POST] user_settings_cover_delete: controller: App\Controller\User\UserCoverDeleteController path: /settings/edit/delete_cover methods: [POST] user_settings_toggle_theme: controller: App\Controller\User\UserThemeController path: /settings/edit/theme methods: [GET, POST] user_settings_stats: controller: App\Controller\User\Profile\UserStatsController defaults: { statsType: content, statsPeriod: 31, withFederated: false } path: /settings/stats/{statsType}/{statsPeriod}/{withFederated} methods: [GET] theme_settings: controller: App\Controller\User\ThemeSettingsController path: /settings/theme/{key}/{value} methods: [GET] ================================================ FILE: config/mbin_routes/user_api.yaml ================================================ api_users_collection: controller: App\Controller\Api\User\UserRetrieveApi::collection path: /api/users methods: [ GET ] format: json api_admins_collection: controller: App\Controller\Api\User\UserRetrieveApi::admins path: /api/users/admins methods: [ GET ] format: json api_moderators_collection: controller: App\Controller\Api\User\UserRetrieveApi::moderators path: /api/users/moderators methods: [ GET ] format: json api_user_blocked: controller: App\Controller\Api\User\UserRetrieveApi::blocked path: /api/users/blocked methods: [ GET ] format: json api_current_user_followed: controller: App\Controller\Api\User\UserRetrieveApi::followedByCurrent path: /api/users/followed methods: [ GET ] format: json api_current_user_followers: controller: App\Controller\Api\User\UserRetrieveApi::followersOfCurrent path: /api/users/followers methods: [ GET ] format: json api_user_retrieve_self: controller: App\Controller\Api\User\UserRetrieveApi::me path: /api/users/me methods: [ GET ] format: json api_user_retrieve_oauth_consent: controller: App\Controller\Api\User\UserRetrieveOAuthConsentsApi path: /api/users/consents/{consent_id} methods: [ GET ] format: json api_user_update_oauth_consent: controller: App\Controller\Api\User\UserUpdateOAuthConsentsApi path: /api/users/consents/{consent_id} methods: [ PUT ] format: json api_user_retrieve_oauth_consents: controller: App\Controller\Api\User\UserRetrieveOAuthConsentsApi::collection path: /api/users/consents methods: [ GET ] format: json api_user_update_profile: controller: App\Controller\Api\User\UserUpdateApi::profile path: /api/users/profile methods: [ PUT ] format: json api_user_retrieve_settings: controller: App\Controller\Api\User\UserRetrieveApi::settings path: /api/users/settings methods: [ GET ] format: json api_user_update_settings: controller: App\Controller\Api\User\UserUpdateApi::settings path: /api/users/settings methods: [ PUT ] format: json api_user_retrieve_filter_lists: controller: App\Controller\Api\User\UserFilterListApi::retrieve path: /api/users/filterLists methods: [ GET ] format: json api_user_retrieve_filter_lists_create: controller: App\Controller\Api\User\UserFilterListApi::create path: /api/users/filterLists methods: [ POST ] format: json api_user_retrieve_filter_lists_edit: controller: App\Controller\Api\User\UserFilterListApi::edit path: /api/users/filterLists/{id} methods: [ PUT ] format: json api_user_retrieve_filter_lists_delete: controller: App\Controller\Api\User\UserFilterListApi::delete path: /api/users/filterLists/{id} methods: [ DELETE ] format: json api_user_update_avatar: controller: App\Controller\Api\User\UserUpdateImagesApi::avatar path: /api/users/avatar methods: [ POST ] format: json api_user_update_cover: controller: App\Controller\Api\User\UserUpdateImagesApi::cover path: /api/users/cover methods: [ POST ] format: json api_user_delete_avatar: controller: App\Controller\Api\User\UserDeleteImagesApi::avatar path: /api/users/avatar methods: [ DELETE ] format: json api_user_delete_cover: controller: App\Controller\Api\User\UserDeleteImagesApi::cover path: /api/users/cover methods: [ DELETE ] format: json api_user_retrieve: controller: App\Controller\Api\User\UserRetrieveApi path: /api/users/{user_id} methods: [ GET ] format: json api_user_retrieve_by_name: controller: App\Controller\Api\User\UserRetrieveApi::username path: /api/users/name/{username} methods: [ GET ] format: json api_user_followed: controller: App\Controller\Api\User\UserRetrieveApi::followed path: /api/users/{user_id}/followed methods: [ GET ] format: json api_user_followers: controller: App\Controller\Api\User\UserRetrieveApi::followers path: /api/users/{user_id}/followers methods: [ GET ] format: json api_user_block: controller: App\Controller\Api\User\UserBlockApi::block path: /api/users/{user_id}/block methods: [ PUT ] format: json api_user_unblock: controller: App\Controller\Api\User\UserBlockApi::unblock path: /api/users/{user_id}/unblock methods: [ PUT ] format: json api_user_follow: controller: App\Controller\Api\User\UserFollowApi::follow path: /api/users/{user_id}/follow methods: [ PUT ] format: json api_user_unfollow: controller: App\Controller\Api\User\UserFollowApi::unfollow path: /api/users/{user_id}/unfollow methods: [ PUT ] format: json api_user_magazine_subscriptions: controller: App\Controller\Api\Magazine\MagazineRetrieveApi::subscriptions path: /api/users/{user_id}/magazines/subscriptions methods: [ GET ] format: json api_user_domain_subscriptions: controller: App\Controller\Api\Domain\DomainRetrieveApi::subscriptions path: /api/users/{user_id}/domains/subscriptions methods: [ GET ] format: json # Get a list of threads from specific user api_user_entries_retrieve: controller: App\Controller\Api\Entry\UserEntriesRetrieveApi path: /api/users/{user_id}/entries methods: [ GET ] format: json # Get a list of comments from specific user api_user_entry_comments_retrieve: controller: App\Controller\Api\Entry\Comments\UserEntryCommentsRetrieveApi path: /api/users/{user_id}/comments methods: [ GET ] format: json # Get a list of posts from specific user api_user_posts_retrieve: controller: App\Controller\Api\Post\UserPostsRetrieveApi path: /api/users/{user_id}/posts methods: [ GET ] format: json # Get a list of post comments from specific user api_user_post_comments_retrieve: controller: App\Controller\Api\Post\Comments\UserPostCommentsRetrieveApi path: /api/users/{user_id}/post-comments methods: [ GET ] format: json api_user_content_retrieve: controller: App\Controller\Api\User\UserContentApi::getUserContent path: /api/users/{user_id}/content methods: [ GET ] format: json api_user_boosts_retrieve: controller: App\Controller\Api\User\UserContentApi::getBoostedContent path: /api/users/{user_id}/boosts methods: [ GET ] format: json api_user_moderated_retrieve: controller: App\Controller\Api\User\UserModeratesApi path: /api/users/{user_id}/moderatedMagazines methods: [ GET ] format: json ================================================ FILE: config/mbin_serialization/badge.yaml ================================================ App\DTO\BadgeDto: attributes: id: groups: [ 'badge_read' ] name: groups: [ 'badge_read' ] ================================================ FILE: config/mbin_serialization/domain.yaml ================================================ App\DTO\DomainDto: attributes: name: groups: [ 'domain:collection:get', 'domain:item:get', 'entry:collection:get', 'entry:item:get' ] entryCount: groups: [ 'domain:collection:get', 'domain:item:get', 'entry:item:get' ] ================================================ FILE: config/mbin_serialization/entry.yaml ================================================ App\DTO\EntryDto: attributes: id: groups: [ 'entry:collection:get', 'entry:item:get', 'entry:comment:collection:get' ] magazine: groups: [ 'entry:collection:get', 'entry:item:get' ] domain: groups: [ 'entry:collection:get', 'entry:item:get' ] user: groups: [ 'entry:collection:get', 'entry:item:get' ] image: groups: [ 'entry:collection:get', 'entry:item:get' ] title: groups: [ 'entry:collection:get', 'entry:item:get', 'entry:comment:collection:get' ] url: groups: [ 'entry:collection:get', 'entry:item:get' ] body: groups: [ 'entry:item:get' ] isAdult: groups: [ 'entry:collection:get', 'entry:item:get' ] hasEmbed: groups: [ 'entry:collection:get', 'entry:item:get' ] type: groups: [ 'entry:collection:get', 'entry:item:get' ] comments: groups: [ 'entry:collection:get', 'entry:item:get' ] uv: groups: [ 'entry:collection:get', 'entry:item:get' ] dv: groups: [ 'entry:collection:get', 'entry:item:get' ] score: groups: [ 'entry:collection:get', 'entry:item:get' ] visibility: groups: [ 'entry:collection:get', 'entry:item:get' ] createdAt: groups: [ 'entry:collection:get', 'entry:item:get' ] lastActive: groups: [ 'entry:collection:get', 'entry:item:get' ] ================================================ FILE: config/mbin_serialization/entry_comment.yaml ================================================ App\DTO\EntryCommentDto: attributes: id: groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ] magazine: groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ] user: groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ] entry: groups: [ 'entry:comment:collection:get' ] image: groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ] parent: groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ] root: groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ] body: groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ] uv: groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ] dv: groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ] createdAt: groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ] lastActive: groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ] ================================================ FILE: config/mbin_serialization/image.yaml ================================================ App\DTO\ImageDto: attributes: id: groups: [ 'image:get' ] filePath: groups: [ 'image:get','magazine:collection:get', 'magazine:item:get', 'entry:collection:get', 'entry:item:get', 'entry:comment:collection:get', 'single:entry:comment:collection:get', 'post:collection:get', 'post:item:get', 'post:comment:collection:get', 'single:post:comment:collection:get' ] width: groups: [ 'image:get','magazine:collection:get', 'magazine:item:get','entry:collection:get', 'entry:item:get', 'entry:comment:collection:get', 'single:entry:comment:collection:get', 'post:collection:get', 'post:item:get', 'post:comment:collection:get', 'single:post:comment:collection:get' ] height: groups: [ 'image:get','magazine:collection:get', 'magazine:item:get','entry:collection:get', 'entry:item:get', 'entry:comment:collection:get', 'single:entry:comment:collection:get', 'post:collection:get', 'post:item:get', 'post:comment:collection:get', 'single:post:comment:collection:get' ] ================================================ FILE: config/mbin_serialization/magazine.yaml ================================================ App\DTO\MagazineDto: attributes: user: groups: [ 'magazine:item:get', 'magazine:collection:get' ] icon: groups: [ 'magazine:item:get', 'magazine:collection:get' ] name: groups: [ 'magazine:item:get', 'magazine:collection:get', 'entry:collection:get', 'entry:item:get', 'entry:comment:collection:get', 'single:entry:comment:collection:get', 'post:collection:get', 'post:item:get', 'post:comment:collection:get', 'single:post:comment:collection:get' ] title: groups: [ 'magazine:item:get', 'magazine:collection:get' ] description: groups: [ 'magazine:item:get', 'magazine:collection:get' ] rules: groups: [ 'magazine:item:get', 'magazine:collection:get' ] subscriptionsCount: groups: [ 'magazine:item:get', 'magazine:collection:get' ] entryCount: groups: [ 'magazine:item:get', 'magazine:collection:get' ] entryCommentCount: groups: [ 'magazine:item:get', 'magazine:collection:get' ] postCount: groups: [ 'magazine:item:get', 'magazine:collection:get' ] postCommentCount: groups: [ 'magazine:item:get', 'magazine:collection:get' ] isAdult: groups: [ 'magazine:item:get', 'magazine:collection:get' ] ================================================ FILE: config/mbin_serialization/post.yaml ================================================ App\DTO\PostDto: attributes: id: groups: [ 'post:collection:get', 'post:item:get', 'post:comment:collection:get' ] magazine: groups: [ 'post:collection:get', 'post:item:get' ] user: groups: [ 'post:collection:get', 'post:item:get' ] image: groups: [ 'post:collection:get', 'post:item:get' ] body: groups: [ 'post:collection:get', 'post:item:get' ] isAdult: groups: [ 'post:collection:get', 'post:item:get' ] comments: groups: [ 'post:collection:get', 'post:item:get' ] uv: groups: [ 'post:collection:get', 'post:item:get' ] dv: groups: [ 'post:collection:get', 'post:item:get' ] score: groups: [ 'post:collection:get', 'post:item:get' ] visibility: groups: [ 'post:collection:get', 'post:item:get' ] createdAt: groups: [ 'post:collection:get', 'post:item:get' ] lastActive: groups: [ 'post:collection:get', 'post:item:get' ] bestComments: groups: [ 'post:collection:get', 'post:item:get', 'post:comment:collection:get' ] ================================================ FILE: config/mbin_serialization/post_comment.yaml ================================================ App\DTO\PostCommentDto: attributes: id: groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get', 'post:collection:get' ] magazine: groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get' ] user: groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get', 'post:collection:get' ] post: groups: [ 'post:comment:collection:get' ] image: groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get', 'post:collection:get' ] parent: groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get' ] body: groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get', 'post:collection:get' ] uv: groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get', 'post:collection:get' ] createdAt: groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get', 'post:collection:get' ] lastActive: groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get', 'post:collection:get' ] ================================================ FILE: config/mbin_serialization/user.yaml ================================================ App\DTO\UserDto: attributes: email: groups: [ 'user:write' ] plainPassword: groups: [ 'user:write' ] username: groups: [ 'user:get', 'magazine:item:get', 'magazine:collection:get', 'entry:collection:get', 'entry:item:get', 'entry:comment:collection:get', 'single:entry:comment:collection:get', 'post:item:get', 'post:collection:get', 'post:comment:collection:get', 'single:post:comment:collection:get' ] avatar: groups: [ 'user:get', 'magazine:item:get', 'entry:item:get', 'entry:comment:collection:get', 'single:entry:comment:collection:get', 'post:item:get', 'post:collection:get', 'single:post:comment:collection:get' ] ================================================ FILE: config/packages/antispam.yaml ================================================ # # This sample configuration sets up a default anti-spam profile that will already stop a lot of # form spam with minimal effort and none to minimal user inconvenience. # # To get started right away read the Quickstart at https://omines.github.io/antispam-bundle/quickstart/ # # For more details on the options available visit https://omines.github.io/antispam-bundle/configuration/ # antispam: stealth: false profiles: default: stealth: false # Insert a honeypot called "full_name" on all forms to lure bots into filling it in honeypot: full_name # Reject all forms that have been submitted either within 6 seconds, or after more than 30 minutes timer: min: 6 max: 1800 # The measures above should already have notable effect on the amount of spam that gets through # your forms. Still getting annoying amounts? Analyze the patterns of uncaught spam, then # consider uncommenting and modifying some of the examples below after careful consideration # about their impact. # # Reject text fields that contain (lame attempts at) HTML or BBCode banned_markup: true # Reject text fields that consist for more than 40% of Cyrillic (Russian) characters # banned_scripts: # scripts: [ cyrillic ] # max_percentage: 40 # Reject fields that contain more than 3 URLs, or repeat a single URL more than once # url_count: # max: 3 # max_identical: 1 when@test: antispam: # In automated tests the bundle and included components are by default disabled. You can still # enable them for individual test cases via the main AntiSpam service. enabled: false ================================================ FILE: config/packages/babdev_pagerfanta.yaml ================================================ babdev_pagerfanta: default_view: twig default_twig_template: 'layout/_pagination.html.twig' ================================================ FILE: config/packages/cache.yaml ================================================ framework: cache: # Unique name of your app: used to compute stable namespaces for cache keys. #prefix_seed: your_vendor_name/app_name # The "app" cache stores to the filesystem by default. # The data in this cache should persist between deploys. # Other options include: # Redis app: cache.adapter.redis_tag_aware default_redis_provider: '%env(REDIS_DNS)%' # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) # app: cache.tagaware.filesystem # Namespaced pools use the above "app" backend by default pools: doctrine.second_level_cache_pool: adapter: cache.app ================================================ FILE: config/packages/commonmark.yaml ================================================ parameters: commonmark.configuration: allow_unsafe_links: false html_input: escape max_nesting_level: 25 renderer: soft_break: "
    \r\n" table: wrap: enabled: true tag: "div" attributes: class: "user-content-table-responsive" commonmark.allowed_schemes: [http, https] services: _defaults: autowire: true public: false League\CommonMark\Extension\Autolink\UrlAutolinkParser: arguments: $allowedProtocols: "%commonmark.allowed_schemes%" League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension: ~ League\CommonMark\Extension\Strikethrough\StrikethroughExtension: ~ League\CommonMark\Extension\Table\TableExtension: ~ ================================================ FILE: config/packages/dama_doctrine_test_bundle.yaml ================================================ when@test: dama_doctrine_test: enable_static_connection: true enable_static_meta_data_cache: true enable_static_query_cache: true ================================================ FILE: config/packages/debug.yaml ================================================ when@dev: debug: # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. # See the "server:dump" command to start a new server. dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" ================================================ FILE: config/packages/dev/rate_limiter.yaml ================================================ framework: rate_limiter: anonymous_api_read: policy: "sliding_window" limit: 1000 interval: "1 second" api_oauth_client: policy: "sliding_window" limit: 1000 interval: "1 second" api_oauth_token_revoke: policy: "sliding_window" limit: 1000 interval: "1 second" api_oauth_client_delete: policy: "sliding_window" limit: 1000 interval: "1 second" api_delete: policy: "sliding_window" limit: 1000 interval: "1 second" api_message: policy: "sliding_window" limit: 1000 interval: "1 second" api_report: policy: "sliding_window" limit: 1000 interval: "1 second" api_read: policy: "sliding_window" limit: 1000 interval: "1 second" api_update: policy: "sliding_window" limit: 1000 interval: "1 second" api_vote: policy: "sliding_window" limit: 1000 interval: "1 second" api_entry: policy: "sliding_window" limit: 1000 interval: "1 second" api_image: policy: "sliding_window" limit: 1000 interval: "1 second" api_post: policy: "sliding_window" limit: 1000 interval: "1 second" api_comment: policy: "sliding_window" limit: 1000 interval: "1 second" api_magazine: policy: "sliding_window" limit: 1000 interval: "1 second" api_notification: policy: "sliding_window" limit: 1000 interval: "1 second" api_moderate: policy: "sliding_window" limit: 1000 interval: "1 second" vote: policy: "sliding_window" limit: 1000 interval: "1 second" entry: policy: "fixed_window" limit: 1000 interval: "1 second" entry_comment: policy: "sliding_window" limit: 1000 interval: "1 second" post: policy: "fixed_window" limit: 1000 interval: "1 second" post_comment: policy: "sliding_window" limit: 1000 interval: "1 second" user_register: policy: "fixed_window" limit: 1000 interval: "1 second" magazine: policy: "fixed_window" limit: 1000 interval: "1 second" ================================================ FILE: config/packages/doctrine.yaml ================================================ doctrine: dbal: url: '%env(resolve:DATABASE_URL)%' types: citext: App\DoctrineExtensions\DBAL\Types\Citext enumApplicationStatus: App\DoctrineExtensions\DBAL\Types\EnumApplicationStatus enumNotificationStatus: App\DoctrineExtensions\DBAL\Types\EnumNotificationStatus enumSortOptions: App\DoctrineExtensions\DBAL\Types\EnumSortOptions enumDirectMessageSettings: App\DoctrineExtensions\DBAL\Types\EnumDirectMessageSettings enumFrontContentOptions: App\DoctrineExtensions\DBAL\Types\EnumFrontContentOptions mapping_types: user_type: string citext: citext enumApplicationStatus: string enumNotificationStatus: string enumSortOptions: string enumDirectMessageSettings: string enumFrontContentOptions: string # IMPORTANT: You MUST configure your server version, # either here or in the DATABASE_URL env var (see .env file) #server_version: '16' profiling_collect_backtrace: '%kernel.debug%' orm: dql: string_functions: JSONB_CONTAINS: Scienta\DoctrineJsonFunctions\Query\AST\Functions\Postgresql\JsonbContains validate_xml_mapping: true naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware identity_generation_preferences: Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity auto_mapping: false controller_resolver: auto_mapping: false mappings: App: type: attribute is_bundle: false dir: '%kernel.project_dir%/src/Entity' prefix: 'App\Entity' alias: App second_level_cache: enabled: true region_cache_driver: type: pool pool: doctrine.second_level_cache_pool when@test: doctrine: dbal: # "TEST_TOKEN" is typically set by ParaTest dbname_suffix: '_test%env(default::TEST_TOKEN)%' when@prod: doctrine: orm: query_cache_driver: type: pool pool: doctrine.system_cache_pool result_cache_driver: type: pool pool: doctrine.result_cache_pool framework: cache: pools: doctrine.result_cache_pool: adapter: cache.app doctrine.system_cache_pool: adapter: cache.system ================================================ FILE: config/packages/doctrine_migrations.yaml ================================================ doctrine_migrations: migrations_paths: # namespace is arbitrary but should be different from App\Migrations # as migrations classes should NOT be autoloaded "DoctrineMigrations": "%kernel.project_dir%/migrations" enable_profiler: false ================================================ FILE: config/packages/fos_js_routing.yaml ================================================ fos_js_routing: routes_to_expose: [ 'ajax_fetch_entry', 'ajax_fetch_entry_comment', 'ajax_fetch_post', 'ajax_fetch_post_comment', 'ajax_fetch_post_comments', 'ajax_fetch_user_popup', 'ajax_fetch_title', 'ajax_fetch_embed', 'ajax_fetch_duplicates', 'theme_settings' ] ================================================ FILE: config/packages/framework.yaml ================================================ # see https://symfony.com/doc/current/reference/configuration/framework.html framework: secret: '%env(APP_SECRET)%' #csrf_protection: true annotations: false #no longer supported http_method_override: false handle_all_throwables: true trusted_proxies: '%env(string:default::TRUSTED_PROXIES)%' trusted_headers: [ 'x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix', ] # Note that the session will be started ONLY if you read or write from it. # Sessions are stored in database, because saving sessions in Redis can give race conditions. # See last paragraph of https://symfony.com/doc/current/session.html#store-sessions-in-a-key-value-database-redis # # PHP session handling is often (in Debian/Ubuntu) not doing gargage collection for sessions # (session.gc_probability option in PHP). # Hence we do also not want to set gc_maxlifetime for idle periods. # We set our cookie session lifetime to the same value as remember_me token. # More info: https://symfony.com/doc/current/session.html#session-idle-time-keep-alive session: handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler cookie_secure: auto cookie_samesite: lax cookie_lifetime: 10512000 # 4 months long lifetime storage_factory_id: session.storage.factory.native http_client: default_options: headers: 'User-Agent': 'Mbin/1.10.0-rc1 (+https://%kbin_domain%/agent)' #esi: true #fragments: true php_errors: log: true property_info: enabled: true with_constructor_extractor: true ================================================ FILE: config/packages/knpu_oauth2_client.yaml ================================================ knpu_oauth2_client: clients: azure: type: azure client_id: '%oauth_azure_id%' client_secret: '%oauth_azure_secret%' tenant: '%oauth_azure_tenant%' redirect_route: oauth_azure_verify redirect_params: { } facebook: type: facebook client_id: '%oauth_facebook_id%' client_secret: '%oauth_facebook_secret%' redirect_route: oauth_facebook_verify redirect_params: { } graph_api_version: v2.12 google: type: google client_id: '%oauth_google_id%' client_secret: '%oauth_google_secret%' redirect_route: oauth_google_verify redirect_params: { } discord: type: discord client_id: '%oauth_discord_id%' client_secret: '%oauth_discord_secret%' redirect_route: oauth_discord_verify redirect_params: { } github: type: github client_id: '%oauth_github_id%' client_secret: '%oauth_github_secret%' redirect_route: oauth_github_verify redirect_params: { } privacyportal: type: generic provider_class: League\OAuth2\Client\Provider\PrivacyPortal client_id: '%oauth_privacyportal_id%' client_secret: '%oauth_privacyportal_secret%' redirect_route: oauth_privacyportal_verify redirect_params: { } keycloak: type: keycloak client_id: '%oauth_keycloak_id%' client_secret: '%oauth_keycloak_secret%' auth_server_url: '%oauth_keycloak_uri%' realm: '%oauth_keycloak_realm%' version: '%oauth_keycloak_version%' redirect_route: oauth_keycloak_verify redirect_params: { } simplelogin: type: generic client_id: '%oauth_simplelogin_id%' client_secret: '%oauth_simplelogin_secret%' redirect_route: oauth_simplelogin_verify redirect_params: { } provider_class: 'App\Provider\SimpleLogin' zitadel: type: generic client_id: '%oauth_zitadel_id%' client_secret: '%oauth_zitadel_secret%' provider_options: base_url: '%oauth_zitadel_base_url%' redirect_route: oauth_zitadel_verify redirect_params: { } provider_class: 'App\Provider\Zitadel' authentik: type: generic client_id: '%oauth_authentik_id%' client_secret: '%oauth_authentik_secret%' provider_options: base_url: '%oauth_authentik_base_url%' redirect_route: oauth_authentik_verify redirect_params: { } provider_class: 'App\Provider\Authentik' ================================================ FILE: config/packages/league_oauth2_server.yaml ================================================ league_oauth2_server: authorization_server: private_key: "%env(resolve:OAUTH_PRIVATE_KEY)%" private_key_passphrase: "%env(resolve:OAUTH_PASSPHRASE)%" encryption_key: "%env(resolve:OAUTH_ENCRYPTION_KEY)%" access_token_ttl: PT1H refresh_token_ttl: P1M auth_code_ttl: PT10M enable_client_credentials_grant: true enable_password_grant: false enable_refresh_token_grant: true enable_auth_code_grant: true require_code_challenge_for_public_clients: true client: classname: App\Entity\Client resource_server: public_key: "%env(resolve:OAUTH_PUBLIC_KEY)%" scopes: available: [ "read", "write", "delete", "subscribe", "block", "vote", "report", "domain", "domain:subscribe", "domain:block", "entry", "entry:create", "entry:edit", "entry:delete", "entry:vote", "entry:report", "entry_comment", "entry_comment:create", "entry_comment:edit", "entry_comment:delete", "entry_comment:vote", "entry_comment:report", "magazine", "magazine:subscribe", "magazine:block", "post", "post:create", "post:edit", "post:delete", "post:vote", "post:report", "post_comment", "post_comment:create", "post_comment:edit", "post_comment:delete", "post_comment:vote", "post_comment:report", "bookmark", "bookmark:add", "bookmark:remove", "bookmark_list", "bookmark_list:read", "bookmark_list:edit", "bookmark_list:delete", "user", "user:profile", "user:profile:read", "user:profile:edit", "user:message", "user:message:read", "user:message:create", "user:notification", "user:notification:read", "user:notification:delete", "user:notification:edit", "user:oauth_clients", "user:oauth_clients:read", "user:oauth_clients:edit", "user:follow", "user:block", "moderate", "moderate:entry", "moderate:entry:language", "moderate:entry:pin", "moderate:entry:lock", "moderate:entry:set_adult", "moderate:entry:trash", "moderate:entry_comment", "moderate:entry_comment:language", "moderate:entry_comment:set_adult", "moderate:entry_comment:trash", "moderate:post", "moderate:post:language", "moderate:post:pin", "moderate:post:lock", "moderate:post:set_adult", "moderate:post:trash", "moderate:post_comment", "moderate:post_comment:language", "moderate:post_comment:set_adult", "moderate:post_comment:trash", "moderate:magazine", "moderate:magazine:ban", "moderate:magazine:ban:read", "moderate:magazine:ban:create", "moderate:magazine:ban:delete", "moderate:magazine:list", "moderate:magazine:reports", "moderate:magazine:reports:read", "moderate:magazine:reports:action", "moderate:magazine:trash:read", "moderate:magazine_admin", "moderate:magazine_admin:create", "moderate:magazine_admin:delete", "moderate:magazine_admin:update", "moderate:magazine_admin:theme", "moderate:magazine_admin:moderators", "moderate:magazine_admin:badges", "moderate:magazine_admin:tags", "moderate:magazine_admin:stats", "admin", "admin:entry:purge", "admin:entry_comment:purge", "admin:post:purge", "admin:post_comment:purge", "admin:magazine", "admin:magazine:move_entry", "admin:magazine:purge", "admin:magazine:moderate", "admin:user", "admin:user:ban", "admin:user:verify", "admin:user:delete", "admin:user:purge", "admin:instance", "admin:instance:stats", "admin:instance:settings", "admin:instance:settings:read", "admin:instance:settings:edit", "admin:instance:information:edit", "admin:federation", "admin:federation:read", "admin:federation:update", "admin:oauth_clients", "admin:oauth_clients:read", "admin:oauth_clients:revoke", ] default: ["read"] persistence: doctrine: entity_manager: default when@test: league_oauth2_server: persistence: doctrine: entity_manager: default ================================================ FILE: config/packages/liip_imagine.yaml ================================================ # Documentation on how to configure the bundle can be found at: https://symfony.com/doc/current/bundles/LiipImagineBundle/basic-usage.html liip_imagine: resolvers: kbin.liip_resolver: flysystem: filesystem_service: oneup_flysystem.public_uploads_filesystem_filesystem root_url: '%kbin_storage_url%' cache_prefix: cache visibility: public loaders: kbin.liip_loader: flysystem: filesystem_service: oneup_flysystem.public_uploads_filesystem_filesystem driver: gd cache: kbin.liip_resolver data_loader: kbin.liip_loader default_image: null twig: mode: lazy default_filter_set_settings: quality: 90 controller: # Set this value to 301 if you want to enable image resolve redirects using 301 *cached* responses (eg. when behind Nginx) redirect_response_code: 302 webp: generate: true quality: 90 cache: ~ data_loader: ~ post_processors: [] filter_sets: entry_thumb: filters: auto_rotate: ~ thumbnail: { size: [380, 380], mode: inset } avatar_thumb: filters: auto_rotate: ~ thumbnail: { size: [100, 100], mode: fixed } post_thumb: filters: auto_rotate: ~ thumbnail: { size: [600, 500], mode: inset } user_cover: filters: auto_rotate: ~ thumbnail: { size: [1500, 500], mode: fixed } magazine_banner: filters: auto_rotate: ~ thumbnail: { size: [1500, 300], mode: fixed } ================================================ FILE: config/packages/lock.yaml ================================================ framework: lock: "%env(LOCK_DSN)%" ================================================ FILE: config/packages/mailer.yaml ================================================ framework: mailer: dsn: "%env(MAILER_DSN)%" ================================================ FILE: config/packages/mercure.yaml ================================================ mercure: hubs: default: url: "%env(MERCURE_URL)%" public_url: "%env(MERCURE_PUBLIC_URL)%" jwt: secret: "%env(MERCURE_JWT_SECRET)%" publish: "*" ================================================ FILE: config/packages/messenger.yaml ================================================ framework: messenger: # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling. failure_transport: failed transports: # https://symfony.com/doc/current/messenger.html#transport-configuration sync: "sync://" async: dsn: "%env(MESSENGER_TRANSPORT_DSN)%" options: queues: async: arguments: x-queue-version: 2 x-queue-type: 'classic' exchange: name: async retry_strategy: max_retries: 5 delay: 300000 multiplier: 4 max_delay: 76800000 jitter: 0 serializer: messenger.transport.symfony_serializer inbox: dsn: "%env(MESSENGER_TRANSPORT_DSN)%" options: queues: inbox: arguments: x-queue-version: 2 x-queue-type: 'classic' exchange: name: inbox retry_strategy: max_retries: 5 delay: 300000 multiplier: 4 max_delay: 76800000 jitter: 0 serializer: messenger.transport.symfony_serializer receive: dsn: "%env(MESSENGER_TRANSPORT_DSN)%" options: queues: receive: arguments: x-queue-version: 2 x-queue-type: 'classic' exchange: name: receive retry_strategy: max_retries: 5 delay: 300000 multiplier: 4 max_delay: 76800000 jitter: 0 serializer: messenger.transport.symfony_serializer deliver: dsn: "%env(MESSENGER_TRANSPORT_DSN)%" options: queues: deliver: arguments: x-queue-version: 2 x-queue-type: 'classic' exchange: name: deliver retry_strategy: max_retries: 5 delay: 300000 multiplier: 4 max_delay: 76800000 jitter: 0 serializer: messenger.transport.symfony_serializer outbox: dsn: "%env(MESSENGER_TRANSPORT_DSN)%" options: queues: outbox: arguments: x-queue-version: 2 x-queue-type: 'classic' exchange: name: outbox retry_strategy: max_retries: 5 delay: 300000 multiplier: 4 max_delay: 76800000 jitter: 0 serializer: messenger.transport.symfony_serializer resolve: dsn: "%env(MESSENGER_TRANSPORT_DSN)%" options: queues: resolve: arguments: x-queue-version: 2 x-queue-type: 'classic' exchange: name: resolve retry_strategy: max_retries: 5 delay: 300000 multiplier: 4 max_delay: 76800000 jitter: 0 serializer: messenger.transport.symfony_serializer old: dsn: "%env(MESSENGER_TRANSPORT_DSN)%" options: queues: messages: ~ retry_strategy: max_retries: 5 delay: 300000 multiplier: 4 max_delay: 76800000 jitter: 0 serializer: messenger.transport.symfony_serializer failed: failure_transport: dead retry_strategy: max_retries: 3 delay: 1800000 multiplier: 2 jitter: 0 dsn: "doctrine://default?queue_name=failed" serializer: messenger.transport.symfony_serializer dead: dsn: "doctrine://default?queue_name=dead" serializer: messenger.transport.symfony_serializer routing: # Route your messages to the transports App\Message\Contracts\AsyncMessageInterface: async App\Message\Contracts\ActivityPubInboxInterface: inbox App\Message\Contracts\ActivityPubInboxReceiveInterface: receive App\Message\Contracts\ActivityPubOutboxDeliverInterface: deliver App\Message\Contracts\ActivityPubOutboxInterface: outbox App\Message\Contracts\ActivityPubResolveInterface: resolve # Consider adding SendEmail from Mailer via async messenger as well: #Symfony\Component\Mailer\Messenger\SendEmailMessage: async #App\Message\Contracts\SendConfirmationEmailInterface: async # when@test: # framework: # messenger: # transports: # # replace with your transport name here (e.g., my_transport: 'in-memory://') # # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test # async: 'in-memory://' ================================================ FILE: config/packages/meteo_concept_h_captcha.yaml ================================================ meteo_concept_h_captcha: hcaptcha: site_key: "%hcaptcha_site_key%" secret: "%hcaptcha_secret%" ================================================ FILE: config/packages/monolog.yaml ================================================ monolog: channels: - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists when@dev: monolog: handlers: main: type: service id: log_filter_handler handler: rotating rotating: type: rotating_file path: '%kernel.logs_dir%/%kernel.environment%.log' # Or use: "debug" instead of "info" for more verbose log (debug) messages level: info # Enable full stacktrace, set this to false to disable stacktraces include_stacktraces: true max_files: 10 channels: ['!event'] stderr: type: stream path: '%kernel.logs_dir%/%kernel.environment%.log' level: info channels: ['!event'] # uncomment to get logging in your browser # you may have to allow bigger header sizes in your Web server configuration #firephp: # type: firephp # level: info #chromephp: # type: chromephp # level: info console: type: console process_psr_3_messages: false channels: ['!event', '!doctrine', '!console'] # uncomment if you wish to see depreciation messages to console # by default it's already logged to the log file #deprecation: # type: stream # channels: [deprecation] # path: php://stderr # formatter: monolog.formatter.json when@test: monolog: handlers: main: type: fingers_crossed action_level: error handler: filtered excluded_http_codes: [404, 405] channels: ['!event'] filtered: type: service id: log_filter_handler handler: nested nested: type: stream path: '%kernel.logs_dir%/%kernel.environment%.log' level: debug when@prod: monolog: handlers: main: type: fingers_crossed action_level: error handler: filtered excluded_http_codes: [404, 405] channels: ["!deprecation"] buffer_size: 50 # How many messages should be saved? Prevent memory leaks filtered: type: service id: log_filter_handler handler: nested nested: type: group members: [nested_file, nested_stderr] nested_file: type: rotating_file max_files: 7 path: '%kernel.logs_dir%/%kernel.environment%.log' level: warning formatter: monolog.formatter.json nested_stderr: type: stream path: 'php://stderr' level: warning formatter: monolog.formatter.json console: type: console process_psr_3_messages: false channels: ['!event', '!doctrine'] deprecation: type: stream channels: [deprecation] path: php://stderr formatter: monolog.formatter.json ================================================ FILE: config/packages/nelmio_api_doc.yaml ================================================ nelmio_api_doc: documentation: info: title: Mbin API description: Documentation for interacting with content on Mbin through the API version: 1.0.0 paths: /authorize: get: tags: - oauth summary: Begin an oauth2 authorization_code grant flow parameters: - name: response_type in: query schema: type: string default: code enum: - code required: true - name: client_id in: query schema: type: string required: true - name: redirect_uri in: query description: One of the valid redirect_uris that were registered for your client during client creation. schema: type: string format: uri required: true - name: scope in: query description: A space delimited list of requested scopes schema: type: string required: true - name: state in: query description: A randomly generated state variable to be used to prevent CSRF attacks schema: type: string required: true - name: code_challenge in: query description: Required for public clients, begins PKCE flow when present schema: type: string - name: code_challenge_method in: query description: Required for public clients, sets the type of code challenge used schema: type: string enum: - S256 - plain /token: post: tags: - oauth summary: Used to retrieve a Bearer token after receiving consent from the user requestBody: content: multipart/form-data: schema: required: - grant_type - client_id properties: grant_type: type: string description: One of the three grant types available enum: - authorization_code - refresh_token - client_credentials client_id: type: string client_secret: type: string description: Required if using the client_credentials or authorization_code flow with a confidential client code_verifier: type: string description: Required if using the PKCE extension to authorization_code flow code: type: string description: Required during authorization_code flow. The code retrieved after redirect during authorization_code flow. refresh_token: type: string description: Required during refresh_token flow. This is the refresh token obtained after a successful authorization_code flow. redirect_uri: type: string description: Required during authorization_code flow. One of the valid redirect_uris that were registered for your client during client creation. scope: type: string description: Required during client_credentials flow. A space-delimited list of scopes the client token will be provided. components: securitySchemes: oauth2: type: oauth2 flows: clientCredentials: tokenUrl: /token scopes: read: Read all content you have access to. write: Create or edit any of your threads, posts, or comments. delete: Delete any of your threads, posts, or comments. report: Report threads, posts, or comments. vote: Upvote, downvote, or boost threads, posts, or comments. subscribe: Subscribe or follow any magazine, domain, or user, and view the magazines, domains, and users you subscribe to. block: Block or unblock any magazine, domain, or user, and view the magazines, domains, and users you have blocked. domain: Subscribe to or block domains, and view the domains you subscribe to or block. domain:subscribe: Subscribe or unsubscribe to domains and view the domains you subscribe to. domain:block: Block or unblock domains and view the domains you have blocked. entry: Create, edit, or delete your threads, and vote, boost, or report any thread. entry:create: Create new threads. entry:edit: Edit your existing threads. entry:vote: Vote or boost threads. entry:delete: Delete your existing threads. entry:report: Report any thread. entry_comment: Create, edit, or delete your comments in threads, and vote, boost, or report any comment in a thread. entry_comment:create: Create new comments in threads. entry_comment:edit: Edit your existing comments in threads. entry_comment:vote: Vote or boost comments in threads. entry_comment:delete: Delete your existing comments in threads. entry_comment:report: Report any comment in a thread. magazine: Subscribe to or block magazines, and view the magazines you subscribe to or block. magazine:subscribe: Subscribe or unsubscribe to magazines and view the magazines you subscribe to. magazine:block: Block or unblock magazines and view the magazines you have blocked. post: Create, edit, or delete your microblogs, and vote, boost, or report any microblog. post:create: Create new posts. post:edit: Edit your existing posts. post:vote: Vote or boost posts. post:delete: Delete your existing posts. post:report: Report any post. post_comment: Create, edit, or delete your comments on posts, and vote, boost, or report any comment on a post. post_comment:create: Create new comments on posts. post_comment:edit: Edit your existing comments on posts. post_comment:vote: Vote or boost comments on posts. post_comment:delete: Delete your existing comments on posts. post_comment:report: Report any comment on a post. user: Read and edit your profile, messages, notifications; follow or block other users; view lists of users you follow or block. user:profile: Read and edit your profile. user:profile:read: Read your profile. user:profile:edit: Edit your profile. user:message: Read your messages and send messages to other users. user:message:read: Read your messages. user:message:create: Send messages to other users. user:notification: Read and clear your notifications. user:notification:read: Read your notifications, including message notifications. user:notification:delete: Clear notifications. user:follow: Follow or unfollow users, and read a list of users you follow. user:block: Block or unblock users, and read a list of users you block. moderate: Perform any moderation action you have permission to perform in your moderated magazines. moderate:entry: Moderate threads in your moderated magazines. moderate:entry:language: Change the language of threads in your moderated magazines. moderate:entry:pin: Pin threads to the top of your moderated magazines. moderate:entry:set_adult: Mark threads as NSFW in your moderated magazines. moderate:entry:trash: Trash or restore threads in your moderated magazines. moderate:entry_comment: Moderate comments in threads in your moderated magazines. moderate:entry_comment:language: Change the language of comments in threads in your moderated magazines. moderate:entry_comment:set_adult: Mark comments in threads as NSFW in your moderated magazines. moderate:entry_comment:trash: Trash or restore comments in threads in your moderated magazines. moderate:post: Moderate posts in your moderated magazines. moderate:post:language: Change the language of posts in your moderated magazines. moderate:post:pin: Pin posts to the top of your moderated magazines. moderate:post:set_adult: Mark posts as NSFW in your moderated magazines. moderate:post:trash: Trash or restore posts in your moderated magazines. moderate:post_comment: Moderate comments on posts in your moderated magazines. moderate:post_comment:language: Change the language of comments on posts in your moderated magazines. moderate:post_comment:set_adult: Mark comments on posts as NSFW in your moderated magazines. moderate:post_comment:trash: Trash or restore comments on posts in your moderated magazines. moderate:magazine: Manage bans, reports, and view trashed items in your moderated magazines. moderate:magazine:ban: Manage banned users in your moderated magazines. moderate:magazine:ban:read: View banned users in your moderated magazines. moderate:magazine:ban:create: Ban users in your moderated magazines. moderate:magazine:ban:delete: Unban users in your moderated magazines. moderate:magazine:list: Read a list of your moderated magazines. moderate:magazine:reports: Manage reports in your moderated magazines. moderate:magazine:reports:read: Read reports in your moderated magazines. moderate:magazine:reports:action: Accept or reject reports in your moderated magazines. moderate:magazine:trash:read: View trashed content in your moderated magazines. moderate:magazine_admin: Create, edit, or delete your owned magazines. moderate:magazine_admin:create: Create new magazines. moderate:magazine_admin:delete: Delete any of your owned magazines. moderate:magazine_admin:update: Edit any of your owned magazines' rules, description, NSFW status, or icon. moderate:magazine_admin:theme: Edit the custom CSS of any of your owned magazines. moderate:magazine_admin:moderators: Add or remove moderators of any of your owned magazines. moderate:magazine_admin:badges: Create or remove badges from your owned magazines. moderate:magazine_admin:tags: Create or remove tags from your owned magazines. moderate:magazine_admin:stats: View the content, vote, and view stats of your owned magazines. admin: Perform any administrative action on your instance. admin:entry:purge: Completely delete any thread from your instance. admin:entry_comment:purge: Completely delete any comment in a thread from your instance. admin:post:purge: Completely delete any post from your instance. admin:post_comment:purge: Completely delete any comment on a post from your instance. admin:magazine: Move threads between, manage moderators of or completely delete magazines on your instance. admin:magazine:move_entry: Move threads between magazines on your instance. admin:magazine:purge: Completely delete magazines on your instance. admin:magazine:moderate: Manage moderators and owners of magazines. admin:user: Ban, verify, or completely delete users on your instance. admin:user:ban: Ban or unban users from your instance. admin:user:verify: Verify users on your instance. admin:user:delete: Delete a user from your instance, leaving a record of their username. admin:user:purge: Completely delete a user from your instance. admin:instance: View your instance's stats and settings, or update instance settings or information. admin:instance:stats: View your instance's stats. admin:instance:settings: View or update settings on your instance. admin:instance:settings:read: View settings on your instance. admin:instance:settings:edit: Update settings on your instance. admin:instance:information:edit: Update the About, FAQ, Contact, Terms of Service, and Privacy Policy on your instance. admin:federation: View and update current (de)federation settings of other instances on your instance. admin:federation:read: View a list of defederated instances on your instance. admin:federation:update: Add or remove instances to the list of defederated instances. admin:oauth_clients: View or revoke OAuth2 clients that exist on your instance. admin:oauth_clients:read: View the OAuth2 clients that exist on your instance, and their usage stats. admin:oauth_clients:revoke: Revoke access to OAuth2 clients on your instance. authorizationCode: authorizationUrl: /authorize tokenUrl: /token scopes: read: Read all content you have access to. write: Create or edit any of your threads, posts, or comments. delete: Delete any of your threads, posts, or comments. subscribe: Report threads, posts, or comments. block: Upvote, downvote, or boost threads, posts, or comments. vote: Subscribe or follow any magazine, domain, or user, and view the magazines, domains, and users you subscribe to. report: Block or unblock any magazine, domain, or user, and view the magazines, domains, and users you have blocked. domain: Subscribe to or block domains, and view the domains you subscribe to or block. domain:subscribe: Subscribe or unsubscribe to domains and view the domains you subscribe to. domain:block: Block or unblock domains and view the domains you have blocked. entry: Create, edit, or delete your threads, and vote, boost, or report any thread. entry:create: Create new threads. entry:edit: Edit your existing threads. entry:delete: Delete your existing threads. entry:vote: Upvote, boost, or downvote any thread. entry:report: Report any thread. entry_comment: Create, edit, or delete your comments in threads, and vote, boost, or report any comment in a thread. entry_comment:create: Create new comments in threads. entry_comment:edit: Edit your existing comments in threads. entry_comment:delete: Delete your existing comments in threads. entry_comment:vote: Upvote, boost, or downvote any comment in a thread. entry_comment:report: Report any comment in a thread. magazine: Subscribe to or block magazines, and view the magazines you subscribe to or block. magazine:subscribe: Subscribe or unsubscribe to magazines and view the magazines you subscribe to. magazine:block: Block or unblock magazines and view the magazines you have blocked. post: Create, edit, or delete your microblogs, and vote, boost, or report any microblog. post:create: Create new posts. post:edit: Edit your existing posts. post:delete: Delete your existing posts. post:vote: Upvote, boost, or downvote any post. post:report: Report any post. post_comment: Create, edit, or delete your comments on posts, and vote, boost, or report any comment on a post. post_comment:create: Create new comments on posts. post_comment:edit: Edit your existing comments on posts. post_comment:delete: Delete your existing comments on posts. post_comment:vote: Upvote, boost, or downvote any comment on a post. post_comment:report: Report any comment on a post. user: Read and edit your profile, messages, notifications; follow or block other users; view lists of users you follow or block. user:profile: Read and edit your profile. user:profile:read: Read your profile. user:profile:edit: Edit your profile. user:message: Read your messages and send messages to other users. user:message:read: Read your messages. user:message:create: Send messages to other users. user:notification: Read and clear your notifications. user:notification:read: Read your notifications, including message notifications. user:notification:delete: Clear notifications. user:follow: Follow or unfollow users, and read a list of users you follow. user:block: Block or unblock users, and read a list of users you block. moderate: Perform any moderation action you have permission to perform in your moderated magazines. moderate:entry: Moderate threads in your moderated magazines. moderate:entry:language: Change the language of threads in your moderated magazines. moderate:entry:pin: Pin threads to the top of your moderated magazines. moderate:entry:set_adult: Mark threads as NSFW in your moderated magazines. moderate:entry:trash: Trash or restore threads in your moderated magazines. moderate:entry_comment: Moderate comments in threads in your moderated magazines. moderate:entry_comment:language: Change the language of comments in threads in your moderated magazines. moderate:entry_comment:set_adult: Mark comments in threads as NSFW in your moderated magazines. moderate:entry_comment:trash: Trash or restore comments in threads in your moderated magazines. moderate:post: Moderate posts in your moderated magazines. moderate:post:language: Change the language of posts in your moderated magazines. moderate:post:set_adult: Mark posts as NSFW in your moderated magazines. moderate:post:trash: Trash or restore posts in your moderated magazines. moderate:post_comment: Moderate comments on posts in your moderated magazines. moderate:post_comment:language: Change the language of comments on posts in your moderated magazines. moderate:post_comment:set_adult: Mark comments on posts as NSFW in your moderated magazines. moderate:post_comment:trash: Trash or restore comments on posts in your moderated magazines. moderate:magazine: Manage bans, reports, and view trashed items in your moderated magazines. moderate:magazine:ban: Manage banned users in your moderated magazines. moderate:magazine:ban:read: View banned users in your moderated magazines. moderate:magazine:ban:create: Ban users in your moderated magazines. moderate:magazine:ban:delete: Unban users in your moderated magazines. moderate:magazine:list: Read a list of your moderated magazines. moderate:magazine:reports: Manage reports in your moderated magazines. moderate:magazine:reports:read: Read reports in your moderated magazines. moderate:magazine:reports:action: Accept or reject reports in your moderated magazines. moderate:magazine:trash:read: View trashed content in your moderated magazines. moderate:magazine_admin: Create, edit, or delete your owned magazines. moderate:magazine_admin:create: Create new magazines. moderate:magazine_admin:delete: Delete any of your owned magazines. moderate:magazine_admin:update: Edit any of your owned magazines' rules, description, NSFW status, or icon. moderate:magazine_admin:theme: Edit the custom CSS of any of your owned magazines. moderate:magazine_admin:moderators: Add or remove moderators of any of your owned magazines. moderate:magazine_admin:badges: Create or remove badges from your owned magazines. moderate:magazine_admin:tags: Create or remove tags from your owned magazines. moderate:magazine_admin:stats: View the content, vote, and view stats of your owned magazines. admin: Perform any administrative action on your instance. admin:entry:purge: Completely delete any thread from your instance. admin:entry_comment:purge: Completely delete any comment in a thread from your instance. admin:post:purge: Completely delete any post from your instance. admin:post_comment:purge: Completely delete any comment on a post from your instance. admin:magazine: Move threads between, manage moderators of or completely delete magazines on your instance. admin:magazine:move_entry: Move threads between magazines on your instance. admin:magazine:purge: Completely delete magazines on your instance. admin:magazine:moderate: Manage moderators and owners of magazines. admin:user: Ban, verify, or completely delete users on your instance. admin:user:ban: Ban or unban users from your instance. admin:user:verify: Verify users on your instance. admin:user:delete: Delete a user from your instance, leaving a record of their username. admin:user:purge: Completely delete a user from your instance. admin:instance: View your instance's stats and settings, or update instance settings or information. admin:instance:stats: View your instance's stats. admin:instance:settings: View or update settings on your instance. admin:instance:settings:read: View settings on your instance. admin:instance:settings:edit: Update settings on your instance. admin:instance:information:edit: Update the About, FAQ, Contact, Terms of Service, and Privacy Policy on your instance. admin:federation: View and update current (de)federation settings of other instances on your instance. admin:federation:read: View a list of defederated instances on your instance. admin:federation:update: Add or remove instances to the list of defederated instances. admin:oauth_clients: View or revoke OAuth2 clients that exist on your instance. admin:oauth_clients:read: View the OAuth2 clients that exist on your instance, and their usage stats. admin:oauth_clients:revoke: Revoke access to OAuth2 clients on your instance. areas: # to filter documented areas path_patterns: - ^/api(?!(/doc$|/\.well-known.*|/docs.*|/doc\.json$|/\{index\}|/contexts.*)) # Accepts routes under /api except /api/doc ================================================ FILE: config/packages/nelmio_cors.yaml ================================================ nelmio_cors: defaults: origin_regex: true allow_origin: ["%env(CORS_ALLOW_ORIGIN)%"] allow_methods: ["GET", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"] allow_headers: ["Content-Type", "Authorization"] max_age: 3600 paths: "^/api/": allow_origin: ["*"] allow_headers: ["X-Custom-Auth"] allow_methods: ["POST", "PUT", "GET", "DELETE"] expose_headers: ["Link"] max_age: 3600 "^/.well-known/|^/nodeinfo/": allow_origin: ["*"] allow_methods: ["GET"] max_age: 3600 ================================================ FILE: config/packages/nyholm_psr7.yaml ================================================ services: # Register nyholm/psr7 services for autowiring with PSR-17 (HTTP factories) Psr\Http\Message\RequestFactoryInterface: "@nyholm.psr7.psr17_factory" Psr\Http\Message\ResponseFactoryInterface: "@nyholm.psr7.psr17_factory" Psr\Http\Message\ServerRequestFactoryInterface: "@nyholm.psr7.psr17_factory" Psr\Http\Message\StreamFactoryInterface: "@nyholm.psr7.psr17_factory" Psr\Http\Message\UploadedFileFactoryInterface: "@nyholm.psr7.psr17_factory" Psr\Http\Message\UriFactoryInterface: "@nyholm.psr7.psr17_factory" nyholm.psr7.psr17_factory: class: Nyholm\Psr7\Factory\Psr17Factory ================================================ FILE: config/packages/oneup_flysystem.yaml ================================================ # Read the documentation: https://github.com/1up-lab/OneupFlysystemBundle oneup_flysystem: adapters: default_adapter: local: location: "%kernel.project_dir%/public/%uploads_dir_name%" kbin.s3_adapter: awss3v3: client: kbin.s3_client bucket: "%amazon.s3.bucket%" options: ACL: public-read # If using an s3 bucket with owner-full-control and no ACL, the following may work: # options: # ACL: '' filesystems: public_uploads_filesystem: adapter: default_adapter #adapter: kbin.s3_adapter alias: League\Flysystem\Filesystem ================================================ FILE: config/packages/prod/routing.yaml ================================================ framework: router: strict_requirements: null ================================================ FILE: config/packages/rate_limiter.yaml ================================================ framework: rate_limiter: # 1 anonymous read per second anonymous_api_read: policy: 'sliding_window' limit: 60 interval: '60 seconds' # 2 API clients created every 6 hours per IP api_oauth_client: policy: 'sliding_window' limit: 2 interval: '360 minutes' # 2 tokens revoked every second, bursting up to 60 api_oauth_token_revoke: policy: 'sliding_window' limit: 60 interval: '30 seconds' # 1 clients deleted every minute, bursting up to 5 api_oauth_client_delete: policy: 'sliding_window' limit: 5 interval: '5 minutes' # 2 deletes per second, bursting up to 240 in 2 minutes api_delete: policy: 'sliding_window' limit: 240 interval: '2 minutes' # 2 messages per second, bursting up to 120 api_message: policy: 'sliding_window' limit: 120 interval: '60 seconds' # 2 reports per minute, bursting up to 10 api_report: policy: 'sliding_window' limit: 10 interval: '300 seconds' # 2 reads per second, bursting up to 240 api_read: policy: 'sliding_window' limit: 240 interval: '2 minutes' # 1 update per second, bursting up to 120 api_update: policy: 'sliding_window' limit: 120 interval: '2 minutes' # 3.6 votes per minute, bursting up to 220 every hour api_vote: policy: 'sliding_window' limit: 220 interval: '60 minutes' # 1 entry per 3 minutes, bursting up to 2 (same rate as normal user) api_entry: policy: 'sliding_window' limit: 2 interval: '6 minutes' # 1 post/microblog per 2 minutes, bursting up to 10 api_post: policy: 'sliding_window' limit: 10 interval: '20 minutes' # 1 image upload every 6 minutes, bursting up to 5 in 30 minutes api_image: policy: 'sliding_window' limit: 5 interval: '30 minutes' # 2 post or entry comments per minute, bursting up to 20 api_comment: policy: 'sliding_window' limit: 20 interval: '10 minutes' # 2 notification reads/updates/deletes per second, bursting up to 240 api_notification: policy: 'sliding_window' limit: 240 interval: '2 minutes' # 3 moderation actions per second, bursting up to 360 api_moderate: policy: 'sliding_window' limit: 360 interval: '2 minutes' vote: policy: 'sliding_window' limit: 220 interval: '60 minutes' entry: policy: 'fixed_window' limit: 20 interval: '60 minutes' entry_comment: policy: 'sliding_window' limit: 30 interval: '60 minutes' post: policy: 'fixed_window' limit: 30 interval: '60 minutes' post_comment: policy: 'sliding_window' limit: 40 interval: '60 minutes' user_register: policy: 'fixed_window' limit: 2 interval: '360 minutes' magazine: policy: 'fixed_window' limit: 3 interval: '360 minutes' contact: policy: 'fixed_window' limit: 3 interval: '2 minutes' user_delete: policy: 'fixed_window' limit: 4 interval: '1 day' ap_update_actor: policy: 'sliding_window' limit: 1 interval: '5 minutes' ================================================ FILE: config/packages/reset_password.yaml ================================================ symfonycasts_reset_password: request_password_repository: App\Repository\ResetPasswordRequestRepository ================================================ FILE: config/packages/routing.yaml ================================================ framework: router: utf8: true # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands #default_uri: %env(DEFAULT_URI)% when@prod: framework: router: strict_requirements: null ================================================ FILE: config/packages/rss_atom.yaml ================================================ debril_rss_atom: # switch to true if you need to set cache-control: private private: false # switch to true if you need to always send status 200 force_refresh: true content_type_xml: application/rss+xml ================================================ FILE: config/packages/scheb_2fa.yaml ================================================ # See the configuration reference at https://symfony.com/bundles/SchebTwoFactorBundle/6.x/configuration.html scheb_two_factor: security_tokens: - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken totp: enabled: true issuer: '%kbin_title%' template: user/2fa.html.twig leeway: 15 # allow codes window seconds away from the current time window (i.e. codes from before and after) parameters: image: 'https://%kbin_domain%/assets/icons/icon-144x144.png' backup_codes: enabled: true ================================================ FILE: config/packages/security.yaml ================================================ security: # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' App\Entity\User: algorithm: auto # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: app_user_provider: entity: class: App\Entity\User firewalls: dev: # Ensure dev tools and static assets are always allowed pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false api_token: pattern: ^/token$ security: false api: pattern: ^/api security: true stateless: true oauth2: true image_resolver: pattern: ^/media/cache/resolve security: false ap_contexts: pattern: ^/contexts(\.jsonld)?$ security: false main: lazy: true provider: app_user_provider # Activate different ways to authenticate: # https://symfony.com/doc/current/security.html#the-firewall # https://symfony.com/doc/current/security/impersonating_user.html # switch_user: true custom_authenticators: - App\Security\KbinAuthenticator - App\Security\AzureAuthenticator - App\Security\DiscordAuthenticator - App\Security\FacebookAuthenticator - App\Security\GoogleAuthenticator - App\Security\GithubAuthenticator - App\Security\PrivacyPortalAuthenticator - App\Security\KeycloakAuthenticator - App\Security\SimpleLoginAuthenticator - App\Security\ZitadelAuthenticator - App\Security\AuthentikAuthenticator logout: enable_csrf: true path: app_logout user_checker: App\Security\UserChecker remember_me: secret: '%kernel.secret%' lifetime: 10512000 # 4 Months path: / token_provider: doctrine: true # see https://symfony.com/doc/current/security/remember_me.html#using-signed-remember-me-tokens signature_properties: ['username', 'password', 'totpSecret', 'isBanned', 'isDeleted', 'markedForDeletionAt'] two_factor: auth_form_path: 2fa_login check_path: 2fa_login_check enable_csrf: true csrf_parameter: _csrf_token csrf_token_id: 2fa # https://symfony.com/doc/current/security/impersonating_user.html # switch_user: true # Note: Only the *first* matching rule is applied access_control: # This makes the logout route accessible during two-factor authentication. Allows the user to # cancel two-factor authentication, if they need to. - { path: ^/logout, role: PUBLIC_ACCESS } # This makes the login form and oauth routes accessible even when private instance is enabled - { path: ^/register, role: PUBLIC_ACCESS } - { path: ^/verify/email, role: PUBLIC_ACCESS } - { path: ^/reset-password, role: PUBLIC_ACCESS } - { path: ^/login, role: PUBLIC_ACCESS } - { path: ^/resend-email-activation, role: PUBLIC_ACCESS } - { path: ^/oauth, role: PUBLIC_ACCESS } - { path: ^/terms, role: PUBLIC_ACCESS } - { path: ^/privacy-policy, role: PUBLIC_ACCESS } # Allow ActivityPub routes to work publicly - { attributes: { '_route': 'ap_webfinger' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_hostmeta' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_node_info' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_node_info_v2' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_instance' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_instance_front' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_instance_inbox' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_instance_outbox' }, role: PUBLIC_ACCESS, } - { attributes: { '_route': 'ap_shared_inbox' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_object' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_user' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_user_inbox' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_user_outbox' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_user_followers' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_user_following' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_magazine' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_magazine_inbox' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_magazine_outbox' }, role: PUBLIC_ACCESS, } - { attributes: { '_route': 'ap_magazine_followers' }, role: PUBLIC_ACCESS, } - { attributes: { '_route': 'ap_magazine_moderators' }, role: PUBLIC_ACCESS, } - { attributes: { '_route': 'ap_entry' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_entry_comment' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_post' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_post_comment' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_report' }, role: PUBLIC_ACCESS } - { attributes: { '_route': 'ap_contexts' }, role: PUBLIC_ACCESS } # allow custom style route access during two-factor authentication # to avoid redirecting (wrongly) to this route after two-factor authentication is completed - { path: ^/custom-style, role: PUBLIC_ACCESS } # This ensures that the form can only be accessed when two-factor authentication is in progress. - { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS } - { path: ^/admin, roles: [ROLE_ADMIN, ROLE_MODERATOR] } - { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED } - { path: ^/token, roles: PUBLIC_ACCESS } - { path: ^/api/doc, roles: PUBLIC_ACCESS } - { path: ^/api/client, roles: PUBLIC_ACCESS } - { path: ^/api/info, roles: PUBLIC_ACCESS } - { path: ^/api/instance, roles: PUBLIC_ACCESS } - { path: ^/api/federated, roles: PUBLIC_ACCESS } - { path: ^/api/defederated, roles: PUBLIC_ACCESS } - { path: ^/api/dead, roles: PUBLIC_ACCESS } - { path: ^/api/users/admins, role: PUBLIC_ACCESS } - { path: ^/api/users/moderators, role: PUBLIC_ACCESS } - { path: ^/, roles: PUBLIC_ACCESS_UNLESS_PRIVATE_INSTANCE } role_hierarchy: ROLE_OAUTH2_WRITE: [ 'ROLE_OAUTH2_ENTRY:CREATE', 'ROLE_OAUTH2_ENTRY:EDIT', 'ROLE_OAUTH2_ENTRY_COMMENT:CREATE', 'ROLE_OAUTH2_ENTRY_COMMENT:EDIT', 'ROLE_OAUTH2_POST:CREATE', 'ROLE_OAUTH2_POST:EDIT', 'ROLE_OAUTH2_POST_COMMENT:CREATE', 'ROLE_OAUTH2_POST_COMMENT:EDIT', 'ROLE_OAUTH2_BOOKMARK:ADD', 'ROLE_OAUTH2_BOOKMARK:REMOVE', 'ROLE_OAUTH2_BOOKMARK_LIST:EDIT', ] ROLE_OAUTH2_DELETE: [ 'ROLE_OAUTH2_ENTRY:DELETE', 'ROLE_OAUTH2_ENTRY_COMMENT:DELETE', 'ROLE_OAUTH2_POST:DELETE', 'ROLE_OAUTH2_POST_COMMENT:DELETE', 'ROLE_OAUTH2_BOOKMARK_LIST:DELETE', ] ROLE_OAUTH2_REPORT: [ 'ROLE_OAUTH2_ENTRY:REPORT', 'ROLE_OAUTH2_ENTRY_COMMENT:REPORT', 'ROLE_OAUTH2_POST:REPORT', 'ROLE_OAUTH2_POST_COMMENT:REPORT', ] ROLE_OAUTH2_VOTE: [ 'ROLE_OAUTH2_ENTRY:VOTE', 'ROLE_OAUTH2_ENTRY_COMMENT:VOTE', 'ROLE_OAUTH2_POST:VOTE', 'ROLE_OAUTH2_POST_COMMENT:VOTE', ] ROLE_OAUTH2_SUBSCRIBE: [ 'ROLE_OAUTH2_DOMAIN:SUBSCRIBE', 'ROLE_OAUTH2_MAGAZINE:SUBSCRIBE', 'ROLE_OAUTH2_USER:FOLLOW', ] 'ROLE_OAUTH2_BOOKMARK': [ 'ROLE_OAUTH2_BOOKMARK:ADD', 'ROLE_OAUTH2_BOOKMARK:REMOVE', ] 'ROLE_OAUTH2_BOOKMARK_LIST': [ 'ROLE_OAUTH2_BOOKMARK_LIST:READ', 'ROLE_OAUTH2_BOOKMARK_LIST:EDIT', 'ROLE_OAUTH2_BOOKMARK_LIST:DELETE', ] ROLE_OAUTH2_BLOCK: [ 'ROLE_OAUTH2_DOMAIN:BLOCK', 'ROLE_OAUTH2_MAGAZINE:BLOCK', 'ROLE_OAUTH2_USER:BLOCK', ] ROLE_OAUTH2_DOMAIN: ['ROLE_OAUTH2_DOMAIN:SUBSCRIBE', 'ROLE_OAUTH2_DOMAIN:BLOCK'] ROLE_OAUTH2_ENTRY: [ 'ROLE_OAUTH2_ENTRY:CREATE', 'ROLE_OAUTH2_ENTRY:EDIT', 'ROLE_OAUTH2_ENTRY:DELETE', 'ROLE_OAUTH2_ENTRY:VOTE', 'ROLE_OAUTH2_ENTRY:REPORT', ] ROLE_OAUTH2_ENTRY_COMMENT: [ 'ROLE_OAUTH2_ENTRY_COMMENT:CREATE', 'ROLE_OAUTH2_ENTRY_COMMENT:EDIT', 'ROLE_OAUTH2_ENTRY_COMMENT:DELETE', 'ROLE_OAUTH2_ENTRY_COMMENT:VOTE', 'ROLE_OAUTH2_ENTRY_COMMENT:REPORT', ] ROLE_OAUTH2_MAGAZINE: ['ROLE_OAUTH2_MAGAZINE:SUBSCRIBE', 'ROLE_OAUTH2_MAGAZINE:BLOCK'] ROLE_OAUTH2_POST: [ 'ROLE_OAUTH2_POST:CREATE', 'ROLE_OAUTH2_POST:EDIT', 'ROLE_OAUTH2_POST:DELETE', 'ROLE_OAUTH2_POST:VOTE', 'ROLE_OAUTH2_POST:REPORT', ] ROLE_OAUTH2_POST_COMMENT: [ 'ROLE_OAUTH2_POST_COMMENT:CREATE', 'ROLE_OAUTH2_POST_COMMENT:EDIT', 'ROLE_OAUTH2_POST_COMMENT:DELETE', 'ROLE_OAUTH2_POST_COMMENT:VOTE', 'ROLE_OAUTH2_POST_COMMENT:REPORT', ] ROLE_OAUTH2_USER: [ 'ROLE_OAUTH2_USER:PROFILE', 'ROLE_OAUTH2_USER:MESSAGE', 'ROLE_OAUTH2_USER:NOTIFICATION', 'ROLE_OAUTH2_USER:OAUTH_CLIENTS', 'ROLE_OAUTH2_USER:FOLLOW', 'ROLE_OAUTH2_USER:BLOCK', ] 'ROLE_OAUTH2_USER:PROFILE': ['ROLE_OAUTH2_USER:PROFILE:READ', 'ROLE_OAUTH2_USER:PROFILE:EDIT'] 'ROLE_OAUTH2_USER:MESSAGE': ['ROLE_OAUTH2_USER:MESSAGE:READ', 'ROLE_OAUTH2_USER:MESSAGE:CREATE'] 'ROLE_OAUTH2_USER:NOTIFICATION': [ 'ROLE_OAUTH2_USER:NOTIFICATION:READ', 'ROLE_OAUTH2_USER:NOTIFICATION:DELETE', 'ROLE_OAUTH2_USER:NOTIFICATION:EDIT', ] 'ROLE_OAUTH2_USER:OAUTH_CLIENTS': [ 'ROLE_OAUTH2_USER:OAUTH_CLIENTS:READ', 'ROLE_OAUTH2_USER:OAUTH_CLIENTS:EDIT', ] 'ROLE_OAUTH2_MODERATE': [ 'ROLE_OAUTH2_MODERATE:ENTRY', 'ROLE_OAUTH2_MODERATE:ENTRY_COMMENT', 'ROLE_OAUTH2_MODERATE:POST', 'ROLE_OAUTH2_MODERATE:POST_COMMENT', 'ROLE_OAUTH2_MODERATE:MAGAZINE', 'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN', ] 'ROLE_OAUTH2_MODERATE:ENTRY': [ 'ROLE_OAUTH2_MODERATE:ENTRY:LANGUAGE', 'ROLE_OAUTH2_MODERATE:ENTRY:PIN', 'ROLE_OAUTH2_MODERATE:ENTRY:SET_ADULT', 'ROLE_OAUTH2_MODERATE:ENTRY:TRASH', 'ROLE_OAUTH2_MODERATE:ENTRY:LOCK', ] 'ROLE_OAUTH2_MODERATE:ENTRY_COMMENT': [ 'ROLE_OAUTH2_MODERATE:ENTRY_COMMENT:LANGUAGE', 'ROLE_OAUTH2_MODERATE:ENTRY_COMMENT:SET_ADULT', 'ROLE_OAUTH2_MODERATE:ENTRY_COMMENT:TRASH', ] 'ROLE_OAUTH2_MODERATE:POST': [ 'ROLE_OAUTH2_MODERATE:POST:LANGUAGE', 'ROLE_OAUTH2_MODERATE:POST:PIN', 'ROLE_OAUTH2_MODERATE:POST:LOCK', 'ROLE_OAUTH2_MODERATE:POST:SET_ADULT', 'ROLE_OAUTH2_MODERATE:POST:TRASH', ] 'ROLE_OAUTH2_MODERATE:POST_COMMENT': [ 'ROLE_OAUTH2_MODERATE:POST_COMMENT:LANGUAGE', 'ROLE_OAUTH2_MODERATE:POST_COMMENT:SET_ADULT', 'ROLE_OAUTH2_MODERATE:POST_COMMENT:TRASH', ] 'ROLE_OAUTH2_MODERATE:MAGAZINE': [ 'ROLE_OAUTH2_MODERATE:MAGAZINE:BAN', 'ROLE_OAUTH2_MODERATE:MAGAZINE:LIST', 'ROLE_OAUTH2_MODERATE:MAGAZINE:REPORTS', 'ROLE_OAUTH2_MODERATE:MAGAZINE:TRASH:READ', ] 'ROLE_OAUTH2_MODERATE:MAGAZINE:BAN': [ 'ROLE_OAUTH2_MODERATE:MAGAZINE:BAN:READ', 'ROLE_OAUTH2_MODERATE:MAGAZINE:BAN:CREATE', 'ROLE_OAUTH2_MODERATE:MAGAZINE:BAN:DELETE', ] 'ROLE_OAUTH2_MODERATE:MAGAZINE:REPORTS': [ 'ROLE_OAUTH2_MODERATE:MAGAZINE:REPORTS:READ', 'ROLE_OAUTH2_MODERATE:MAGAZINE:REPORTS:ACTION', ] 'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN': [ 'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:CREATE', 'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:DELETE', 'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:UPDATE', 'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:THEME', 'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:MODERATORS', 'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:BADGES', 'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:TAGS', 'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:STATS', ] 'ROLE_OAUTH2_ADMIN': [ 'ROLE_OAUTH2_ADMIN:ENTRY:PURGE', 'ROLE_OAUTH2_ADMIN:ENTRY_COMMENT:PURGE', 'ROLE_OAUTH2_ADMIN:POST:PURGE', 'ROLE_OAUTH2_ADMIN:POST_COMMENT:PURGE', 'ROLE_OAUTH2_ADMIN:MAGAZINE', 'ROLE_OAUTH2_ADMIN:USER', 'ROLE_OAUTH2_ADMIN:INSTANCE', 'ROLE_OAUTH2_ADMIN:FEDERATION', 'ROLE_OAUTH2_ADMIN:OAUTH_CLIENTS', ] 'ROLE_OAUTH2_ADMIN:MAGAZINE': [ 'ROLE_OAUTH2_ADMIN:MAGAZINE:MOVE_ENTRY', 'ROLE_OAUTH2_ADMIN:MAGAZINE:PURGE', 'ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE', ] 'ROLE_OAUTH2_ADMIN:USER': [ 'ROLE_OAUTH2_ADMIN:USER:BAN', 'ROLE_OAUTH2_ADMIN:USER:VERIFY', 'ROLE_OAUTH2_ADMIN:USER:DELETE', 'ROLE_OAUTH2_ADMIN:USER:PURGE', ] 'ROLE_OAUTH2_ADMIN:INSTANCE': [ 'ROLE_OAUTH2_ADMIN:INSTANCE:STATS', 'ROLE_OAUTH2_ADMIN:INSTANCE:SETTINGS', 'ROLE_OAUTH2_ADMIN:INSTANCE:INFORMATION:EDIT', ] 'ROLE_OAUTH2_ADMIN:INSTANCE:SETTINGS': [ 'ROLE_OAUTH2_ADMIN:INSTANCE:SETTINGS:READ', 'ROLE_OAUTH2_ADMIN:INSTANCE:SETTINGS:EDIT', ] 'ROLE_OAUTH2_ADMIN:FEDERATION': [ 'ROLE_OAUTH2_ADMIN:FEDERATION:READ', 'ROLE_OAUTH2_ADMIN:FEDERATION:UPDATE', ] 'ROLE_OAUTH2_ADMIN:OAUTH_CLIENTS': [ 'ROLE_OAUTH2_ADMIN:OAUTH_CLIENTS:READ', 'ROLE_OAUTH2_ADMIN:OAUTH_CLIENTS:REVOKE', ] # - { path: ^/admin, roles: ROLE_ADMIN } # - { path: ^/profile, roles: ROLE_USER } when@test: security: password_hashers: # By default, password hashers are resource intensive and take time. This is # important to generate secure password hashes. In tests however, secure hashes # are not important, waste resources and increase test times. The following # reduces the work factor to the lowest possible values. Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: algorithm: auto cost: 4 # Lowest possible value for bcrypt time_cost: 3 # Lowest possible value for argon memory_cost: 10 # Lowest possible value for argon ================================================ FILE: config/packages/test/framework.yaml ================================================ framework: test: true session: storage_factory_id: session.storage.factory.mock_file ================================================ FILE: config/packages/test/messenger.yaml ================================================ framework: messenger: routing: # Route your messages to the transports App\Message\Contracts\AsyncMessageInterface: sync App\Message\Contracts\ActivityPubInboxInterface: sync App\Message\Contracts\ActivityPubInboxReceiveInterface: sync App\Message\Contracts\ActivityPubOutboxDeliverInterface: sync App\Message\Contracts\ActivityPubOutboxInterface: sync App\Message\Contracts\ActivityPubResolveInterface: sync # Consider adding SendEmail from Mailer via async messenger as well: Symfony\Component\Mailer\Messenger\SendEmailMessage: sync App\Message\Contracts\SendConfirmationEmailInterface: sync ================================================ FILE: config/packages/test/rate_limiter.yaml ================================================ framework: rate_limiter: anonymous_api_read: policy: 'sliding_window' limit: 1000 interval: '1 second' api_oauth_client: policy: 'sliding_window' limit: 1000 interval: '1 second' api_oauth_token_revoke: policy: 'sliding_window' limit: 1000 interval: '1 second' api_oauth_client_delete: policy: 'sliding_window' limit: 1000 interval: '1 second' api_delete: policy: 'sliding_window' limit: 1000 interval: '1 second' api_message: policy: 'sliding_window' limit: 1000 interval: '1 second' api_report: policy: 'sliding_window' limit: 1000 interval: '1 second' api_read: policy: 'sliding_window' limit: 1000 interval: '1 second' api_update: policy: 'sliding_window' limit: 1000 interval: '1 second' api_vote: policy: 'sliding_window' limit: 1000 interval: '1 second' api_entry: policy: 'sliding_window' limit: 1000 interval: '1 second' api_image: policy: 'sliding_window' limit: 1000 interval: '1 second' api_post: policy: 'sliding_window' limit: 1000 interval: '1 second' api_comment: policy: 'sliding_window' limit: 1000 interval: '1 second' api_magazine: policy: 'sliding_window' limit: 1000 interval: '1 second' api_notification: policy: 'sliding_window' limit: 1000 interval: '1 second' api_moderate: policy: 'sliding_window' limit: 1000 interval: '1 second' vote: policy: 'sliding_window' limit: 1000 interval: '1 second' entry: policy: 'fixed_window' limit: 1000 interval: '1 second' entry_comment: policy: 'sliding_window' limit: 1000 interval: '1 second' post: policy: 'fixed_window' limit: 1000 interval: '1 second' post_comment: policy: 'sliding_window' limit: 1000 interval: '1 second' user_register: policy: 'fixed_window' limit: 1000 interval: '1 second' magazine: policy: 'fixed_window' limit: 1000 interval: '1 second' ================================================ FILE: config/packages/test/twig.yaml ================================================ twig: strict_variables: true ================================================ FILE: config/packages/translation.yaml ================================================ framework: default_locale: '%env(KBIN_DEFAULT_LANG)%' translator: default_path: '%kernel.project_dir%/translations' fallbacks: - en providers: ================================================ FILE: config/packages/twig.yaml ================================================ twig: globals: mercure_public_url: '%env(MERCURE_PUBLIC_URL)%' file_name_pattern: '*.twig' form_themes: [ 'form_div_layout.html.twig', '@MeteoConceptHCaptcha/hcaptcha_form.html.twig', ] when@test: twig: strict_variables: true ================================================ FILE: config/packages/twig_component.yaml ================================================ twig_component: anonymous_template_directory: 'components/' defaults: # Namespace & directory for components App\Twig\Components\: 'components/' ================================================ FILE: config/packages/uid.yaml ================================================ framework: uid: default_uuid_version: 7 time_based_uuid_version: 7 ================================================ FILE: config/packages/validator.yaml ================================================ framework: validation: email_validation_mode: html5 # Enables validator auto-mapping support. # For instance, basic validation constraints will be inferred from Doctrine's metadata. #auto_mapping: # App\Entity\: [] when@test: framework: validation: not_compromised_password: false ================================================ FILE: config/packages/web_profiler.yaml ================================================ when@dev: web_profiler: toolbar: true framework: profiler: collect_serializer_data: true when@test: framework: profiler: collect: false collect_serializer_data: true ================================================ FILE: config/packages/webpack_encore.yaml ================================================ webpack_encore: # The path where Encore is building the assets - i.e. Encore.setOutputPath() output_path: '%kernel.project_dir%/public/build' # If multiple builds are defined (as shown below), you can disable the default build: # output_path: false # Set attributes that will be rendered on all script and link tags script_attributes: defer: true # Uncomment (also under link_attributes) if using Turbo Drive # https://turbo.hotwired.dev/handbook/drive#reloading-when-assets-change # 'data-turbo-track': reload # link_attributes: # Uncomment if using Turbo Drive # 'data-turbo-track': reload # If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials') # crossorigin: 'anonymous' # Preload all rendered script and link tags automatically via the HTTP/2 Link header # preload: true # Throw an exception if the entrypoints.json file is missing or an entry is missing from the data # strict_mode: false # If you have multiple builds: # builds: # frontend: '%kernel.project_dir%/public/frontend/build' # pass the build name as the 3rd argument to the Twig functions # {{ encore_entry_script_tags('entry1', null, 'frontend') }} framework: assets: json_manifest_path: '%kernel.project_dir%/public/build/manifest.json' #when@prod: # webpack_encore: # # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes) # # Available in version 1.2 # cache: true #when@test: # webpack_encore: # strict_mode: false ================================================ FILE: config/packages/workflow.yaml ================================================ framework: workflows: reports: type: 'state_machine' audit_trail: enabled: true marking_store: type: 'method' property: 'status' supports: - App\Entity\Report initial_marking: pending places: - pending - approved - rejected - appeal - closed transitions: approve: from: pending to: approved reject: from: pending to: rejected appeal: from: rejected to: appeal close: from: appeal to: closed ================================================ FILE: config/preload.php ================================================ uri query { replace authorization REDACTED } } } root /app/public encode zstd br gzip mercure { # The transport to use transport bolt { path {$MERCURE_BOLT_PATH:/data/mercure.db} {$MERCURE_BOLT_EXTRA_DIRECTIVES} } # Publisher JWT key publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG} # Subscriber JWT key subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG} # Allow anonymous subscribers (double-check that it's what you want) anonymous # Enable the subscription API (double-check that it's what you want) subscriptions # Extra directives {$MERCURE_EXTRA_DIRECTIVES} } vulcain {$CADDY_SERVER_EXTRA_DIRECTIVES} # Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics header ?Permissions-Policy "browsing-topics=()" @phpRoute { not path /.well-known/mercure* not file {path} } rewrite @phpRoute index.php @frontController path index.php php @frontController file_server { hide *.php } } ================================================ FILE: docker/Dockerfile ================================================ #syntax=docker/dockerfile:1 # Base FrankenPHP image FROM dunglas/frankenphp:1-php8.4-trixie AS base WORKDIR /app VOLUME /app/var/ # persistent / runtime deps # hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ acl \ file \ gettext \ git \ gosu \ procps \ && rm -rf /var/lib/apt/lists/* RUN set -eux; \ install-php-extensions \ @composer \ amqp \ bcmath \ pgsql \ pdo_pgsql \ gd \ curl \ simplexml \ dom \ xml \ redis \ intl \ opcache \ apcu \ pcntl \ exif \ zip \ ; # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser ENV COMPOSER_ALLOW_SUPERUSER=1 ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d" COPY --link docker/conf.d/10-app.ini $PHP_INI_DIR/app.conf.d/ COPY --link --chmod=755 docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint COPY --link docker/Caddyfile /etc/caddy/Caddyfile ENTRYPOINT ["docker-entrypoint"] HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1 CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ] # Dev FrankenPHP image FROM base AS dev ENV APP_ENV=dev ENV XDEBUG_MODE=off ENV FRANKENPHP_WORKER_CONFIG=watch RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" RUN set -eux; \ install-php-extensions \ xdebug \ ; COPY --link docker/conf.d/20-app.dev.ini $PHP_INI_DIR/app.conf.d/ CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ] # Prod FrankenPHP image FROM base AS prod_deps ENV APP_ENV=prod # prevent the reinstallation of vendors at every changes in the source code COPY --link composer.* symfony.* ./ RUN set -eux; \ composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress # Node assets builder FROM node:24-trixie-slim AS prod_node RUN mkdir /app WORKDIR /app COPY --link ./package.json package-lock.json ./ RUN npm ci COPY --link ./webpack.config.js ./ COPY --link ./assets ./assets COPY --link ./public/js ./public/js COPY --link --from=prod_deps /app/vendor ./vendor RUN npm run build FROM prod_deps AS prod RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" COPY --link docker/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/ COPY --link . ./ RUN rm -Rf docker/ RUN cp .env.example_docker .env RUN set -eux; \ mkdir -p var/cache var/log; \ composer dump-autoload --classmap-authoritative --no-dev; \ composer dump-env prod; \ composer run-script --no-dev post-install-cmd; \ chmod +x bin/console; sync; COPY --link --from=prod_node /app/public/build /app/public/build ================================================ FILE: docker/conf.d/10-app.ini ================================================ expose_php = 0 date.timezone = UTC apc.enable_cli = 1 session.use_strict_mode = 1 zend.detect_unicode = 0 ; Maximum execution time of each script, in seconds max_execution_time = 120 ; Both max file size and post body size are personal preferences upload_max_filesize = 12M post_max_size = 12M ; Remember the memory limit is per child process memory_limit = 512M ; maximum memory allocated to store the results realpath_cache_size = 4096K ; save the results for 10 minutes (600 seconds) realpath_cache_ttl = 600 ================================================ FILE: docker/conf.d/20-app.dev.ini ================================================ ; See https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host ; See https://github.com/docker/for-linux/issues/264 ; The `client_host` below may optionally be replaced with `discover_client_host=yes` ; Add `start_with_request=yes` to start debug session on each request xdebug.client_host = host.docker.internal ================================================ FILE: docker/conf.d/20-app.prod.ini ================================================ opcache.enable = 1 opcache.enable_cli = 1 opcache.preload = /app/config/preload.php opcache.preload_user = root ; Memory consumption (in MBs), personal preference opcache.memory_consumption = 512 ; Internal string buffer (in MBs), personal preference opcache.interned_strings_buffer = 128 opcache.max_accelerated_files = 100000 opcache.validate_timestamps = 0 opcache.enable_file_override = 1 ; Enable PHP JIT with all optimizations opcache.jit = 1255 opcache.jit_buffer_size = 128M ================================================ FILE: docker/docker-entrypoint.sh ================================================ #!/bin/sh set -e if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then # Install dependencies if missing (needed for development) if [ -z "$(ls -A 'vendor/' 2>/dev/null)" ]; then composer install --prefer-dist --no-progress --no-interaction fi # Display information about the current project # Or about an error in project initialization php bin/console -V # Additional Mbin docker configurations (only for production) if [ "$APP_ENV" = 'prod' ]; then # Use 301 response for image redirects to reduce server load sed -i 's|redirect_response_code: 302|redirect_response_code: 301|' config/packages/liip_imagine.yaml # Override log level when PHP_LOG_LEVEL is not empty if [ -n "$PHP_LOG_LEVEL" ]; then sed -i "s|action_level: error|action_level: $PHP_LOG_LEVEL|" config/packages/monolog.yaml fi # Use S3 file system adapter when S3_KEY is not empty if [ -n "$S3_KEY" ]; then sed -i 's|adapter: default_adapter|adapter: kbin.s3_adapter|' config/packages/oneup_flysystem.yaml fi fi # Needed to apply the above config changes php bin/console cache:clear if grep -q ^DATABASE_URL= .env; then echo 'Waiting for database to be ready...' ATTEMPTS_LEFT_TO_REACH_DATABASE=60 until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do if [ $? -eq 255 ]; then # If the Doctrine command exits with 255, an unrecoverable error occurred ATTEMPTS_LEFT_TO_REACH_DATABASE=0 break fi sleep 1 ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1)) echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left." done if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then echo 'The database is not up or not reachable:' echo "$DATABASE_ERROR" exit 1 else echo 'The database is now ready and reachable' fi php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing fi # Solution to allow non-root users, given here: https://github.com/dunglas/symfony-docker/issues/679#issuecomment-2501369223 chown -R $MBIN_USER var /data /config echo 'PHP app ready!' fi exec /usr/sbin/gosu $MBIN_USER "$@" ================================================ FILE: docker/setup.sh ================================================ #!/usr/bin/env bash # Ensure script is always ran in Mbin's root directory. cd "$(dirname "$0")/.." if [[ "$1" == "" || "$1" == "-h" || "$1" == "--help" ]]; then cat << EOF Usage: ./docker/setup.sh MODE DOMAIN Automate your Mbin docker setup! MODE needs to be either "prod" or "dev". DOMAIN will set the correct domain related fields in the .env file. Use "localhost" if you are just testing locally. Examples: ./docker/setup.sh prod mbin.domain.tld ./docker/setup.sh dev localhost EOF exit 0 fi case $1 in prod) mode=prod ;; dev) mode=dev ;; *) echo "Invalid mode provided: $1" echo "Must be either prod (recommended for most cases) or dev." exit 1 ;; esac domain=$2 if [[ -z $domain ]]; then echo "DOMAIN must be provided. Use \"localhost\" if you are just testing locally." exit 1 fi verify_no_file () { if [ -f "$1" ]; then echo "ERROR: $1 file already exists. Cannot continue setup." exit 1 fi } verify_no_dir () { if [ -d "$1" ]; then echo "ERROR: $1 directory already exists. Cannot continue setup." exit 1 fi } verify_no_file .env verify_no_file compose.override.yaml verify_no_dir storage echo "Starting Mbin $mode setup..." echo echo "Generating .env file with passwords..." GEN_PASSWORD_LENGTH=32 GEN_PASSWORD_REGEX='!Change\w*!' while IFS= read -r line; do # Replace instances of !ChangeAnything! with a generated password. if [[ $line =~ $GEN_PASSWORD_REGEX ]]; then PASSWORD=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c $GEN_PASSWORD_LENGTH) # Save oauth password for later if [[ ${BASH_REMATCH[0]} == '!ChangeThisOauthPass!' ]]; then OAUTH_PASS=$PASSWORD fi line=${line/${BASH_REMATCH[0]}/$PASSWORD} fi # Replace "mbin.domain.tld" with passed in domain if [[ -n $domain ]]; then line=${line/mbin.domain.tld/$domain} fi # Populate MBIN_USER field if [[ $line == 'MBIN_USER=1000:1000' ]]; then line="MBIN_USER=$(id -u):$(id -g)" fi # Populate OAUTH_ENCRYPTION_KEY field if [[ $line == 'OAUTH_ENCRYPTION_KEY=' ]]; then line="$line$(openssl rand -hex 16)" fi echo "$line" >> .env done < .env.example_docker echo "Creating compose.override.yaml file... Any additional customizations to the compose setup should be added here." if [[ $mode == "dev" ]]; then cat > compose.override.yaml << EOF include: - compose.dev.yaml EOF else cat > compose.override.yaml << EOF # Customizations to the docker compose should be added here. # Hint: If you want to combine multiple configurations, be sure to only define the services once (php & messenger). # Uncomment the following to pin Mbin image docker tag to a specific release (example: v1.8.2). # services: # php: # image: ghcr.io/mbinorg/mbin:v1.8.2 # messenger: # image: ghcr.io/mbinorg/mbin:v1.8.2 # Uncomment the following to build the Mbin image locally. # services: # php: # pull_policy: build # messenger: # pull_policy: build # Uncomment the following to use Mbin behind a reverse proxy. # services: # php: # environment: # CADDY_GLOBAL_OPTIONS: auto_https off # ports: !override # - 8080:80 EOF fi echo "Setting up storage directories..." mkdir -p storage/{caddy_config,caddy_data,media,messenger_logs,oauth,php_logs,postgres,rabbitmq_data,rabbitmq_logs} echo "Configuring OAuth2 keys..." openssl genrsa -des3 -out ./storage/oauth/private.pem -passout "pass:$OAUTH_PASS" 4096 openssl rsa -in ./storage/oauth/private.pem --outform PEM -pubout -out ./storage/oauth/public.pem -passin "pass:$OAUTH_PASS" echo echo "Mbin environment setup complete!" echo "Please refer back to the documentation for finishing touches." ================================================ FILE: docker/tests/compose.yaml ================================================ services: db: image: postgres:${POSTGRES_VERSION:-17}-trixie container_name: mbin-tests-db shm_size: 128mb restart: unless-stopped ports: - '5433:5432' environment: - POSTGRES_DB=mbin_test - POSTGRES_USER=mbin - POSTGRES_PASSWORD=ChangeThisPostgresPass valkey: image: valkey/valkey:trixie container_name: mbin-tests-valkey restart: unless-stopped ports: - '6380:6379' healthcheck: test: ['CMD', 'redis-cli', 'ping'] ================================================ FILE: docker/valkey.conf ================================================ # NETWORK timeout 300 tcp-keepalive 300 # MEMORY MANAGEMENT maxmemory 1gb maxmemory-policy volatile-ttl # LAZY FREEING lazyfree-lazy-eviction yes lazyfree-lazy-expire yes lazyfree-lazy-server-del yes replica-lazy-flush yes # THREADED I/O io-threads 4 io-threads-do-reads yes # DISABLE SNAPSHOTS save "" ================================================ FILE: docs/01-user/01-user_guide.md ================================================ # User guide Mbin is a decentralized content aggregator and microblogging platform running on the Fediverse network. It can communicate with many other ActivityPub services, including Mastodon, Lemmy, Pleroma, Peertube. The initiative aims to promote a free and open internet. ## Introduction The platform is divided into thematic categories called magazines. By default, any user can create their own magazine and automatically become its owner. Then they receive a number of administrative tools that will help them personalize and moderate the magazine, including appointing moderators among other interested users. Content from the Fediverse is also cataloged based on groups or tags. A registered user can follow magazines, other users or domains and create his own personalized homepage. There is also the option to block undesired topics. Content can be posted on the main page - external links and more relevant articles or on microblog section - aggregating short posts. All content can be additionally categorized and labeled. There is a good facility to search for interesting topics and people, something that distinguishes mbin. The platform is equally suitable for a small personal instance for friends and family, a school or university community, company platform or a general instance with thousands of active users. ## User guide ### Customization Everyone has the ability to customize the appearance to suit your preferences. In the sidebar, you'll find an options button that allows you to adjust a variety of settings, including the ability to choose from several templates, enabling automatic refreshing of posts and comments, activating infinite scroll, and enabling automatic media previews. By using these options, you can completely transform the appearance of the platform to fit your personal needs. Whether you prefer a minimalist design or a more colorful and lively look, you can easily make the changes that will make your experience on platform more enjoyable. So don't be afraid to experiment with the various options available in the sidebar. You might be surprised by how much you can change the appearance of the platform to suit your preferences. (pic1) ### Register account The process of registering for a user account on a platform usually involves providing a username (which will also serve as your identifier in the fediverse), password, and email address to receive an activation link. Another option is to create an account through social media platforms such as Google, Facebook, Github, or PrivacyPortal. In this case, you can use your social media login credentials to sign up, but you will need to visit your user panel and set up your username before you can take any actions on the platform. However, **you will have only up to an hour after registration ** to set up your default username before this option expires go to (Settings > Profile). (pic2) ### User settings You are now ready to start using /mbin to connect with others. You can access your account settings at any time by clicking on your username located in the header. (pic3) We've included a wide range of options that will allow you to customize your experience. Take your time to check all the options. - **General:** In this section, you can set your preferred home page (all, subscribed, moderated, favorites), hide adult content, set user tagging options, adjust privacy settings, and configure notification settings. - **Profile:** Here, you can write a few words about yourself (which will be visible in the "People" section), add an avatar and cover image. - **Email:** In this section, you can change your email address. After changing to a new email, you will receive an activation link. - **Password:** In this section, you can change your account password. - **Blocks:** Here, you can manage blocked accounts, magazines, and domains. - **Subscriptions:** In this section, you can manage subscriptions to other user accounts, magazines, and domains. - **Reports:** In this section, you can manage reports from moderated magazines. - **Statistics:** Here, you can find some charts and numbers related to your account. ### Feed Timelines ### Fediverse ================================================ FILE: docs/01-user/02-FAQ.md ================================================ # FAQ ## What is Mbin? Mbin is an _open-source federated link aggregation, content rating and discussion_ software that is built on top of _ActivityPub_. ## What is ActivityPub (AP)? ActivityPub is a open standard protocol that empowers the creation of decentralized social networks, allowing different servers to interact and share content while giving users control over their data. It fosters a more user-centric and distributed approach to social networking, promoting interoperability across platforms and safeguarding user privacy and choice. This protocol is vital for building a more open, inclusive, and user-empowered digital social landscape. ## I have an issue! You can [join our Matrix community](https://matrix.to/#/#mbin:melroy.org) and ask for help, and/or make an [issue ticket](https://github.com/MbinOrg/mbin/issues) in GitHub if that adds value (always check for duplicates). ================================================ FILE: docs/01-user/README.md ================================================ # User Thanks for using Mbin! Do you want to learn more? See our [user guide](01-user_guide.md) and [FAQ](02-FAQ.md) pages. ================================================ FILE: docs/02-admin/01-installation/01-bare_metal.md ================================================ # Bare Metal/VM Installation Below is a step-by-step guide of the process for creating your own Mbin instance from the moment a new VPS/VM is created or directly on bare-metal. This is a preliminary outline that will help you launch an instance for your own needs. This guide is aimed for Debian / Ubuntu distribution servers, but it could run on any modern Linux distro. This guide will however uses the `apt` commands. > [!NOTE] > In this document a few services that are specific to the bare metal installation are configured. > You do need to follow the configuration guide as well. It describes the configuration of services shared between bare metal and docker. ## Minimum hardware requirements - **vCPU:** 4 virtual cores (>= 2GHz, _more is recommended_ on larger instances) - **RAM:** 6GB (_more is recommended_ for large instances) - **Storage:** 40GB (_more is recommended_, especially if you have a lot of remote/local magazines and/or have a lot of (local) users) You can start with a smaller server and add more resources later if you are using a VPS for example. Our _recommendation_ is to have 12 vCPUs with 32GB of RAM. ## Software Requirements - Debian 12 or Ubuntu 22.04 LTS or later - PHP v8.3 or higher - NodeJS v22 or higher - Valkey / KeyDB / Redis (pick one) - PostgreSQL - Supervisor - RabbitMQ - AMQProxy - Nginx / OpenResty (pick one) - _Optionally:_ Mercure This guide will show you how-to install and configure all of the above. Except for Mercure and Nginx, for Mercure see the [optional features page](../03-optional-features/README.md). > [!TIP] > Once the installation is completed, also check out the [additional configuration guides](../02-configuration/README.md) (including the Nginx setup). ## System Prerequisites Bring your system up-to-date: ```bash sudo apt-get update && sudo apt-get upgrade -y ``` Install prequirements: ```bash sudo apt-get install lsb-release ca-certificates curl wget unzip gnupg apt-transport-https software-properties-common python3-launchpadlib git redis-server postgresql postgresql-contrib nginx acl -y ``` On **Ubuntu 22.04 LTS** or older, prepare latest PHP package repositoy (8.4) by using a Ubuntu PPA (this step is optional for Ubuntu 23.10 or later) via: ```bash sudo add-apt-repository ppa:ondrej/php -y ``` On **Debian 12** or later, you can install the latest PHP package repository (this step is optional for Debian 13 or later) via: ```bash sudo apt-get -y install lsb-release ca-certificates curl sudo curl -sSLo /tmp/debsuryorg-archive-keyring.deb https://packages.sury.org/debsuryorg-archive-keyring.deb sudo dpkg -i /tmp/debsuryorg-archive-keyring.deb sudo sh -c 'echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list' ``` Install _PHP 8.4_ with the required additional PHP extensions: ```bash sudo apt-get update sudo apt-get install php8.4 php8.4-common php8.4-fpm php8.4-cli php8.4-amqp php8.4-bcmath php8.4-pgsql php8.4-gd php8.4-curl php8.4-xml php8.4-redis php8.4-mbstring php8.4-zip php8.4-bz2 php8.4-intl php8.4-bcmath -y ``` > [!NOTE] > If you are upgrading to PHP 8.3 from an older version, please re-review the [PHP configuration](#php) section of this guide as existing `ini` settings are NOT automatically copied to new versions. Additionally review which php-fpm version is configured in your Nginx site. > [!IMPORTANT] > **Never** even install `xdebug` PHP extension in production environments. Even if you don't enabled it but only installed `xdebug` can give massive performance issues. Install Composer: ```bash sudo curl -sS https://getcomposer.org/installer -o /tmp/composer-setup.php sudo php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer ``` ## Nginx / OpenResty Mbin bare metal setup requires a reverse proxy called Nginx (or OpenResty) to be installed and configured correctly. This is a requirement for Mbin to work safe, properly and to scale well. For Nginx/OpenResty setup see the [Nginx configuration](../02-configuration/02-nginx.md). ## Firewall If you have a firewall installed (or you're behind a NAT), be sure to open port `443` for the web server. As said above, Mbin should run behind a reverse proxy like Nginx or OpenResty. ## Install Node.JS (frontend tools) 1. Prepare & download keyring: > [!NOTE] > This assumes you already installed all the prerequisites packages from the "System prerequisites" chapter. 2. Setup the Nodesource repository: ```bash curl -fsSL https://deb.nodesource.com/setup_24.x | sudo bash - ``` 3. Install Node.JS: ```bash sudo apt-get install nodejs -y ``` ## Create new user ```bash sudo adduser mbin sudo usermod -aG sudo mbin sudo usermod -aG www-data mbin sudo su - mbin ``` ## Create folder ```bash sudo mkdir -p /var/www/mbin cd /var/www/mbin sudo chown mbin:www-data /var/www/mbin ``` ## Generate Secrets > [!NOTE] > This will generate several valid tokens for the Mbin setup, you will need quite a few. ```bash for counter in {1..2}; do node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"; done && for counter in {1..3}; do node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"; done ``` ## First setup steps ### Clone git repository ```bash cd /var/www/mbin git clone https://github.com/MbinOrg/mbin.git . ``` > [!TIP] > You might now want to switch to the latest stable release tag instead of using the `main` branch. > Try: `git checkout v1.7.4` (v1.7.4 might **not** be the latest version: [lookup the latest version](https://github.com/MbinOrg/mbin/releases)) ### Create & configure media directory ```bash cd /var/www/mbin mkdir public/media sudo chmod -R 775 public/media sudo chown -R mbin:www-data public/media ``` ### Configure `var` directory Create & set permissions to the `var` directory (used for cache and log files): ```bash cd /var/www/mbin mkdir var # See also: https://symfony.com/doc/current/setup/file_permissions.html # if the following commands don't work, try adding `-n` option to `setfacl` HTTPDUSER=$(ps axo user,comm | grep -E '[a]pache|[h]ttpd|[_]www|[w]ww-data|[n]ginx' | grep -v root | head -1 | cut -d\ -f1) # Set permissions for future files and folders sudo setfacl -dR -m u:"$HTTPDUSER":rwX -m u:$(whoami):rwX var # Set permissions on the existing files and folders sudo setfacl -R -m u:"$HTTPDUSER":rwX -m u:$(whoami):rwX var ``` ### The dot env file The `.env` file holds a lot of environment variables and is the main point for configuring mbin. We suggest you place your variables in the `.env.local` file and have a 'clean' default one as the `.env` file. Each time this documentation talks about the `.env` file be sure to edit the `.env.local` file if you decided to use that. > In all environments, the following files are loaded if they exist, the latter taking precedence over the former: > > - .env contains default values for the environment variables needed by the app > - .env.local uncommitted file with local overrides Make a copy of the `.env.example` to `.env` and `.env.local` and edit the `.env.local` file: ```bash cp .env.example .env cp .env.example .env.local nano .env.local ``` #### Service Passwords Make sure you have substituted all the passwords and configured the basic services in `.env` file. > [!NOTE] > The snippet below are to variables inside the `.env` file. Using the keys generated in the section above "Generating Secrets" fill in the values. You should fully review this file to ensure everything is configured correctly. ```ini REDIS_PASSWORD="{!SECRET!!KEY!-32_1-!}" APP_SECRET="{!SECRET!!KEY-16_1-!}" POSTGRES_PASSWORD={!SECRET!!KEY!-32_2-!} RABBITMQ_PASSWORD="{!SECRET!!KEY!-16_2-!}" MERCURE_JWT_SECRET="{!SECRET!!KEY!-32_3-!}" ``` #### Other important `.env` configs: ```ini # Configure your media URL correctly: KBIN_STORAGE_URL=https://domain.tld/media # Ubuntu 22.04 installs PostgreSQL v14 by default, Debian 12 PostgreSQL v15 is the default POSTGRES_VERSION=14 # Configure email, eg. using SMTP MAILER_DSN=smtp://127.0.0.1 # When you have a local SMTP server listening # But if already have Postfix configured, just use sendmail: MAILER_DSN=sendmail://default # Or Gmail (%40 = @-sign) use: MAILER_DSN=gmail+smtp://user%40domain.com:pass@smtp.gmail.com # Or remote SMTP with TLS on port 587: MAILER_DSN=smtp://username:password@smtpserver.tld:587?encryption=tls&auth_mode=log # Or remote SMTP with SSL on port 465: MAILER_DSN=smtp://username:password@smtpserver.tld:465?encryption=ssl&auth_mode=log ``` ### OAuth2 keys for API credential grants 1. Create an RSA key pair using OpenSSL: ```bash mkdir ./config/oauth2/ # If you protect the key with a passphrase, make sure to remember it! # You will need it later openssl genrsa -des3 -out ./config/oauth2/private.pem 4096 openssl rsa -in ./config/oauth2/private.pem --outform PEM -pubout -out ./config/oauth2/public.pem ``` 2. Generate a random hex string for the OAuth2 encryption key: ```bash openssl rand -hex 16 ``` 3. Add the public and private key paths to `.env`: ```ini OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/oauth2/private.pem OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/oauth2/public.pem OAUTH_PASSPHRASE= OAUTH_ENCRYPTION_KEY= ``` See also: [Mbin config files](../02-configuration/01-mbin_config_files.md) for more configuration options. ## Service Configuration ### PHP Edit some PHP settings within your `php.ini` file: ```bash sudo nano /etc/php/8.4/fpm/php.ini ``` ```ini ; Maximum execution time of each script, in seconds max_execution_time = 60 ; Both max file size and post body size are personal preferences upload_max_filesize = 12M post_max_size = 12M ; Remember the memory limit is per child process memory_limit = 512M ; maximum memory allocated to store the results realpath_cache_size = 4096K ; save the results for 10 minutes (600 seconds) realpath_cache_ttl = 600 ``` Optionally also enable OPCache for improved performances with PHP (recommended for both fpm and cli ini files): ```ini opcache.enable = 1 opcache.enable_cli = 1 opcache.preload = /var/www/mbin/config/preload.php opcache.preload_user = www-data ; Memory consumption (in MBs), personal preference opcache.memory_consumption = 512 ; Internal string buffer (in MBs), personal preference opcache.interned_strings_buffer = 128 opcache.max_accelerated_files = 100000 opcache.validate_timestamps = 0 ; Enable PHP JIT with all optimizations opcache.jit = 1255 opcache.jit_buffer_size = 500M ``` > [!CAUTION] > Be aware that activating `opcache.preload` can lead to errors if you run multiple sites > (because of re-declaring classes). More info: [Symfony Performance docs](https://symfony.com/doc/current/performance.html) Edit your PHP `www.conf` file as well, to increase the amount of PHP child processes (optional): ```bash sudo nano /etc/php/8.4/fpm/pool.d/www.conf ``` With the content (these are personal preferences, adjust to your needs): ```ini pm = dynamic pm.max_children = 70 pm.start_servers = 10 pm.min_spare_servers = 5 pm.max_spare_servers = 10 ``` Be sure to restart (or reload) the PHP-FPM service after you applied any changing to the `php.ini` file: ```bash sudo systemctl restart php8.4-fpm.service ``` ### Composer Setup composer in production mode: ```bash composer install --no-dev composer dump-env prod APP_ENV=prod APP_DEBUG=0 php bin/console cache:clear composer clear-cache ``` > [!CAUTION] > When running Symfony in _development mode_, your instance may _expose sensitive information_ to the public, > including database credentials, through the debug toolbar and stack traces. > **NEVER** expose your development instance to the Internet — doing so can lead to serious security risks. ### Caching You can choose between either Valkey, KeyDB or Redis. > [!TIP] > More Valkey/KeyDB/Redis fine-tuning settings can be found in the [Valkey / KeyDB / Redis configuration guide](../02-configuration/05-redis.md). #### Valkey / KeyDB or Redis Edit `redis.conf` file (or the corresponding Valkey or KeyDB config file): ```bash sudo nano /etc/redis/redis.conf # Search on (ctrl + w): requirepass foobared # Remove the #, change foobared to the new {!SECRET!!KEY!-32_1-!} password, generated earlier # Search on (ctrl + w): supervised no # Change no to systemd, considering Ubuntu is using systemd ``` Save and exit (ctrl+x) the file. Restart Redis: ```bash sudo systemctl restart redis.service ``` Within your `.env` file set your Redis password: ```ini REDIS_PASSWORD={!SECRET!!KEY!-32_1-!} REDIS_DNS=redis://${REDIS_PASSWORD}@$127.0.0.1:6379 # Or if you want to use socket file: #REDIS_DNS=redis://${REDIS_PASSWORD}/var/run/redis/redis-server.sock # Or KeyDB socket file: #REDIS_DNS=redis://${REDIS_PASSWORD}/var/run/keydb/keydb.sock ``` #### KeyDB [KeyDB](https://github.com/Snapchat/KeyDB) is a fork of Redis. If you wish to use KeyDB instead, that is possible. Do **NOT** run both Redis & KeyDB, just pick one. After KeyDB run on the same default port 6379 (IANA #815344). Be sure you disabled redis first: ```bash sudo systemctl stop redis sudo systemctl disable redis ``` Or even removed Redis: `sudo apt purge redis-server` For Debian/Ubuntu you can install KeyDB package repository via: ```bash echo "deb https://download.keydb.dev/open-source-dist $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/keydb.list sudo wget -O /etc/apt/trusted.gpg.d/keydb.gpg https://download.keydb.dev/open-source-dist/keyring.gpg sudo apt update sudo apt install keydb ``` During the install you can choose between different installation methods, I advice to pick: "keydb", which comes with systemd files as well as the CLI tools (eg. `keydb-cli`). Start & enable the service if it isn't already: ```bash sudo systemctl start keydb-server sudo systemctl enable keydb-server ``` Configuration file is located at: `/etc/keydb/keydb.conf`. See also: [config documentation](https://docs.keydb.dev/docs/config-file). For example, you can also configure Unix socket files if you wish: ```ini unixsocket /var/run/keydb/keydb.sock unixsocketperm 777 ``` Optionally, if you want to set a password with KeyDB, _also add_ the following option to the bottom of the file: ```ini # Replace {!SECRET!!KEY!-32_1-!} with the password generated earlier requirepass "{!SECRET!!KEY!-32_1-!}" ``` ### PostgreSQL (Database) Create new `mbin` database user, using the password, `{!SECRET!!KEY!-32_2-!}`, you generated earlier: ```bash sudo -u postgres createuser --createdb --createrole --pwprompt mbin ``` Create tables and database structure: ```bash cd /var/www/mbin php bin/console doctrine:database:create php bin/console doctrine:migrations:migrate ``` > [!IMPORTANT] > Check out the [PostgreSQL configuration page](../02-configuration/04-postgresql.md). You should not run the default PostgreSQL configuration in production! ## Message Handling ### RabbitMQ RabbitMQ is a feature rich, multi-protocol messaging and streaming broker, used by Mbin to process outgoing and incoming messages. Read also [What is RabbitMQ](../FAQ.md#what-is-rabbitmq) and [Symfony Messenger Queues](../04-running-mbin/04-messenger.md) for more information. #### Installing RabbitMQ See also: [RabbitMQ Install](https://www.rabbitmq.com/docs/install-debian#apt-quick-start). > [!NOTE] > This assumes you already installed all the prerequisites packages from the "System prerequisites" chapter. ```bash ## Team RabbitMQ's signing key curl -1sLf "https://keys.openpgp.org/vks/v1/by-fingerprint/0A9AF2115F4687BD29803A206B73A36E6026DFCA" | sudo gpg --dearmor | sudo tee /usr/share/keyrings/com.rabbitmq.team.gpg > /dev/null ## Add apt repositories maintained by Team RabbitMQ sudo tee /etc/apt/sources.list.d/rabbitmq.list < /dev/null . /etc/os-release echo "deb [signed-by=/usr/share/keyrings/amqproxy.gpg] https://packagecloud.io/cloudamqp/amqproxy/$ID $VERSION_CODENAME main" | sudo tee /etc/apt/sources.list.d/amqproxy.list sudo apt-get update sudo apt-get install amqproxy ``` ### Configure Queue Messenger Handler ```bash cd /var/www/mbin nano .env ``` We recommend to use RabbitMQ together with AMQProxy, AMQProxy is listening on port `5673` by default (you could also directly use RabbitMQ, but that is *not* recommended): ```ini # Use RabbitMQ (recommended for production): RABBITMQ_PASSWORD=!ChangeThisRabbitPass! # Use RabbitMQ with AMQProxy (port 5673, recommended for production): MESSENGER_TRANSPORT_DSN=amqp://mbin:${RABBITMQ_PASSWORD}@127.0.0.1:5673/%2f/messages # Directly connect to RabbitMQ, without proxy (port 5672) #MESSENGER_TRANSPORT_DSN=amqp://mbin:${RABBITMQ_PASSWORD}@127.0.0.1:5672/%2f/messages # or Redis/KeyDB: #MESSENGER_TRANSPORT_DSN=redis://${REDIS_PASSWORD}@127.0.0.1:6379/messages # or PostgreSQL Database (Doctrine): #MESSENGER_TRANSPORT_DSN=doctrine://default ``` ### Setup Supervisor We use Supervisor to run our background workers, aka. "Messengers", which are processes that work together with RabbitMQ to consume the actual data. Install Supervisor: ```bash sudo apt-get install supervisor ``` Configure the messenger jobs: ```bash sudo nano /etc/supervisor/conf.d/messenger-worker.conf ``` With the following content: ```ini [program:messenger] command=php /var/www/mbin/bin/console messenger:consume scheduler_default old async outbox deliver inbox resolve receive failed --time-limit=3600 #stdout_logfile=NONE #redirect_stderr=true user=www-data numprocs=6 startsecs=0 autostart=true autorestart=true startretries=10 process_name=%(program_name)s_%(process_num)02d ``` > [!IMPORTANT] > Uncomment the `stdout_logfile` and `redirect_stderr` lines if you do **not** want the Supervisor worker logs being written to `/var/log/supervisor`. After all the same log entries will be written to the Mbin production log. > [!NOTE] > You can increase the number of running messenger jobs if your queue is building up (i.e. more messages are coming in than your messengers can handle). Save and close the file. Restart supervisor jobs: ```bash sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start all ``` > [!TIP] > If you wish to restart your supervisor jobs in the future, use: > > ```bash > sudo supervisorctl restart all > ``` ================================================ FILE: docs/02-admin/01-installation/02-docker.md ================================================ # Docker Installation ## Minimum hardware requirements - **vCPU:** 4 virtual cores (>= 2GHz, _more is recommended_ on larger instances) - **RAM:** 6GB (_more is recommended_ for large instances) - **Storage:** 40GB (_more is recommended_, especially if you have a lot of remote/local magazines and/or have a lot of (local) users) You can start with a smaller server and add more resources later if you are using a VPS for example. ## System Prerequisites - Docker Engine - Docker Compose V2 > If you are using Compose V1, replace `docker compose` with `docker-compose` in those commands below. ### Docker Install The most convenient way to install docker is using an official [convenience script](https://docs.docker.com/engine/install/ubuntu/#install-using-the-convenience-script) provided at [get.docker.com](https://get.docker.com/): ```bash curl -fsSL https://get.docker.com -o get-docker.sh sudo sh get-docker.sh ``` Alternatively, you can follow the official [Docker install documentation](https://docs.docker.com/engine/install/) for your platform. Once Docker is installed on your system, it is recommended to create a `docker` group and add it to your user: ```bash sudo groupadd docker sudo usermod -aG docker $USER ``` ## Mbin Installation ### Preparation Clone git repository: ```bash git clone https://github.com/MbinOrg/mbin.git cd mbin ``` ### Environment configuration Use either the automatic environment setup script _OR_ manually configure the `.env`, `compose.override.yaml`, and OAuth2 keys. Select one of the two options. > [!TIP] > Everything configured for your specific instance is in `.env`, `compose.override.yaml`, and `storage/` (assuming you haven't modified anything else). If you'd like to backup, or even completely reset/delete your instance, then these are the files to do so with. #### Automatic setup script Run the setup script and pass in a mode (either `prod` or `dev`) and your domain (which can be `localhost` if you plan to just test locally): ```bash ./docker/setup.sh prod mbin.domain.tld ``` > [!NOTE] > Once the script has been run, you will not be able to run it again, in order to prevent data loss. You can always edit the `.env` and `compose.override.yaml` files manually if you'd like to make changes. Continue on to the [_Docker image preparation_](#docker-image-preparation) section for the next steps. #### Manually configure `.env` and `compose.override.yaml` Create config files and storage directories: ```bash cp .env.example_docker .env touch compose.override.yaml mkdir -p storage/{caddy_config,caddy_data,media,messenger_logs,oauth,php_logs,postgres,rabbitmq_data,rabbitmq_logs} ``` 1. Choose your Valkey password, PostgreSQL password, RabbitMQ password, and Mercure password. 2. Place the passwords in the corresponding variables in `.env`. 3. Update the `SERVER_NAME`, `KBIN_DOMAIN` and `KBIN_STORAGE_URL` in `.env`. 4. Update `APP_SECRET` in `.env`, see the note below to generate one. 5. Update `MBIN_USER` in `.env` to match your user and group id (`id -u` & `id -g`). 6. _Optionally_: Use a newer PostgreSQL version. Update/set the `POSTGRES_VERSION` variable in your `.env`. > [!NOTE] > To generate a random password or secret, use the following command: > > ```bash > tr -dc A-Za-z0-9 < /dev/urandom | head -c 32 && echo > ``` ##### Configure OAuth2 keys 1. Create an RSA key pair using OpenSSL: ```bash # If you protect the key with a passphrase, make sure to remember it! # You will need it later openssl genrsa -des3 -out ./storage/oauth/private.pem 4096 openssl rsa -in ./storage/oauth/private.pem --outform PEM -pubout -out ./storage/oauth/public.pem ``` 2. Generate a random hex string for the OAuth2 encryption key: ```bash openssl rand -hex 16 ``` 3. Add the public and private key paths to `.env`: ```env OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/oauth2/private.pem OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/oauth2/public.pem OAUTH_PASSPHRASE= OAUTH_ENCRYPTION_KEY= ``` ### Docker image preparation > [!NOTE] > If you're using a version of Docker Engine earlier than 23.0, run `export DOCKER_BUILDKIT=1`, prior to building the image. This does not apply to users running Docker Desktop. More info can be found [here](https://docs.docker.com/build/buildkit/#getting-started) Use the Mbin provided Docker image (default) _OR_ build the docker image locally. Select one of the two options. The default is to use our prebuilt images from [ghcr.io](https://github.com/MbinOrg/mbin/pkgs/container/mbin). Reference the next section if you'd like to build the Docker image locally instead. > [!IMPORTANT] > In **production** a recommended practice is to pin the image tag to a specific release (example: v1.8.2) _instead_ of using `latest`. > Pinning the docker image version can be done by editing the `compose.override.yaml` file and uncommenting the following lines (update the version number to one you want to pin to and is available on [ghcr.io](https://github.com/MbinOrg/mbin/pkgs/container/mbin)): ```yaml services: php: image: ghcr.io/mbinorg/mbin:v1.8.2 messenger: image: ghcr.io/mbinorg/mbin:v1.8.2 ``` #### Build your own image If you want to build your own image, add `pull_policy: build` to both the `php` and `messenger` services in `compose.override.yaml`: ```yaml services: php: pull_policy: build messenger: pull_policy: build ``` Once that's done, run `docker compose build --no-cache` in order to build the Mbin Docker image. ### Uploaded media files Uploaded media files (e.g. photos uploaded by users) will be stored on the host directory `storage/media`. They will be served by the web server in the `php` container as static files. Make sure `KBIN_STORAGE_URL` in your `.env` configuration file is set to be `https://yourdomain.tld/media`. You can also serve those media files on another server by mirroring the files at `storage/media` and changing `KBIN_STORAGE_URL` correspondingly. > [!TIP] > S3 can also be utilized to store images in the cloud. Just fill in the `S3_` fields in `.env` and Mbin will take care of the rest. See [this page](../03-optional-features/06-s3_storage.md) for more info. ### Running behind a reverse proxy A reverse proxy is unneeded with this Docker setup, as HTTPS is automatically applied through the built in Caddy server. If you'd like to use a reverse proxy regardless, then you'll need to make a few changes: 1. In `.env`, change your `SERVER_NAME` to `":80"`: ```env SERVER_NAME=":80" ``` 2. In `compose.override.yaml`, add `CADDY_GLOBAL_OPTIONS: auto_https off` to your php service environment: ```yaml services: php: environment: CADDY_GLOBAL_OPTIONS: auto_https off ``` 3. Also in `compose.override.yaml`, add `!override` to your php `ports` to override the current configuration and add your own based on what your reverse proxy needs: ```yaml services: php: ports: !override - 8080:80 ``` In this example, port `8080` will connect to your Mbin server. 4. Make sure your reverse proxy correctly sets the common `X-Forwarded` headers (especially `X-Forwarded-Proto`). This is needed so that both rate limiting works correctly, but especially so that your server can detect its correct outward facing protocol (HTTP vs HTTPS). > [!WARNING] > `TRUSTED_PROXIES` in `.env` needs to be a valid value (which is the default) in order for your server to work correctly behind a reverse proxy. > [!TIP] > In order to verify your server is correctly detecting it's public protocol (HTTP vs HTTPS), visit `/.well-known/nodeinfo` and look at which protocol is being used in the `href` fields. A public server should always be using HTTPS and not contain port numbers (i.e., `https://DOMAINHERE/`). ### Additional configuration (Optional) If you run a larger Mbin instance, its recommended to increase the `shm_size` value of the `postgres` service (first try the default `2gb`!). Although you can also decrease the number if you wish on smaller instances. `shm_size` sets the size of the shared memory (`/dev/shm`) and used for dynamic memory allocation. PostgreSQL is using this for buffering the write-ahead logs (also known as "WALL buffer"). The following step is **optional** and also depends on how much RAM you have left as well as how many parallel workers, table sizes, expected concurrent users and more. You can first use the default `2gb`, which should be sufficient, however below is explained how to further increase this number. In `compose.override.yaml`, add `shm_size` to the `postgres` service (`4gb` is an example here): ```yaml services: postgres: shm_size: '4gb' ``` ## Running the containers By default `docker compose` will execute the `compose.yaml` and `compose.override.yaml` files. Run the container in the background (`-d` means detach, but this can also be omitted for testing or debugging purposes): ```bash docker compose up -d ``` See your running containers via: `docker ps`. This docker setup comes with automatic HTTPS support. Assuming you have set up your DNS and firewall (allow ports `80` & `443`) configured correctly, then you should be able to access the new instance via your domain. > [!NOTE] > If you specified `localhost` as your domain, then a self signed HTTPS certificate is provided and you should be able to access your instance here: [https://localhost](https://localhost). You can also access the RabbitMQ management UI via [http://localhost:15672](http://localhost:15672). > [!WARNING] > Be sure not to forget the [Mbin first setup](../04-running-mbin/01-first_setup.md) instructions in order to create your admin user, `random` magazine, and AP & Push Notification keys. ================================================ FILE: docs/02-admin/01-installation/README.md ================================================ # Installation You can choose between server production installations: - [Bare metal/VM installation](01-bare_metal.md) Or: - [Docker installation](02-docker.md) ================================================ FILE: docs/02-admin/02-configuration/01-mbin_config_files.md ================================================ # Mbin configuration files These are additional configuration YAML file changes in the `config` directory. ## Image redirect response code > [!NOTE] > The Docker setup already utilizes permanent image redirects, so you can safely ignore the following. Assuming you **are using Nginx** (as described above, with the correct configs), you can reduce the server load by changing the image redirect response code from `302` to `301`, which allows the client to cache the complete response. Edit the following file (from the root directory of Mbin): ```bash nano config/packages/liip_imagine.yaml ``` And now change: `redirect_response_code: 302` to: `redirect_response_code: 301`. If you are experience image loading issues, validate your Nginx configuration or revert back your changes to `302`. --- > [!TIP] > There are also other configuration files, eg. `config/packages/monolog.yaml` where you can change logging settings if you wish, but this is not required (these defaults are fine for production). ================================================ FILE: docs/02-admin/02-configuration/02-nginx.md ================================================ # NGINX We will use NGINX as a reverse proxy between the public site and various backend services (static files, PHP and Mercure). ## General NGINX configs Generate DH parameters (used later): ```bash sudo openssl dhparam -dsaparam -out /etc/nginx/dhparam.pem 4096 ``` Set the correct permissions: ```bash sudo chmod 644 /etc/nginx/dhparam.pem ``` Edit the main NGINX config file: `sudo nano /etc/nginx/nginx.conf` with the following content within the `http {}` section (replace when needed): ```nginx ssl_protocols TLSv1.2 TLSv1.3; # Requires nginx >= 1.13.0 else only use TLSv1.2 ssl_dhparam /etc/nginx/dhparam.pem; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305; ssl_prefer_server_ciphers off; ssl_ecdh_curve secp521r1:secp384r1:secp256k1; # Requires nginx >= 1.1.0 ssl_session_timeout 1d; ssl_session_cache shared:MozSSL:10m; # about 40000 sessions ssl_session_tickets off; # Requires nginx >= 1.5.9 ssl_stapling on; # Requires nginx >= 1.3.7 ssl_stapling_verify on; # Requires nginx => 1.3.7 # This is an example resolver configuration (replace the DNS IPs if you prefer) resolver 1.1.1.1 9.9.9.9 valid=300s; resolver_timeout 5s; # Gzip compression gzip on; gzip_disable msie6; gzip_vary on; gzip_comp_level 5; gzip_min_length 256; gzip_buffers 16 8k; gzip_proxied any; gzip_types text/css text/plain text/javascript text/cache-manifest text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy application/javascript application/json application/x-javascript application/ld+json application/xml application/xml+rss application/xhtml+xml application/x-font-ttf application/x-font-opentype application/vnd.ms-fontobject application/manifest+json application/rss+xml application/atom_xml application/vnd.geo+json application/x-web-app-manifest+json image/svg+xml image/x-icon image/bmp font/opentype; ``` ## Mbin Nginx Server Block ```bash sudo nano /etc/nginx/sites-available/mbin.conf ``` With the content: ```nginx upstream mercure { server 127.0.0.1:3000; keepalive 10; } # Map instance requests vs the rest map "$http_accept:$request" $mbinInstanceRequest { ~^.*:GET\ \/.well-known\/.+ 1; ~^.*:GET\ \/nodeinfo\/.+ 1; ~^.*:GET\ \/i\/actor 1; ~^.*:POST\ \/i\/inbox 1; ~^.*:POST\ \/i\/outbox 1; ~^.*:POST\ \/f\/inbox 1; ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:GET\ \/ 1; ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:GET\ \/f\/object\/.+ 1; default 0; } # Map user requests vs the rest map "$http_accept:$request" $mbinUserRequest { ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:GET\ \/u\/.+ 1; ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:POST\ \/u\/.+ 1; default 0; } # Map magazine requests vs the rest map "$http_accept:$request" $mbinMagazineRequest { ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:GET\ \/m\/.+ 1; ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:POST\ \/m\/.+ 1; default 0; } # Miscellaneous requests map "$http_accept:$request" $mbinMiscRequest { ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:GET\ \/reports\/.+ 1; ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:GET\ \/message\/.+ 1; ~^.*:GET\ \/contexts\..+ 1; default 0; } # Determine if a request should go into the regular log map "$mbinInstanceRequest$mbinUserRequest$mbinMagazineRequest$mbinMiscRequest" $mbinRegularRequest { 0000 1; # Regular requests default 0; # Other requests } map $mbinRegularRequest $mbin_limit_key { 0 ""; 1 $binary_remote_addr; } # Two stage rate limit (10 MB zone): 5 requests/second limit (=second stage) limit_req_zone $mbin_limit_key zone=mbin_limit:10m rate=5r/s; # Redirect HTTP to HTTPS server { server_name domain.tld; listen 80; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name domain.tld; root /var/www/mbin/public; index index.php; charset utf-8; # TLS ssl_certificate /etc/letsencrypt/live/domain.tld/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/domain.tld/privkey.pem; # Don't leak powered-by fastcgi_hide_header X-Powered-By; # Security headers add_header X-Frame-Options "DENY" always; add_header X-XSS-Protection "1; mode=block" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "same-origin" always; add_header X-Download-Options "noopen" always; add_header X-Permitted-Cross-Domain-Policies "none" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; client_max_body_size 20M; # Max size of a file that a user can upload # Two stage rate limit limit_req zone=mbin_limit burst=300 delay=200; # Error log (if you want you can add "warn" at the end of error_log to also log warnings) error_log /var/log/nginx/mbin_error.log; # Access logs access_log /var/log/nginx/mbin_access.log combined if=$mbinRegularRequest; access_log /var/log/nginx/mbin_instance.log combined if=$mbinInstanceRequest buffer=32k flush=5m; access_log /var/log/nginx/mbin_user.log combined if=$mbinUserRequest buffer=32k flush=5m; access_log /var/log/nginx/mbin_magazine.log combined if=$mbinMagazineRequest buffer=32k flush=5m; access_log /var/log/nginx/mbin_misc.log combined if=$mbinMiscRequest buffer=32k flush=5m; open_file_cache max=1000 inactive=20s; open_file_cache_valid 60s; open_file_cache_min_uses 2; open_file_cache_errors on; location / { # try to serve file directly, fallback to index.php try_files $uri /index.php$is_args$args; } location = /favicon.ico { access_log off; log_not_found off; } location = /robots.txt { allow all; access_log off; log_not_found off; } location /.well-known/mercure { proxy_pass http://mercure$request_uri; # Increase this time-out if you want clients have a Mercure connection open for longer (eg. 24h) proxy_read_timeout 2h; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto $scheme; } location ~ ^/index\.php(/|$) { default_type application/x-httpd-php; fastcgi_pass unix:/var/run/php/php-fpm.sock; fastcgi_split_path_info ^(.+\.php)(/.*)$; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; fastcgi_param DOCUMENT_ROOT $realpath_root; # Prevents URIs that include the front controller. This will 404: # http://domain.tld/index.php/some-path # Remove the internal directive to allow URIs like this internal; } # bypass thumbnail cache image files location ~ ^/media/cache/resolve { expires 1M; access_log off; add_header Cache-Control "public"; try_files $uri $uri/ /index.php?$query_string; } # Static assets location ~* \.(?:css(\.map)?|js(\.map)?|jpe?g|png|tgz|gz|rar|bz2|doc|pdf|ptt|tar|gif|ico|cur|heic|webp|tiff?|mp3|m4a|aac|ogg|midi?|wav|mp4|mov|webm|mpe?g|avi|ogv|flv|wmv|svgz?|ttf|ttc|otf|eot|woff2?)$ { expires 30d; add_header Access-Control-Allow-Origin "*"; add_header Cache-Control "public, no-transform"; access_log off; } # return 404 for all other php files not matching the front controller # this prevents access to other php files you don't want to be accessible. location ~ \.php$ { return 404; } # Deny dot folders and files, except for the .well-known folder location ~ /\.(?!well-known).* { deny all; } } ``` > [!TIP] > If you have multiple PHP versions installed. You can switch the PHP version that Nginx is using (`/var/run/php/php-fpm.sock`) via the the following command: > `sudo update-alternatives --config php-fpm.sock` > > Same is true for the PHP CLI command (`/usr/bin/php`), via the following command: > `sudo update-alternatives --config php` > [!WARNING] > If also want to configure your `www.domain.tld` subdomain; our advice is to use a HTTP 301 redirect from the `www` subdomain towards the root domain. Do _NOT_ try to setup a second instance (you want to _avoid_ that ActivityPub will see `www` as a separate instance). See Nginx example below: ```nginx # Example of a 301 redirect response for the www subdomain server { listen 80; server_name www.domain.tld; if ($host = www.domain.tld) { return 301 https://domain.tld$request_uri; } } server { listen 443 ssl; http2 on; server_name www.domain.tld; # TLS ssl_certificate /etc/letsencrypt/live/domain.tld/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/domain.tld/privkey.pem; # Don't leak powered-by fastcgi_hide_header X-Powered-By; return 301 https://domain.tld$request_uri; } ``` Enable the NGINX site, using a symlink: ```bash sudo ln -s /etc/nginx/sites-available/mbin.conf /etc/nginx/sites-enabled/ ``` Restart (or reload) NGINX: ```bash sudo systemctl restart nginx ``` ## Trusted Proxies If you are using a reverse proxy, you need to configure your trusted proxies to use the `X-Forwarded-For` header. Mbin already configures the following trusted headers: `x-forwarded-for`, `x-forwarded-proto`, `x-forwarded-port` and `x-forwarded-prefix`. Trusted proxies can be configured in the `.env` file (or your `.env.local` file): ```sh nano /var/www/mbin/.env ``` You can configure a single IP address and/or a range of IP addresses (this configuration should be sufficient if you are running Nginx yourself): ```ini # Change the IP range if needed, this is just an example TRUSTED_PROXIES=127.0.0.1,192.168.1.0/24 ``` Or if the IP address is dynamic, you can set the `REMOTE_ADDR` string which will be replaced at runtime by `$_SERVER['REMOTE_ADDR']`: ```ini TRUSTED_PROXIES=127.0.0.1,REMOTE_ADDR ``` > [!WARNING] > In this last example, be sure that you configure the web server to _not_ > respond to traffic from _any_ clients other than your trusted load balancers > (eg. within AWS this can be achieved via security groups). Finally run the `post-upgrade` script to dump the `.env` to the `.env.local.php` and clear any cache: ```sh ./bin/post-upgrade ``` More detailed info can be found at: [Symfony Trusted Proxies docs](https://symfony.com/doc/current/deployment/proxies.html) ## Media reverse proxy We suggest that you do not use this configuration: ```ini KBIN_STORAGE_URL=https://mbin.domain.tld/media ``` Instead we suggest to use a subdomain for serving your media files: ```ini KBIN_STORAGE_URL=https://media.mbin.domain.tld ``` That way you can let nginx cache media assets and seamlessly switch to an object storage provider later. ```bash sudo nano /etc/nginx/sites-available/mbin-media.conf ``` ```nginx proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=CACHE:10m inactive=7d max_size=10g; server { server_name media.mbin.domain.tld; root /var/www/mbin/public/media; listen 80; } ``` Make sure the `root /path` is correct (you may be using `/var/www/mbin/public`). Enable the NGINX site, using a symlink: ```bash sudo ln -s /etc/nginx/sites-available/mbin-media.conf /etc/nginx/sites-enabled/ ``` > [!TIP] > Before reloading nginx in a production environment you can run `nginx -t` to test your configuration. > If your configuration is faulty and you run `systemctl reload nginx` it will cause Nginx to stop instead of reloading cleanly. Run `systemctl reload nginx` so the site configuration is reloaded. For it to be a usable HTTPS site, you must run: `certbot --nginx` and select the media domain or supply your certificates manually. > [!TIP] > Don't forget to enable HTTP/2 by adding `http2 on;` after certbot ran (underneath the `listen 443 ssl;` line). It used to be part of the same line, however in recent NGINX versions `http2 on` is a separate directive for enabling the HTTP/2 protocol. ================================================ FILE: docs/02-admin/02-configuration/03-lets_encrypt.md ================================================ # Let's Encrypt (TLS) > [!TIP] > The Certbot authors recommend installing through snap as some distros' versions from APT tend to fall out-of-date; see https://eff-certbot.readthedocs.io/en/latest/install.html#snap-recommended for more. Install Snapd: ```bash sudo apt-get install snapd ``` Install Certbot: ```bash sudo snap install core; sudo snap refresh core sudo snap install --classic certbot ``` Add symlink: ```bash sudo ln -s /snap/bin/certbot /usr/bin/certbot ``` Follow the prompts to create TLS certificates for your domain(s). If you don't already have NGINX up, you can use standalone mode. ```bash sudo certbot certonly # Or if you wish not to use the standalone mode but the Nginx plugin: sudo certbot --nginx -d domain.tld ``` ================================================ FILE: docs/02-admin/02-configuration/04-postgresql.md ================================================ # PostgreSQL PostgreSQL is used as the database for Mbin. For production, you **do** want to change the default PostgreSQL settings (since the default settings are _not_ recommended). Edit your PostgreSQL configuration file (assuming you're running PostgreSQL v16 or up): ```bash sudo nano /etc/postgresql/16/main/postgresql.conf ``` These settings below are more **an indication and heavily depends on your server specifications**. As well as if you are running other services on the same server. However, the following settings are a good starting point when your serve is around 12 vCPUs and 32GB of RAM. Be sure to fune-tune these settings to your needs. ```ini # Increase max connections max_connections = 200 # Increase shared buffers shared_buffers = 8GB # Enable huge pages (Be sure to check the note down below in order to enable huge pages!) # This will fail if you didn't configure huge pages under Linux # (if you do NOT want to use huge pages, set it to: "try" instead of: "on") huge_pages = on # Increase work memory work_mem = 15MB # Increase maintenance work memory maintenance_work_mem = 2GB # Should be posix under Linux anyway, just to be sure... dynamic_shared_memory_type = posix # Increase the number of IO current disk operations (especially useful for SSDs) effective_io_concurrency = 200 # Increase the number of work processes (do not exceed your number of CPU cores) # Adjusting this setting, means you should also change: # max_parallel_workers, max_parallel_maintenance_workers and max_parallel_workers_per_gather max_worker_processes = 16 # Increase parallel workers per gather max_parallel_workers_per_gather = 4 max_parallel_maintenance_workers = 4 # Maximum number of work processes that can be used in parallel operations (we set it the same as max_worker_processes) # You should *not* increase this value more than max_worker_processes max_parallel_workers = 16 # Boost transaction speeds and reduce I/O wait for writes (with the risk of losing un-flushed data in case of a crash) # If you do not want to take that risk, keep it to: "on". synchronous_commit = off # Group write commits to combine multiple transactions by a single flush (this is a time delay in μs) commit_delay = 300 # Increase the checkpoint timeout (time between two checkpoints) to reduce the disk I/O # This will significantly reduce the disk I/O and speed-up the write times to disk. The only downside is time needed for crash recovery. checkpoint_timeout = 40min checkpoint_completion_target = 0.9 # Write ahead log sizes (so the WAL file can contain around 1 hour of data) max_wal_size = 10GB min_wal_size = 2GB # Query tuning # Set to 1.1 for SSDs. # Increase this number (eg. 4.0) if you are running on slow spinning disks random_page_cost = 1.1 # Increase the cache size, increasing the likelihood of index scans (if we have enough RAM memory) # Try to aim for: RAM size * 0.8 (on a dedicated DB server) effective_cache_size = 24GB ``` For reference check out [PGTune](https://pgtune.leopard.in.ua/) (this tool will **not** cover all the settings mentioned above, so be aware of that). > [!NOTE] > We try to set `huge_pages` to: `on` in PostgreSQL, in order to make this work you will need to [enable huge pages under Linux (click here)](https://www.enterprisedb.com/blog/tuning-debian-ubuntu-postgresql) as well! Please follow that guide. And play around with your kernel configurations. ================================================ FILE: docs/02-admin/02-configuration/05-redis.md ================================================ # Redis / KeyDB / Valkey This documentation is valid for both Redis as well as KeyDB and Valkey. Both Valkey and KeyDB are forks of Redis, but should work mostly in the same manner. ## Configure Redis Edit the Redis instance for Mbin: `sudo nano /etc/redis/redis.conf`: ```ruby # NETWORK timeout 300 tcp-keepalive 300 # MEMORY MANAGEMENT maxmemory 1gb maxmemory-policy volatile-ttl # LAZY FREEING lazyfree-lazy-eviction yes lazyfree-lazy-expire yes lazyfree-lazy-server-del yes replica-lazy-flush yes ``` Feel free to adjust the memory settings to your liking. > [!WARNING] > Mbin (more specifically Symfony RedisTagAwareAdapter) only support `noeviction` and `volatile-*` settings for the `maxmemory-policy` Redis setting. ## Multithreading Configure multiple threads in Redis/Valkey by setting the following two lines: ```ruby # THREADED I/O io-threads 4 io-threads-do-reads yes ``` However, when using **KeyDB**, you need to update the following line (`io-threads` doesn't exists in KeyDB): ```ruby # WORKER THREADS server-threads 4 ``` ## Redis as a cache _Optionally:_ If you are using this Redis instance only for Mbin as a cache, you can disable snapshots in Redis/Valkey/KeyDB. Which will no longer dump the database to disk and reduce the amount of disk space used as well the disk I/O. First comment out existing "save lines" in the Redis/Valkey/KeyDB configuration file: ```ruby #save 900 1 #save 300 10 #save 60 10000 ``` Then add the following line to disable snapshots fully: ```ruby save "" ``` ================================================ FILE: docs/02-admin/02-configuration/README.md ================================================ # Configuration These configuration guides can help you configure specific services in more detail. Assuming you have already Mbin installed and followed the installation guide. Currently, the following configuration guides are provided: - [Mbin config](./01-mbin_config_files.md) - [Nginx](./02-nginx.md) - [Let's Encrypt](./03-lets_encrypt.md) - [PostgreSQL](./04-postgresql.md) - [Valkey / KeyDB / Redis](./05-redis.md) ================================================ FILE: docs/02-admin/03-optional-features/01-mercure.md ================================================ # Mercure More info: [Mercure Website](https://mercure.rocks/), Mercure is used in Mbin for real-time communication between the server and the clients. ### Caddybundle Download and install Mercure (we are using [Caddyserver.com](https://caddyserver.com/download?package=github.com%2Fdunglas%2Fmercure) mirror to download Mercure): ```bash sudo wget "https://caddyserver.com/api/download?os=linux&arch=amd64&p=github.com%2Fdunglas%2Fmercure%2Fcaddy&idempotency=69982897825265" -O /usr/local/bin/mercure sudo chmod +x /usr/local/bin/mercure ``` Prepare folder structure with the correct permissions: ```bash cd /var/www/mbin mkdir -p metal/caddy sudo chmod -R 775 metal/caddy sudo chown -R mbin:www-data metal/caddy ``` [Caddyfile Global Options](https://caddyserver.com/docs/caddyfile/options) > [!NOTE] > Caddyfiles: The one provided should work for most people, edit as needed via the previous link. Combination of mercure.conf and Caddyfile Add new `Caddyfile` file: ```bash nano metal/caddy/Caddyfile ``` The content of the `Caddyfile`: ```conf { {$GLOBAL_OPTIONS} # No SSL needed auto_https off http_port {$HTTP_PORT} persist_config off log { # DEBUG, INFO, WARN, ERROR, PANIC, and FATAL level WARN output discard output file /var/www/mbin/var/log/mercure.log { roll_size 50MiB roll_keep 3 } format filter { wrap console fields { uri query { replace authorization REDACTED } } } } } {$SERVER_NAME:localhost} {$EXTRA_DIRECTIVES} route { mercure { # Transport to use (default to Bolt with max 1000 events) transport_url {$MERCURE_TRANSPORT_URL:bolt://mercure.db?size=1000} # Publisher JWT key publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG} # Subscriber JWT key subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG} # Workaround for now anonymous # Extra directives {$MERCURE_EXTRA_DIRECTIVES} } respond /healthz 200 respond "Not Found" 404 } ``` Ensure not random formatting errors in the Caddyfile ```bash mercure fmt metal/caddy/Caddyfile --overwrite ``` ### Supervisor Job We use supervisor for running the Mercure job: ```bash sudo nano /etc/supervisor/conf.d/mercure.conf ``` With the following content: ```ini [program:mercure] command=/usr/local/bin/mercure run --config /var/www/mbin/metal/caddy/Caddyfile process_name=%(program_name)s_%(process_num)s numprocs=1 environment=MERCURE_PUBLISHER_JWT_KEY="{!SECRET!!KEY!-32_3-!}",MERCURE_SUBSCRIBER_JWT_KEY="{!SECRET!!KEY!-32_3-!}",SERVER_NAME=":3000",HTTP_PORT="3000" directory=/var/www/mbin/metal/caddy autostart=true autorestart=true startsecs=5 startretries=10 user=www-data redirect_stderr=false stdout_syslog=true ``` Afterwards let supervisor reread the configuration and update the processing groups: ```bash sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start all ``` ================================================ FILE: docs/02-admin/03-optional-features/02-sso.md ================================================ # SSO (Single Sign On) Providers SSOs are used to simplify the registration flow. You authorize the server to use an existing account from one of the available SSO providers. Mbin supports a multitude of SSO providers: - Google - Facebook - GitHub - Keycloak - Zitadel - SimpleLogin - Discord - Authentik - Privacy Portal - Azure To enable an SSO provider you (usually) have to create a developer account on the specific platform, create an app and provide the app/client ID and a secret. These have to be entered in the correct environment variable in the `.env`|`.env.local` file ### Google https://developers.google.com/ ```ini OAUTH_GOOGLE_ID=AS2easdioh912 # your client ID OAUTH_GOOGLE_SECRET=sdfpsajh329ura39ßseaoßjf30u # your client secret ``` ### Facebook https://developers.facebook.com ```ini OAUTH_FACEBOOK_ID=AS2easdioh912 # your client ID OAUTH_FACEBOOK_SECRET=sdfpsajh329ura39ßseaoßjf30u # your client secret ``` ### GitHub You need a GitHub account, if you do no have one, yet, go and create one: https://github.com/signup 1. Go to https://github.com/settings/developers 2. Click on "New OAuth App" 3. Enter the app name, description and Homepage URL (just your instance URL) 4. Insert `https://YOURINSTANCE/oauth/github/verify` as the "Authorization callback URL" (replace `YOURINSTANCE` with the URL of your instance) 5. Scroll down and click "Register application" 6. Now you have the chance to upload an icon (at the bottom of the page) 7. Click "Generate a new client secret" 8. Insert the "Client ID" and the generated client secret into the `.env` file: ```ini OAUTH_GITHUB_ID=AS2easdioh912 # your client ID OAUTH_GITHUB_SECRET=sdfpsajh329ura39ßseaoßjf30u # your client secret ``` ### Keycloak Self-hosted, https://www.keycloak.org/ ```ini OAUTH_KEYCLOAK_ID=AS2easdioh912 # your client ID OAUTH_KEYCLOAK_SECRET=sdfpsajh329ura39ßseaoßjf30u # your client secret OAUTH_KEYCLOAK_URI= OAUTH_KEYCLOAK_REALM= OAUTH_KEYCLOAK_VERSION= ``` ### Zitadel Self-hosted, https://zitadel.com/ ```ini OAUTH_ZITADEL_ID=AS2easdioh912 # your client ID OAUTH_ZITADEL_SECRET=sdfpsajh329ura39ßseaoßjf30u # your client secret OAUTH_ZITADEL_BASE_URL= ``` ### SimpleLogin You need a SimpleLogin account, if you do not have one, yet, go and create one: https://app.simplelogin.io/auth/register 1. Go to https://app.simplelogin.io/developer and click on "New website" 2. Enter the name of your instance and the url to your instance 3. Choose an icon (if you want to) 4. Click on "OAuth Settings" on the right 5. Insert the client ID ("AppID / OAuth2 Client ID") and the client secret ("AppSecret / OAuth2 Client Secret") in your `.env` file ```ini OAUTH_SIMPLELOGIN_ID=gehirneimer.de-vycjfiaznc # your client ID OAUTH_SIMPLELOGIN_SECRET=fdiuasdfusdfsdfpsdagofweopf # your client secret ``` 6. Back in the browser, scroll down to "Authorized Redirect URIs" and click on "Add new uri" ### Discord You need a Discord account, if you do not have one, yet, go and create one: https://discord.com/register 1. Go to https://discord.com/developers/applications and create a new application. If you want, add an image and a description. 2. Click the "OAuth2" tab on the left 3. Under "Client information" click "Reset Secret" 4. The newly generated secret and the "Client ID" need to go in our `.env` file: ```ini OAUTH_DISCORD_ID=3245498543 # your client ID OAUTH_DISCORD_SECRET=xJHGApsadOPUIAsdoih # your client secret ``` 5. Back in the browser: click on "Add Redirect" 6. enter the URL: `https://YOURINSTANCE/oauth/discord/verify`, replace `YOURINSTANCE` with your instance domain 7. If you are on docker, restart the containers, on bare metal execute the `post-upgrade` script 8. When you go to the login page you should see a button to "Continue with Discord" ### Authentik Self-hosted, https://goauthentik.io/ ```ini OAUTH_AUTHENTIK_ID=3245498543 # your client ID OAUTH_AUTHENTIK_SECRET=xJHGApsadOPUIAsdoih # your client secret OAUTH_AUTHENTIK_BASE_URL= ``` ### Privacy Portal You need a Privacy Portal account, if you do not have one, yet, go and create one: https://app.privacyportal.org/ 1. Go to https://app.privacyportal.org/settings/developers and create a new application. Add a meaningful name. - Insert `https://YOURINSTANCE` as the "Homepage URL" (replace `YOURINSTANCE` with the URL of your instance). - Insert `https://YOURINSTANCE/oauth/privacyportal/verify` as the "Callback URL" (replace `YOURINSTANCE` with the URL of your instance). 2. Click "Register" to save the application. 3. You may change icon, homepage URL and callback URL in the "App info" tab. 4. Enable "Public access" in the "Access management" tab, so other Privacy Portal users can log into your instance. 5. In the "Credentials" tab, generate a new secret. This secret and the client ID from the same tab will go into your `.env` file: ```ini OAUTH_PRIVACYPORTAL_ID=3245498543 # your client ID OAUTH_PRIVACYPORTAL_SECRET=xJHGApsadOPUIAsdoih # your client secret ``` ### Azure https://login.microsoftonline.com ```ini OAUTH_AZURE_ID=3245498543 # your client ID OAUTH_AZURE_SECRET=xJHGApsadOPUIAsdoih # your client secret OAUTH_AZURE_TENANT= ``` ================================================ FILE: docs/02-admin/03-optional-features/03-captcha.md ================================================ # Captcha Go to [hcaptcha.com](https://www.hcaptcha.com) and create a free account. Make a sitekey and a secret. Add domain.tld to the sitekey. Optionally, increase the difficulty threshold. Making it even harder for bots. Edit your `.env` file: ```ini KBIN_CAPTCHA_ENABLED=true HCAPTCHA_SITE_KEY=sitekey HCAPTCHA_SECRET=secret ``` Then dump-env your configuration file: ```bash composer dump-env prod ``` or: ```bash composer dump-env dev ``` Finally, go to the admin panel, settings tab and check "Captcha enabled" and press "Save". ================================================ FILE: docs/02-admin/03-optional-features/04-user_application.md ================================================ # Manually Approving New Users Mbin allows you to manually approve new users before they can log into your server. If you want to manually approve users before they can log into your server, you can either tick the 'New users have to be approved by an admin before they can log in' checkbox in the admin settings. Or put this in the `.env` file: ```ini MBIN_NEW_USERS_NEED_APPROVAL=true ``` The admin will then see a new 'Signup request' panel in the admin interface where new user registrations will appear pending your approval or denial. When an administrator approves or denies an user application, the user will receive an email notification about the decision. ================================================ FILE: docs/02-admin/03-optional-features/05-image_metadata_cleaning.md ================================================ # Image metadata cleaning with `exiftool` It is possible to configure Mbin to remove meta-data from images. To use this feature, install `exiftool` (`libimage-exiftool-perl` package for Ubuntu/Debian) and make sure `exiftool` executable exist and and visible in PATH Available options in `.env`: ```bash # available modes: none, sanitize, scrub # can be set differently for user uploaded and external media EXIF_CLEAN_MODE_UPLOADED=sanitize EXIF_CLEAN_MODE_EXTERNAL=none # path to exiftool binary, leave blank for auto PATH search EXIF_EXIFTOOL_PATH= # max execution time for exiftool in seconds, defaults to 10 seconds EXIF_EXIFTOOL_TIMEOUT=10 ``` Available cleaning modes are: - `none`: no metadata cleaning occurs. - `sanitize`: GPS and serial number metadata is removed. This is the default for uploaded images. - `scrub`: most metadata is removed, except for the metadata required for proper image rendering and XMP IPTC attribution metadata. More detailed information can [be found in the source-code](https://github.com/MbinOrg/mbin/blob/de20877d2d10e085bb35e1e1716ea393b7b8b9fc/src/Utils/ExifCleaner.php#L16) (for example look at `EXIFTOOL_ARGS_SCRUB`). Showing which arguments are passed to the `exiftool` CLI command. ================================================ FILE: docs/02-admin/03-optional-features/06-s3_storage.md ================================================ # S3 Images storage ## Migrating the media files If you're starting a new instance, you can skip this part. To migrate to S3 storage we have to sync the media files located at `/var/www/mbin/public/media` into our S3 bucket. We suggest running the sync once while your instance is still up and using the local storage for media, then shutting mbin down, configure it to use the S3 storage and do another sync to get all the files created during the initial sync. To actually do the file sync you can use different tools, like `aws-cli`, `rclone` and others, just search for it and you will find plenty tutorials on how to do that ## Configuring Mbin Edit your `.env` file: ```ini S3_KEY=$AWS_ACCESS_KEY_ID S3_SECRET=$AWS_SECRET_ACCESS_KEY S3_BUCKET=bucket-name # safe default for S3 deployments like minio or single zone ceph/radosgw S3_REGION=us-east-1 # set if not using aws S3, note that the scheme is also required S3_ENDPOINT=https://endpoint.domain.tld S3_VERSION=latest ``` Then edit the: `config/packages/oneup_flysystem.yaml` file (only needed on bare metal/VM, not Docker): ```yaml oneup_flysystem: adapters: default_adapter: local: location: "%kernel.project_dir%/public/%uploads_dir_name%" kbin.s3_adapter: awss3v3: client: kbin.s3_client bucket: "%amazon.s3.bucket%" options: ACL: public-read filesystems: public_uploads_filesystem: # switch the adapter to s3 adapter #adapter: default_adapter adapter: kbin.s3_adapter alias: League\Flysystem\Filesystem ``` ## NGINX reverse proxy If you are using an object storage provider, we strongly advise you to use a media reverse proxy. That way media URLs will not change and break links on remote instances when you decide to switch providers and it hides your S3 endpoint from users of your instance. This replaces the media reverse proxy from [NGINX](../02-configuration/02-nginx.md). If you already had a reverse proxy for your media, then you only have to change the NGINX config, otherwise please follow the steps in our [media-reverse-proxy](../02-configuration/02-nginx.md) docs This config is heavily inspired by [Mastodons Nginx config](https://docs.joinmastodon.org/admin/optional/object-storage-proxy/). ```nginx proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=CACHE:10m inactive=7d max_size=10g; server { server_name https://media.mbin.domain.tld; location / { try_files $uri @s3; } set $s3_backend 'https://your.s3.endpoint.tld'; location @s3 { limit_except GET { deny all; } resolver 1.1.1.1; proxy_set_header Accept 'image/*'; proxy_set_header Connection ''; proxy_set_header Authorization ''; proxy_hide_header Set-Cookie; proxy_hide_header 'Access-Control-Allow-Origin'; proxy_hide_header 'Access-Control-Allow-Methods'; proxy_hide_header 'Access-Control-Allow-Headers'; proxy_hide_header x-amz-id-2; proxy_hide_header x-amz-request-id; proxy_hide_header x-amz-meta-server-side-encryption; proxy_hide_header x-amz-server-side-encryption; proxy_hide_header x-amz-bucket-region; proxy_hide_header x-amzn-requestid; proxy_ignore_headers Set-Cookie; proxy_pass $s3_backend$uri; proxy_intercept_errors off; proxy_cache CACHE; proxy_cache_valid 200 48h; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; proxy_cache_lock on; expires 1y; add_header Cache-Control public; add_header 'Access-Control-Allow-Origin' '*'; add_header X-Cache-Status $upstream_cache_status; add_header X-Content-Type-Options nosniff; add_header Content-Security-Policy "default-src 'none'; form-action 'none'"; } listen 80; } ``` For it to be a usable HTTPS site you have to run `certbot` or supply your certificates manually. > [!TIP] > Do not forget to enable http2 by adding `http2 on;` after certbot ran successfully. ================================================ FILE: docs/02-admin/03-optional-features/07-anubis.md ================================================ # Anubis setup for Mbin ### Why? Anubis is a program that attempts to block bots by presenting proof-of-work challenges to the user. A normal browser will simply solve the challenges (provided JavaScript is enabled), while AI scrapers will most likely just accept the challenge as the response. The simple answer is: because it's better than entirely blocking anonymous access to Mbin. ### How does it work? See the official [How Anubis works](https://anubis.techaro.lol/docs/design/how-anubis-works) web page. # Bare metal with Nginx ## External links - [Installation (Official documentation)](https://anubis.techaro.lol/docs/admin/installation) - [Native installation (Official documentation)](https://anubis.techaro.lol/docs/admin/native-install) - [Best practices for unix socket (on Anubis GitHub.com project)](https://github.com/TecharoHQ/anubis/discussions/541) ## Anubis setup ### Installation Download the package for your system from [the most recent release on GitHub](https://github.com/TecharoHQ/anubis/releases) and install the package via your package manager: - `deb`: `sudo apt install ./anubis-$VERSION-$ARCH.deb` - `rpm`: `sudo dnf -y install ./anubis-$VERSION.$ARCH.rpm` ### Configuration Then create the environment file `/etc/anubis/mbin.env` with the following content: ```dotenv BIND=/run/anubis/mbin.sock BIND_NETWORK=unix SOCKET_MODE=0666 DIFFICULTY=4 METRICS_BIND=:4673 SERVE_ROBOTS_TXT=0 TARGET=unix:///run/nginx/mbin.sock POLICY_FNAME=/etc/anubis/mbin.botPolicies.yaml ``` Copy the content from [default bot policy](https://github.com/TecharoHQ/anubis/blob/main/data/botPolicies.yaml) to `/etc/anubis/mbin.botPolicies.yaml`. In the `bots` section of the `mbin.botPolicies.yaml` file, prepend the following (has to be in front of the other rules) to explicitly allow all API, RSS, and ActivityPub requests: ```yaml - name: mbin-activity-pub headers_regex: Accept: application\/activity\+json|application\/ld\+json action: ALLOW - name: mbin-api headers_regex: Accept: application\/json action: ALLOW - name: mbin-rss headers_regex: Accept: application\/rss\+xml action: ALLOW - name: nodeinfo path_regex: ^\/nodeinfo\/.*$ action: ALLOW ``` You should also switch the store backend to something different from the default in-memory one. If you want to use a local Bolt database, by using the `bbolt` package ([see alternatives](https://anubis.techaro.lol/docs/admin/policies#storage-backends)), change the `store` section to the following (in `mbin.botPolicies.yaml`): ```yaml store: backend: bbolt parameters: path: /opt/anubis/mbin.bdb ``` Adjust the `thresholds` section to match this (the only difference is that the `preact` type of challenge is removed): ```yaml thresholds: # By default Anubis ships with the following thresholds: - name: minimal-suspicion # This client is likely fine, its soul is lighter than a feather expression: weight <= 0 # a feather weighs zero units action: ALLOW # Allow the traffic through # For clients that had some weight reduced through custom rules, give them a # lightweight challenge. - name: mild-suspicion expression: all: - weight > 0 - weight < 10 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh algorithm: metarefresh difficulty: 1 report_as: 1 # For clients that are browser-like but have either gained points from custom rules or # report as a standard browser. - name: moderate-suspicion expression: all: - weight >= 10 - weight < 30 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work algorithm: fast difficulty: 2 # two leading zeros, very fast for most clients report_as: 2 # For clients that are browser like and have gained many points from custom rules - name: extreme-suspicion expression: weight >= 30 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work algorithm: fast difficulty: 4 report_as: 4 ``` The default config includes a few snippets that require a subscription. To avoid any warning messages, you should comment out any sections that contain "Requires a subscription to Thoth to use" (just search for it in the file). For Anubis to be able to access the socket that we will use later, we will have to change the service file (`/usr/lib/systemd/system/anubis@.service`) and run the Anubis service under the `www-data` user: 1. Remove: `DynamicUser=yes` 2. Add: `User=www-data` There are some paths that need to be created and then owned by `www-data` user and group: ```bash sudo mkdir -p /opt/anubis/ && sudo mkdir -p /run/anubis/ && sudo mkdir -p /run/nginx/ sudo chown -R www-data.www-data /opt/anubis/ && sudo chown -R www-data.www-data /run/anubis/ && sudo chown -R www-data.www-data /run/nginx/ ``` ### Starting it Start the Anubis service with the following command: ```bash sudo systemctl enable --now anubis@mbin.service ``` Test it to make sure Anibus is running by using `curl`: ```bash curl http://localhost:4673/metrics ``` If you need to restart Anubis, just run: ```bash sudo systemctl restart anubis@mbin.service ``` ## Nginx preparations Create an nginx upstream to anubis ([anubis docs](https://anubis.techaro.lol/docs/admin/environments/nginx)): ```nginx upstream anubis { # Make sure this matches the values you set for `BIND` and `BIND_NETWORK`. # If this does not match, your services will not be protected by Anubis. # Try Anubis first over a UNIX socket server unix:/run/anubis/mbin.sock; #server 127.0.0.1:8923; # Optional: fall back to serving the websites directly. This allows your # websites to be resilient against Anubis failing, at the risk of exposing # them to the raw internet without protection. This is a tradeoff and can # be worth it in some edge cases. #server unix:/run/nginx.sock backup; } ``` You can just put it in `/etc/nginx/conf.d/anubis.conf`, for example, and the default Nginx configuration will then import this file. ## Change nginx mbin.conf Now we need to modify the Nginx configuration that is serving Mbin. We will use the default config as an example. ### Short Explainer Version **Without** Anubis: ```nginx # Redirect HTTP to HTTPS server { server_name domain.tld; listen 80; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name domain.tld; root /var/www/mbin/public; location / { # try to serve file directly, fallback to index.php try_files $uri /index.php$is_args$args; } } ``` **With** Anubis: ```nginx # Redirect HTTP to HTTPS server { server_name domain.tld; listen 80; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name domain.tld; location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Http-Version $server_protocol; proxy_pass http://anubis; } } server { listen unix:/run/nginx/mbin.sock; server_name domain.tld; root /var/www/mbin/public; # Get the visiting IP from the TLS termination server set_real_ip_from unix:; real_ip_header X-Real-IP; location / { # try to serve file directly, fallback to index.php try_files $uri /index.php$is_args$args; # lie to Symfony that the request is an HTTPS one, so it generates HTTPS URLs fastcgi_param SERVER_PORT "443"; fastcgi_param HTTPS "on"; } } ``` As you can see, instead of serving Mbin directly, we proxy it through the Anubis service. Anubis will then decide whether to call the UNIX socket that the actual Mbin site is served over, or if it will present a challenge to the client (or straight up deny it). During the actual Mbin call, we lie to Symfony that the request is coming from port 443 (`fastcgi_param SERVER_PORT`) and that HTTPS is being used (`fastcgi_param HTTPS`). The reason is that it will otherwise generate HTTP (non-secure) URLs that are incompatible with some other Fediverse software, such as Lemmy. ### The long one ```nginx upstream mercure { server 127.0.0.1:3000; keepalive 10; } # Map instance requests vs the rest map "$http_accept:$request" $mbinInstanceRequest { ~^.*:GET\ \/.well-known\/.+ 1; ~^.*:GET\ \/nodeinfo\/.+ 1; ~^.*:GET\ \/i\/actor 1; ~^.*:POST\ \/i\/inbox 1; ~^.*:POST\ \/i\/outbox 1; ~^.*:POST\ \/f\/inbox 1; ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:GET\ \/ 1; ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:GET\ \/f\/object\/.+ 1; default 0; } # Map user requests vs the rest map "$http_accept:$request" $mbinUserRequest { ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:GET\ \/u\/.+ 1; ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:POST\ \/u\/.+ 1; default 0; } # Map magazine requests vs the rest map "$http_accept:$request" $mbinMagazineRequest { ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:GET\ \/m\/.+ 1; ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:POST\ \/m\/.+ 1; default 0; } # Miscellaneous requests map "$http_accept:$request" $mbinMiscRequest { ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:GET\ \/reports\/.+ 1; ~^(?:application\/activity\+json|application\/ld\+json|application\/json).*:GET\ \/message\/.+ 1; ~^.*:GET\ \/contexts\..+ 1; default 0; } # Determine if a request should go into the regular log map "$mbinInstanceRequest$mbinUserRequest$mbinMagazineRequest$mbinMiscRequest" $mbinRegularRequest { 0000 1; # Regular requests default 0; # Other requests } map $mbinRegularRequest $mbin_limit_key { 0 ""; 1 $binary_remote_addr; } # Two stage rate limit (10 MB zone): 5 requests/second limit (=second stage) limit_req_zone $mbin_limit_key zone=mbin_limit:10m rate=5r/s; # Redirect HTTP to HTTPS server { server_name domain.tld; listen 80; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name domain.tld; # uncomment for troubleshooting purposes #access_log /var/log/nginx/anubis_mbin_access.log combined; location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Http-Version $server_protocol; proxy_pass http://anubis; } } server { listen unix:/run/nginx/mbin.sock; server_name domain.tld; root /var/www/mbin/public; # Get the visiting IP from the TLS termination server set_real_ip_from unix:; real_ip_header X-Real-IP; index index.php; charset utf-8; # TLS ssl_certificate /etc/letsencrypt/live/domain.tld/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/domain.tld/privkey.pem; # Don't leak powered-by fastcgi_hide_header X-Powered-By; # Security headers add_header X-Frame-Options "DENY" always; add_header X-XSS-Protection "1; mode=block" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "same-origin" always; add_header X-Download-Options "noopen" always; add_header X-Permitted-Cross-Domain-Policies "none" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; client_max_body_size 20M; # Max size of a file that a user can upload # Two stage rate limit limit_req zone=mbin_limit burst=300 delay=200; # Error log (if you want you can add "warn" at the end of error_log to also log warnings) error_log /var/log/nginx/mbin_error.log; # Access logs access_log /var/log/nginx/mbin_access.log combined if=$mbinRegularRequest; access_log /var/log/nginx/mbin_instance.log combined if=$mbinInstanceRequest buffer=32k flush=5m; access_log /var/log/nginx/mbin_user.log combined if=$mbinUserRequest buffer=32k flush=5m; access_log /var/log/nginx/mbin_magazine.log combined if=$mbinMagazineRequest buffer=32k flush=5m; access_log /var/log/nginx/mbin_misc.log combined if=$mbinMiscRequest buffer=32k flush=5m; open_file_cache max=1000 inactive=20s; open_file_cache_valid 60s; open_file_cache_min_uses 2; open_file_cache_errors on; location / { # try to serve file directly, fallback to index.php try_files $uri /index.php$is_args$args; } location = /favicon.ico { access_log off; log_not_found off; } location = /robots.txt { allow all; access_log off; log_not_found off; } location /.well-known/mercure { proxy_pass http://mercure$request_uri; # Increase this time-out if you want clients have a Mercure connection open for longer (eg. 24h) proxy_read_timeout 2h; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto $scheme; } location ~ ^/index\.php(/|$) { default_type application/x-httpd-php; fastcgi_pass unix:/var/run/php/php-fpm.sock; fastcgi_split_path_info ^(.+\.php)(/.*)$; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; fastcgi_param DOCUMENT_ROOT $realpath_root; # lie to Symfony that the request is an HTTPS one, so it generates HTTPS URLs fastcgi_param SERVER_PORT "443"; fastcgi_param HTTPS "on"; # Prevents URIs that include the front controller. This will 404: # http://domain.tld/index.php/some-path # Remove the internal directive to allow URIs like this internal; } # bypass thumbs cache image files location ~ ^/media/cache/resolve { expires 1M; access_log off; add_header Cache-Control "public"; try_files $uri $uri/ /index.php?$query_string; } # Static assets location ~* \.(?:css(\.map)?|js(\.map)?|jpe?g|png|tgz|gz|rar|bz2|doc|pdf|ptt|tar|gif|ico|cur|heic|webp|tiff?|mp3|m4a|aac|ogg|midi?|wav|mp4|mov|webm|mpe?g|avi|ogv|flv|wmv|svgz?|ttf|ttc|otf|eot|woff2?)$ { expires 30d; add_header Access-Control-Allow-Origin "*"; add_header Cache-Control "public, no-transform"; access_log off; } # return 404 for all other php files not matching the front controller # this prevents access to other php files you don't want to be accessible. location ~ \.php$ { return 404; } # Deny dot folders and files, except for the .well-known folder location ~ /\.(?!well-known).* { deny all; } } ``` To test whether Mbin correctly uses the HTTPS scheme, you can run this command (replaced with your URL and username): ```bash curl --header "Accept: application/activity+json" https://example.mbin/u/admin | jq ``` The `| jq` part outputs formatted JSON, which should make this easier to view. There should not be any `http://` URLs in this output. ### Take it live To start routing traffic through Anubis, Nginx must be restarted (not just reloaded) due to the new socket that needs to be created. However, before we do that, we should check the config for validity: ```bash sudo nginx -t ``` If `nginx -t` runs successfully, then you should ensure Anubis is also running without any issues: ```bash systemctl status anubis@mbin.service ``` You can finally restart Nginx with: ```bash sudo systemctl restart nginx ``` Once you reload the Mbin website, you should see the Anubis challenge page that checks your browser. ### Troubleshooting Use the following to view the Anubis logs: ```bash journalctl -ru anubis@mbin.service ``` In the Nginx config for Mbin, you can uncomment the access log line to see the access logs for the Anubis upstream. If you combine that with changing the status codes in the Anubis policy (just open the policy and search for `status_codes`) this is a good way to check whether RSS, API and ActivityPub requests still make it through. ================================================ FILE: docs/02-admin/03-optional-features/08-monitoring.md ================================================ # Internal Mbin Monitoring We have a few environment variables that can enable monitoring of the mbin server, specifically the executed database queries, rendered html components and requested web resources during the execution of an HTTP request or a message handler (a background job). This allows the admin to collect performance metrics for optimizing the server settings, or developers to optimize the code. Enabling monitoring on your server will have a performance impact. It is not necessarily noticeable for your users, but it will increase the resource consumption. During an execution context (request or messenger) the server will collect monitoring information according to your settings. After the execution is finished the collected information will be saved to the DB according to your settings (which is the main performance impact and happens after a request is finished). The available settings are: - `MBIN_MONITORING_ENABLED`: Whether monitoring is enabled at all, if `false` then the other settings do not matter - `MBIN_MONITORING_QUERIES_ENABLED`: Whether to monitor query execution, defaults to true - `MBIN_MONITORING_QUERY_PERSISTING_ENABLED`: Whether the monitored queries are persisted to the database. If this is disabled only the total query time will be persisted. - `MBIN_MONITORING_QUERY_PARAMETERS_ENABLED`: Whether the parameter of database queries should be saved. If enabled the spaces used might increase a lot. - `MBIN_MONITORING_TWIG_RENDERS_ENABLED`: Whether to monitor twig rendering, defaults to true - `MBIN_MONITORING_TWIG_RENDER_PERSISTING_ENABLED`: Whether to persist the monitored twig renders. If this is disabled only the total rendering time will be persisted. - `MBIN_MONITORING_CURL_REQUESTS_ENABLED`: Whether to monitor curl requests, defaults to true - `MBIN_MONITORING_CURL_REQUEST_PERSISTING_ENABLED`: Whether to persist the monitored curl requests. If this is disabled only total request time will be persisted. If the monitoring of e.g. queries is enabled, but the persistence is not, then the execution context will have a total duration of the executed queries, but you cannot inspect the executed queries. Depending on your persistence settings the monitoring can take up a lot of space. The largest amount will come from the queries, then the twig renders and at last the curl requests. > [!TIP] > To delete you monitoring data see [cli docs](../04-running-mbin/05-cli.md#delete-monitoring-data). ## UI At `/admin/monitoring` is the overview of the monitoring. There you can see a chart of the longest taking execution contexts. Underneath that is a table containing the most recent execution contexts according to your filter settings. The chart also takes the filter settings into account. By clicking the alphanumeric string in the first column of the table (part of the GUID of the execution context) you get to the overview of that context, containing the meta information about this context. > [!TIP] > The percentage numbers of "SQL Queries", "Twig Renders" and "Curl Requests" do not necessarily add up to 100% or even below 100%, > because "Twig Renders" could execute database queries for example. ================================================ FILE: docs/02-admin/03-optional-features/09-image-compression.md ================================================ # Image compression You can enable compression of images uploaded to mbin by users or downloaded from remote instances, for increased compatibility, to save on size and for a better user experience. To enable image compression set `MBIN_IMAGE_COMPRESSION_QUALITY` in your `.env` file to a value between 0.1 and 0.95. This setting is used as a starting point to compress the image. It is gradually lowered (in 0.05 steps) until the maximum size is no longer exceeded. > [!HINT] > The maximum file size is determined by the `MBIN_MAX_IMAGE_BYTES` setting in your `.env` file > [!NOTE] > Enabling this setting can cause a higher memory usage ## Better compatibility If another instance shares a thread with an image attached that exceeds your maximum image size, it will not be downloaded, but instead loaded directly from the other instance. This works most of the time, but sometimes website settings will block it and thus your users will see an image that cannot be loaded. This behavior also introduces web requests to other servers, which may unintentionally leak information to the remote instance. If instead your server compresses the image and saves it locally this will never happen. ## Saving space When image compression is enabled you can reduce your maximum image size to, lets say 1MB. Without the compression this might not be suitable, because too many images exceed that size, and you don't want to risk compatibility problems, but with it enabled the images will just be compressed, saving space. ## A better user experience Normally there is a maximum image size your users must adhere to, but if image compression is enabled, instead of showing your user an error that the image exceeds that size, the upload goes through and the image is compressed. ================================================ FILE: docs/02-admin/03-optional-features/README.md ================================================ # Optional Features There are several options features in Mbin, which you may want to configure. Like setting-up: - [Mercure](01-mercure.md) - Mercure is used to provide real-time data from the server towards the clients. - [Single sign-on (SSO)](02-sso.md) - SSO can be configured to allow registrations via other SSO providers. - [Captcha](03-captcha.md) - Captcha protection against spam and anti-bot. - [User application approval](04-user_application.md) - Manually approve users before they can log into your server (eg. to avoid spam accounts). - [Image metadata cleaning](05-image_metadata_cleaning.md) - Clean-up and remove metadata from images using `exiftool`. - [S3 storage](06-s3_storage.md) - Configure an object storage service (S3) compatible bucket for storing images. - [Anubis](07-anubis.md) - A service for weighing the incoming requests and may present them with a proof-of-work challenge. It is useful if your instance gets hit a lot of bot traffic that you're tired of filtering through - [Monitoring](08-monitoring.md) - Internal monitoring of requests and messengers - [Image compression](09-image-compression.md) - compress images if they exceed your maximum image size ================================================ FILE: docs/02-admin/04-running-mbin/01-first_setup.md ================================================ # Mbin first setup > [!TIP] > If you are running docker, then you have to prefix the following commands with > `docker compose exec php`. Create new admin user (without email verification), please change the `username`, `email` and `password` below: ```bash php bin/console mbin:user:create php bin/console mbin:user:admin ``` ```bash php bin/console mbin:ap:keys:update ``` Next, log in and create a magazine named `random` to which unclassified content from the fediverse will flow. > [!IMPORTANT] > Creating a `random` magazine is a requirement to getting microblog posts that don't fall under an existing magazine. ```bash php bin/console mbin:magazine:create random ``` ### Manual user activation Activate a user account (bypassing email verification), please change the `username` below: ```bash php bin/console mbin:user:verify -a ``` ### Mercure If you are not going to use Mercure, you have to disable it in the admin panel. ### NPM (bare metal only) ```bash cd /var/www/mbin npm install # Installs all NPM dependencies npm run build # Builds frontend ``` Make sure you have substituted all the passwords and configured the basic services. ### Push Notification setup The push notification system needs encryption keys to work. They have to be generated only once, by running ```bash php bin/console mbin:push:keys:update ``` ================================================ FILE: docs/02-admin/04-running-mbin/02-backup.md ================================================ # Backup and restore ## Bare Metal ### Backup ```bash PGPASSWORD="YOUR_PASSWORD" pg_dump -U mbin mbin > dump.sql ``` ### Restore ```bash psql -U mbin mbin < dump.sql ``` ## Docker ### Backup: ```bash docker compose exec -it postgres pg_dump -U mbin mbin > dump.sql ``` ### Restore: ```bash docker compose exec -T postgres psql -U mbin mbin < dump.sql ``` ================================================ FILE: docs/02-admin/04-running-mbin/03-upgrades.md ================================================ # Upgrades ## Bare Metal If you perform a Mbin upgrade (eg. `git pull`), be aware to _always_ execute the following Bash script: ```bash ./bin/post-upgrade ``` ### Clear Cache And when needed also execute: `sudo redis-cli FLUSHDB` to get rid of Redis/KeyDB cache issues. And reload the PHP FPM service if you have OPCache enabled. ## Docker 1. Pull the latest Docker image: ```bash docker compose pull ``` Or, if you are building locally, then you'll need to rebuild the Mbin docker image (without using cached layers): ```bash docker compose build --no-cache ``` 2. Bring down the containers and up again (with `-d` for detach): ```bash docker compose down docker compose up -d ``` ================================================ FILE: docs/02-admin/04-running-mbin/04-messenger.md ================================================ # Symfony Messenger (Queues) The symphony messengers are background workers for a lot of different task, the biggest one being handling all the ActivityPub traffic. We have a few different queues: 1. `receive` [RabbitMQ]: everything any remote instance sends to us will first end up in this queue. When processing it will be determined what kind of message it is (creation of a thread, a new comment, etc.) 2. `inbox` [RabbitMQ]: messages from `receive` with the determined kind of incoming message will end up here and the necessary actions will be executed. This is the place where the thread or comment will actually be created 3. `outbox` [RabbitMQ]: when a user creates a thread or a comment, a message will be created and send to the outbox queue to build the ActivityPub object that will be sent to remote instances. After the object is built and the inbox addresses of all the remote instances who are interested in the message are gathered, we will create a `DeliverMessage` for every one of them, which will be sent to the `deliver` queue 4. `deliver` [RabbitMQ]: Actually sending out the ActivityPub objects to other instances 5. `resolve` [RabbitMQ]: Resolving dependencies or ActivityPub actors. For example if your instance gets a like message for a post that is not on your instance a message resolving that dependency will be dispatched to this queue 6. `async` [RabbitMQ]: messages in async are local actions that are relevant to this instance, e.g. creating notifications, fetching embedded images, etc. 7. `old` [RabbitMQ]: the standard messages queue that existed before. This exists solely for compatibility purposes and might be removed later on 8. `failed` [PostgreSQL]: jobs from the other queues that have been retried, but failed. They get retried a few times again, before they end up in 9. `dead` [PostgreSQL]: dead jobs that will not be retried We need the `dead` queue so that messages that throw a `UnrecoverableMessageHandlingException`, which is used to indicate that a message should not be retried and go straight to the supplied failure queue ## Remove failed messages We created a simple command to clean-up all the failed messages from the database at once: ```bash ./bin/console mbin:messenger:failed:remove_all ``` And to remove the dead messages from the database at once: ```bash ./bin/console mbin:messenger:dead:remove_all ``` However, most messages stored in the database are most likely failed messages. So it is advised to regularly run the `./bin/console mbin:messenger:failed:remove_all` command to clean-up the database. ================================================ FILE: docs/02-admin/04-running-mbin/05-cli.md ================================================ # CLI When you are the server administrator of the Mbin instance and you have access to the terminal / shell (via SSH for example), Mbin provides you several console commands. > [!WARNING] > We assume you are in the root directory of the Mbin instance (eg. `/var/www/mbin`), > this is the directory location where you want to execute console commands listed below. > [!WARNING] > Run the commands as the correct user. In case you used the `mbin` user during setup, > switch to the mbin user fist via: `sudo -u mbin bash` > (or if you used the `www-data` user during setup: `sudo -u www-data bash`). > This prevent potential unwanted file permission issues. ## Getting started List all available console commands, execute: ```bash php bin/console ``` In the next chapters the focus is on the `mbin` section of the `bin/console` console commands and go into more detail. ## User Management ### User-Create This command allows you to create user, optionally granting administrator or global moderator privileges. Usage: ```bash php bin/console mbin:user:create [-r|--remove] [--admin] [--moderator] ``` Arguments: - `username`: the username that should be created. - `email`: the email for the user, keep in mind that this command will automatically verify the created user, so no email will be sent. - `password`: the password for the user. Options: - `-r|--remove`: purge the user from the database, **without notifying the rest of the fediverse about it**. If you want the rest of the fediverse notified please use the `mbin:user:delete` command instead. - `--admin`: make the created user an admin. - `--moderator`: make the created user a global moderator. ### User-Admin This command allows you to grant administrator privileges to the user. Usage: ```bash php bin/console mbin:user:admin [-r|--remove] ``` Arguments: - `username`: the username whose rights are to be modified. Options: - `-r|--remove`: instead of granting privileges, remove them. ### User-delete This command will delete the supplied user and notify the fediverse about it. This is an asynchronous job, so you will not see the change immediately. Usage: ```bash php bin/console mbin:user:delete ``` Arguments: - `user`: the username of the user. ### User-Moderator This command allows you to grant global moderator privileges to the user. Usage: ```bash php bin/console mbin:user:moderator [-r|--remove] ``` Arguments: - `username`: the username whose rights are to be modified. Options: - `-r|--remove`: instead of granting privileges, remove them. ### User-Password This command allows you to manually set or reset a users' password. Usage: ```bash php bin/console mbin:user:password ``` Arguments: - `username`: the username whose password should be changed. - `password`: the password to change to. ### User-Verify This command allows you to manually activate or deactivate a user, bypassing email verification requirement. Usage: ```bash php bin/console mbin:user:verify [-a|--activate] [-d|--deactivate] ``` Arguments: - `username`: the user to activate (verify) or deactivate (remove verification). Options: - `-a|--activate`: Activate user, bypass email verification. - `-d|--deactivate`: Deactivate user, require email (re)verification. > [!NOTE] > If neither `--activate` nor `--deactivate` are provided, the current verification status will be returned ### Rotate users private keys > [!WARNING] > After running this command it can take up to 24 hours for other instances to update their stored public keys. > In this timeframe federation might be impacted by this, > as those services cannot successfully verify the identity of your users. > Please inform your users about this when you're running this command. This command allows you to rotate the private keys of your users with which the activities sent by them are authenticated. If private keys have been leaked you should rotate the private keys to avoid the potential for impersonation. Usage: ```bash php bin/console mbin:user:private-keys:rotate [-a|--all-local-users] [-r|--revert] [] ``` Arguments: - `username`: the single user for which this command should be executed (not required when using the `-a` / `--all-local-users` option, see below) Options: - `-a|--all-local-users`: Rotate private keys of all local users - `-r|--revert`: revert to the old private and public keys ### User-Unsub > [!NOTE] > This command is old and should probably not be used Removes all followers from a user. Usage: ```bash php bin/console mbin:user:unsub ``` Arguments: - `username`: the user from which to remove all local followers. ### Fix user duplicates This command allows you to fix duplicate usernames. There is a unique index on the usernames, but it is case-sensitive. This command will go through all the users with duplicate case-insensitive usernames, where the username is not part of the public id (meaning the original URL) or handle (you will be asked what to use for deduplication) and update them from the remote server. After that it will go through the rest of the duplicates and ask you whether you want to merge matching pairs (if you deduplicate by handle) or delete some of them (if you deduplicate by URL). Usage: ```bash php bin/console mbin:check:duplicates-users-magazines [--dry-run] # then select 'users' # then select either 'handle' or 'profileUrl' ``` Options: - `--dry-run`: don't change anything in the DB ## Magazine Management ### Magazine-Create This command allows you to create, delete and purge magazines. Usage: ```bash php bin/console mbin:magazine:create [-o|--owner OWNER] [-r|--remove] [--purge] [--restricted] [-t|--title TITLE] [-d|--description DESCRIPTION] ``` Arguments: - `name`: the name of the magazine that is part of the URL Options: - `-o|--owner OWNER`: makes the supplied username the owner of the newly created magazine. If this is omitted the admin account will be the owner of the magazine. - `--restricted`: create the magazine with posting restricted to the moderators of this magazine. - `-r|--remove`: instead of creating the magazine, remove it (not notifying the rest of the fediverse). - `--purge`: completely remove the magazine from the db (not notifying the rest of the fediverse). If this and `--remove` are supplied, `--remove` has precedence over this. - `-t|--title TITLE`: makes the supplied string the title of the magazine (aka. the display name). - `-d|--description DESCRIPTION`: makes the supplied string the description of the magazine. ### Magazine-Sub This command allows to subscribe a user to a magazine. Usage: ```bash php bin/console mbin:magazine:sub [-u|--unsub] ``` Arguments: - `magazine`: the magazine name to subscribe the user to. - `username`: the user that should be subscribed to the magazine. Options: - `-u|--unsub`: instead of subscribing to the magazine, unsubscribe the user from the magazine. ### Magazine-Unsub Remove all the subscribers from a magazine. Usage: ```bash php bin/console mbin:magazine:unsub ``` Arguments: - `magazine`: the magazine name from which to remove all the subscribers. ## Direct Messages ### Remove-and-Ban Search for direct messages using the body input parameter. List all the found matches, and ask for permission to continue. If you agree to continue, *all* the sender users will be **banned** and *all* the direct messages will be **removed**! > [!WARNING] > This action cannot be undone (once you confirmed with `yes`)! Usage: ```bash php bin/console mbin:messages:remove_and_ban "" ``` Arguments: - `body`: the direct message body to search for. ## Post Management ### Entries-Move > [!WARNING] > This command should not be used, as none of the changes will be federated. This command allows you to move entries to a new magazine based on their tag. Usage: ```bash php bin/console mbin:entries:move ``` Arguments: - `magazine`: the magazine to which the entries should be moved - `tag`: the (hash)tag based on which the entries should be moved ### Posts-Move > [!WARNING] > This command should not be used, as none of the changes will be federated. This command allows you to move posts to a new magazine based on their tag. Usage: ```bash php bin/console mbin:posts:move ``` Arguments: - `magazine`: the magazine to which the posts should be moved - `tag`: the (hash)tag based on which the posts should be moved ### Posts-Magazine > [!WARNING] > This command should not be used. Posts are automatically assigned to a magazine based on their tag. This command will assign magazines to posts based on their tags. Usage: ```bash php bin/console mbin:posts:magazines ``` ## Activity Pub ### Actor update > [!NOTE] > This command will trigger **asynchronous** updates of remote users or magazines This command will allow you to update remote actor (user/magazine) info. Usage: ```bash php bin/console mbin:actor:update [--users] [--magazines] [--force] [] ``` Arguments: - `user`: the username to dispatch an update for. Options: - `--users`: if this options is provided up to 10,000 remote users ordered by their last update time will be updated - `--magazines`: if this options is provided up to 10,000 remote magazines ordered by their last update time will be updated ### ActivityPub resource import > [!NOTE] > This command will trigger an **asynchronous** import This command allows you to import an AP resource. Usage: ```bash php bin/console mbin:ap:import ``` Arguments: - `url`: the "id" of the ActivityPub object to import ## Images ### Remove cached remote media This command allows you to remove the cached file of remote media, **without** deleting the reference. You can run this command as a cron job to only keep cached media from the last 30 days for example. > [!TIP] > If a thread or microblog is opened without a local cache of the attached image existing, the image will be downloaded again. > Once an image is downloaded again, it will not get deleted for the number of days you set as a parameter. > [!NOTE] > User avatars and covers and magazine icons and banners are not affected by this command, > only images from threads, microblogs and comments. Usage: ```bash php bin/console mbin:images:remove-remote [--days|-d] [--batch-size] [--dry-run] ``` Options: - `--days`|`-d`: the number of days of media you want to keep. Everything older than the amount of days will be deleted - `--batch-size` (default `10000`): the number of images to retrieve per query from the DB. A higher number means less queries, but higher memory usage. - `--dry-run`: if set, no images will be deleted ### Refresh the meta data of stored images This command allows you to refresh the filesize of the stored media, as well as the status. If an image is no longer present on storage this command adjusts it in the DB. Usage: ```bash php bin/console mbin:images:refresh-meta [--batch-size] [--dry-run] ``` Options: - `--batch-size` (default `10000`): the number of images to retrieve per query from the DB. A higher number means less queries, but higher memory usage. - `--dry-run`: if set, no metadata will be changed ### Remove old federated images This command allows you to remove old federated images, without removing the content. The image delete command works in batches, by default it will remove 800 images for each type. The image(s) will be removed from the database as well as from disk / storage. > [!WARNING] > This action cannot be undone! Usage: ```bash php bin/console mbin:images:delete ``` Arguments: - `type`: type of images that will get deleted, either: `all` (except for user images), `threads`, `thread_comments`, `posts`, `post_comments` or `users`. (default: `all`) - `monthsAgo`: Delete images older than given months are getting deleted (default: `12`) Options: - `--noActivity`: delete images that doesn't have recorded activity. Like comments, updates and/or boosts. (default: `false`) - `--batchSize`: the number of images to delete for each type at a time. (default: `800`) ### Remove orphaned media This command iterates over your media filesystem and deletes all files that do not appear in the database. ```bash php bin/console mbin:images:remove-orphaned ``` Options: - `--ignored-paths=IGNORED-PATHS`: A comma seperated list of paths to be ignored in this process. If the path starts with one of the supplied strings it will be skipped. e.g. "/cache" [default: ""] - `--dry-run`: Dry run, don't delete anything ### Rebuild image cache This command allows you to rebuild image thumbnail cache. It executes the `liip:imagine:cache:resolve` command for every user- and magazine-avatar and linked image in entries and posts. > [!NOTE] > This command will trigger **a lot** of processing if you execute it on a long-running server. Usage: ```bash php bin/console mbin:cache:build ``` ## Miscellaneous ### Delete monitoring data > [!HINT] > For information about monitoring see [Optional Features/Monitoring](../03-optional-features/08-monitoring.md). This command allows you to delete monitoring data according to the passed parameters. Usage: ```bash php bin/console mbin:monitoring:delete-data [-a|--all] [--queries] [--twig] [--requests] [--before [BEFORE]] ``` Options: - `-a`|`--all`: delete all contexts, including all linked data (queries, twig renders and curl requests) - `--queries`: delete all query data (this is the most space consuming data) - `--twig`: delete all twig rendering data (this is the second most space consuming data) - `--requests`: delete all curl request data - `--before [BEFORE]]`: if you want to limit the data deleted by their creation date, including via the `-a|--all` option. You can pass something like _"now - 1 day"_ As an example you could delete all query data by running `php bin/console mbin:monitoring:delete-data --queries --before "now - 8 hours"`. This way you could still view the average request times without the query data for every request older than 8 hours and the newer requests would not be affected at all. This way you can limit the space consumed by query data. You can also mix and match the `--queries`, `--twig` and `--requests` options. ### Search for duplicate magazines or users and remove them This command provides a guided tour to search for, and remove duplicate magazines or users. This has been added to make the creation of unique indexes easier if the migration failed. Usage: ```bash php bin/console mbin:check:duplicates-users-magazines ``` ### Users-Remove-Marked-For-Deletion > [!NOTE] > The same job is executed on a daily schedule automatically. There should be no need to execute this command. Removes all accounts that are marked for deletion today or in the past. Usage: ```bash php bin/console mbin:users:remove-marked-for-deletion ``` ### Messengers-Failed-Remove-All > [!NOTE] > The same job is executed on a daily schedule automatically. There should be no need to execute this command. This command removes all failed messages from the failed queue (database). Usage: ```bash php bin/console mbin:messenger:failed:remove_all ``` ### Messengers-Dead-Remove-All > [!NOTE] > The same job is executed on a daily schedule automatically. There should be no need to execute this command. This command removes all dead messages from the dead queue (database). Usage: ```bash php bin/console mbin:messenger:dead:remove_all ``` ### Post-Remove-Duplicates This command removes post and user duplicates by their ActivityPub ID. > [!NOTE] > We've had a unique index on the ActivityPub ID for a while, hence this command should not do anything Usage: ```bash php bin/console mbin:user:create [-r|--remove] [--admin] [--moderator] ``` ### Update-Local-Domain This command will remove all remote posts from belonging to the local domain. This command is only relevant for instances created before v1.7.4 as the local domain was the fallback if no domain could be extracted from a post. Usage: ```bash php bin/console mbin:update:local-domain ``` ================================================ FILE: docs/02-admin/04-running-mbin/README.md ================================================ # Running Mbin When you're the server admin, you mind find the following pages useful: - [First setup](01-first_setup.md) - Helps creating your admin first account on Mbin and more... - [Back-up](02-backup.md) - How to back-up your databases? - [Upgrades](03-upgrades.md) - Where to think about when performing Mbin upgrades. - [Messenger jobs](04-messenger.md) - When running Symfony Messenger & RabbitMQ - [CLI commands](05-cli.md) - Available Mbin console commands ================================================ FILE: docs/02-admin/05-troubleshooting/01-bare_metal.md ================================================ # Troubleshooting Bare Metal ## Logs RabbitMQ: - `sudo tail -f /var/log/rabbitmq/rabbit@*.log` Supervisor: - `sudo tail -f /var/log/supervisor/supervisord.log` Supervisor jobs (Mercure and Messenger): - `sudo tail -f /var/log/supervisor/mercure*.log` - `sudo tail -f /var/log/supervisor/messenger*.log` The separate Mercure log: - `sudo tail -f /var/www/mbin/var/log/mercure.log` Application Logs (prod or dev logs): - `tail -f /var/www/mbin/var/log/prod-{YYYY-MM-DD}.log` Or: - `tail -f /var/www/mbin/var/log/dev-{YYYY-MM-DD}.log` Web-server (Nginx): - Normal access log: `sudo tail -f /var/log/nginx/mbin_access.log` - Inbox access log: `sudo tail -f /var/log/nginx/mbin_inbox.log` - Error log: `sudo tail -f /var/log/nginx/mbin_error.log` ### A useful command to view the logs If you have `tail`, `jq` and `awk` installed you can run this command to turn the json log into a color coded human-readable log: ```shell tail -f /var/www/mbin/var/log/prod-2025-08-11.log | jq -r '"\(.datetime) \(.level_name) \(.channel): \(.message)"' | awk ' { level=$2 color_reset="\033[0m" color_info="\033[36m" # Cyan color_warning="\033[33m" # Yellow color_error="\033[31m" # Red color_debug="\033[35m" # Magenta if (level == "INFO") color=color_info else if (level == "WARNING") color=color_warning else if (level == "ERROR") color=color_error else if (level == "DEBUG") color=color_debug else color=color_reset print color $0 color_reset }' ``` ## Debugging **Please, check the logs above first.** If you are really stuck, visit to our [Matrix space](https://matrix.to/#/%23mbin:melroy.org), there is a 'General' room and dedicated room for 'Issues/Support'. Test PostgreSQL connections if using a remote server, same with Redis (or KeyDB is you are using that instead). Ensure no firewall rules blocking are any incoming or out-coming traffic (eg. port on 80 and 443). ================================================ FILE: docs/02-admin/05-troubleshooting/02-docker.md ================================================ # Troubleshooting Docker ## Debugging / Logging 1. List the running service containers with `docker compose ps`. 2. You can see the logs with `docker compose logs -f ` (use `-f` to follow the output). 3. For `php` and `messenger` services, the application log is also available at `storage/php_logs/` & `storage/messenger_logs/` on the host. ================================================ FILE: docs/02-admin/05-troubleshooting/README.md ================================================ # Troubleshooting For troubleshooting, see also the [FAQ page we have created](../FAQ.md)! And to get more (debug) **logging output** see: - [Bare metal setup troubleshooting page](./01-bare_metal.md) Or: - [Docker troubleshooting page](./02-docker.md) ================================================ FILE: docs/02-admin/FAQ.md ================================================ # FAQ See below our Frequently Asked Questions (FAQ). The questions (and corresponding answers) below are in random order. ## Where can I find more info about AP? There exists an official [ActivityPub specification](https://www.w3.org/TR/activitypub/), as well as [several AP extensions](https://codeberg.org/fediverse/fep/) on this specification. There is also a **very good** [forum post on activitypub.rocks](https://socialhub.activitypub.rocks/t/guide-for-new-activitypub-implementers/479), containing a lot of links and resources to various documentation and information pages. ## How to setup my own Mbin instance? Have a look at our guides. Both a bare metal/VM setup and a Docker setup is provided. ## I have an issue! You can [join our Matrix community](https://matrix.to/#/#mbin:melroy.org) and ask for help, and/or make an [issue ticket](https://github.com/MbinOrg/mbin/issues) in GitHub if that adds value (always check for duplicates). See also our [contributing page](../03-contributing/README.md). ## How can I contribute? New contributors are always _warmly welcomed_ to join us. The most valuable contributions come from helping with bug fixes and features through Pull Requests. As well as helping out with [translations](https://hosted.weblate.org/engage/mbin/) and documentation. Read more on our [contributing page](../03-contributing/README.md). Do _not_ forget to [join our Matrix community](https://matrix.to/#/#mbin:melroy.org). ## What is Matrix? Matrix is an open-standard, decentralized, and federated communication protocol. You can the [download clients for various platforms here](https://matrix.org/ecosystem/clients/). As a part of our software development and discussions, Matrix is our primary platform. ## What is Mercure? Mercure is a _real-time communication protocol_ and server that facilitates server-sent _events_ for web applications. It enables _real-time updates_ by allowing clients to subscribe and receiving updates pushed by the server. Mbin uses Mercure (optionally), on very large instances you might want to consider disabling Mercure whenever it _degrades_ our server performance. ## What is Redis? Redis is a _persinstent key-value store_ that runs in-memory, which can help for caching purposes or other storage requirements. We **recommend** to setup Redis/Valkey or KeyDB (pick one) when running Mbin. ## What is RabbitMQ? RabbitMQ is an open-source _message broker_ software that facilitates the exchange of messages between different server instances (in our case ActivityPub messages), using queues to store and manage messages. We highly **recommend** to setup RabbitMQ on your Mbin instance, but RabbitMQ is optional. Failed messages are no longer stored in RabbitMQ, but in PostgreSQL instead (table: `public.messenger_messages`). Read more below about AMQProxy. ## What is AMQProxy? AMQProxy is a proxy service for AMQP (Advanced Message Queuing Protocol) most used with message brokers like RabbitMQ. It allows for channel pooling and reusing, hence reducing the AMQP protocol (TCP packages) overhead. AMQProxy is a proxy service for AMQP (Advanced Message Queuing Protocol), most often used with message brokers like RabbitMQ. It allows for channel pooling and reuse, significantly reducing AMQP protocol overhead and TCP connection. By maintaining persistent connections to the broker, AMQProxy minimizes connection setup latency and resource consumption, improving throughput and scalability for high-load applications. It also simplifies client configuration and load balancing by acting as a single entry point between multiple clients and one (or more) RabbitMQ instances. Therefor we highly **recommend** to use RabbitMQ together with AMQProxy for more efficient messenger processing and higher performance. Especially when all services are hosted on the same server. ## How do I know Redis is working? Execute: `sudo redis-cli ping` expect a PONG back. If it requires authentication, add the following flags: `--askpass` to the `redis-cli` command. Ensure you do not see any connection errors in your `var/log/prod.log` file. In the Mbin Admin settings, be sure to also enable Mercure: ![image](https://github.com/MbinOrg/mbin/assets/628926/7a955912-57c1-4d5a-b0bc-4aab6e436cb4) When you visit your own Mbin instance domain, you can validate whether a connection was successfully established between your browser (client) and Mercure (server), by going to the browser developer toolbar and visit the "Network" tab. The browser should successfully connect to the `https:///.well-known/mercure` URL (thus without any errors). Since it's streaming data, don't expect any response from Mercure. ## How do I know RabbitMQ is working? Execute: `sudo rabbitmqctl status`, that should provide details about your RabbitMQ instance. The output should also contain information about which plugins are installed, various usages and on which ports it is listening on (eg. `5672` for AMQP protocol). Ensure you do not see any connection errors in your `var/log/prod-{YYYY-MM-DD}.log` file. Talking about plugins, we advise to also enable the `rabbitmq_management` plugin by executing: ```sh sudo rabbitmq-plugins enable rabbitmq_management ``` Let's create a new admin user in RabbitMQ (replace `` and `password` with a username & password you like to use): ```sh sudo rabbitmqctl add_user ``` Give this new user administrator permissions (`-p /` is the virtual host path of RabbitMQ, which is `/` by default): ```sh # Again don't forget to change to your username in the lines below sudo rabbitmqctl set_user_tags administrator sudo rabbitmqctl set_permissions -p / ".*" ".*" ".*" ``` Now you can open the RabbitMQ management page: (insecure connection!) `http://:15672` with the username and the password provided earlier. [More info can be found here](https://www.rabbitmq.com/management.html#getting-started). See screenshot below of a typical small instance of Mbin running RabbitMQ management interface ("Queued message" of 4k or even 10k is normal after recent Mbin changes, see down below for more info): ![Typical load on very small instances](../images/rabbit_small_load_typical.png) ## Messenger Queue is building up even though my messengers are idling We recently changed the messenger config to retry failed messages 3 times, instead of sending them straight to the `failed` queue. RabbitMQ will now have new queues being added for the different delays (so a message does not get retried 5 times per second): ![Queue overview](../images/rabbit_queue_tab_cut.png) The global overview from RabbitMQ shows the ready messages for all queues combined. Messages in the retry queues count as ready messages the whole time they are in there, so for a correct ready count you have to go to the queue specific overview. | Overview | Queue Tab | "Message" Queue Overview | | ------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------- | | ![Queued messages](../images/rabbit_queue_overview.png) | ![Queue overview](../images/rabbit_queue_tab.png) | ![Message Queue Overview](../images/rabbit_messages_overview.png) | ## RabbitMQ Prometheus exporter See [RabbitMQ Docs](https://rabbitmq.com/prometheus.html) If you are running the prometheus exporter plugin you do not have queue specific metrics by default. There is another endpoint with the default config that you can scrape, that will return queue metrics for our default virtual host `/`: `/metrics/detailed?vhost=%2F&family=queue_metrics` Example scrape config: ```yaml scrape_configs: - job_name: "mbin-rabbit_queues" static_configs: - targets: ["example.org"] metrics_path: "/metrics/detailed" params: vhost: ["/"] family: [ "queue_coarse_metrics", "queue_consumer_count", "channel_queue_metrics", ] ``` ## How to clean-up all failed messages? If you want to delete all failed messages (`failed` queue) you can execute the following command: ```bash ./bin/console mbin:messenger:failed:remove_all ``` And if you want to delete the dead messages (`dead` queue) you can execute the following command: ```bash ./bin/console mbin:messenger:dead:remove_all ``` _Hint:_ Most messages that are stored in the database are most likely in the `failed` queue, thus running the first command (`mbin:messenger:failed:remove_all`) will most likely delete all messages in the `messenger_messages` table. Regularly running this command will keep your database clean. ## Where can I find my logging? You can find the Mbin logging in the `var/log/` directory from the root folder of the Mbin installation. When running production the file is called `prod-{YYYY-MM-DD}.log`, when running development the log file is called `dev-{YYYY-MM-DD}.log`. See also [troubleshooting (bare metal)](./05-troubleshooting/01-bare_metal.md). ## Should I run development mode? **NO!** Try to avoid running development mode when you are hosting our own _public_ instance. Running in development mode can cause sensitive data to be leaked, such as secret keys or passwords (eg. via development console). Development mode will log a lot of messages to disk (incl. stacktraces). That said, if you are _experiencing serious issues_ with your instance which you cannot resolve by looking at the log file (`prod-{YYYY-MM-DD}.log`) or server logs, you can try running in development mode to debug the problem or issue you are having. Enabling development mode **during development** is also very useful. ## I changed my .env configuration but the error still appears/new config doesn't seem to be applied? After you edited your `.env` configuration file on a bare metal/VM setup, you always need to execute the `composer dump-env` command (in Docker you just restart the containers). Running the `post-upgrade` script will also execute `composer dump-env` for you: ```bash ./bin/post-upgrade ``` **Important:** If you want to switch between `prod` to `dev` (or vice versa), you need explicitly execute: `composer dump-env dev` or `composer dump-env prod` respectively. Followed by restarting the services that are depending on the (new) configuration: ```bash # Clear PHP Opcache by restarting the PHP FPM service sudo systemctl restart php8.4-fpm.service # Restarting the PHP messenger jobs and Mercure service (also reread the latest configuration) sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl restart all ``` ## How to retrieve missing/update remote user data? If you want to update all the remote users on your instance, you can execute the following command (which will also re-download the avatars): ```bash ./bin/console mbin:ap:actor:update ``` _Important:_ This might have quite a performance impact (temporarily), if you are running a very large instance. Due to the huge amount of remote users. ## Running `php bin/console mbin:ap:keys:update` does not appear to set keys If you're seeing this error in logs: > getInstancePrivateKey(): Return value must be of type string, null returned At time of writing, `getInstancePrivateKey()` [calls out to the Redis cache](https://github.com/MbinOrg/mbin/blob/main/src/Service/ActivityPub/ApHttpClient.php#L348) first, so any updates to the keys requires a `DEL instance_private_key instance_public_key` (or `FLUSHDB` to be certain, as documented here: [bare metal](04-running-mbin/03-upgrades.md#clear-cache) and [docker](04-running-mbin/03-upgrades.md#clear-cache-1)) ## RabbitMQ shows a really high publishing rate First thing you should do to debug the issue is looking at the "Queues and Streams" tab to find out what queues have the high publishing rate. If the queue/s in question are `inbox` and `resolve` it is most likely a circulating `ChainActivityMessage`. To verify that assumption: 1. stop all messengers - if you're on bare metal, as root: `supervisorctl stop messenger:*` - if you're on docker: `docker compose down messenger` 2. look again at the publishing rate. If it has gone down, then it definitely is a circulating message To fix the problem: 1. start the messengers if they are not already started 2. go to the `resolve` queue 3. open the "Get Message" panel 4. change the `Ack Mode` to `Automatic Ack` 5. As long as your publishing rate is still high, press the `Get Message` button. It might take a few tries before you got all of them and you might get a "Queue is empty" message a few times ### Discarding queued messages If you believe you have a queued message that is infinitely looping / stuck, you can discard it by setting the `Get messages` `Ack mode` in RabbitMQ to `Reject requeue false` with a `Messages` setting of `1` and clicking `Get message(s)`. > [!WARNING] > This will permanently discard the payload ![Rabbit discard payload](../images/rabbit_reject_requeue_false.png) ## Performance hints - [Resolve cache images in background](https://symfony.com/bundles/LiipImagineBundle/current/optimizations/resolve-cache-images-in-background.html#symfony-messenger) ## References - [https://symfony.com/doc/current/setup.html](https://symfony.com/doc/current/setup.html) - [https://symfony.com/doc/current/deployment.html](https://symfony.com/doc/current/deployment.html) - [https://symfony.com/doc/current/setup/web_server_configuration.html](https://symfony.com/doc/current/setup/web_server_configuration.html) - [https://symfony.com/doc/current/messenger.html#deploying-to-production](https://symfony.com/doc/current/messenger.html#deploying-to-production) - [https://codingstories.net/how-to/how-to-install-and-use-mercure/](https://codingstories.net/how-to/how-to-install-and-use-mercure/) ================================================ FILE: docs/02-admin/README.md ================================================ # Admin Welcome to the admin section of the Mbin documentation. ## Installation You can install Mbin via: - [Bare metal](01-installation/01-bare_metal.md) - or via [Docker](01-installation/02-docker.md) ## Configuration - [Mbin configuration files (Symfony)](02-configuration/01-mbin_config_files.md) - [Nginx configuration](02-configuration/02-nginx.md) - [Let's Encrypt](02-configuration/03-lets_encrypt.md) - [PostgreSQL database](02-configuration/04-postgresql.md) - [Redis, KeyDB, Valkey cache configuration](02-configuration/05-redis.md) ## Optional features Optional features like Mercure, SSO, Captcha, Image metadata cleaning or S3 storage: [More information on the optional features page](./03-optional-features/README.md) ## Running Mbin - [First setup](04-running-mbin/01-first_setup.md) - [Backup](04-running-mbin/02-backup.md) - [Upgrades](04-running-mbin/03-upgrades.md) - [Symfony messenger](04-running-mbin/04-messenger.md) - [Command-line maintenance tool (CLI)](04-running-mbin/05-cli.md) ## Troubleshooting See [FAQ](FAQ.md). And how-to get logging information see either: - [Bare metal troubleshooting](05-troubleshooting/01-bare_metal.md) - [Docker troubleshooting](05-troubleshooting/02-docker.md) ## FAQ See [Frequently Asked Questions (FAQ) page](./FAQ.md). ================================================ FILE: docs/03-contributing/01-getting_started.md ================================================ # Getting started as a developer There are several ways to get started. Like using the Docker setup or use the development server, which is explained in detail below. The code is mainly written in PHP using the Symfony framework with Twig templating and a bit of JavaScript & CSS and of course HTML. ## Docker as a dev server To save yourself much time setting up a development server, you can use our Docker setup instead of a manual configuration: 1. Make sure you are currently in the root of your Mbin directory. 2. Run the auto setup script with `./docker/setup.sh dev localhost` to configure `.env`, `compose.override.yaml`, and `storage/`. > [!NOTE] > The Docker setup uses ports `80` and `443` by default. If you'd prefer to use a different port for development on your device, then in `.env` you'll need to update `KBIN_DOMAIN` and `KBIN_STORAGE_URL` to include the port number (e.g., `localhost:8443`). Additionally, add the following to `compose.dev.yaml` under the `php` service: > > ```yaml > ports: !override > - 8443:443 > ``` 3. Run `docker compose up` to build and start the Docker containers. Please note that the first time you start the containers, they will need an extra minute or so to install dependencies before becoming available. 4. From here, you should be able to access your server at [https://localhost/](https://localhost/). Any edits to the source files will automatically rebuild your server. 5. Optionally, follow the [Mbin first setup](../02-admin/04-running-mbin/01-first_setup.md) instructions. 6. If you'd like to enable federation capabilities, then in `compose.dev.yaml`, change the two lines from `replicas: 0` to `replicas: 1` (under the `messenger` and `rabbitmq` services). Make sure you've ran the containers at least once before doing this, to give the `php` service a chance to install dependencies without overlap. > [!NOTE] > If you'd prefer to manually configure your Docker environment (instead of using the setup script) then follow the manual environment setup steps in the [Docker install guide](../02-admin/01-installation/02-docker.md), but while you're creating `compose.override.yaml`, use the following: > > ```yaml > include: > - compose.dev.yaml > ``` > [!TIP] > Once you are done with your development server and would like to shutdown the Docker containers, hit `Ctrl+C` in your terminal. If you'd prefer a development setup without using Docker, then continue on the section [Bare metal installation](#bare-metal-installation). ## Dev Container This project also provides a configuration to create a Dev Container which can be launched from IDEs which support it. To use it, follow these steps: 1. If you are using Podman, then uncomment the lines below `Uncomment if you are using Podman` in `./.devcontainer/devcontainer.json` 2. Adjust values in: `./.devcontainer/.env.devcontainer`: 1. `SERVER_NAME`: change `mbin.domain.tld` to `localhost` 2. `KBIN_DOMAIN`: change to `localhost` 3. `KBIN_STORAGE_URL`: change `https://mbin.domain.tld/media` to `http://localhost:8080/media` (or whatever port you set in `devcontainer.json`) 3. Start and open the Dev Container 4. Run `chmod o+rwx public/` 5. Check if all needed services are running: `sudo service --status-all`; services which should have a `+`: - apache2 - apache-htcacheclean - postgresql - rabbitmq-server - redis-server 6. If some service are not running, try: - Start it with `sudo service start` - If postgres fails: `sudo chmod -R postgres:postgres /var/lib/postgresql/` 7. Run `bin/console doctrine:migrations:migrate` 8. Run `npm install && npm run dev` 9. Open `http://localhost:8080` in a browser; you should see some status page or the Mbin startpage 10. Run `sudo find public/ -type d -exec chgrp www-data '{}' \;` and `sudo find public/ -type d -exec chmod g+rwx '{}' \;` 11. You can now follow the [initial configuration guide](../02-admin/04-running-mbin/01-first_setup.md) > [!TIP] > If you get at some point an error with `Expected to find class while importing services from resource "../src/", but it was not found!` > you can fix this by running `composer dump-autoload`. ### OAuth keys If you want to use OAuth for the API, do the following **before** creating the Dev Container: 1. Generate the key material by following *OAuth2 keys for API credential grants* in [Bare Metal/VM Installation](../02-admin/01-installation/01-bare_metal.md) 2. Configure the described env variables in: `./.devcontainer/.env.devcontainer` (they are already declared at the end of the file) 3. After the Dev Container is created and opened: 1. Run `chgrp www-data ./config/oauth2/private.pem` 2. Run `chmod g+r ./config/oauth2/private.pem` ### Running tests To run test inside the Dev Container, some preparation is needed. These steps have to be repeated after every recreation of the Container: 1. Run: `sudo pg_createcluster 18 tests --port=5433 --start` 2. Run: `sudo su postgres -c 'psql -p 5433 -U postgres -d postgres'` 3. Inside the SQL shell, run: `CREATE USER mbin WITH PASSWORD 'ChangeThisPostgresPass' SUPERUSER;` Now the testsuite can be launched with: `SYMFONY_DEPRECATIONS_HELPER=disabled ./bin/phpunit tests/Unit` or: `SYMFONY_DEPRECATIONS_HELPER=disabled ./bin/phpunit tests/Functional/`.\ For more information, read the [Testing](#testing) section on this page. ## Bare metal installation ### Initial setup Requirements: - PHP v8.3 or higher - NodeJS v20 or higher - Valkey / KeyDB / Redis (pick one) - PostgreSQL - _Optionally:_ Mercure - _Optionally:_ Symfony CLI First install some generic packages you will need: ```sh sudo apt update sudo apt install lsb-release ca-certificates curl wget unzip gnupg apt-transport-https software-properties-common git valkey-server ``` ### Clone the code With an account on [GitHub](https://github.com) you will be able to [fork this repository](https://github.com/MbinOrg/mbin). Once you forked the GitHub repository you can clone it locally (our advice is to use SSH to clone repositories from GitHub): ```sh git clone git-repository-url ``` For example: ```sh git clone git@github.com:MbinOrg/mbin.git ``` > [!TIP] > You do not need to fork the GitHub repository if you are member of our Mbin Organisation on GitHub. Just create a new branch right away. ### Prepare PHP 1. Install PHP + additional PHP extensions: ```sh sudo apt install php8.4 php8.4-common php8.4-fpm php8.4-cli php8.4-amqp php8.4-bcmath php8.4-pgsql php8.4-gd php8.4-curl php8.4-xml php8.4-redis php8.4-mbstring php8.4-zip php8.4-bz2 php8.4-intl php8.4-bcmath -y ``` 2. Fine-tune PHP settings: - Increase execution time in PHP config file: `/etc/php/8.4/fpm/php.ini`: ```ini max_execution_time = 120 ``` - _Optional:_ Increase/set max_nesting_level in `/etc/php/8.4/fpm/conf.d/20-xdebug.ini` (in case you have the `xdebug` extension installed): ```ini xdebug.max_nesting_level=512 ``` 3. Restart the PHP-FPM service: ```sh sudo systemctl restart php8.4-fpm.service ``` ### Prepare PostgreSQL DB 1. Install PostgreSQL: ```sh sudo apt-get install postgresql postgresql-contrib ``` 2. Connect to PostgreSQL using the postgres user: ```sh sudo -u postgres psql ``` 3. Create a new `mbin` database user with database: ```sql sudo -u postgres createuser --createdb --createrole --pwprompt mbin ``` 4. If you are using `127.0.0.1` to connect to the PostgreSQL server, edit the following file: `/etc/postgresql//main/pg_hba.conf` and add: ```conf local mbin mbin md5 ``` 5. Finally, restart the PostgreSQL server: ``` sudo systemctl restart postgresql ``` ### Prepare dotenv file 1. Change to the `mbin` git repository directory (if you weren't there already). 2. Copy the dot env file: `cp .env.example .env`. And let's configure the `.env` file to your needs. Pay attention to the following changes: ```ini # Set domain to 127.0.0.1:8000 SERVER_NAME=127.0.0.1:8000 KBIN_DOMAIN=127.0.0.1:8000 KBIN_STORAGE_URL=http://127.0.0.1:8000/media # Valkey/Redis (without password) REDIS_DNS=redis://127.0.0.1:6379 # Set App configs APP_ENV=dev APP_SECRET=427f5e2940e5b2472c1b44b2d06e0525 # Configure PostgreSQL POSTGRES_DB=mbin POSTGRES_USER=mbin # Change your PostgreSQL password for Mbin user POSTGRES_PASSWORD= # Set messenger to Doctrine (= PostgresQL DB) MESSENGER_TRANSPORT_DSN=doctrine://default ``` ### Change yaml configuration In case you are using Doctrine as the messenger transport (see `MESSENGER_TRANSPORT_DSN` above), then you will also need to comment-out all the `options:` sections in the `config/packages/messenger.yaml` file. So the whole section, for example: ```yaml # options: # queues: # receive: # arguments: # x-queue-version: 2 # x-queue-type: 'classic' # exchange: # name: receive ``` This is because those options are only meant for AMQP transport (like with RabbitMQ), but these options can **not** be used with Doctrine transport. ### Install Symfony CLI tool 1. Install Symfony CLI: `wget https://get.symfony.com/cli/installer -O - | bash` 2. Check the requirements: `symfony check:requirements` ### Fill Database 1. Assuming you are still in the `mbin` directory. 2. Create the database: `php bin/console doctrine:database:create` 3. Create tables and database structure: `php bin/console doctrine:migrations:migrate` ### Fixtures > [!TIP] > This fixtures section is optional. Feel free to skip this section. You might want to load random data to database instead of manually adding magazines, users, posts, comments etc. To do so, execute: ```sh php bin/console doctrine:fixtures:load --append --no-debug ``` --- If you have messenger jobs configured, be sure to stop them: - Docker: `docker compose stop messenger` - Bare Metal: `supervisorctl stop messenger:*` If you are using the Docker setup and want to load the fixture, execute: ```sh docker compose exec php bin/console doctrine:fixtures:load --append --no-debug ``` Please note, that the command may take some time and data will not be visible during the process, but only after the finish. - Omit `--append` flag to override data currently stored in the database - Customize inserted data by editing files inside `src/DataFixtures` directory ### Starting the development server Prepare the server: 1. Build frontend assets: `npm install && npm run dev` 2. Install dependencies: `composer install` 3. Dump `.env` into `.env.local.php` via: `composer dump-env dev` 4. _Optionally:_ Increase verbosity log level in: `config/packages/monolog.yaml` in the `when@dev` section: `level: debug` (instead of `level: info`), 5. **Important:** clear Symfony cache: `APP_ENV=dev APP_DEBUG=1 php bin/console cache:clear -n` 6. _Optionally:_ clear the Composer cache: `composer clear-cache` Start the development server: 6. Start Mbin: `symfony server:start` 7. Go to: [http://127.0.0.1:8000](http://127.0.0.1:8000/) > [!TIP] > Once you are done with your development server and would like to shut it down, hit `Ctrl+C` in your terminal. You might want to also follow the [Mbin first setup](../02-admin/04-running-mbin/01-first_setup.md). This explains how to create a user. This will give you a minimal working frontend with PostgreSQL setup. Keep in mind: this will _not_ start federating. _Optionally:_ If you want to start federating, you will also need to messenger jobs + RabbitMQ and host your server behind a reverse proxy with valid SSL certificate. Generally speaking, it's **not** required to setup federation for development purposes. More info: [Contributing guide](https://github.com/MbinOrg/mbin/blob/main/CONTRIBUTING.md), [Admin guide](../02-admin/README.md) and [Symfony Local Web Server](https://symfony.com/doc/current/setup/symfony_server.html) ## Testing When fixing a bug or implementing a new feature or improvement, we expect that test code will also be included with every delivery of production code. There are three levels of tests that we distinguish between: - Unit Tests: test a specific unit (SUT), mock external functions/classes/database calls, etc. Unit-tests are fast, isolated and repeatable - Integration Tests: test larger part of the code, combining multiple units together (classes, services or alike). - Application Tests: test high-level functionality, APIs or web calls. For more info read: [Symfony Testing guide](https://symfony.com/doc/current/testing.html). ### Prepare testing 1. First increase execution time in your PHP config file: `/etc/php/8.4/fpm/php.ini`: ```ini max_execution_time = 120 ``` 2. _Optional:_ Increase/set max_nesting_level in `/etc/php/8.4/fpm/conf.d/20-xdebug.ini` (in case you have the `xdebug` extension installed): ```ini xdebug.max_nesting_level=512 ``` 3. Restart the PHP-FPM service: `sudo systemctl restart php8.4-fpm.service` 4. Copy the dot env file (if you haven't already): `cp .env.example .env` 5. Install composer packages: `composer install --no-scripts` ### Running unit tests Running the unit tests can be done by executing: ```sh SYMFONY_DEPRECATIONS_HELPER=disabled ./bin/phpunit tests/Unit ``` ### Running integration tests Our integration tests depend on a database and a caching server (Valkey / KeyDB / Redis). The database and cache are cleared / dumped every test run. To start the services in the background: ```sh docker compose -f docker/tests/compose.yaml up -d ``` Then run all the integration test(s): ```sh SYMFONY_DEPRECATIONS_HELPER=disabled ./bin/phpunit tests/Functional ``` Or maybe better, run the non-thread-safe group using `phpunit`: ```sh SYMFONY_DEPRECATIONS_HELPER=disabled ./bin/phpunit tests/Functional --group NonThreadSafe ``` And run the remaining thread-safe integration tests using `paratest`, which runs the test in parallel: ```sh SYMFONY_DEPRECATIONS_HELPER=disabled php vendor/bin/paratest tests/Functional --exclude-group NonThreadSafe ``` ## Linting For linting see the [linting documentation page](02-linting.md). ================================================ FILE: docs/03-contributing/02-linting.md ================================================ # Linting ## PHP Code We use [php-cs-fixer](https://cs.symfony.com/) to automatically fix code style issues according to [Symfony coding standard](https://symfony.com/doc/current/contributing/code/standards.html). Install tooling via: ```sh composer -d tools install ``` Try to automatically fix linting errors: ```sh ./tools/vendor/bin/php-cs-fixer fix ``` _Note:_ First time you run the linter, it might take a while. After a hot cache, linting will be much faster. ## JavaScript Code For JavaScript inside the `assets/` directory, we use ESLint for linting and potentially fix the code style issues. Install Eslint and its required packages by: ```sh npm install ``` Run the following command to perform linting: ```sh npm run lint ``` Run the following command to attempt auto-fix linting issues: ```sh npm run lint-fix ``` Note that unlike PHP-CS-Fixer, _not all linting problems could be automatically fixed_, some of these would requires manually fixing them as appropriate, be sure to do those. ================================================ FILE: docs/03-contributing/03-project-overview.md ================================================ # Project Overview Mbin is a big project with a lot of code. We do not use an existing library to handle ActivityPub requests, therefore we have a lot of code to handle that. While that is more error-prone it is also a lot more flexible. ## Directory Structure - `.devcontainer` - Docker containers that are configured to provide a fully featured development environment. - `.github` - our GitHub specific CI workflows are stored here. - `assets` - the place for all our frontend code, that includes JavaScript and SCSS. - `bin` - only the Symfony console, PHPUnit and our `post-upgrade` script are stores here. - `ci` - Storing our CI/CD helper code / Dockerfiles. - `config` - the config files for Symfony are stored here. - `config/mbin_routes` the HTTP routes to our controllers are defined here. - `config/packages` all Symfony add-ons are configured here. - `docker` - some docker configs that are partly outdated. The one still in use is in `docker/tests`. - `docs` - you guessed it our documentation is stored here. - `LICENSES` - third party licenses. - `migrations` - all SQL migrations are stored here. - `public` - this is the publicly accessible directory through the webserver. There should mostly be compiled files in here. - `src` - that is where our PHP files are stored and the directory you will modify the most files. - `src/ActivityPub` - some things that are ActivityPub related and do not fit in another directory. - `src/ArgumentValueResolver` - `src/Command` - Every command that is executable via the symfone cli (`php bin/console`). - `src/Controller` - Every Controller, meaning every HTTP endpoint, belongs in the directory. - `src/DataFixtures` - The classes responsible for generating test data. - `src/DoctrineExtensions` - Some doctrine extensions, mainly to handle enums. - `src/Document` - `src/DTO` - **D**ata **T**ransport **O**bjects are exactly that, a form for the data that is transferable (e.g.: via API) . - `src/Entity` - The classes to represent the data stored in the database, a.k.a. database entities. - `src/Enums` - self-explanatory. - `src/Event` - self-explanatory. - `src/EventListener` - classes that listens on framework events. - `src/EventSubscriber` - classes subscribing to our own events. - `src/Exception` - self-explanatory. - `src/Factory` - classes that transform objects. Mostly entities to DTOs and ActivityPub objects to JSON. - `src/Feed` - The home for our RSS feed provider - `src/Form` - All form types belong to here, also other things related to forms. - `src/Markdown` - Everything markdown related: converter, extensions, events, etc. - `src/Message` - All classes sent to RabbitMQ (messaging queue system), they should always only contain primitives and never objects. - `src/MessageHandler` - Our background workers fetching messages from RabbitMQ, getting the `Message` objects, are stored here - `src/PageView` - page views are a collection of criteria to query for a specific view - `src/Pagination` - some extensions to the `PagerFanta` - `src/Payloads` - some objects passed via request body to controllers - `src/Provider` - some OAuth providers are stored here - `src/Repository` - the classes used to fetch data from the database - `src/Scheduler` - the schedule provider (regularly running tasks) - `src/Schema` - some OpenAPI schemas are stored here - `src/Security` - everything related to authentication and authorization should be stored here, that includes OAuth providers - `src/Service` - every service should be stored here. A service should be something that manipulates data or is checking for visibility, etc. - `src/Twig` - the PHP code related to Twig is stored here. That includes runtime extensions and component classes. - `src/Utils` - some general utils - `src/Validator` - `templates` - the Twig folder. All Twig files are stored in here. - `tests` - everything relating to testing is stored here. - `translations` - self-explanatory. ## Writing Code Our linter adds `declare(strict_types=1);` to every class, so the parameter typing has to be correct. Every class in the `src` directory can be injected in the constructor of a class. Be aware of cyclic dependencies. We will go over some common things one might want to add and further down we'll explain some concepts that Mbin makes use of. ### Changing the database schema To change the database schema one does not really need to do much. Change the corresponding `Entity`. For some info on doctrine, check out [their documentation](https://www.doctrine-project.org/projects/doctrine-orm/en/3.5/reference/basic-mapping.html). After you have changed the entity, open a terminal and go to the mbin repo and run: ```bash php bin:console doctrine:migrations:diff ``` This will create a class in the `migrations` directory. It might contain things really not relevant to you, so you have to manually check the changes created. > [!NOTE] > The `up` and `down` methods both have to be implemented. After modifying the migration to your needs, you can either have them be executed by running the `bin/post_upgrade` script or restarting the docker containers or manually execute them by running: ```bash php bin/console doctrine:migrations:execute [YOUR MIGRATION HERE] ``` After that your changes should have been applied to the database. > [!NOTE] > If your handling enums it is a bit more complicated as doctrine needs to know how to decode it. ### Adding a controller Adding a controller is very simple. You just need to add a class to the `src/Controller/` directory (and the subdirectory that can be applied) and then extend `AbstractController`. If your controller is a only-one-endpoint-controller then you can override the `__invoke` methode, but you can also just create a normal methode, that is up to you. After you've created the controller you have to configure a route from which this controller can be accessed. For that you have to go into the `config/mbin_routes` directory and pick a `yaml` file which fits your controller (or create a new one if none of them fit). Then you just add something like this: ```yaml your_route_name: controller: App\Controller\YourControllerName::yourMethodeName path: /path/to/your/controller methods: [GET] ``` > [!TIP] > You can look at other examples in there or look at [Symfony's documentation](https://symfony.com/doc/current/routing.html#creating-routes-in-yaml-xml-or-php-files). > [!NOTE] > We do not use the attribute style for defining routes. Your controller needs to return a response. The most common way to do that is to return a rendered Twig template: ```php return $this->render('some_template.html.twig') ``` > [!TIP] > You can also pass parameters/variables to the Twig template so it has access to it. You also have to think about permissions a user needs to access an endpoint. On "normal" controllers we do that by added an `IsGranted` attribute like this: ```php #[IsGranted('ROLE_USER')] public function someControllerMethod(): Response ``` The options there are (OAuth has a lot more of them): 1. `ROLE_USER`: a logged-in user, anonymouse access is not allowed 2. `ROLE_MODERATOR`: a global moderator 3. `ROLE_ADMIN`: an instance admin > [!NOTE] > There are also so called `Voters` which can determine whether a user has access to specific content, > which we mostly use in the API at the moment (the syntax is `#[IsGranted('expression', 'subject')]`). > [Symfony documentation](https://symfony.com/doc/current/security/voters.html) ### Adding an API controller This is much the same as the "normal" controller, except that you extend `BaseApi` (or another class derived from that) instead of `AbstractController`. Additionally, you have to return a `JsonResponse` instead of rendering a Twig template and declare the correct OpenAPI attributes on your controller methods, so that the OpenAPI definition is generated accordingly. To check for that you can visit `/api/docs` on your local instance and check for your method and how it is documented there. ## Explanation of some concepts In this paragraph we'll explain some of our core concepts and nomenclature we use in our code. Some Mbin terms: 1. `Entry`: an entry is the database representation of a thread. We use the same object for Threads, Links or images. 2. `Post`: a post is called "Microblog" in the UI. The main differentiator from an `Entry` is the missing `title` property. 3. `Favourite`: we have the `favourite` table which contains all the upvotes of entries and all the likes of posts. 4. `*_votes`: the tables `entry_votes`, `entry_comment_votes`, `post_votes` and `post_comment_votes` contain all the downvotes and **boosts**. This is very confusing and will be changed in the future. The `choice` property can either be `1`, `0` or `-1`. It is at `0` if the user had voted, but decided to undo that. `1` equals a boost, while `-1` equals a downvote. That does of course mean that a user cannot downvote and boost content at the same time. ### Federation Federation is generally handled by our `MessageHandler`s in the background. When talking about federation we generally need to differentiate between incoming/inbound/inbox federation and outgoing/outbound/outbox federation. Because of that a lof `MessageHandler`s with the same name exist in an `Inbox` and an `Outbox` directory, doing completely different things. The name of the message handler is usually the type of activity it handles (see sources in [Federation](./04-about-federation.md)) followed by `Handler` (e.g.: `AnnounceHandler`, `LikeHandler`, `CreateHandler`, etc.). Outgoing federation is usually triggered by some event subscriber (e.g.: `UserEditedSubscriber`), which sends a specific `Message` to the `MessageBusInterface`, meaning (in our case) to RabbitMQ. In the background (handled by another docker container or supervisor) we have some processes retrieving messages from RabbitMQ to process them. Inbox federation is triggered by other instances sending activities to an inbox (`InstanceInboxController`, `SharedInboxController`, `UserInboxController` or `MagazineInboxController`), which sends a new `ActivityMessage` to RabbitMQ. This is then handled by the `ActivityHandler`, which then determines whether it is valid, has a correct signature, etc. and sends another message to RabbitMQ depending on the type of activity it received. ### The markdown compilation process Since this process is implemented in a complicated way we are going to explain it here. The classes relevant here are 1. `ConvertMarkdown` - the event used to transfer the data to different event subscribers 2. `MarkdownConverter` - the class that needs to be called to convert markdown to html 3. `CacheMarkdownListener` - handling the caching of converted markdown 4. `ConvertMarkdownListener` - the class actually converting the markdown The important thing is the priority of the registered event listeners, which are: 1. At `-64`: `CacheMarkdownListener::preConvertMarkdown` 2. At `0`: `ConvertMarkdownListener::onConvertMarkdown` 3. At `64`: `CacheMarkdownListener::postConvertMarkdown` So what is happening here is simply: check if we already cached the result of the request, if so: return it, if not then compile it and safe the result to the cache. ### The random magazine Every Mbin server has to have a magazine with the name `random`. The cause of this is simple: every `Entry` and `Post` has to have a magazine assigned to it. Since microblog posts coming from other platforms such as Mastodon do not necessarily have a magazine associated with them (though they might via mentioning) we have this fallback. It is not the right way to do it, since the software should just be able to handle content without a magazine, but we are not there, yet. The random magazine has a bit of a special treatment in some places: 1. Nobody from another server can subscribe to it 2. The magazine does not announce anything (you could previously subscribe to it, so this was an additional safeguard against announcing every incoming microblog to other servers) 3. It cannot be found via webfinger request ================================================ FILE: docs/03-contributing/04-about-federation.md ================================================ # About Federation ## Official Documents - [ActivityPub standard](https://www.w3.org/TR/activitypub/) - [ActivityPub vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/) - [Activity Streams](https://www.w3.org/TR/activitystreams-core/) ## Unofficial Sources - [A highly opinionated guide to learning about ActivityPub](https://tinysubversions.com/notes/reading-activitypub/) - [ActivityPub as it has been understood](https://flak.tedunangst.com/post/ActivityPub-as-it-has-been-understood) - [Schema Generator 3: A Step Towards Redecentralizing the Web!](https://dunglas.fr/2021/01/schema-generator-3-a-step-towards-redecentralizing-the-web/) - [API Platform ActivityPub](https://github.com/api-platform/activity-pub) ================================================ FILE: docs/03-contributing/README.md ================================================ # Contributing Thanks for considering contributing to Mbin! We appreciate your interest in helping us improve the project. ## Code Mbin uses a number of frameworks for different purposes: 1. Symfony - the PHP framework that runs it all. This is our backend framework, which is handling all requests and connecting all the other frameworks together. 2. Doctrine - our ORM to make it easier to make calls to the database. 3. Stimulus - the frontend framework that ties in nicely to Twig and Symfony. It is very vanilla JavaScripty, but allows for easily reusable component code. 4. Twig - a simple templating language to write reusable components that are rendered through PHP, so on the server-side. Follow the [getting started instructions](01-getting_started.md) to setup a development server. ## Coding Style Please, follow the [linting guide](02-linting.md). ## Way of Working Comply with **our version** of [Collective Code Construction Contract (C4)](03-C4.md) specification. Read this document to understand how we work together and how the development process works at Mbin. ## Translations Translations are done in [Weblate](https://hosted.weblate.org/projects/mbin/). ## Documentation Documentation is stored at in the [`docs` folder](https://github.com/MbinOrg/mbin/tree/main/docs) within git. Create a [new pull request](https://github.com/MbinOrg/mbin/pulls) with changes to the documentation files. ## Community We have a very active [Matrix community](https://matrix.to/#/#mbin:melroy.org). Feel free to join our community, ask questions, share your ideas or help others! ## Reporting Issues If you observe an error or any other issue, [create an new issue](https://github.com/MbinOrg/mbin/issues) in GitHub. And select the correct issue template. ## Reporting Security Vulnerability Contact Melroy (`@melroy:melroy.org`) or any other community member you trust via Matrix chat, using an encrypted room. ================================================ FILE: docs/04-app_developers/README.md ================================================ # App Developers If you wish to develop an app that uses the Mbin API end-points, that is possible by using the OAuth2. ## API Endpoints For all the API endpoints go to the [API documentation page](https://docs.joinmbin.org/api/). Or use the Swagger documentation on an existing Mbin instance: `https://mbin_site.com/api/docs`. Assuming you setup the server and the API correctly. ## OAuth2 Guide ### Available Grants 1. `client_credentials` - [documentation here](https://www.oauth.com/oauth2-servers/access-tokens/client-credentials/) - Best used for bots and clients that only ever need to authenticate as a single user, from a trusted device. - Note that bots authenticating with this grant type will be distinguished as bots and will not be allowed to vote on content. 2. `authorization_code` - [documentation here](https://www.oauth.com/oauth2-servers/access-tokens/authorization-code-request/) - public clients must use [PKCE](https://www.oauth.com/oauth2-servers/pkce/) to authenticate. - A public client is any client that will be installed on a device that is not controlled by the client's creator - Native apps - Single page web apps - Or similar 3. `refresh_token` - [documentation here](https://www.oauth.com/oauth2-servers/making-authenticated-requests/refreshing-an-access-token/) - Refresh tokens are used with the `authorization_code` grant type to reduce the number of times the user must log in. ### Obtaining OAuth2 credentials from a new server > [!NOTE] > Some of these structures contain comments that need to be removed before making the API calls. Copy/paste with care. 1. Create a private OAuth2 client (for use in secure environments that you control), the post request body should be JSON. ``` POST /api/client { "name": "My OAuth2 Authorization Code Client", "contactEmail": "contact@some.dev", "description": "A client that I will be using to authenticate to /mbin's API", "public": false, "redirectUris": [ "https://localhost:3000/redirect", "myapp://redirect" ], "grants": [ "authorization_code", "refresh_token" ], # All the scopes the client will be allowed to request # See following section for a list of available fine grained scopes. "scopes": [ "read" ] } ``` 2. Save the identifier and secret returned by this API call - this will be the only time you can access the secret for a private client. ```json { "identifier": "someRandomString", "secret": "anEvenLongerRandomStringThatYouShouldKeepSafe", ... # more info about the client that just confirms what you've created } ``` 3. Use the OAuth2 client id (`identifier`) and `secret` you just created to obtain credentials for a user (This is a standard authorization_code OAuth2 flow, which is supported by many libraries for your preferred language) 1. Begin authorization_code OAuth2 flow, by providing the `/authorize` endpint with the following query parameters: ``` GET /authorize?response_type=code&client_id=(the client id generated at client creation)&redirect_uri=(One of the URIs added during client creation)&scope=(space-delimited list of scopes)&state=(random string for CSRF protection) ``` 2. The user will be directed to log in to their account and grant their consent for the scopes you have requested. 3. When the user grants their consent, their browser will be redirected to the given redirect_uri with a `code` query parameter, as long as it matches one of the URIs provided when the client was created. 4. After obtaining the code, obtain an authorization token with a `multipart/form-data` POST request towards the `/token` endpoint: ``` POST /token grant_type=authorization_code client_id=(the client id generated at client creation) client_secret=(the client secret generated at client creation) code=(OAuth2 code received from redirect) redirect_uri=(One of the URIs added during client creation) ``` 5. The `/token` endpoint will respond with the access token, refresh token and information about it: ```json { "token_type": "Bearer", "expires_in": 3600, // seconds "access_token": "aLargeEncodedTokenToBeUsedInTheAuthorizationHeader", "refresh_token": "aLargeEncodedTokenToBeUsedInTheRefreshTokenFlow" } ``` 6. Once you have obtained an access token, you can use it to make authenticated requests to the API end-points that need authentication. This is done by adding the `Authorization` header to the request with the value: `Bearer `. ### Available Scopes #### Scope tree 1. `read` - Allows retrieval of threads from the user's subscribed magazines/domains and viewing the user's favorited entries. 2. `write` - Provides all of the following nested scopes - `entry:create` - `entry:edit` - `entry_comment:create` - `entry_comment:edit` - `post:create` - `post:edit` - `post_comment:create` - `post_comment:edit` 3. `delete` - Provides all of the following nested scopes, for deleting the current user's content - `entry:delete` - `entry_comment:delete` - `post:delete` - `post_comment:delete` 4. `subscribe` - Provides the following nested scopes - `domain:subscribe` - Allows viewing and editing domain subscriptions - `magazine:subscribe` - Allows viewing and editing magazine subscriptions - `user:follow` - Allows viewing and editing user follows 5. `block` - Provides the following nested scopes - `domain:block` - Allows viewing and editing domain blocks - `magazine:block` - Allows viewing and editing magazine blocks - `user:block` - Allows viewing and editing user blocks 6. `vote` - Provides the following nested scopes, for up/down voting and boosting content - `entry:vote` - `entry_comment:vote` - `post:vote` - `post_comment:vote` 7. `report` - Provides the following nested scopes - `entry:report` - `entry_comment:report` - `post:report` - `post_comment:report` 8. `domain` - Provides all domain scopes - `domain:subscribe` - `domain:block` 9. `entry` - Provides all entry scopes - `entry:create` - `entry:edit` - `entry:delete` - `entry:vote` - `entry:report` 10. `entry_comment` - Provides all entry comment scopes - `entry_comment:create` - `entry_comment:edit` - `entry_comment:delete` - `entry_comment:vote` - `entry_comment:report` 11. `magazine` - Provides all magazine user level scopes - `magazine:subscribe` - `magazine:block` 12. `post` - Provides all post scopes - `post:create` - `post:edit` - `post:delete` - `post:vote` - `post:report` 13. `post_comment` - Provides all post comment scopes - `post_comment:create` - `post_comment:edit` - `post_comment:delete` - `post_comment:vote` - `post_comment:report` 14. `user` - Provides all user access scopes - `user:profile` - `user:profile:read` - Allows access to current user's settings and profile via the `/api/user/me` endpoint - `user:profile:edit` - Allows updating the current user's settings and profile - `user:message` - `user:message:read` - Allows the client to view the current user's messages - Also allows the client to mark unread messages as read or read messages as unread - `user:message:create` - Allows the client to create new messages to other users or reply to existing messages - `user:notification` - `user:notification:read` - Allows the client to read notifications about threads, posts, or comments being replied to, as well as moderation notifications. - Does not allow the client to read the content of messages. Message notifications will have their content censored unless the `user:message:read` scope is granted. - Allows the client to read the number of unread notifications, and mark them as read/unread - `user:notification:delete` - Allows the client to clear notifications 15. `moderate` - grants all moderation permissions. The user must be a moderator to perform these actions - `moderate:entry` - Allows the client to retrieve a list of threads from magazines moderated by the user - `moderate:entry:language` - Allows changing the language of threads moderated by the user - `moderate:entry:pin` - Allows pinning/unpinning threads to the top of magazines moderated by the user - `moderate:entry:lock` - Allows locking/unlocking of threads - `moderate:entry:set_adult` - Allows toggling the NSFW status of threads moderated by the user - `moderate:entry:trash` - Allows soft deletion or restoration of threads moderated by the user - `moderate:entry_comment` - `moderate:entry_comment:language` - Allows changing the language of comments in threads moderated by the user - `moderate:entry_comment:set_adult` - Allows toggling the NSFW status of comments in threads moderated by the user - `moderate:entry_comment:trash` - Allows soft deletion or restoration of comments in threads moderated by the user - `moderate:post` - `moderate:post:language` - Allows changing the language of posts moderated by the user - `moderate:post:set_adult` - Allows toggling the NSFW status of posts moderated by the user - `moderate:post:trash` - Allows soft deletion or restoration of posts moderated by the user - `moderate:post:pin` - Allows pinning/unpinning posts to the top of magazines moderated by the user - `moderate:post:lock` - Allows locking/unlocking of posts - `moderate:post_comment` - `moderate:post_comment:language` - Allows changing the language of comments on posts moderated by the user - `moderate:post_comment:set_adult` - Allows toggling the NSFW status of comments on posts moderated by the user - `moderate:post_comment:trash` - Allows soft deletion or restoration of comments on posts moderated by the user - `moderate:magazine` - `moderate:magazine:ban` - `moderate:magazine:ban:read` - Allows viewing the users banned from the magazine - `moderate:magazine:ban:create` - Allows the client to ban a user from the magazine - `moderate:magazine:ban:delete` - Allows the client to unban a user from the magazine - `moderate:magazine:list` - Allows the client to view a list of magazines the user moderates - `moderate:magazine:reports` - `moderate:magazine:reports:read` - Allows the client to read reports about content from magazines the user moderates - `moderate:magazine:reports:action` - Allows the client to take action on reports, either accepting or rejecting them - `moderate:magazine:trash:read` - Allows viewing the removed content of a moderated magazine - `moderate:magazine_admin` - `moderate:magazine_admin:create` - Allows the creation of new magazines - `moderate:magazine_admin:delete` - Allows the deletion of magazines the user has permission to delete - `moderate:magazine_admin:update` - Allows magazine rules, description, settings, title, etc to be updated - `moderate:magazine_admin:theme` - Allows updates to the magazine theme - `moderate:magazine_admin:moderators` - Allows the addition or removal of moderators to/from an owned magazine - `moderate:magazine_admin:badges` - Allows the addition or removal of badges to/from an owned magazine - `moderate:magazine_admin:tags` - Allows the addition or removal of tags to/from an owned magazine - `moderate:magazine_admin:stats` - Allows the client to view stats from an owned magazine 16. `admin` - All scopes require the instance admin role to perform - `admin:entry:purge` - Allows threads to be completely removed from the instance - `admin:entry_comment:purge` - Allows comments in threads to be completely removed from the instance - `admin:post:purge` - Allows posts to be completely removed from the instance - `admin:post_comment:purge` - Allows post comments to be completely removed from the instance - `admin:magazine` - `admin:magazine:move_entry` - Allows an admin to move an entry to another magazine - `admin:magazine:purge` - Allows an admin to completely purge a magazine from the instance - `admin:magazine:moderate` - Allows an admin to accept or reject moderator and ownership requests of magazines - `admin:user` - `admin:user:ban` - Allows the admin to ban or unban users from the instance - `admin:user:verify` - Allows the admin to verify a user on the instance - `admin:user:purge` - Allows the admin to completely purge a user from the instance - `admin:instance` - `admin:instance:settings` - `admin:instance:settings:read` - Allows the admin to read instance settings - `admin:instance:settings:edit` - Allows the admin to update instance settings - `admin:instance:information:edit` - Allows the admin to update information on the About, Contact, FAQ, Privacy Policy, and Terms of Service pages. - `admin:federation` - `admin:federation:read` - Allows the admin to read a list of defederated instances - `admin:federation:update` - Allows the admin to edit the list of defederated instances - `admin:oauth_clients` - `admin:oauth_clients:read` - Allows the admin to read usage stats of oauth clients, as well as list clients on the instance - `admin:oauth_clients:revoke` - Allows the admin to revoke a client's permission to access the instance ================================================ FILE: docs/05-fediverse_developers/README.md ================================================ # Fediverse Developers This page is mainly for outlining the activities and circumstances Mbin sends out activities and how activities, objects and actors are represented. To communicate between instances, Mbin utilizes the ActivityPub protocol ([ActivityPub standard](https://www.w3.org/TR/activitypub/), [ActivityPub vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/)) and the [FEP Group federation](https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-1b12.md). ## Context The `@context` property for all Mbin payloads **should** be this: ```json %@context% ``` The `/contexts` endpoint resolves to this: ```json %@context_additional% ``` ## Actors The actors Mbin uses are - the Instance actor (AP `Application`) - the User actor (AP `Person`) - the Magazine actor (AP `Group`) ### Instance Actor Each instance has an instance actor at `https://instance.tld/i/actor` and `https://instance.tld` (they are the same): ```json %actor_instance% ``` ### User actor Each registered user has an AP actor at `https://instance.tld/u/username`: ```json %actor_user% ``` ### Magazine actor Each magazine has an AP actor at `https://instance.tld/m/name`: ```json %actor_magazine% ``` ## Objects ### Threads ```json %object_entry% ``` ### Comments on threads ```json %object_entry_comment% ``` ### Microblogs ```json %object_post% ``` ### Comments on microblogs ```json %object_post_comment% ``` ### Private message ```json %object_message% ``` ## Collections ### User Outbox ```json %collection_user_outbox% ``` First Page: ```json %collection_items_user_outbox% ``` ### User Followers ```json %collection_user_followers% ``` ### User Followings ```json %collection_user_followings% ``` ### Magazine Outbox The magazine outbox endpoint does technically exist, but it just returns an empty JSON object at the moment. ```json %collection_magazine_outbox% ``` ### Magazine Moderators The moderators collection contains all moderators and is not paginated: ```json %collection_magazine_moderators% ``` ### Magazine Featured The featured collection contains all threads and is not paginated: ```json %collection_magazine_featured% ``` ### Magazine Followers The followers collection does not contain items, it only shows the number of subscribed users: ```json %collection_magazine_followers% ``` ## User Activities ### Follow and unfollow If the user wants to follow another user or magazine: ```json %activity_user_follow% ``` If the user stops following another user or magazine: ```json %activity_user_undo_follow% ``` ### Accept and Reject Mbin automatically sends an `Accept` activity when a user receives a `Follow` activity. ```json %activity_user_accept% ``` ### Create ```json %activity_user_create% ``` ### Report ```json %activity_user_flag% ``` ### Vote When a user votes it is translated to a `Like` activity for an up-vote and a `Dislike` activity for a down-vote. Down-votes are not federated, yet. ```json %activity_user_like% ``` If the vote is removed: ```json %activity_user_undo_like% ``` ### Boost If a user boosts content: ```json %activity_user_announce% ``` ### Edit account ```json %activity_user_update_user% ``` ### Edit content ```json %activity_user_update_content% ``` ### Delete content ```json %activity_user_delete% ``` ### Delete own account ```json %activity_user_delete_account% ``` ### Lock own content Only top level content (meaning no comments) can be locked. When content is locked, comments can no longer be created for it. ```json %activity_user_lock% ``` ## Moderator Activities ### Add or Remove moderator When a moderator is added: ```json %activity_mod_add_mod% ``` When a moderator is removed: ```json %activity_mod_remove_mod% ``` ### Pin or Unpin a thread When a thread is pinned: ```json %activity_mod_add_pin% ``` When a thread is unpinned: ```json %activity_mod_remove_pin% ``` ### Delete content ```json %activity_mod_delete% ``` ### Ban user from magazine ```json %activity_mod_ban% ``` ### Lock content When content is locked, comments can no longer be created for it. ```json %activity_mod_lock% ``` ## Admin Activities ### Ban user from instance ```json %activity_admin_ban% ``` ### Delete account If an admin deletes another user's account the activity actually does not reflect that, it looks exactly as if the user deleted their own account. ```json %activity_admin_delete_account% ``` ## Magazine Activities ### Announce activities The magazine is mainly there to announce the activities users do with it as the audience. The announced type can be `Create`, `Update`, `Add`, `Remove`, `Announce`, `Delete`, `Like`, `Dislike`, `Flag` and `Lock`. `Announce(Flag)` activities are only sent to instances with moderators of this magazine on them. ```json %activity_mag_announce% ``` ================================================ FILE: docs/README.md ================================================ # Documentation We split up the documentation for: - [End users](01-user/README.md) - End user guide - [Admins](02-admin/README.md) - How-to guides for admins - [Contributing](03-contributing/README.md) - How-to contribute to the Mbin project - [App developers](04-app_developers/README.md) - How-to use the Mbin API - [Fediverse developers](05-fediverse_developers/README.md) - How Mbin is using the ActivityPub protocol to federate with other servers ================================================ FILE: docs/postman/kbin.postman_collection.json ================================================ { "info": { "_postman_id": "0a2a252b-440c-421a-8702-5019985fb288", "name": "mbin", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "_exporter_id": "13837673", "_collection_link": "https://red-crater-797471.postman.co/workspace/My-Workspace~e6f4fbde-d09f-4847-8a63-b7491fde7dc1/collection/13837673-0a2a252b-440c-421a-8702-5019985fb288?action=share&source=collection_link&creator=13837673" }, "item": [ { "name": "Authentication", "item": [ { "name": "Authorization Code", "item": [ { "name": "OAuth2 Authorize", "event": [ { "listen": "prerequest", "script": { "exec": [ "pm.environment.set(\"oauth_state\", pm.variables.replaceIn(\"{{$randomPassword}}\"));\r", "" ], "type": "text/javascript" } } ], "request": { "method": "GET", "header": [], "url": { "raw": "https://{{host}}/authorize?response_type=code&client_id={{oauth_client_id}}&redirect_uri=http://localhost:3001&scope=read write admin:oauth_clients:read&state={{oauth_state}}", "protocol": "https", "host": [ "{{host}}" ], "path": [ "authorize" ], "query": [ { "key": "response_type", "value": "code" }, { "key": "client_id", "value": "{{oauth_client_id}}" }, { "key": "redirect_uri", "value": "http://localhost:3001" }, { "key": "scope", "value": "read write admin:oauth_clients:read" }, { "key": "state", "value": "{{oauth_state}}" } ] } }, "response": [] }, { "name": "OAuth2 Token", "event": [ { "listen": "test", "script": { "exec": [ "pm.test(\"Has a token\", function () {\r", " var jsonData = pm.response.json();\r", " pm.expect(jsonData.access_token).to.exist;\r", " pm.environment.set(\"token\", jsonData.access_token);\r", " pm.expect(jsonData.refresh_token).to.exist;\r", " pm.environment.set(\"oauth_refresh_token\", jsonData.refresh_token);\r", "});" ], "type": "text/javascript" } } ], "request": { "method": "POST", "header": [], "body": { "mode": "formdata", "formdata": [ { "key": "grant_type", "value": "authorization_code", "type": "text" }, { "key": "client_id", "value": "{{oauth_client_id}}", "type": "text" }, { "key": "client_secret", "value": "{{oauth_client_secret}}", "type": "text" }, { "key": "code", "value": "{{oauth_code}}", "type": "text" }, { "key": "redirect_uri", "value": "http://localhost:3001", "type": "text" } ] }, "url": { "raw": "https://{{host}}/token", "protocol": "https", "host": [ "{{host}}" ], "path": [ "token" ] } }, "response": [] } ] }, { "name": "Auth Code with PKCE", "item": [ { "name": "OAuth2 Authorize PKCE", "event": [ { "listen": "prerequest", "script": { "exec": [ "// hash is a Uint8Array\r", "function base64_urlencode(hash) {\r", " return btoa(String.fromCharCode.apply(null, hash)).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\r", "}\r", "\r", "function sha256_to_array(sha256) {\r", " const array = new Uint8Array(sha256.sigBytes);\r", " for(var i = 0; i < sha256.sigBytes; i++) {\r", " array[i] = (sha256.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;\r", " }\r", " return array;\r", "}\r", "\r", "pm.environment.set(\"oauth_state\", pm.variables.replaceIn(\"{{$randomPassword}}\"));\r", "\r", "const array = new Uint8Array(64);\r", "array.forEach((_, index) => {\r", " array[index] = Math.floor(Math.random() * 256);\r", "});\r", "const alphabet = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~\";\r", "var verifier = \"\";\r", "array.forEach((value) => {\r", " verifier += alphabet[value % alphabet.length];\r", "});\r", "\r", "pm.environment.set(\"oauth_code_verifier\", verifier);\r", "\r", "const challenge = base64_urlencode(sha256_to_array(CryptoJS.SHA256(verifier)));\r", "\r", "pm.environment.set(\"oauth_code_challenge\", challenge);" ], "type": "text/javascript" } } ], "request": { "method": "GET", "header": [], "url": { "raw": "https://{{host}}/authorize?response_type=code&client_id={{oauth_public_client_id}}&redirect_uri=http://localhost:3001&scope=read write&state={{oauth_state}}&code_challenge={{oauth_code_challenge}}&code_challenge_method=S256", "protocol": "https", "host": [ "{{host}}" ], "path": [ "authorize" ], "query": [ { "key": "response_type", "value": "code" }, { "key": "client_id", "value": "{{oauth_public_client_id}}" }, { "key": "redirect_uri", "value": "http://localhost:3001" }, { "key": "scope", "value": "read write" }, { "key": "state", "value": "{{oauth_state}}" }, { "key": "code_challenge", "value": "{{oauth_code_challenge}}" }, { "key": "code_challenge_method", "value": "S256" } ] } }, "response": [] }, { "name": "OAuth2 Token PKCE", "event": [ { "listen": "test", "script": { "exec": [ "pm.test(\"Has a token\", function () {\r", " var jsonData = pm.response.json();\r", " pm.expect(jsonData.access_token).to.exist;\r", " pm.environment.set(\"token\", jsonData.access_token);\r", " pm.expect(jsonData.refresh_token).to.exist;\r", " pm.environment.set(\"oauth_public_refresh_token\", jsonData.refresh_token);\r", "});" ], "type": "text/javascript" } } ], "request": { "method": "POST", "header": [], "body": { "mode": "formdata", "formdata": [ { "key": "grant_type", "value": "authorization_code", "type": "text" }, { "key": "client_id", "value": "{{oauth_public_client_id}}", "type": "text" }, { "key": "code_verifier", "value": "{{oauth_code_verifier}}", "type": "text" }, { "key": "code", "value": "{{oauth_code}}", "type": "text" }, { "key": "redirect_uri", "value": "http://localhost:3001", "type": "text" } ] }, "url": { "raw": "https://{{host}}/token", "protocol": "https", "host": [ "{{host}}" ], "path": [ "token" ] } }, "response": [] } ] }, { "name": "Refresh Token", "item": [ { "name": "OAuth2 Refresh Token", "event": [ { "listen": "test", "script": { "exec": [ "pm.test(\"Has a token\", function () {\r", " var jsonData = pm.response.json();\r", " pm.expect(jsonData.access_token).to.exist;\r", " pm.environment.set(\"token\", jsonData.access_token);\r", " pm.expect(jsonData.refresh_token).to.exist;\r", " pm.environment.set(\"oauth_refresh_token\", jsonData.refresh_token);\r", "});" ], "type": "text/javascript" } } ], "request": { "method": "POST", "header": [], "body": { "mode": "formdata", "formdata": [ { "key": "grant_type", "value": "refresh_token", "type": "text" }, { "key": "client_id", "value": "{{oauth_client_id}}", "type": "text" }, { "key": "client_secret", "value": "{{oauth_client_secret}}", "type": "text" }, { "key": "refresh_token", "value": "{{oauth_refresh_token}}", "type": "text" } ] }, "url": { "raw": "https://{{host}}/token", "protocol": "https", "host": [ "{{host}}" ], "path": [ "token" ] } }, "response": [] } ] }, { "name": "Client Credentials", "item": [ { "name": "OAuth2 Token Client Credentials", "event": [ { "listen": "test", "script": { "exec": [ "pm.test(\"Has a token\", function () {\r", " var jsonData = pm.response.json();\r", " pm.expect(jsonData.access_token).to.exist;\r", " pm.environment.set(\"token\", jsonData.access_token);\r", "});" ], "type": "text/javascript" } } ], "request": { "method": "POST", "header": [], "body": { "mode": "formdata", "formdata": [ { "key": "grant_type", "value": "client_credentials", "type": "text" }, { "key": "client_id", "value": "{{oauth_client_creds_id}}", "type": "text" }, { "key": "client_secret", "value": "{{oauth_client_creds_secret}}", "type": "text" }, { "key": "scope", "value": "read write", "type": "text" } ] }, "url": { "raw": "https://{{host}}/token", "protocol": "https", "host": [ "{{host}}" ], "path": [ "token" ] } }, "response": [] } ] } ] }, { "name": "Domain", "item": [ { "name": "Get domain by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/domain/:domain_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "domain", ":domain_id" ], "variable": [ { "key": "domain_id", "value": "1" } ] } }, "response": [] }, { "name": "Block domain by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/domain/:domain_id/block", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "domain", ":domain_id", "block" ], "variable": [ { "key": "domain_id", "value": "1" } ] } }, "response": [] }, { "name": "Unblock domain by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/domain/:domain_id/unblock", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "domain", ":domain_id", "unblock" ], "variable": [ { "key": "domain_id", "value": "1" } ] } }, "response": [] }, { "name": "Subscribe to domain by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/domain/:domain_id/subscribe", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "domain", ":domain_id", "subscribe" ], "variable": [ { "key": "domain_id", "value": "1" } ] } }, "response": [] }, { "name": "Unsubscribe to domain by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/domain/:domain_id/unsubscribe", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "domain", ":domain_id", "unsubscribe" ], "variable": [ { "key": "domain_id", "value": "1" } ] } }, "response": [] }, { "name": "Get domains", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/domains", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "domains" ] } }, "response": [] }, { "name": "Get subscribed domains", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/domains/subscribed", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "domains", "subscribed" ] } }, "response": [] }, { "name": "Get blocked domains", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/domains/blocked", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "domains", "blocked" ] } }, "response": [] } ] }, { "name": "Entry", "item": [ { "name": "Comments", "item": [ { "name": "Create comment on entry", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"Test API comment\",\r\n \"tags\": [\r\n \"bot\",\r\n \"api\"\r\n ],\r\n \"lang\": \"en\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/entry/:entry/comments", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entry", ":entry", "comments" ], "variable": [ { "key": "entry", "value": "163" } ] } }, "response": [] }, { "name": "Create comment on entry with image", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "formdata", "formdata": [ { "key": "body", "value": "", "type": "text" }, { "key": "lang", "value": "en", "type": "text" }, { "key": "alt", "value": "A test cat image", "type": "text" }, { "key": "entry_comment", "type": "file", "src": "/C:/Users/Ryan/Pictures/kbin Test Images/large.jpg" } ] }, "url": { "raw": "https://{{host}}/api/entry/:entry/comments/image", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entry", ":entry", "comments", "image" ], "variable": [ { "key": "entry", "value": "163" } ] } }, "response": [] }, { "name": "Create comment reply", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"Test API comment reply #bot\",\r\n \"lang\": \"en\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/entry/:entry/comments/:comment/reply", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entry", ":entry", "comments", ":comment", "reply" ], "variable": [ { "key": "entry", "value": "163" }, { "key": "comment", "value": "29" } ] } }, "response": [] }, { "name": "Create comment reply with image", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "formdata", "formdata": [ { "key": "body", "value": "", "type": "text" }, { "key": "lang", "value": "en", "type": "text" }, { "key": "alt", "value": "A test cat image", "type": "text" }, { "key": "entry_comment", "type": "file", "src": "/C:/Users/Ryan/Pictures/kbin Test Images/stock.jpg" } ] }, "url": { "raw": "https://{{host}}/api/entry/:entry/comments/:comment/reply/image", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entry", ":entry", "comments", ":comment", "reply", "image" ], "variable": [ { "key": "entry", "value": "163" }, { "key": "comment", "value": "31" } ] } }, "response": [] }, { "name": "Retrieve comment", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/comments/:comment?d=0", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "comments", ":comment" ], "query": [ { "key": "d", "value": "0" } ], "variable": [ { "key": "comment", "value": "30" } ] } }, "response": [] }, { "name": "Get comments from entry", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/entry/:entry/comments?d=1&sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entry", ":entry", "comments" ], "query": [ { "key": "d", "value": "1" }, { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" }, { "key": "lang[]", "value": "en", "disabled": true }, { "key": "usePreferredLangs", "value": "false", "disabled": true } ], "variable": [ { "key": "entry", "value": "9" } ] } }, "response": [] }, { "name": "Get comments in domain", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/domain/:domain_id/comments?d=1", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "domain", ":domain_id", "comments" ], "query": [ { "key": "d", "value": "1" } ], "variable": [ { "key": "domain_id", "value": "1" } ] } }, "response": [] }, { "name": "Get comments from user", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/:user_id/comments?d=1", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id", "comments" ], "query": [ { "key": "d", "value": "1" } ], "variable": [ { "key": "user_id", "value": "1" } ] } }, "response": [] }, { "name": "Update comment", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"Test API comment reply updated once again\",\r\n \"isAdult\": false,\r\n \"lang\": \"en\",\r\n \"imageAlt\": \"cat\",\r\n \"imageUrl\": \"https://i.imgur.com/ThloWfz.jpeg\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/comments/:comment?d=0", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "comments", ":comment" ], "query": [ { "key": "d", "value": "0" } ], "variable": [ { "key": "comment", "value": "11" } ] } }, "response": [] }, { "name": "Report comment by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"reason\": \"It's a terrible meme\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/comments/:comment/report", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "comments", ":comment", "report" ], "variable": [ { "key": "comment", "value": "1" } ] } }, "response": [] }, { "name": "Delete comment", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/comments/:comment", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "comments", ":comment" ], "variable": [ { "key": "comment", "value": "12" } ] } }, "response": [] }, { "name": "Vote comment by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/comments/:comment/vote/:vote", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "comments", ":comment", "vote", ":vote" ], "variable": [ { "key": "comment", "value": "11" }, { "key": "vote", "value": "1" } ] } }, "response": [] }, { "name": "Favourite comment by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/comments/:comment/favourite", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "comments", ":comment", "favourite" ], "variable": [ { "key": "comment", "value": "11" } ] } }, "response": [] } ] }, { "name": "Create article in magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\r\n \"title\": \"Test Bot Post\",\r\n \"body\": \"I'm making this from the API again, using client credentials, and the role is set too!\",\r\n \"tags\": [\r\n \"bot\",\r\n \"api\",\r\n \"magazine\"\r\n ],\r\n \"isAdult\": false,\r\n \"isOc\": true,\r\n \"lang\": \"en\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/magazine/:magazine_id/article", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine_id", "article" ], "variable": [ { "key": "magazine_id", "value": "1" } ] } }, "response": [] }, { "name": "Create link in magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\r\n \"title\": \"Test link post\",\r\n \"tags\": [\r\n \"bot\",\r\n \"api\",\r\n \"magazine\"\r\n ],\r\n \"isAdult\": false,\r\n \"isOc\": true,\r\n \"lang\": \"en\",\r\n \"url\": \"https://i.imgur.com/ThloWfz.jpeg\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/magazine/:magazine_id/link", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine_id", "link" ], "variable": [ { "key": "magazine_id", "value": "1" } ] } }, "response": [] }, { "name": "Create image in magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/json" } ], "body": { "mode": "formdata", "formdata": [ { "key": "title", "value": "Image posted from the API! 2", "type": "text" }, { "key": "tags[]", "value": "image", "type": "text" }, { "key": "tags[]", "value": "api", "type": "text" }, { "key": "badges[]", "value": "", "type": "text", "disabled": true }, { "key": "isOc", "value": "false", "type": "text" }, { "key": "lang", "value": "en", "type": "text" }, { "key": "isAdult", "value": "false", "type": "text" }, { "key": "alt", "value": "An image of a cat", "type": "text" }, { "key": "uploadImage", "type": "file", "src": "/D:/Users/Ryan/Pictures/concerned_cat.png" } ] }, "url": { "raw": "https://{{host}}/api/magazine/:magazine_id/image", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine_id", "image" ], "variable": [ { "key": "magazine_id", "value": "1" } ] } }, "response": [] }, { "name": "Get entries in magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazine/:magazine_id/entries?sort=hot&time=∞", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine_id", "entries" ], "query": [ { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" } ], "variable": [ { "key": "magazine_id", "value": "1" } ] } }, "response": [] }, { "name": "Get entries in domain", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/domain/:domain_id/entries?sort=hot&time=∞&p=1&perPage=25", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "domain", ":domain_id", "entries" ], "query": [ { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "25" } ], "variable": [ { "key": "domain_id", "value": "1" } ] } }, "response": [] }, { "name": "Get entries from user", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/:user_id/entries?sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id", "entries" ], "query": [ { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" } ], "variable": [ { "key": "user_id", "value": "1" } ] } }, "response": [] }, { "name": "Get entries in magazine anonymous", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "auth": { "type": "noauth" }, "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazine/:magazine_id/entries", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine_id", "entries" ], "variable": [ { "key": "magazine_id", "value": "1" } ] } }, "response": [] }, { "name": "Get entries in instance", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/ld+json", "type": "text" } ], "url": { "raw": "https://{{host}}/api/entries?sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entries" ], "query": [ { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" } ] } }, "response": [] }, { "name": "Get entries in subscribed magazines", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/ld+json", "type": "text" } ], "url": { "raw": "https://{{host}}/api/entries/subscribed?sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entries", "subscribed" ], "query": [ { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" } ] } }, "response": [] }, { "name": "Get entries in moderated magazines", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/ld+json", "type": "text" } ], "url": { "raw": "https://{{host}}/api/entries/moderated?sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entries", "moderated" ], "query": [ { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" } ] } }, "response": [] }, { "name": "Get favourited entries", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/ld+json", "type": "text" } ], "url": { "raw": "https://{{host}}/api/entries/favourited?sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entries", "favourited" ], "query": [ { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" } ] } }, "response": [] }, { "name": "Get entry by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/entry/:entry_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entry", ":entry_id" ], "variable": [ { "key": "entry_id", "value": "60405" } ] } }, "response": [] }, { "name": "Update entry by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"Test body updated again\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/entry/:entry", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entry", ":entry" ], "variable": [ { "key": "entry", "value": "1" } ] } }, "response": [] }, { "name": "Report entry by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"reason\": \"It's a terrible meme\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/entry/:entry/report", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entry", ":entry", "report" ], "variable": [ { "key": "entry", "value": "1" } ] } }, "response": [] }, { "name": "Delete entry by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/entry/:entry", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entry", ":entry" ], "variable": [ { "key": "entry", "value": "5" } ] } }, "response": [] }, { "name": "Vote entry by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/entry/:entry/vote/:vote", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entry", ":entry", "vote", ":vote" ], "variable": [ { "key": "entry", "value": "5" }, { "key": "vote", "value": "1" } ] } }, "response": [] }, { "name": "Favourite entry by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/entry/:entry/favourite", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "entry", ":entry", "favourite" ], "variable": [ { "key": "entry", "value": "5" } ] } }, "response": [] } ] }, { "name": "Instance", "item": [ { "name": "Admin", "item": [ { "name": "Get instance settings", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/instance/settings", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "instance", "settings" ] } }, "response": [] }, { "name": "Update instance settings", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"KBIN_ADMIN_ONLY_OAUTH_CLIENTS\": false\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/instance/settings", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "instance", "settings" ] } }, "response": [] }, { "name": "Update instance about page", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"about\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/instance/about", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "instance", "about" ] } }, "response": [] }, { "name": "Update instance contact page", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"contact\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/instance/contact", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "instance", "contact" ] } }, "response": [] }, { "name": "Update instance faq page", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"Frequently asked questions\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/instance/faq", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "instance", "faq" ] } }, "response": [] }, { "name": "Update instance privacy policy page", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"privacy policy\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/instance/privacyPolicy", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "instance", "privacyPolicy" ] } }, "response": [] }, { "name": "Update instance terms page", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"terms\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/instance/terms", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "instance", "terms" ] } }, "response": [] }, { "name": "Update instance federation list", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"instances\": [\r\n \"www.lemmygrad.ml\",\r\n \"exploding-heads.com\"\r\n ]\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/defederated", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "defederated" ] } }, "response": [] } ] }, { "name": "Get instance info", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/instance", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "instance" ] } }, "response": [] }, { "name": "Get instance modlog", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/modlog", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "modlog" ] } }, "response": [] }, { "name": "Get instance view stats", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/stats/views?resolution=day&start=2023-07-01T00:00:00", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "stats", "views" ], "query": [ { "key": "resolution", "value": "day" }, { "key": "start", "value": "2023-07-01T00:00:00" } ] } }, "response": [] }, { "name": "Get instance vote stats", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/stats/votes?resolution=year", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "stats", "votes" ], "query": [ { "key": "resolution", "value": "year" } ] } }, "response": [] }, { "name": "Get instance content stats", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/stats/content?resolution=year", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "stats", "content" ], "query": [ { "key": "resolution", "value": "year" } ] } }, "response": [] }, { "name": "Get instance federation list", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/defederated", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "defederated" ] } }, "response": [] } ] }, { "name": "Magazine", "item": [ { "name": "Admin", "item": [ { "name": "Create magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"name\": \"apimade\",\r\n \"title\": \"Created using the API\",\r\n \"description\": \"A magazine created on the API\",\r\n \"rules\": \"Anarchy\",\r\n \"isAdult\": false\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/moderate/magazine/new", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", "new" ] } }, "response": [] }, { "name": "Update magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"title\": \"title\",\r\n \"description\": \"description\",\r\n \"rules\": \"rules\",\r\n \"isAdult\": true\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id" ], "variable": [ { "key": "magazine_id", "value": "7" } ] } }, "response": [] }, { "name": "Update magazine theme", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "formdata", "formdata": [ { "key": "customCss", "value": "", "description": "Custom css to be applied to the magazine", "type": "text" }, { "key": "backgroundImage", "value": "", "description": "One of 'shape1' or 'shape2' or empty", "type": "text", "disabled": true }, { "key": "uploadImage", "description": "The icon of the magazine", "type": "file", "src": [], "disabled": true } ] }, "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/theme", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "theme" ], "variable": [ { "key": "magazine_id", "value": "1" } ] } }, "response": [] }, { "name": "Delete magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id" ], "variable": [ { "key": "magazine_id", "value": "4" } ] } }, "response": [] }, { "name": "Delete magazine icon", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/icon", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "icon" ], "variable": [ { "key": "magazine_id", "value": "4" } ] } }, "response": [] }, { "name": "Purge magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/admin/magazine/:magazine_id/purge", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "admin", "magazine", ":magazine_id", "purge" ], "variable": [ { "key": "magazine_id", "value": "7" } ] } }, "response": [] }, { "name": "Add moderator to magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/mod/:user_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "mod", ":user_id" ], "variable": [ { "key": "magazine_id", "value": "2" }, { "key": "user_id", "value": "3" } ] } }, "response": [] }, { "name": "Remove moderator from magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/mod/:user_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "mod", ":user_id" ], "variable": [ { "key": "magazine_id", "value": "2" }, { "key": "user_id", "value": "3" } ] } }, "response": [] }, { "name": "Add badge to magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"name\": \"good\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/badge", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "badge" ], "variable": [ { "key": "magazine_id", "value": "2" } ] } }, "response": [] }, { "name": "Remove badge from magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/badge/:badge_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "badge", ":badge_id" ], "variable": [ { "key": "magazine_id", "value": "2" }, { "key": "badge_id", "value": "3" } ] } }, "response": [] }, { "name": "Add tag to magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/tag/:tag", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "tag", ":tag" ], "variable": [ { "key": "magazine_id", "value": "2" }, { "key": "tag", "value": "sometag" } ] } }, "response": [] }, { "name": "Remove tag from magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/tag/:tag", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "tag", ":tag" ], "variable": [ { "key": "magazine_id", "value": "2" }, { "key": "tag", "value": "sometag" } ] } }, "response": [] }, { "name": "Get views from magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/stats/magazine/:magazine_id/views?start=2023-06-02T00:00:00&end=2023-07-08T23:59:59&resolution=hour&local=false", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "stats", "magazine", ":magazine_id", "views" ], "query": [ { "key": "start", "value": "2023-06-02T00:00:00" }, { "key": "end", "value": "2023-07-08T23:59:59" }, { "key": "resolution", "value": "hour" }, { "key": "local", "value": "false" } ], "variable": [ { "key": "magazine_id", "value": "1" } ] } }, "response": [] }, { "name": "Get votes from magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/stats/magazine/:magazine_id/votes?start=2023-06-02T00:00:00&end=2023-07-08T23:59:59&resolution=all&local=false", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "stats", "magazine", ":magazine_id", "votes" ], "query": [ { "key": "start", "value": "2023-06-02T00:00:00" }, { "key": "end", "value": "2023-07-08T23:59:59" }, { "key": "resolution", "value": "all" }, { "key": "local", "value": "false" } ], "variable": [ { "key": "magazine_id", "value": "1" } ] } }, "response": [] }, { "name": "Get submission stats from magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/stats/magazine/:magazine_id/content?start=2023-06-02T00:00:00&end=2023-07-09T23:59:59&resolution=hour&local=false", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "stats", "magazine", ":magazine_id", "content" ], "query": [ { "key": "start", "value": "2023-06-02T00:00:00" }, { "key": "end", "value": "2023-07-09T23:59:59" }, { "key": "resolution", "value": "hour" }, { "key": "local", "value": "false" } ], "variable": [ { "key": "magazine_id", "value": "1" } ] } }, "response": [] } ] }, { "name": "Moderate", "item": [ { "name": "Get reports in magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/reports?status=approved", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "reports" ], "query": [ { "key": "status", "value": "approved" } ], "variable": [ { "key": "magazine_id", "value": "2" } ] } }, "response": [] }, { "name": "Get report by id in magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/reports/:report_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "reports", ":report_id" ], "variable": [ { "key": "magazine_id", "value": "1" }, { "key": "report_id", "value": "7" } ] } }, "response": [] }, { "name": "Accept report by id in magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/reports/:report_id/accept", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "reports", ":report_id", "accept" ], "variable": [ { "key": "magazine_id", "value": "2" }, { "key": "report_id", "value": "6" } ] } }, "response": [] }, { "name": "Reject report by id in magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/reports/:report_id/reject", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "reports", ":report_id", "reject" ], "variable": [ { "key": "magazine_id", "value": "1" }, { "key": "report_id", "value": "7" } ] } }, "response": [] }, { "name": "Ban user from magazine", "event": [ { "listen": "prerequest", "script": { "exec": [ "var time = new Date();\r", "time.setMinutes(time.getMinutes() + 5);\r", "\r", "pm.environment.set('banExpiry', time.toISOString());" ], "type": "text/javascript" } } ], "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"reason\": \"Testing\",\r\n \"expiredAt\": \"{{banExpiry}}\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/ban/:user_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "ban", ":user_id" ], "variable": [ { "key": "magazine_id", "value": "1" }, { "key": "user_id", "value": "3" } ] } }, "response": [] }, { "name": "Unban user from magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/ban/:user_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "ban", ":user_id" ], "variable": [ { "key": "magazine_id", "value": "1" }, { "key": "user_id", "value": "3" } ] } }, "response": [] }, { "name": "Get bans in magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/bans", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "bans" ], "variable": [ { "key": "magazine_id", "value": "2" } ] } }, "response": [] }, { "name": "Get trash in magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/moderate/magazine/:magazine_id/trash", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "moderate", "magazine", ":magazine_id", "trash" ], "variable": [ { "key": "magazine_id", "value": "1" } ] } }, "response": [] } ] }, { "name": "Get magazine by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazine/:magazine_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine_id" ], "variable": [ { "key": "magazine_id", "value": "2" } ] } }, "response": [] }, { "name": "Get magazine theme by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazine/:magazine_id/theme", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine_id", "theme" ], "variable": [ { "key": "magazine_id", "value": "2" } ] } }, "response": [] }, { "name": "Get magazine by name", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazine/name/:magazine_name", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", "name", ":magazine_name" ], "variable": [ { "key": "magazine_name", "value": "testing" } ] } }, "response": [] }, { "name": "Get magazine moderation log", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazine/:magazine_id/log", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine_id", "log" ], "variable": [ { "key": "magazine_id", "value": "1" } ] } }, "response": [] }, { "name": "Block magazine by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazine/:magazine_id/block", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine_id", "block" ], "variable": [ { "key": "magazine_id", "value": "2" } ] } }, "response": [] }, { "name": "Unblock magazine by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazine/:magazine_id/unblock", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine_id", "unblock" ], "variable": [ { "key": "magazine_id", "value": "2" } ] } }, "response": [] }, { "name": "Subscribe to magazine by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazine/:magazine_id/subscribe", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine_id", "subscribe" ], "variable": [ { "key": "magazine_id", "value": "2" } ] } }, "response": [] }, { "name": "Unsubscribe to magazine by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazine/:magazine_id/unsubscribe", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine_id", "unsubscribe" ], "variable": [ { "key": "magazine_id", "value": "2" } ] } }, "response": [] }, { "name": "Get magazines", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazines?p=1&perPage=10&sort=active&federation=all&hide_adult=hide", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazines" ], "query": [ { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" }, { "key": "q", "value": null, "disabled": true }, { "key": "sort", "value": "active" }, { "key": "federation", "value": "all" }, { "key": "hide_adult", "value": "hide" } ] } }, "response": [] }, { "name": "Get subscribed magazines", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazines/subscribed", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazines", "subscribed" ] } }, "response": [] }, { "name": "Get moderated magazines", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazines/moderated", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazines", "moderated" ] } }, "response": [] }, { "name": "Get blocked magazines", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazines/blocked", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazines", "blocked" ] } }, "response": [] } ] }, { "name": "Message", "item": [ { "name": "Get message by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/messages/:message_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "messages", ":message_id" ], "variable": [ { "key": "message_id", "value": "1" } ] } }, "response": [] }, { "name": "Read message by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/messages/:message_id/read", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "messages", ":message_id", "read" ], "variable": [ { "key": "message_id", "value": "1" } ] } }, "response": [] }, { "name": "Unread message by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/messages/:message_id/unread", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "messages", ":message_id", "unread" ], "variable": [ { "key": "message_id", "value": "1" } ] } }, "response": [] }, { "name": "Get message threads", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/messages", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "messages" ] } }, "response": [] }, { "name": "Get messages in thread by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/messages/thread/:thread_id/:sort?p=1&perPage=25", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "messages", "thread", ":thread_id", ":sort" ], "query": [ { "key": "p", "value": "1" }, { "key": "perPage", "value": "25" } ], "variable": [ { "key": "thread_id", "value": "1" }, { "key": "sort", "value": "oldest" } ] } }, "response": [] }, { "name": "Reply to thread", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"Message from the API!\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/messages/thread/:thread_id/reply", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "messages", "thread", ":thread_id", "reply" ], "variable": [ { "key": "thread_id", "value": "1" } ] } }, "response": [] }, { "name": "Create new thread", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"A new message thread, made by the API!\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/users/:user_id/message", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id", "message" ], "variable": [ { "key": "user_id", "value": "3" } ] } }, "response": [] } ] }, { "name": "Notification", "item": [ { "name": "Get notification by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/notification/:notification_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "notification", ":notification_id" ], "variable": [ { "key": "notification_id", "value": "12" } ] } }, "response": [] }, { "name": "Mark notification as read", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/notifications/:notification_id/read", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "notifications", ":notification_id", "read" ], "variable": [ { "key": "notification_id", "value": "13" } ] } }, "response": [] }, { "name": "Mark notification as unread", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/notifications/:notification_id/unread", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "notifications", ":notification_id", "unread" ], "variable": [ { "key": "notification_id", "value": "13" } ] } }, "response": [] }, { "name": "Mark all notifications as read", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/notifications/read", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "notifications", "read" ] } }, "response": [] }, { "name": "Delete notification", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/notifications/:notification_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "notifications", ":notification_id" ], "variable": [ { "key": "notification_id", "value": "12" } ] } }, "response": [] }, { "name": "Delete all notifications", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/notifications", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "notifications" ] } }, "response": [] }, { "name": "Get notifications by status", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/notifications/:status", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "notifications", ":status" ], "variable": [ { "key": "status", "value": "all" } ] } }, "response": [] }, { "name": "Get unread notifications count", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/notifications/count", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "notifications", "count" ] } }, "response": [] } ] }, { "name": "OAuth2", "item": [ { "name": "Admin", "item": [ { "name": "Get OAuth2 client stats", "request": { "method": "GET", "header": [], "url": { "raw": "https://{{host}}/api/clients/stats?resolution=minute&start=2023-07-15T00:00:00&end={{$isoTimestamp}}", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "clients", "stats" ], "query": [ { "key": "resolution", "value": "minute" }, { "key": "start", "value": "2023-07-15T00:00:00" }, { "key": "end", "value": "{{$isoTimestamp}}" } ] } }, "response": [] }, { "name": "Get OAuth2 client by identifier", "request": { "method": "GET", "header": [], "url": { "raw": "https://{{host}}/api/clients/:identifier", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "clients", ":identifier" ], "variable": [ { "key": "identifier", "value": null } ] } }, "response": [] }, { "name": "Get OAuth2 clients", "request": { "method": "GET", "header": [], "url": { "raw": "https://{{host}}/api/clients", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "clients" ] } }, "response": [] } ] }, { "name": "Create OAuth2 Auth Code Application", "event": [ { "listen": "test", "script": { "exec": [ "pm.test(\"Set Client ID and Secret\", function () {\r", " pm.response.to.have.status(201);\r", "\r", " var jsonData = pm.response.json();\r", " pm.expect(jsonData.identifier).to.not.be.empty;\r", " pm.expect(jsonData.secret).to.not.be.empty;\r", " \r", " pm.environment.set(\"oauth_client_id\", jsonData.identifier);\r", " pm.environment.set(\"oauth_client_secret\", jsonData.secret);\r", "});" ], "type": "text/javascript" } } ], "request": { "auth": { "type": "noauth" }, "method": "POST", "header": [], "body": { "mode": "raw", "raw": "{\r\n \"name\": \"My private kbin application\",\r\n \"contactEmail\": \"contact@some.dev\",\r\n \"username\": null,\r\n \"public\": false,\r\n \"redirectUris\": [\r\n \"https://localhost:3001\"\r\n ],\r\n \"grants\": [\r\n \"authorization_code\",\r\n \"refresh_token\"\r\n ],\r\n \"scopes\": [\r\n \"read\",\r\n \"write\"\r\n ]\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/client", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "client" ] } }, "response": [] }, { "name": "Create OAuth2 Public Auth Code Application", "event": [ { "listen": "test", "script": { "exec": [ "pm.test(\"Set Client ID and Secret\", function () {\r", " pm.response.to.have.status(201);\r", "\r", " var jsonData = pm.response.json();\r", " pm.expect(jsonData.identifier).to.not.be.empty;\r", " pm.expect(jsonData.secret).to.be.empty;\r", " \r", " pm.environment.set(\"oauth_public_client_id\", jsonData.identifier);\r", "});" ], "type": "text/javascript" } } ], "request": { "auth": { "type": "noauth" }, "method": "POST", "header": [], "body": { "mode": "raw", "raw": "{\r\n \"name\": \"My public kbin application\",\r\n \"contactEmail\": \"contact@some.dev\",\r\n \"username\": null,\r\n \"public\": true,\r\n \"redirectUris\": [\r\n \"https://localhost:3001\"\r\n ],\r\n \"grants\": [\r\n \"authorization_code\",\r\n \"refresh_token\"\r\n ],\r\n \"scopes\": [\r\n \"read\",\r\n \"write\"\r\n ]\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/client", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "client" ] } }, "response": [] }, { "name": "Create OAuth2 Client Creds Application", "event": [ { "listen": "test", "script": { "exec": [ "pm.test(\"Set Client ID and Secret\", function () {\r", " pm.response.to.have.status(201);\r", "\r", " var jsonData = pm.response.json();\r", " pm.expect(jsonData.identifier).to.not.be.empty;\r", " pm.expect(jsonData.secret).to.not.be.empty;\r", " \r", " pm.environment.set(\"oauth_client_creds_id\", jsonData.identifier);\r", " pm.environment.set(\"oauth_client_creds_secret\", jsonData.secret);\r", "});" ], "type": "text/javascript" } } ], "request": { "auth": { "type": "noauth" }, "method": "POST", "header": [], "body": { "mode": "raw", "raw": "{\r\n \"name\": \"My client creds kbin application\",\r\n \"contactEmail\": \"contact@some.dev\",\r\n \"username\": \"{{$randomUserName}}\",\r\n \"public\": false,\r\n \"redirectUris\": [\r\n \"https://localhost:3001\"\r\n ],\r\n \"grants\": [\r\n \"client_credentials\"\r\n ],\r\n \"scopes\": [\r\n \"read\",\r\n \"write\"\r\n ]\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/client", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "client" ] } }, "response": [] }, { "name": "Create OAuth2 Application with logo", "request": { "method": "POST", "header": [], "body": { "mode": "raw", "raw": "{\r\n \"name\": \"kbot application\",\r\n \"contactEmail\": \"kbotbuilder@gmail.com\",\r\n \"username\": \"kbot\",\r\n \"redirectUris\": [\r\n \"https://localhost:3001\"\r\n ],\r\n \"grants\": [\r\n \"client_credentials\",\r\n \"authorization_code\",\r\n \"refresh_token\"\r\n ],\r\n \"scopes\": [\r\n \"read\",\r\n \"write\"\r\n ]\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/client", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "client" ] } }, "response": [] }, { "name": "Delete OAuth2 Application", "request": { "method": "DELETE", "header": [], "url": { "raw": "https://{{host}}/api/client?client_id={{oauth_client_id}}&client_secret={{oauth_client_secret}}", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "client" ], "query": [ { "key": "client_id", "value": "{{oauth_client_id}}" }, { "key": "client_secret", "value": "{{oauth_client_secret}}" } ] } }, "response": [] }, { "name": "Revoke User and Client Tokens", "request": { "method": "POST", "header": [], "url": { "raw": "https://{{host}}/api/revoke", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "revoke" ] } }, "response": [] } ] }, { "name": "Post", "item": [ { "name": "Comment", "item": [ { "name": "Get post comment by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/post-comments/:comment_id?d=-1", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "post-comments", ":comment_id" ], "query": [ { "key": "d", "value": "-1" } ], "variable": [ { "key": "comment_id", "value": "7" } ] } }, "response": [] }, { "name": "Get post's comments", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/posts/:post_id/comments?d=1&sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "posts", ":post_id", "comments" ], "query": [ { "key": "d", "value": "1" }, { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" }, { "key": "lang[]", "value": "en", "disabled": true }, { "key": "usePreferredLangs", "value": "false", "disabled": true } ], "variable": [ { "key": "post_id", "value": "3" } ] } }, "response": [] }, { "name": "Get post comments from user", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/:user_id/post-comments?d=1&sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id", "post-comments" ], "query": [ { "key": "d", "value": "1" }, { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" }, { "key": "lang[]", "value": "en", "disabled": true }, { "key": "usePreferredLangs", "value": "false", "disabled": true } ], "variable": [ { "key": "user_id", "value": "1" } ] } }, "response": [] }, { "name": "Update post comment by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"Updated post comment\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/post-comments/:comment_id?d=0", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "post-comments", ":comment_id" ], "query": [ { "key": "d", "value": "0" } ], "variable": [ { "key": "comment_id", "value": "4" } ] } }, "response": [] }, { "name": "Report post comment by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"reason\": \"It's a terrible meme\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/post-comments/:comment/report", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "post-comments", ":comment", "report" ], "variable": [ { "key": "comment", "value": "1" } ] } }, "response": [] }, { "name": "Delete post comment by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/post-comments/:comment_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "post-comments", ":comment_id" ], "variable": [ { "key": "comment_id", "value": "6" } ] } }, "response": [] }, { "name": "Vote post comment by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/post-comments/:comment_id/vote/:choice", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "post-comments", ":comment_id", "vote", ":choice" ], "variable": [ { "key": "comment_id", "value": "4" }, { "key": "choice", "value": "1" } ] } }, "response": [] }, { "name": "Favourite post comment by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/post-comments/:comment_id/favourite", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "post-comments", ":comment_id", "favourite" ], "variable": [ { "key": "comment_id", "value": "4" } ] } }, "response": [] }, { "name": "Create comment on post", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"Test post comment\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/posts/:post_id/comments", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "posts", ":post_id", "comments" ], "variable": [ { "key": "post_id", "value": "3" } ] } }, "response": [] }, { "name": "Create image comment on post", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "formdata", "formdata": [ { "key": "body", "value": "Dang that's a cute cat", "type": "text" }, { "key": "lang", "value": "en", "type": "text" }, { "key": "alt", "value": "also a cute cat", "type": "text" }, { "key": "uploadImage", "type": "file", "src": "/C:/Users/Ryan/Pictures/kbin Test Images/sleepy.jpg" } ] }, "url": { "raw": "https://{{host}}/api/posts/:post_id/comments/image", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "posts", ":post_id", "comments", "image" ], "variable": [ { "key": "post_id", "value": "3" } ] } }, "response": [] }, { "name": "Create reply on post comment", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"Thanks @rideranton@kbintesting.duckdns.org\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/posts/:post/comments/:comment/reply", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "posts", ":post", "comments", ":comment", "reply" ], "variable": [ { "key": "post", "value": "3" }, { "key": "comment", "value": "9" } ] } }, "response": [] }, { "name": "Create image reply on post comment", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "formdata", "formdata": [ { "key": "body", "value": "I love him", "type": "text" }, { "key": "lang", "value": "en", "type": "text" }, { "key": "alt", "value": "<3", "type": "text" }, { "key": "uploadImage", "type": "file", "src": "/C:/Users/Ryan/Pictures/kbin Test Images/yawn.jpg" } ] }, "url": { "raw": "https://{{host}}/api/posts/:post/comments/:comment/reply/image", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "posts", ":post", "comments", ":comment", "reply", "image" ], "variable": [ { "key": "post", "value": "3" }, { "key": "comment", "value": "7" } ] } }, "response": [] } ] }, { "name": "Get post by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/post/:post_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "post", ":post_id" ], "variable": [ { "key": "post_id", "value": "1" } ] } }, "response": [] }, { "name": "Get posts from instance", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/posts?sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "posts" ], "query": [ { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" } ] } }, "response": [] }, { "name": "Get posts from magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/magazine/:magazine/posts?sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine", "posts" ], "query": [ { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" } ], "variable": [ { "key": "magazine", "value": "1" } ] } }, "response": [] }, { "name": "Get posts from moderated magazines", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/posts/moderated?sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "posts", "moderated" ], "query": [ { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" } ] } }, "response": [] }, { "name": "Get posts from subscribed magazines", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/posts/subscribed?sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "posts", "subscribed" ], "query": [ { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" } ] } }, "response": [] }, { "name": "Get favourited posts", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/posts/favourited?sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "posts", "favourited" ], "query": [ { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" } ] } }, "response": [] }, { "name": "Get posts from user", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/:user_id/posts?sort=hot&time=∞&p=1&perPage=10", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id", "posts" ], "query": [ { "key": "sort", "value": "hot" }, { "key": "time", "value": "∞" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "10" } ], "variable": [ { "key": "user_id", "value": "3" } ] } }, "response": [] }, { "name": "Create post in magazine", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"Microblogging from the API!\",\r\n \"lang\": \"en\",\r\n \"isAdult\": false\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/magazine/:magazine/posts", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine", "posts" ], "variable": [ { "key": "magazine", "value": "1" } ] } }, "response": [] }, { "name": "Create post in magazine with image", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "formdata", "formdata": [ { "key": "body", "value": "Hey, here's a cat pic from the API", "type": "text" }, { "key": "lang", "value": "en", "type": "text" }, { "key": "isAdult", "value": "false", "type": "text" }, { "key": "alt", "value": "A cute cat on a microblog", "type": "text" }, { "key": "uploadImage", "type": "file", "src": "/C:/Users/Ryan/Pictures/kbin Test Images/kitten.gif" } ] }, "url": { "raw": "https://{{host}}/api/magazine/:magazine/posts/image", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "magazine", ":magazine", "posts", "image" ], "variable": [ { "key": "magazine", "value": "1" } ] } }, "response": [] }, { "name": "Update post by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"body\": \"test, updated!\",\r\n \"lang\": \"en\",\r\n \"isAdult\": false\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/post/:post", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "post", ":post" ], "variable": [ { "key": "post", "value": "1" } ] } }, "response": [] }, { "name": "Report post by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"reason\": \"It's a terrible meme\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/post/:post/report", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "post", ":post", "report" ], "variable": [ { "key": "post", "value": null } ] } }, "response": [] }, { "name": "Delete post by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/post/:post_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "post", ":post_id" ], "variable": [ { "key": "post_id", "value": "1" } ] } }, "response": [] }, { "name": "Vote on post by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/post/:post_id/vote/:choice", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "post", ":post_id", "vote", ":choice" ], "variable": [ { "key": "post_id", "value": "3" }, { "key": "choice", "value": "1" } ] } }, "response": [] }, { "name": "Favourite post by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/post/:post_id/favourite", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "post", ":post_id", "favourite" ], "variable": [ { "key": "post_id", "value": "3" } ] } }, "response": [] } ] }, { "name": "Search", "item": [ { "name": "Get Results", "request": { "method": "GET", "header": [], "url": { "raw": "https://{{host}}/api/search?q=test&p=1&perPage=25", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "search" ], "query": [ { "key": "q", "value": "test" }, { "key": "p", "value": "1" }, { "key": "perPage", "value": "25" } ] } }, "response": [] } ] }, { "name": "User", "item": [ { "name": "Admin", "item": [ { "name": "Retrieve banned users", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/admin/users/banned", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "admin", "users", "banned" ] } }, "response": [] }, { "name": "Ban user by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/admin/users/:user_id/ban", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "admin", "users", ":user_id", "ban" ], "variable": [ { "key": "user_id", "value": "3" } ] } }, "response": [] }, { "name": "Unban user by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/admin/users/:user_id/unban", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "admin", "users", ":user_id", "unban" ], "variable": [ { "key": "user_id", "value": "3" } ] } }, "response": [] }, { "name": "Delete user by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/admin/users/:user_id/delete", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "admin", "users", ":user_id", "delete" ], "variable": [ { "key": "user_id", "value": "3" } ] } }, "response": [] }, { "name": "Purge user by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/admin/users/:user_id/purge", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "admin", "users", ":user_id", "purge" ], "variable": [ { "key": "user_id", "value": "3" } ] } }, "response": [] }, { "name": "Verify user by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/admin/users/:user_id/verify", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "admin", "users", ":user_id", "verify" ], "variable": [ { "key": "user_id", "value": "3" } ] } }, "response": [] } ] }, { "name": "ActivityPub", "item": [ { "name": "Post to inbox", "request": { "method": "POST", "header": [], "body": { "mode": "raw", "raw": "{\r\n \r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/u/:username/inbox", "protocol": "https", "host": [ "{{host}}" ], "path": [ "u", ":username", "inbox" ], "variable": [ { "key": "username", "value": null } ] } }, "response": [] }, { "name": "Get outbox", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/activity+json", "type": "text" } ], "url": { "raw": "https://{{host}}/u/:username/outbox", "protocol": "https", "host": [ "{{host}}" ], "path": [ "u", ":username", "outbox" ], "variable": [ { "key": "username", "value": "rideranton" } ] } }, "response": [] } ] }, { "name": "Get user by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/:user_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id" ], "variable": [ { "key": "user_id", "value": "1" } ] } }, "response": [] }, { "name": "Get users", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users" ] } }, "response": [] }, { "name": "Get blocked users", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/blocked", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "blocked" ] } }, "response": [] }, { "name": "Get user by name", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/name/:username", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "name", ":username" ], "variable": [ { "key": "username", "value": "rideranton" } ] } }, "response": [] }, { "name": "Get current user", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/me", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "me" ] } }, "response": [] }, { "name": "Get current user settings", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/settings", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "settings" ] } }, "response": [] }, { "name": "Get current user's oauth consents", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/consents", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "consents" ] } }, "response": [] }, { "name": "Get oauth consent by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/consents/:consent_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "consents", ":consent_id" ], "variable": [ { "key": "consent_id", "value": "2" } ] } }, "response": [] }, { "name": "Update oauth consent by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"scopes\": [\r\n \"delete\",\r\n \"subscribe\",\r\n \"block\",\r\n \"vote\",\r\n \"report\",\r\n \"user\",\r\n \"moderate\",\r\n \"admin\",\r\n \"user:oauth_clients:edit\",\r\n \"user:oauth_clients:read\",\r\n \"read\",\r\n \"write\",\r\n \"admin:oauth_clients:read\"\r\n ]\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/users/consents/:consent_id", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "consents", ":consent_id" ], "variable": [ { "key": "consent_id", "value": "2" } ] } }, "response": [] }, { "name": "Update current user settings", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"notifyOnNewEntry\": true,\r\n \"notifyOnNewEntryReply\": false,\r\n \"preferredLanguages\": [\r\n \"en\"\r\n ]\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/users/settings", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "settings" ] } }, "response": [] }, { "name": "Update current user profile", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "raw", "raw": "{\r\n \"about\": \"Updated from the API once more!\"\r\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "https://{{host}}/api/users/profile", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "profile" ] } }, "response": [] }, { "name": "Update current user avatar", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "formdata", "formdata": [ { "key": "uploadImage", "type": "file", "src": "/C:/Users/ryans/Pictures/kbin/robot-face_1f916.png" } ] }, "url": { "raw": "https://{{host}}/api/users/avatar", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "avatar" ] } }, "response": [] }, { "name": "Delete current user avatar", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/avatar", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "avatar" ] } }, "response": [] }, { "name": "Update current user cover", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "POST", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "body": { "mode": "formdata", "formdata": [ { "key": "uploadImage", "type": "file", "src": "/C:/Users/ryans/Documents/blender/ceres_hydro_lights.png" } ] }, "url": { "raw": "https://{{host}}/api/users/cover", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "cover" ] } }, "response": [] }, { "name": "Delete current user cover", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "DELETE", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/cover", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "cover" ] } }, "response": [] }, { "name": "Get followers by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/:user_id/followers", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id", "followers" ], "variable": [ { "key": "user_id", "value": "1" } ] } }, "response": [] }, { "name": "Get current user's followers", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/followers", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "followers" ] } }, "response": [] }, { "name": "Get followed by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/:user_id/followed", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id", "followed" ], "variable": [ { "key": "user_id", "value": "3" } ] } }, "response": [] }, { "name": "Get current user's followed", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/followed", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", "followed" ] } }, "response": [] }, { "name": "Get magazine subscriptions by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/:user_id/magazines/subscribed", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id", "magazines", "subscribed" ], "variable": [ { "key": "user_id", "value": "1" } ] } }, "response": [] }, { "name": "Get domain subscriptions by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "GET", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/:user_id/domains/subscribed", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id", "domains", "subscribed" ], "variable": [ { "key": "user_id", "value": "1" } ] } }, "response": [] }, { "name": "Follow by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/:user_id/follow", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id", "follow" ], "variable": [ { "key": "user_id", "value": "3" } ] } }, "response": [] }, { "name": "Unfollow by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/:user_id/unfollow", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id", "unfollow" ], "variable": [ { "key": "user_id", "value": "3" } ] } }, "response": [] }, { "name": "Block by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/:user_id/block", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id", "block" ], "variable": [ { "key": "user_id", "value": "3" } ] } }, "response": [] }, { "name": "Unblock by id", "protocolProfileBehavior": { "disabledSystemHeaders": { "accept": true } }, "request": { "method": "PUT", "header": [ { "key": "Accept", "type": "text", "value": "application/ld+json" } ], "url": { "raw": "https://{{host}}/api/users/:user_id/unblock", "protocol": "https", "host": [ "{{host}}" ], "path": [ "api", "users", ":user_id", "unblock" ], "variable": [ { "key": "user_id", "value": "3" } ] } }, "response": [] } ] } ], "auth": { "type": "oauth2", "oauth2": [ { "key": "state", "value": "{{$randomColor}}", "type": "string" }, { "key": "scope", "value": "read write delete subscribe block vote report user moderate admin", "type": "string" }, { "key": "tokenName", "value": "Full Access", "type": "string" }, { "key": "accessTokenUrl", "value": "https://{{host}}/token", "type": "string" }, { "key": "authUrl", "value": "https://{{host}}/authorize", "type": "string" }, { "key": "redirect_uri", "value": "https://localhost:3001", "type": "string" }, { "key": "refreshRequestParams", "value": [], "type": "any" }, { "key": "tokenRequestParams", "value": [], "type": "any" }, { "key": "authRequestParams", "value": [], "type": "any" }, { "key": "challengeAlgorithm", "value": "S256", "type": "string" }, { "key": "grant_type", "value": "authorization_code", "type": "string" }, { "key": "clientSecret", "value": "{{oauth_client_secret}}", "type": "string" }, { "key": "clientId", "value": "{{oauth_client_id}}", "type": "string" }, { "key": "addTokenTo", "value": "header", "type": "string" }, { "key": "client_authentication", "value": "header", "type": "string" } ] } } ================================================ FILE: docs/postman/kbin.postman_environment.json ================================================ { "id": "963a0881-8a56-4ca3-ae07-b4ad4435f304", "name": "mbin dev", "values": [ { "key": "host", "value": "localhost", "enabled": true }, { "key": "oauth_client_id", "value": "", "type": "default", "enabled": true }, { "key": "oauth_client_secret", "value": "", "type": "secret", "enabled": true }, { "key": "oauth_client_creds_id", "value": "", "type": "default", "enabled": true }, { "key": "oauth_client_creds_secret", "value": "", "type": "secret", "enabled": true }, { "key": "oauth_public_client_id", "value": "", "type": "default", "enabled": true }, { "key": "token", "value": "", "enabled": true }, { "key": "oauth_state", "value": "", "enabled": true }, { "key": "oauth_code", "value": "", "type": "default", "enabled": true }, { "key": "oauth_refresh_token", "value": "", "type": "secret", "enabled": true }, { "key": "oauth_code_challenge", "value": "", "enabled": true }, { "key": "oauth_code_verifier", "value": "", "type": "any", "enabled": true }, { "key": "oauth_public_refresh_token", "value": "", "type": "any", "enabled": true }, { "key": "banExpiry", "value": "", "type": "any", "enabled": true } ], "_postman_variable_scope": "environment", "_postman_exported_at": "2023-07-24T08:59:15.566Z", "_postman_exported_using": "Postman/10.16.0" } ================================================ FILE: eslint.config.mjs ================================================ import js from "@eslint/js"; import globals from "globals"; import stylistic from "@stylistic/eslint-plugin"; export default [ { ignores: ["*", "!assets"], }, js.configs.recommended, { plugins: { "@stylistic": stylistic, }, languageOptions: { globals: { ...globals.browser, ...globals.node, ...globals.es2021 }, ecmaVersion: "latest", sourceType: "module", }, rules: { "@stylistic/indent": ["error", 4], "@stylistic/linebreak-style": ["error", "unix"], "@stylistic/eol-last": ["error", "always"], "@stylistic/arrow-parens": ["error", "always"], "@stylistic/brace-style": ["error", "1tbs"], "@stylistic/comma-dangle": ["error", "always-multiline"], "@stylistic/comma-spacing": ["error"], "@stylistic/keyword-spacing": ["error"], "@stylistic/no-multiple-empty-lines": ["error", { max: 2, maxEOF: 0, maxBOF: 0, }], "@stylistic/no-trailing-spaces": ["error"], "@stylistic/no-multi-spaces": ["error"], "@stylistic/object-curly-spacing": ["error", "always"], "@stylistic/quotes": ["error", "single", { avoidEscape: true, }], "@stylistic/semi": ["error", "always"], "@stylistic/space-before-blocks": ["error"], "@stylistic/space-in-parens": ["error", "never"], camelcase: ["error", { ignoreImports: true, }], curly: ["error", "all"], eqeqeq: ["error", "always"], "no-console": ["off"], "no-duplicate-imports": ["error"], "no-empty": ["error", { allowEmptyCatch: true, }], "no-eval": ["error"], "no-implied-eval": ["error"], "prefer-const": ["error"], "sort-imports": ["error"], yoda: ["error", "always"], "no-undef": ["warn"], }, } ]; ================================================ FILE: migrations/.gitignore ================================================ ================================================ FILE: migrations/Version20210527210529.php ================================================ addSql('CREATE SEQUENCE badge_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE domain_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE entry_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE entry_badge_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE entry_comment_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE entry_comment_vote_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE entry_vote_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE image_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE magazine_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE magazine_ban_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE magazine_block_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE magazine_log_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE magazine_subscription_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE message_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE message_thread_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE moderator_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE notification_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE post_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE post_comment_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE post_comment_vote_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE post_vote_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE report_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE site_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE "user_id_seq" INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE user_block_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE user_follow_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE view_counter_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE badge (id INT NOT NULL, magazine_id INT NOT NULL, name VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_FEF0481D3EB84A1D ON badge (magazine_id)'); $this->addSql('CREATE TABLE domain (id INT NOT NULL, name VARCHAR(255) NOT NULL, entry_count INT NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE UNIQUE INDEX domain_name_idx ON domain (name)'); $this->addSql('CREATE TABLE entry (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, image_id INT DEFAULT NULL, domain_id INT DEFAULT NULL, slug VARCHAR(255) DEFAULT NULL, title VARCHAR(255) NOT NULL, url VARCHAR(2048) DEFAULT NULL, body TEXT DEFAULT NULL, type VARCHAR(255) NOT NULL, has_embed BOOLEAN NOT NULL, comment_count INT NOT NULL, score INT NOT NULL, views INT DEFAULT NULL, is_adult BOOLEAN DEFAULT NULL, sticky BOOLEAN NOT NULL, last_active TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, ip VARCHAR(255) DEFAULT NULL, up_votes INT NOT NULL, down_votes INT NOT NULL, ranking INT NOT NULL, visibility TEXT DEFAULT \'visible\' NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_2B219D70A76ED395 ON entry (user_id)'); $this->addSql('CREATE INDEX IDX_2B219D703EB84A1D ON entry (magazine_id)'); $this->addSql('CREATE INDEX IDX_2B219D703DA5256D ON entry (image_id)'); $this->addSql('CREATE INDEX IDX_2B219D70115F0EE5 ON entry (domain_id)'); $this->addSql('COMMENT ON COLUMN entry.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE entry_badge (id INT NOT NULL, badge_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_7AEA2BBBF7A2C2FC ON entry_badge (badge_id)'); $this->addSql('CREATE INDEX IDX_7AEA2BBBBA364942 ON entry_badge (entry_id)'); $this->addSql('CREATE TABLE entry_comment (id INT NOT NULL, user_id INT NOT NULL, entry_id INT NOT NULL, magazine_id INT NOT NULL, image_id INT DEFAULT NULL, parent_id INT DEFAULT NULL, root_id INT DEFAULT NULL, body TEXT DEFAULT NULL, last_active TIMESTAMP(0) WITH TIME ZONE NOT NULL, ip VARCHAR(255) DEFAULT NULL, up_votes INT NOT NULL, down_votes INT NOT NULL, visibility TEXT DEFAULT \'visible\' NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_B892FDFBA76ED395 ON entry_comment (user_id)'); $this->addSql('CREATE INDEX IDX_B892FDFBBA364942 ON entry_comment (entry_id)'); $this->addSql('CREATE INDEX IDX_B892FDFB3EB84A1D ON entry_comment (magazine_id)'); $this->addSql('CREATE INDEX IDX_B892FDFB3DA5256D ON entry_comment (image_id)'); $this->addSql('CREATE INDEX IDX_B892FDFB727ACA70 ON entry_comment (parent_id)'); $this->addSql('CREATE INDEX IDX_B892FDFB79066886 ON entry_comment (root_id)'); $this->addSql('COMMENT ON COLUMN entry_comment.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE entry_comment_vote (id INT NOT NULL, comment_id INT NOT NULL, user_id INT NOT NULL, author_id INT NOT NULL, choice INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_9E561267F8697D13 ON entry_comment_vote (comment_id)'); $this->addSql('CREATE INDEX IDX_9E561267A76ED395 ON entry_comment_vote (user_id)'); $this->addSql('CREATE INDEX IDX_9E561267F675F31B ON entry_comment_vote (author_id)'); $this->addSql('CREATE UNIQUE INDEX user_entry_comment_vote_idx ON entry_comment_vote (user_id, comment_id)'); $this->addSql('COMMENT ON COLUMN entry_comment_vote.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE entry_vote (id INT NOT NULL, entry_id INT NOT NULL, user_id INT NOT NULL, author_id INT NOT NULL, choice INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_FE32FD77BA364942 ON entry_vote (entry_id)'); $this->addSql('CREATE INDEX IDX_FE32FD77A76ED395 ON entry_vote (user_id)'); $this->addSql('CREATE INDEX IDX_FE32FD77F675F31B ON entry_vote (author_id)'); $this->addSql('CREATE UNIQUE INDEX user_entry_vote_idx ON entry_vote (user_id, entry_id)'); $this->addSql('COMMENT ON COLUMN entry_vote.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE image (id INT NOT NULL, file_path VARCHAR(255) NOT NULL, file_name VARCHAR(255) NOT NULL, sha256 BYTEA NOT NULL, width INT DEFAULT NULL, height INT DEFAULT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE UNIQUE INDEX images_file_name_idx ON image (file_name)'); $this->addSql('CREATE UNIQUE INDEX images_sha256_idx ON image (sha256)'); $this->addSql('CREATE TABLE magazine (id INT NOT NULL, cover_id INT DEFAULT NULL, name VARCHAR(25) NOT NULL, title VARCHAR(50) DEFAULT NULL, description TEXT DEFAULT NULL, rules TEXT DEFAULT NULL, subscriptions_count INT NOT NULL, entry_count INT NOT NULL, entry_comment_count INT NOT NULL, post_count INT NOT NULL, post_comment_count INT NOT NULL, is_adult BOOLEAN DEFAULT NULL, custom_css TEXT DEFAULT NULL, custom_js TEXT DEFAULT NULL, visibility TEXT DEFAULT \'visible\' NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_378C2FE4922726E9 ON magazine (cover_id)'); $this->addSql('CREATE UNIQUE INDEX magazine_name_idx ON magazine (name)'); $this->addSql('COMMENT ON COLUMN magazine.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE magazine_ban (id INT NOT NULL, magazine_id INT NOT NULL, user_id INT NOT NULL, banned_by_id INT NOT NULL, reason TEXT DEFAULT NULL, expired_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_6A126CE53EB84A1D ON magazine_ban (magazine_id)'); $this->addSql('CREATE INDEX IDX_6A126CE5A76ED395 ON magazine_ban (user_id)'); $this->addSql('CREATE INDEX IDX_6A126CE5386B8E7 ON magazine_ban (banned_by_id)'); $this->addSql('COMMENT ON COLUMN magazine_ban.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE magazine_block (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_41CC6069A76ED395 ON magazine_block (user_id)'); $this->addSql('CREATE INDEX IDX_41CC60693EB84A1D ON magazine_block (magazine_id)'); $this->addSql('CREATE UNIQUE INDEX magazine_block_idx ON magazine_block (user_id, magazine_id)'); $this->addSql('COMMENT ON COLUMN magazine_block.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE magazine_log (id INT NOT NULL, magazine_id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, ban_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, log_type TEXT NOT NULL, meta VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_87D3D4C53EB84A1D ON magazine_log (magazine_id)'); $this->addSql('CREATE INDEX IDX_87D3D4C5A76ED395 ON magazine_log (user_id)'); $this->addSql('CREATE INDEX IDX_87D3D4C5BA364942 ON magazine_log (entry_id)'); $this->addSql('CREATE INDEX IDX_87D3D4C560C33421 ON magazine_log (entry_comment_id)'); $this->addSql('CREATE INDEX IDX_87D3D4C54B89032C ON magazine_log (post_id)'); $this->addSql('CREATE INDEX IDX_87D3D4C5DB1174D2 ON magazine_log (post_comment_id)'); $this->addSql('CREATE INDEX IDX_87D3D4C51255CD1D ON magazine_log (ban_id)'); $this->addSql('COMMENT ON COLUMN magazine_log.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE magazine_subscription (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_ACCE935A76ED395 ON magazine_subscription (user_id)'); $this->addSql('CREATE INDEX IDX_ACCE9353EB84A1D ON magazine_subscription (magazine_id)'); $this->addSql('CREATE UNIQUE INDEX magazine_subsription_idx ON magazine_subscription (user_id, magazine_id)'); $this->addSql('COMMENT ON COLUMN magazine_subscription.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE message (id INT NOT NULL, thread_id INT NOT NULL, sender_id INT NOT NULL, body TEXT NOT NULL, status VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_B6BD307FE2904019 ON message (thread_id)'); $this->addSql('CREATE INDEX IDX_B6BD307FF624B39D ON message (sender_id)'); $this->addSql('COMMENT ON COLUMN message.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE message_thread (id INT NOT NULL, updated_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); $this->addSql('COMMENT ON COLUMN message_thread.updated_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE message_thread_participants (message_thread_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY(message_thread_id, user_id))'); $this->addSql('CREATE INDEX IDX_F2DE92908829462F ON message_thread_participants (message_thread_id)'); $this->addSql('CREATE INDEX IDX_F2DE9290A76ED395 ON message_thread_participants (user_id)'); $this->addSql('CREATE TABLE moderator (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, is_owner BOOLEAN NOT NULL, is_confirmed BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_6A30B268A76ED395 ON moderator (user_id)'); $this->addSql('CREATE INDEX IDX_6A30B2683EB84A1D ON moderator (magazine_id)'); $this->addSql('CREATE UNIQUE INDEX moderator_magazine_user_idx ON moderator (magazine_id, user_id)'); $this->addSql('COMMENT ON COLUMN moderator.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE notification (id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, message_id INT DEFAULT NULL, ban_id INT DEFAULT NULL, status VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, notification_type TEXT NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_BF5476CAA76ED395 ON notification (user_id)'); $this->addSql('CREATE INDEX IDX_BF5476CABA364942 ON notification (entry_id)'); $this->addSql('CREATE INDEX IDX_BF5476CA60C33421 ON notification (entry_comment_id)'); $this->addSql('CREATE INDEX IDX_BF5476CA4B89032C ON notification (post_id)'); $this->addSql('CREATE INDEX IDX_BF5476CADB1174D2 ON notification (post_comment_id)'); $this->addSql('CREATE INDEX IDX_BF5476CA537A1329 ON notification (message_id)'); $this->addSql('CREATE INDEX IDX_BF5476CA1255CD1D ON notification (ban_id)'); $this->addSql('COMMENT ON COLUMN notification.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE post (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, image_id INT DEFAULT NULL, slug VARCHAR(255) DEFAULT NULL, body TEXT DEFAULT NULL, comment_count INT NOT NULL, score INT NOT NULL, is_adult BOOLEAN DEFAULT NULL, last_active TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, ip VARCHAR(255) DEFAULT NULL, up_votes INT NOT NULL, down_votes INT NOT NULL, ranking INT NOT NULL, visibility TEXT DEFAULT \'visible\' NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_5A8A6C8DA76ED395 ON post (user_id)'); $this->addSql('CREATE INDEX IDX_5A8A6C8D3EB84A1D ON post (magazine_id)'); $this->addSql('CREATE INDEX IDX_5A8A6C8D3DA5256D ON post (image_id)'); $this->addSql('COMMENT ON COLUMN post.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE post_comment (id INT NOT NULL, user_id INT NOT NULL, post_id INT NOT NULL, magazine_id INT NOT NULL, image_id INT DEFAULT NULL, parent_id INT DEFAULT NULL, body TEXT DEFAULT NULL, last_active TIMESTAMP(0) WITH TIME ZONE NOT NULL, ip VARCHAR(255) DEFAULT NULL, up_votes INT NOT NULL, down_votes INT NOT NULL, visibility TEXT DEFAULT \'visible\' NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_A99CE55FA76ED395 ON post_comment (user_id)'); $this->addSql('CREATE INDEX IDX_A99CE55F4B89032C ON post_comment (post_id)'); $this->addSql('CREATE INDEX IDX_A99CE55F3EB84A1D ON post_comment (magazine_id)'); $this->addSql('CREATE INDEX IDX_A99CE55F3DA5256D ON post_comment (image_id)'); $this->addSql('CREATE INDEX IDX_A99CE55F727ACA70 ON post_comment (parent_id)'); $this->addSql('COMMENT ON COLUMN post_comment.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE post_comment_vote (id INT NOT NULL, comment_id INT NOT NULL, user_id INT NOT NULL, author_id INT NOT NULL, choice INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_D71B5A5BF8697D13 ON post_comment_vote (comment_id)'); $this->addSql('CREATE INDEX IDX_D71B5A5BA76ED395 ON post_comment_vote (user_id)'); $this->addSql('CREATE INDEX IDX_D71B5A5BF675F31B ON post_comment_vote (author_id)'); $this->addSql('CREATE UNIQUE INDEX user_post_comment_vote_idx ON post_comment_vote (user_id, comment_id)'); $this->addSql('COMMENT ON COLUMN post_comment_vote.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE post_vote (id INT NOT NULL, post_id INT NOT NULL, user_id INT NOT NULL, author_id INT NOT NULL, choice INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_9345E26F4B89032C ON post_vote (post_id)'); $this->addSql('CREATE INDEX IDX_9345E26FA76ED395 ON post_vote (user_id)'); $this->addSql('CREATE INDEX IDX_9345E26FF675F31B ON post_vote (author_id)'); $this->addSql('CREATE UNIQUE INDEX user_post_vote_idx ON post_vote (user_id, post_id)'); $this->addSql('COMMENT ON COLUMN post_vote.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE report (id INT NOT NULL, magazine_id INT NOT NULL, reporting_id INT NOT NULL, reported_id INT NOT NULL, considered_by_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, reason VARCHAR(255) DEFAULT NULL, weight INT NOT NULL, considered_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, status VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, report_type TEXT NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_C42F77843EB84A1D ON report (magazine_id)'); $this->addSql('CREATE INDEX IDX_C42F778427EE0E60 ON report (reporting_id)'); $this->addSql('CREATE INDEX IDX_C42F778494BDEEB6 ON report (reported_id)'); $this->addSql('CREATE INDEX IDX_C42F7784607E02EB ON report (considered_by_id)'); $this->addSql('CREATE INDEX IDX_C42F7784BA364942 ON report (entry_id)'); $this->addSql('CREATE INDEX IDX_C42F778460C33421 ON report (entry_comment_id)'); $this->addSql('CREATE INDEX IDX_C42F77844B89032C ON report (post_id)'); $this->addSql('CREATE INDEX IDX_C42F7784DB1174D2 ON report (post_comment_id)'); $this->addSql('COMMENT ON COLUMN report.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE site (id INT NOT NULL, title VARCHAR(255) NOT NULL, enabled BOOLEAN NOT NULL, registration_open BOOLEAN NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE TABLE "user" (id INT NOT NULL, avatar_id INT DEFAULT NULL, email VARCHAR(180) DEFAULT NULL, roles JSONB NOT NULL, username VARCHAR(35) NOT NULL, followers_count INT NOT NULL, theme VARCHAR(255) DEFAULT \'light\' NOT NULL, notify_on_new_entry BOOLEAN NOT NULL, notify_on_new_entry_reply BOOLEAN NOT NULL, notify_on_new_entry_comment_reply BOOLEAN NOT NULL, notify_on_new_post BOOLEAN NOT NULL, notify_on_new_post_reply BOOLEAN NOT NULL, notify_on_new_post_comment_reply BOOLEAN NOT NULL, is_verified BOOLEAN NOT NULL, password VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649E7927C74 ON "user" (email)'); $this->addSql('CREATE INDEX IDX_8D93D64986383B10 ON "user" (avatar_id)'); $this->addSql('COMMENT ON COLUMN "user".created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE user_block (id INT NOT NULL, blocker_id INT NOT NULL, blocked_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_61D96C7A548D5975 ON user_block (blocker_id)'); $this->addSql('CREATE INDEX IDX_61D96C7A21FF5136 ON user_block (blocked_id)'); $this->addSql('CREATE UNIQUE INDEX user_block_idx ON user_block (blocker_id, blocked_id)'); $this->addSql('COMMENT ON COLUMN user_block.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE user_follow (id INT NOT NULL, follower_id INT NOT NULL, following_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_D665F4DAC24F853 ON user_follow (follower_id)'); $this->addSql('CREATE INDEX IDX_D665F4D1816E3A3 ON user_follow (following_id)'); $this->addSql('CREATE UNIQUE INDEX user_follows_idx ON user_follow (follower_id, following_id)'); $this->addSql('COMMENT ON COLUMN user_follow.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE view_counter (id INT NOT NULL, entry_id INT NOT NULL, ip TEXT NOT NULL, view_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_E87F8182BA364942 ON view_counter (entry_id)'); $this->addSql('ALTER TABLE badge ADD CONSTRAINT FK_FEF0481D3EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry ADD CONSTRAINT FK_2B219D70A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry ADD CONSTRAINT FK_2B219D703EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry ADD CONSTRAINT FK_2B219D703DA5256D FOREIGN KEY (image_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry ADD CONSTRAINT FK_2B219D70115F0EE5 FOREIGN KEY (domain_id) REFERENCES domain (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_badge ADD CONSTRAINT FK_7AEA2BBBF7A2C2FC FOREIGN KEY (badge_id) REFERENCES badge (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_badge ADD CONSTRAINT FK_7AEA2BBBBA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFBA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFBBA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFB3EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFB3DA5256D FOREIGN KEY (image_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFB727ACA70 FOREIGN KEY (parent_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFB79066886 FOREIGN KEY (root_id) REFERENCES entry_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT FK_9E561267F8697D13 FOREIGN KEY (comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT FK_9E561267A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT FK_9E561267F675F31B FOREIGN KEY (author_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT FK_FE32FD77BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT FK_FE32FD77A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT FK_FE32FD77F675F31B FOREIGN KEY (author_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine ADD CONSTRAINT FK_378C2FE4922726E9 FOREIGN KEY (cover_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE53EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE5A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE5386B8E7 FOREIGN KEY (banned_by_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_block ADD CONSTRAINT FK_41CC6069A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_block ADD CONSTRAINT FK_41CC60693EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C53EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C560C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C54B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C51255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_subscription ADD CONSTRAINT FK_ACCE935A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_subscription ADD CONSTRAINT FK_ACCE9353EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message ADD CONSTRAINT FK_B6BD307FE2904019 FOREIGN KEY (thread_id) REFERENCES message_thread (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message ADD CONSTRAINT FK_B6BD307FF624B39D FOREIGN KEY (sender_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE92908829462F FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE9290A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE moderator ADD CONSTRAINT FK_6A30B268A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE moderator ADD CONSTRAINT FK_6A30B2683EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CAA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CABA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA60C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA4B89032C FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CADB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA537A1329 FOREIGN KEY (message_id) REFERENCES message (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA1255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post ADD CONSTRAINT FK_5A8A6C8DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post ADD CONSTRAINT FK_5A8A6C8D3EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post ADD CONSTRAINT FK_5A8A6C8D3DA5256D FOREIGN KEY (image_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55FA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55F4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55F3EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55F3DA5256D FOREIGN KEY (image_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55F727ACA70 FOREIGN KEY (parent_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT FK_D71B5A5BF8697D13 FOREIGN KEY (comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT FK_D71B5A5BA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT FK_D71B5A5BF675F31B FOREIGN KEY (author_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT FK_9345E26F4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT FK_9345E26FA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT FK_9345E26FF675F31B FOREIGN KEY (author_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F77843EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778427EE0E60 FOREIGN KEY (reporting_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778494BDEEB6 FOREIGN KEY (reported_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F7784607E02EB FOREIGN KEY (considered_by_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F7784BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778460C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F77844B89032C FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F7784DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE "user" ADD CONSTRAINT FK_8D93D64986383B10 FOREIGN KEY (avatar_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE user_block ADD CONSTRAINT FK_61D96C7A548D5975 FOREIGN KEY (blocker_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE user_block ADD CONSTRAINT FK_61D96C7A21FF5136 FOREIGN KEY (blocked_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE user_follow ADD CONSTRAINT FK_D665F4DAC24F853 FOREIGN KEY (follower_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE user_follow ADD CONSTRAINT FK_D665F4D1816E3A3 FOREIGN KEY (following_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE view_counter ADD CONSTRAINT FK_E87F8182BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE entry_badge DROP CONSTRAINT FK_7AEA2BBBF7A2C2FC'); $this->addSql('ALTER TABLE entry DROP CONSTRAINT FK_2B219D70115F0EE5'); $this->addSql('ALTER TABLE entry_badge DROP CONSTRAINT FK_7AEA2BBBBA364942'); $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFBBA364942'); $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT FK_FE32FD77BA364942'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5BA364942'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CABA364942'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F7784BA364942'); $this->addSql('ALTER TABLE view_counter DROP CONSTRAINT FK_E87F8182BA364942'); $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFB727ACA70'); $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFB79066886'); $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT FK_9E561267F8697D13'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C560C33421'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA60C33421'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F778460C33421'); $this->addSql('ALTER TABLE entry DROP CONSTRAINT FK_2B219D703DA5256D'); $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFB3DA5256D'); $this->addSql('ALTER TABLE magazine DROP CONSTRAINT FK_378C2FE4922726E9'); $this->addSql('ALTER TABLE post DROP CONSTRAINT FK_5A8A6C8D3DA5256D'); $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55F3DA5256D'); $this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_8D93D64986383B10'); $this->addSql('ALTER TABLE badge DROP CONSTRAINT FK_FEF0481D3EB84A1D'); $this->addSql('ALTER TABLE entry DROP CONSTRAINT FK_2B219D703EB84A1D'); $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFB3EB84A1D'); $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE53EB84A1D'); $this->addSql('ALTER TABLE magazine_block DROP CONSTRAINT FK_41CC60693EB84A1D'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C53EB84A1D'); $this->addSql('ALTER TABLE magazine_subscription DROP CONSTRAINT FK_ACCE9353EB84A1D'); $this->addSql('ALTER TABLE moderator DROP CONSTRAINT FK_6A30B2683EB84A1D'); $this->addSql('ALTER TABLE post DROP CONSTRAINT FK_5A8A6C8D3EB84A1D'); $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55F3EB84A1D'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F77843EB84A1D'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C51255CD1D'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA1255CD1D'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA537A1329'); $this->addSql('ALTER TABLE message DROP CONSTRAINT FK_B6BD307FE2904019'); $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE92908829462F'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C54B89032C'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA4B89032C'); $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55F4B89032C'); $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT FK_9345E26F4B89032C'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F77844B89032C'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5DB1174D2'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CADB1174D2'); $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55F727ACA70'); $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT FK_D71B5A5BF8697D13'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F7784DB1174D2'); $this->addSql('ALTER TABLE entry DROP CONSTRAINT FK_2B219D70A76ED395'); $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFBA76ED395'); $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT FK_9E561267A76ED395'); $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT FK_9E561267F675F31B'); $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT FK_FE32FD77A76ED395'); $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT FK_FE32FD77F675F31B'); $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE5A76ED395'); $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE5386B8E7'); $this->addSql('ALTER TABLE magazine_block DROP CONSTRAINT FK_41CC6069A76ED395'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5A76ED395'); $this->addSql('ALTER TABLE magazine_subscription DROP CONSTRAINT FK_ACCE935A76ED395'); $this->addSql('ALTER TABLE message DROP CONSTRAINT FK_B6BD307FF624B39D'); $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE9290A76ED395'); $this->addSql('ALTER TABLE moderator DROP CONSTRAINT FK_6A30B268A76ED395'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CAA76ED395'); $this->addSql('ALTER TABLE post DROP CONSTRAINT FK_5A8A6C8DA76ED395'); $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55FA76ED395'); $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT FK_D71B5A5BA76ED395'); $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT FK_D71B5A5BF675F31B'); $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT FK_9345E26FA76ED395'); $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT FK_9345E26FF675F31B'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F778427EE0E60'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F778494BDEEB6'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F7784607E02EB'); $this->addSql('ALTER TABLE user_block DROP CONSTRAINT FK_61D96C7A548D5975'); $this->addSql('ALTER TABLE user_block DROP CONSTRAINT FK_61D96C7A21FF5136'); $this->addSql('ALTER TABLE user_follow DROP CONSTRAINT FK_D665F4DAC24F853'); $this->addSql('ALTER TABLE user_follow DROP CONSTRAINT FK_D665F4D1816E3A3'); $this->addSql('DROP SEQUENCE badge_id_seq CASCADE'); $this->addSql('DROP SEQUENCE domain_id_seq CASCADE'); $this->addSql('DROP SEQUENCE entry_id_seq CASCADE'); $this->addSql('DROP SEQUENCE entry_badge_id_seq CASCADE'); $this->addSql('DROP SEQUENCE entry_comment_id_seq CASCADE'); $this->addSql('DROP SEQUENCE entry_comment_vote_id_seq CASCADE'); $this->addSql('DROP SEQUENCE entry_vote_id_seq CASCADE'); $this->addSql('DROP SEQUENCE image_id_seq CASCADE'); $this->addSql('DROP SEQUENCE magazine_id_seq CASCADE'); $this->addSql('DROP SEQUENCE magazine_ban_id_seq CASCADE'); $this->addSql('DROP SEQUENCE magazine_block_id_seq CASCADE'); $this->addSql('DROP SEQUENCE magazine_log_id_seq CASCADE'); $this->addSql('DROP SEQUENCE magazine_subscription_id_seq CASCADE'); $this->addSql('DROP SEQUENCE message_id_seq CASCADE'); $this->addSql('DROP SEQUENCE message_thread_id_seq CASCADE'); $this->addSql('DROP SEQUENCE moderator_id_seq CASCADE'); $this->addSql('DROP SEQUENCE notification_id_seq CASCADE'); $this->addSql('DROP SEQUENCE post_id_seq CASCADE'); $this->addSql('DROP SEQUENCE post_comment_id_seq CASCADE'); $this->addSql('DROP SEQUENCE post_comment_vote_id_seq CASCADE'); $this->addSql('DROP SEQUENCE post_vote_id_seq CASCADE'); $this->addSql('DROP SEQUENCE report_id_seq CASCADE'); $this->addSql('DROP SEQUENCE site_id_seq CASCADE'); $this->addSql('DROP SEQUENCE "user_id_seq" CASCADE'); $this->addSql('DROP SEQUENCE user_block_id_seq CASCADE'); $this->addSql('DROP SEQUENCE user_follow_id_seq CASCADE'); $this->addSql('DROP SEQUENCE view_counter_id_seq CASCADE'); $this->addSql('DROP TABLE badge'); $this->addSql('DROP TABLE domain'); $this->addSql('DROP TABLE entry'); $this->addSql('DROP TABLE entry_badge'); $this->addSql('DROP TABLE entry_comment'); $this->addSql('DROP TABLE entry_comment_vote'); $this->addSql('DROP TABLE entry_vote'); $this->addSql('DROP TABLE image'); $this->addSql('DROP TABLE magazine'); $this->addSql('DROP TABLE magazine_ban'); $this->addSql('DROP TABLE magazine_block'); $this->addSql('DROP TABLE magazine_log'); $this->addSql('DROP TABLE magazine_subscription'); $this->addSql('DROP TABLE message'); $this->addSql('DROP TABLE message_thread'); $this->addSql('DROP TABLE message_thread_participants'); $this->addSql('DROP TABLE moderator'); $this->addSql('DROP TABLE notification'); $this->addSql('DROP TABLE post'); $this->addSql('DROP TABLE post_comment'); $this->addSql('DROP TABLE post_comment_vote'); $this->addSql('DROP TABLE post_vote'); $this->addSql('DROP TABLE report'); $this->addSql('DROP TABLE site'); $this->addSql('DROP TABLE "user"'); $this->addSql('DROP TABLE user_block'); $this->addSql('DROP TABLE user_follow'); $this->addSql('DROP TABLE view_counter'); } } ================================================ FILE: migrations/Version20210830133327.php ================================================ addSql('ALTER TABLE "user" ADD mode VARCHAR(255) DEFAULT \'normal\' NOT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP mode'); } } ================================================ FILE: migrations/Version20211016124104.php ================================================ addSql('ALTER TABLE badge ALTER magazine_id DROP NOT NULL'); $this->addSql('ALTER TABLE badge ALTER name SET NOT NULL'); $this->addSql('ALTER TABLE entry ALTER is_adult SET NOT NULL'); $this->addSql('ALTER TABLE entry ALTER last_active SET NOT NULL'); $this->addSql('ALTER TABLE entry_comment ALTER body SET NOT NULL'); $this->addSql('ALTER TABLE magazine ALTER title SET NOT NULL'); $this->addSql('ALTER TABLE magazine ALTER is_adult SET NOT NULL'); $this->addSql('ALTER TABLE message_thread ALTER updated_at SET NOT NULL'); $this->addSql('ALTER TABLE post ALTER is_adult SET NOT NULL'); $this->addSql('ALTER TABLE post ALTER last_active SET NOT NULL'); $this->addSql('ALTER TABLE post_comment ALTER body SET NOT NULL'); $this->addSql('ALTER TABLE site ADD domain VARCHAR(255) NOT NULL'); $this->addSql('ALTER TABLE site ADD description TEXT NOT NULL'); $this->addSql('ALTER TABLE "user" ALTER email SET NOT NULL'); $this->addSql('ALTER TABLE view_counter ALTER entry_id DROP NOT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE site DROP domain'); $this->addSql('ALTER TABLE site DROP description'); $this->addSql('ALTER TABLE "user" ALTER email DROP NOT NULL'); $this->addSql('ALTER TABLE magazine ALTER title DROP NOT NULL'); $this->addSql('ALTER TABLE magazine ALTER is_adult DROP NOT NULL'); $this->addSql('ALTER TABLE badge ALTER magazine_id SET NOT NULL'); $this->addSql('ALTER TABLE badge ALTER name DROP NOT NULL'); $this->addSql('ALTER TABLE entry ALTER is_adult DROP NOT NULL'); $this->addSql('ALTER TABLE entry ALTER last_active DROP NOT NULL'); $this->addSql('ALTER TABLE entry_comment ALTER body DROP NOT NULL'); $this->addSql('ALTER TABLE post ALTER is_adult DROP NOT NULL'); $this->addSql('ALTER TABLE post ALTER last_active DROP NOT NULL'); $this->addSql('ALTER TABLE post_comment ALTER body DROP NOT NULL'); $this->addSql('ALTER TABLE message_thread ALTER updated_at DROP NOT NULL'); $this->addSql('ALTER TABLE view_counter ALTER entry_id SET NOT NULL'); } } ================================================ FILE: migrations/Version20211107140830.php ================================================ addSql('ALTER TABLE magazine ADD last_active TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE magazine DROP last_active'); } } ================================================ FILE: migrations/Version20211113102713.php ================================================ addSql('ALTER TABLE "user" ADD cardano_wallet_id VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP cardano_wallet_id'); } } ================================================ FILE: migrations/Version20211117170048.php ================================================ addSql('CREATE SEQUENCE cardano_payment_init_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE cardano_payment_init (id INT NOT NULL, magazine_id INT DEFAULT NULL, user_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, cpi_type TEXT NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_AFB29E403EB84A1D ON cardano_payment_init (magazine_id)'); $this->addSql('CREATE INDEX IDX_AFB29E40A76ED395 ON cardano_payment_init (user_id)'); $this->addSql('CREATE INDEX IDX_AFB29E40BA364942 ON cardano_payment_init (entry_id)'); $this->addSql('COMMENT ON COLUMN cardano_payment_init.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE cardano_payment_init ADD CONSTRAINT FK_AFB29E403EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_payment_init ADD CONSTRAINT FK_AFB29E40A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_payment_init ADD CONSTRAINT FK_AFB29E40BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP SEQUENCE cardano_payment_init_id_seq CASCADE'); $this->addSql('DROP TABLE cardano_payment_init'); } } ================================================ FILE: migrations/Version20211121182824.php ================================================ addSql('DROP SEQUENCE cardano_payment_init_id_seq CASCADE'); $this->addSql('CREATE SEQUENCE cardano_tx_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE cardano_tx_init_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE cardano_tx (id INT NOT NULL, magazine_id INT DEFAULT NULL, receiver_id INT DEFAULT NULL, sender_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, amount INT NOT NULL, tx_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, ctx_type TEXT NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_F74C620E3EB84A1D ON cardano_tx (magazine_id)'); $this->addSql('CREATE INDEX IDX_F74C620ECD53EDB6 ON cardano_tx (receiver_id)'); $this->addSql('CREATE INDEX IDX_F74C620EF624B39D ON cardano_tx (sender_id)'); $this->addSql('CREATE INDEX IDX_F74C620EBA364942 ON cardano_tx (entry_id)'); $this->addSql('COMMENT ON COLUMN cardano_tx.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE cardano_tx_init (id INT NOT NULL, magazine_id INT DEFAULT NULL, user_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, session_id VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, cpi_type TEXT NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_973316583EB84A1D ON cardano_tx_init (magazine_id)'); $this->addSql('CREATE INDEX IDX_97331658A76ED395 ON cardano_tx_init (user_id)'); $this->addSql('CREATE INDEX IDX_97331658BA364942 ON cardano_tx_init (entry_id)'); $this->addSql('COMMENT ON COLUMN cardano_tx_init.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT FK_F74C620E3EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT FK_F74C620ECD53EDB6 FOREIGN KEY (receiver_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT FK_F74C620EF624B39D FOREIGN KEY (sender_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT FK_F74C620EBA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_tx_init ADD CONSTRAINT FK_973316583EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_tx_init ADD CONSTRAINT FK_97331658A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_tx_init ADD CONSTRAINT FK_97331658BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('DROP TABLE cardano_payment_init'); $this->addSql('ALTER TABLE entry ADD ada_amount INT DEFAULT 0 NOT NULL'); $this->addSql('ALTER TABLE "user" ADD cardano_wallet_address VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP SEQUENCE cardano_tx_id_seq CASCADE'); $this->addSql('DROP SEQUENCE cardano_tx_init_id_seq CASCADE'); $this->addSql('CREATE SEQUENCE cardano_payment_init_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE cardano_payment_init (id INT NOT NULL, magazine_id INT DEFAULT NULL, user_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, cpi_type TEXT NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX idx_afb29e40ba364942 ON cardano_payment_init (entry_id)'); $this->addSql('CREATE INDEX idx_afb29e40a76ed395 ON cardano_payment_init (user_id)'); $this->addSql('CREATE INDEX idx_afb29e403eb84a1d ON cardano_payment_init (magazine_id)'); $this->addSql('COMMENT ON COLUMN cardano_payment_init.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE cardano_payment_init ADD CONSTRAINT fk_afb29e403eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_payment_init ADD CONSTRAINT fk_afb29e40a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_payment_init ADD CONSTRAINT fk_afb29e40ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('DROP TABLE cardano_tx'); $this->addSql('DROP TABLE cardano_tx_init'); $this->addSql('ALTER TABLE "user" DROP cardano_wallet_address'); $this->addSql('ALTER TABLE entry DROP ada_amount'); } } ================================================ FILE: migrations/Version20211205133802.php ================================================ addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CABA364942'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA60C33421'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA4B89032C'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CADB1174D2'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA537A1329'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA1255CD1D'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CABA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA60C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CADB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA537A1329 FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA1255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476caba364942'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca60c33421'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca4b89032c'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476cadb1174d2'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca537a1329'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca1255cd1d'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476caba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca60c33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca4b89032c FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476cadb1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca537a1329 FOREIGN KEY (message_id) REFERENCES message (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca1255cd1d FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); } } ================================================ FILE: migrations/Version20211220092653.php ================================================ addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CABA364942'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA60C33421'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA4B89032C'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CADB1174D2'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA537A1329'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA1255CD1D'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CABA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA60C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA4B89032C FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CADB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA537A1329 FOREIGN KEY (message_id) REFERENCES message (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA1255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE "user" ADD is_banned BOOLEAN DEFAULT \'false\' NOT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP is_banned'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476caba364942'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca60c33421'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca4b89032c'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476cadb1174d2'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca537a1329'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca1255cd1d'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476caba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca60c33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca4b89032c FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476cadb1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca537a1329 FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca1255cd1d FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } } ================================================ FILE: migrations/Version20211231174542.php ================================================ addSql('ALTER TABLE "user" ADD hide_images BOOLEAN DEFAULT \'false\' NOT NULL'); $this->addSql('ALTER TABLE "user" ADD show_profile_subscriptions BOOLEAN DEFAULT \'false\' NOT NULL'); $this->addSql('ALTER TABLE "user" ADD show_profile_followings BOOLEAN DEFAULT \'false\' NOT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP hide_images'); $this->addSql('ALTER TABLE "user" DROP show_profile_subscriptions'); $this->addSql('ALTER TABLE "user" DROP show_profile_followings'); } } ================================================ FILE: migrations/Version20220116141404.php ================================================ addSql('ALTER TABLE "user" ADD right_pos_images BOOLEAN DEFAULT \'false\' NOT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP right_pos_images'); } } ================================================ FILE: migrations/Version20220123173726.php ================================================ addSql('ALTER TABLE entry ADD lang VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE entry ADD is_oc BOOLEAN DEFAULT false NOT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE entry DROP lang'); $this->addSql('ALTER TABLE entry DROP is_oc'); } } ================================================ FILE: migrations/Version20220125212007.php ================================================ addSql('ALTER TABLE entry ADD tags TEXT DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN entry.tags IS \'(DC2Type:array)\''); $this->addSql('ALTER TABLE entry_comment ADD tags TEXT DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN entry_comment.tags IS \'(DC2Type:array)\''); $this->addSql('ALTER TABLE post ADD tags TEXT DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN post.tags IS \'(DC2Type:array)\''); $this->addSql('ALTER TABLE post_comment ADD tags TEXT DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN post_comment.tags IS \'(DC2Type:array)\''); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE entry_comment DROP tags'); $this->addSql('ALTER TABLE post DROP tags'); $this->addSql('ALTER TABLE post_comment DROP tags'); $this->addSql('ALTER TABLE entry DROP tags'); } } ================================================ FILE: migrations/Version20220131190012.php ================================================ addSql('ALTER TABLE "user" ADD hide_adult BOOLEAN DEFAULT true NOT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP hide_adult'); } } ================================================ FILE: migrations/Version20220204202829.php ================================================ addSql('CREATE SEQUENCE domain_block_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE domain_subscription_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE domain_block (id INT NOT NULL, user_id INT NOT NULL, domain_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_5060BFF4A76ED395 ON domain_block (user_id)'); $this->addSql('CREATE INDEX IDX_5060BFF4115F0EE5 ON domain_block (domain_id)'); $this->addSql('CREATE UNIQUE INDEX domain_block_idx ON domain_block (user_id, domain_id)'); $this->addSql('COMMENT ON COLUMN domain_block.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE domain_subscription (id INT NOT NULL, user_id INT NOT NULL, domain_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_3AC9125EA76ED395 ON domain_subscription (user_id)'); $this->addSql('CREATE INDEX IDX_3AC9125E115F0EE5 ON domain_subscription (domain_id)'); $this->addSql('CREATE UNIQUE INDEX domain_subsription_idx ON domain_subscription (user_id, domain_id)'); $this->addSql('COMMENT ON COLUMN domain_subscription.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE domain_block ADD CONSTRAINT FK_5060BFF4A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE domain_block ADD CONSTRAINT FK_5060BFF4115F0EE5 FOREIGN KEY (domain_id) REFERENCES domain (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE domain_subscription ADD CONSTRAINT FK_3AC9125EA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE domain_subscription ADD CONSTRAINT FK_3AC9125E115F0EE5 FOREIGN KEY (domain_id) REFERENCES domain (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE domain ADD subscriptions_count INT DEFAULT 0 NOT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP SEQUENCE domain_block_id_seq CASCADE'); $this->addSql('DROP SEQUENCE domain_subscription_id_seq CASCADE'); $this->addSql('DROP TABLE domain_block'); $this->addSql('DROP TABLE domain_subscription'); $this->addSql('ALTER TABLE domain DROP subscriptions_count'); } } ================================================ FILE: migrations/Version20220206143129.php ================================================ addSql('CREATE SEQUENCE user_note_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE user_note (id INT NOT NULL, user_id INT NOT NULL, target_id INT NOT NULL, body TEXT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_B53CB6DDA76ED395 ON user_note (user_id)'); $this->addSql('CREATE INDEX IDX_B53CB6DD158E0B66 ON user_note (target_id)'); $this->addSql('CREATE UNIQUE INDEX user_noted_idx ON user_note (user_id, target_id)'); $this->addSql('COMMENT ON COLUMN user_note.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE user_note ADD CONSTRAINT FK_B53CB6DDA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE user_note ADD CONSTRAINT FK_B53CB6DD158E0B66 FOREIGN KEY (target_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP SEQUENCE user_note_id_seq CASCADE'); $this->addSql('DROP TABLE user_note'); } } ================================================ FILE: migrations/Version20220208192443.php ================================================ addSql('CREATE SEQUENCE favourite_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE favourite (id INT NOT NULL, magazine_id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, favourite_type TEXT NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_62A2CA193EB84A1D ON favourite (magazine_id)'); $this->addSql('CREATE INDEX IDX_62A2CA19A76ED395 ON favourite (user_id)'); $this->addSql('CREATE INDEX IDX_62A2CA19BA364942 ON favourite (entry_id)'); $this->addSql('CREATE INDEX IDX_62A2CA1960C33421 ON favourite (entry_comment_id)'); $this->addSql('CREATE INDEX IDX_62A2CA194B89032C ON favourite (post_id)'); $this->addSql('CREATE INDEX IDX_62A2CA19DB1174D2 ON favourite (post_comment_id)'); $this->addSql('COMMENT ON COLUMN favourite.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA193EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA19A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA19BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA1960C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA194B89032C FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA19DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry ADD favourite_count INT DEFAULT 0 NOT NULL'); $this->addSql('ALTER TABLE entry_comment ADD favourite_count INT DEFAULT 0 NOT NULL'); $this->addSql('ALTER TABLE post ADD favourite_count INT DEFAULT 0 NOT NULL'); $this->addSql('ALTER TABLE post_comment ADD favourite_count INT DEFAULT 0 NOT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP SEQUENCE favourite_id_seq CASCADE'); $this->addSql('DROP TABLE favourite'); $this->addSql('ALTER TABLE entry DROP favourite_count'); $this->addSql('ALTER TABLE entry_comment DROP favourite_count'); $this->addSql('ALTER TABLE post DROP favourite_count'); $this->addSql('ALTER TABLE post_comment DROP favourite_count'); } } ================================================ FILE: migrations/Version20220216211707.php ================================================ addSql('ALTER TABLE "user" ADD homepage VARCHAR(255) DEFAULT \'front_subscribed\' NOT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP homepage'); } } ================================================ FILE: migrations/Version20220218220935.php ================================================ addSql('ALTER TABLE site ADD terms TEXT DEFAULT NULL'); $this->addSql('ALTER TABLE site ADD privacy_policy TEXT DEFAULT NULL'); $this->addSql('ALTER TABLE site ALTER description DROP NOT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE site DROP terms'); $this->addSql('ALTER TABLE site DROP privacy_policy'); $this->addSql('ALTER TABLE site ALTER description SET NOT NULL'); } } ================================================ FILE: migrations/Version20220306181222.php ================================================ addSql('ALTER TABLE "user" ADD oauth_github_id VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD oauth_google_id VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD oauth_facebook_id VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP oauth_github_id'); $this->addSql('ALTER TABLE "user" DROP oauth_google_id'); $this->addSql('ALTER TABLE "user" DROP oauth_facebook_id'); } } ================================================ FILE: migrations/Version20220308201003.php ================================================ addSql('ALTER TABLE "user" ADD featured_magazines TEXT DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN "user".featured_magazines IS \'(DC2Type:array)\''); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP featured_magazines'); } } ================================================ FILE: migrations/Version20220320191810.php ================================================ addSql('CREATE SEQUENCE award_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE award_type_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE award (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT DEFAULT NULL, type_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_8A5B2EE7A76ED395 ON award (user_id)'); $this->addSql('CREATE INDEX IDX_8A5B2EE73EB84A1D ON award (magazine_id)'); $this->addSql('CREATE INDEX IDX_8A5B2EE7C54C8C93 ON award (type_id)'); $this->addSql('COMMENT ON COLUMN award.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE award_type (id INT NOT NULL, name VARCHAR(255) NOT NULL, category VARCHAR(255) NOT NULL, count INT DEFAULT 0 NOT NULL, attributes TEXT DEFAULT NULL, PRIMARY KEY(id))'); $this->addSql('COMMENT ON COLUMN award_type.attributes IS \'(DC2Type:array)\''); $this->addSql('ALTER TABLE award ADD CONSTRAINT FK_8A5B2EE7A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE award ADD CONSTRAINT FK_8A5B2EE73EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE award ADD CONSTRAINT FK_8A5B2EE7C54C8C93 FOREIGN KEY (type_id) REFERENCES award_type (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE award DROP CONSTRAINT FK_8A5B2EE7C54C8C93'); $this->addSql('DROP SEQUENCE award_id_seq CASCADE'); $this->addSql('DROP SEQUENCE award_type_id_seq CASCADE'); $this->addSql('DROP TABLE award'); $this->addSql('DROP TABLE award_type'); } } ================================================ FILE: migrations/Version20220404185534.php ================================================ addSql('ALTER TABLE "user" ADD hide_user_avatars BOOLEAN DEFAULT true NOT NULL'); $this->addSql('ALTER TABLE "user" ADD hide_magazine_avatars BOOLEAN DEFAULT true NOT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP hide_user_avatars'); $this->addSql('ALTER TABLE "user" DROP hide_magazine_avatars'); } } ================================================ FILE: migrations/Version20220407171552.php ================================================ addSql('ALTER TABLE "user" ADD entry_popup BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE "user" ADD post_popup BOOLEAN DEFAULT false NOT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP entry_popup'); $this->addSql('ALTER TABLE "user" DROP post_popup'); } } ================================================ FILE: migrations/Version20220408100230.php ================================================ addSql('ALTER TABLE entry ADD edited_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN entry.edited_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE entry_comment ADD edited_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN entry_comment.edited_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE post ADD edited_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN post.edited_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE post_comment ADD edited_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN post_comment.edited_at IS \'(DC2Type:datetimetz_immutable)\''); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE entry DROP edited_at'); $this->addSql('ALTER TABLE entry_comment DROP edited_at'); $this->addSql('ALTER TABLE post DROP edited_at'); $this->addSql('ALTER TABLE post_comment DROP edited_at'); } } ================================================ FILE: migrations/Version20220411203149.php ================================================ addSql('CREATE SEQUENCE reset_password_request_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE reset_password_request (id INT NOT NULL, user_id INT NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_7CE748AA76ED395 ON reset_password_request (user_id)'); $this->addSql('COMMENT ON COLUMN reset_password_request.requested_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('COMMENT ON COLUMN reset_password_request.expires_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP SEQUENCE reset_password_request_id_seq CASCADE'); $this->addSql('DROP TABLE reset_password_request'); } } ================================================ FILE: migrations/Version20220421082111.php ================================================ addSql('ALTER TABLE "user" ALTER hide_magazine_avatars SET DEFAULT false'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" ALTER hide_magazine_avatars SET DEFAULT true'); } } ================================================ FILE: migrations/Version20220621144628.php ================================================ addSql('CREATE SEQUENCE settings_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE settings (id INT NOT NULL, name VARCHAR(255) NOT NULL, value VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP SEQUENCE settings_id_seq CASCADE'); $this->addSql('DROP TABLE settings'); } } ================================================ FILE: migrations/Version20220705184724.php ================================================ addSql('ALTER TABLE entry ADD mentions JSONB DEFAULT NULL'); $this->addSql('ALTER TABLE entry_comment ADD mentions JSONB DEFAULT NULL'); $this->addSql('ALTER TABLE post ADD mentions JSONB DEFAULT NULL'); $this->addSql('ALTER TABLE post_comment ADD mentions JSONB DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE entry DROP mentions'); $this->addSql('ALTER TABLE post_comment DROP mentions'); $this->addSql('ALTER TABLE entry_comment DROP mentions'); $this->addSql('ALTER TABLE post DROP mentions'); } } ================================================ FILE: migrations/Version20220716120139.php ================================================ addSql('CREATE SEQUENCE ap_inbox_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE ap_outbox_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE ap_inbox (id INT NOT NULL, body JSON NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('COMMENT ON COLUMN ap_inbox.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE ap_outbox (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT DEFAULT NULL, type VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_FF9FD54A76ED395 ON ap_outbox (user_id)'); $this->addSql('CREATE INDEX IDX_FF9FD543EB84A1D ON ap_outbox (magazine_id)'); $this->addSql('COMMENT ON COLUMN ap_outbox.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE ap_outbox ADD CONSTRAINT FK_FF9FD54A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE ap_outbox ADD CONSTRAINT FK_FF9FD543EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP SEQUENCE ap_inbox_id_seq CASCADE'); $this->addSql('DROP SEQUENCE ap_outbox_id_seq CASCADE'); $this->addSql('DROP TABLE ap_inbox'); $this->addSql('DROP TABLE ap_outbox'); } } ================================================ FILE: migrations/Version20220716142146.php ================================================ addSql('ALTER TABLE ap_outbox ADD subject_id VARCHAR(255) NOT NULL'); $this->addSql('ALTER TABLE ap_outbox ADD body JSONB DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE ap_outbox DROP subject_id'); $this->addSql('ALTER TABLE ap_outbox DROP body'); } } ================================================ FILE: migrations/Version20220717101149.php ================================================ addSql('ALTER TABLE magazine ADD private_key TEXT DEFAULT NULL'); $this->addSql('ALTER TABLE magazine ADD public_key TEXT DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD private_key TEXT DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD public_key TEXT DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE magazine DROP private_key'); $this->addSql('ALTER TABLE magazine DROP public_key'); $this->addSql('ALTER TABLE "user" DROP private_key'); $this->addSql('ALTER TABLE "user" DROP public_key'); } } ================================================ FILE: migrations/Version20220723095813.php ================================================ addSql('ALTER TABLE magazine ADD ap_id VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE magazine ADD ap_profile_id VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_id VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_profile_id VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ALTER email TYPE VARCHAR(255)'); $this->addSql('ALTER TABLE "user" ALTER username TYPE VARCHAR(255)'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE magazine DROP ap_id'); $this->addSql('ALTER TABLE magazine DROP ap_profile_id'); $this->addSql('ALTER TABLE "user" DROP ap_id'); $this->addSql('ALTER TABLE "user" DROP ap_profile_id'); $this->addSql('ALTER TABLE "user" ALTER email TYPE VARCHAR(180)'); $this->addSql('ALTER TABLE "user" ALTER username TYPE VARCHAR(35)'); } } ================================================ FILE: migrations/Version20220723182602.php ================================================ addSql('CREATE SEQUENCE embed_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE embed (id INT NOT NULL, url VARCHAR(255) NOT NULL, has_embed BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE UNIQUE INDEX url_idx ON embed (url)'); $this->addSql('COMMENT ON COLUMN embed.created_at IS \'(DC2Type:datetimetz_immutable)\''); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP SEQUENCE embed_id_seq CASCADE'); $this->addSql('DROP TABLE embed'); } } ================================================ FILE: migrations/Version20220801085018.php ================================================ addSql('DROP SEQUENCE ap_inbox_id_seq CASCADE'); $this->addSql('DROP SEQUENCE ap_outbox_id_seq CASCADE'); $this->addSql('CREATE SEQUENCE ap_activity_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE ap_activity (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT DEFAULT NULL, subject_id VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, body JSONB DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_68292518A76ED395 ON ap_activity (user_id)'); $this->addSql('CREATE INDEX IDX_682925183EB84A1D ON ap_activity (magazine_id)'); $this->addSql('COMMENT ON COLUMN ap_activity.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE ap_activity ADD CONSTRAINT FK_68292518A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE ap_activity ADD CONSTRAINT FK_682925183EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('DROP TABLE ap_inbox'); $this->addSql('DROP TABLE ap_outbox'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP SEQUENCE ap_activity_id_seq CASCADE'); $this->addSql('CREATE SEQUENCE ap_inbox_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE ap_outbox_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE ap_inbox (id INT NOT NULL, body JSON NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('COMMENT ON COLUMN ap_inbox.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE ap_outbox (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT DEFAULT NULL, type VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, subject_id VARCHAR(255) NOT NULL, body JSONB DEFAULT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX idx_ff9fd543eb84a1d ON ap_outbox (magazine_id)'); $this->addSql('CREATE INDEX idx_ff9fd54a76ed395 ON ap_outbox (user_id)'); $this->addSql('COMMENT ON COLUMN ap_outbox.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE ap_outbox ADD CONSTRAINT fk_ff9fd54a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE ap_outbox ADD CONSTRAINT fk_ff9fd543eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('DROP TABLE ap_activity'); } } ================================================ FILE: migrations/Version20220808150935.php ================================================ addSql('ALTER TABLE entry ADD ap_id VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE entry_comment ADD ap_id VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE post ADD ap_id VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE post_comment ADD ap_id VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE entry DROP ap_id'); $this->addSql('ALTER TABLE entry_comment DROP ap_id'); $this->addSql('ALTER TABLE post DROP ap_id'); $this->addSql('ALTER TABLE post_comment DROP ap_id'); } } ================================================ FILE: migrations/Version20220903070858.php ================================================ addSql('ALTER TABLE "user" ADD cover_id INT DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD about VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD fields JSON DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD CONSTRAINT FK_8D93D649922726E9 FOREIGN KEY (cover_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE INDEX IDX_8D93D649922726E9 ON "user" (cover_id)'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_8D93D649922726E9'); $this->addSql('DROP INDEX IDX_8D93D649922726E9'); $this->addSql('ALTER TABLE "user" DROP cover_id'); $this->addSql('ALTER TABLE "user" DROP about'); $this->addSql('ALTER TABLE "user" DROP fields'); } } ================================================ FILE: migrations/Version20220911120737.php ================================================ addSql('ALTER TABLE "user" ADD ap_public_url VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_fetched_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ALTER email TYPE VARCHAR(500)'); $this->addSql('ALTER TABLE "user" ALTER username TYPE VARCHAR(500)'); $this->addSql('ALTER TABLE "user" ALTER about TYPE TEXT'); $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649F85E0677 ON "user" (username)'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP INDEX UNIQ_8D93D649F85E0677'); $this->addSql('ALTER TABLE "user" DROP ap_public_url'); $this->addSql('ALTER TABLE "user" DROP ap_fetched_at'); $this->addSql('ALTER TABLE "user" ALTER email TYPE VARCHAR(255)'); $this->addSql('ALTER TABLE "user" ALTER username TYPE VARCHAR(255)'); $this->addSql('ALTER TABLE "user" ALTER about TYPE VARCHAR(255)'); } } ================================================ FILE: migrations/Version20220917102655.php ================================================ addSql('CREATE SEQUENCE user_follow_request_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE user_follow_request (id INT NOT NULL, follower_id INT NOT NULL, following_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_EE70876AC24F853 ON user_follow_request (follower_id)'); $this->addSql('CREATE INDEX IDX_EE708761816E3A3 ON user_follow_request (following_id)'); $this->addSql('CREATE UNIQUE INDEX user_follow_requests_idx ON user_follow_request (follower_id, following_id)'); $this->addSql('COMMENT ON COLUMN user_follow_request.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE user_follow_request ADD CONSTRAINT FK_EE70876AC24F853 FOREIGN KEY (follower_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE user_follow_request ADD CONSTRAINT FK_EE708761816E3A3 FOREIGN KEY (following_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP SEQUENCE user_follow_request_id_seq CASCADE'); $this->addSql('ALTER TABLE user_follow_request DROP CONSTRAINT FK_EE70876AC24F853'); $this->addSql('ALTER TABLE user_follow_request DROP CONSTRAINT FK_EE708761816E3A3'); $this->addSql('DROP TABLE user_follow_request'); } } ================================================ FILE: migrations/Version20220918140533.php ================================================ addSql('ALTER TABLE magazine ADD ap_followers_url VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE magazine ADD ap_preferred_username VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE magazine ADD ap_discoverable BOOLEAN DEFAULT NULL'); $this->addSql('ALTER TABLE magazine ADD ap_manually_approves_followers BOOLEAN DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_followers_url VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_preferred_username VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_discoverable BOOLEAN DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_manually_approves_followers BOOLEAN DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE magazine DROP ap_followers_url'); $this->addSql('ALTER TABLE magazine DROP ap_preferred_username'); $this->addSql('ALTER TABLE magazine DROP ap_discoverable'); $this->addSql('ALTER TABLE magazine DROP ap_manually_approves_followers'); $this->addSql('ALTER TABLE "user" DROP ap_followers_url'); $this->addSql('ALTER TABLE "user" DROP ap_preferred_username'); $this->addSql('ALTER TABLE "user" DROP ap_discoverable'); $this->addSql('ALTER TABLE "user" DROP ap_manually_approves_followers'); } } ================================================ FILE: migrations/Version20220924182955.php ================================================ addSql('ALTER TABLE magazine ADD ap_public_url VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE magazine ADD ap_fetched_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE magazine DROP ap_public_url'); $this->addSql('ALTER TABLE magazine DROP ap_fetched_at'); } } ================================================ FILE: migrations/Version20221015120344.php ================================================ addSql('ALTER TABLE image ADD blurhash VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE image ADD alt_text VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE image ADD source_url VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE image DROP blurhash'); $this->addSql('ALTER TABLE image DROP alt_text'); $this->addSql('ALTER TABLE image DROP source_url'); } } ================================================ FILE: migrations/Version20221030095047.php ================================================ addSql('ALTER TABLE "user" ADD last_active TIMESTAMP(0) WITH TIME ZONE NOT NULL DEFAULT NOW()'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP last_active'); } } ================================================ FILE: migrations/Version20221108164813.php ================================================ addSql('CREATE UNIQUE INDEX UNIQ_2B219D70904F155E ON entry (ap_id)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_B892FDFB904F155E ON entry_comment (ap_id)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_378C2FE4904F155E ON magazine (ap_id)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_5A8A6C8D904F155E ON post (ap_id)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_A99CE55F904F155E ON post_comment (ap_id)'); $this->addSql('ALTER TABLE "user" ALTER last_active DROP DEFAULT'); $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649904F155E ON "user" (ap_id)'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP INDEX UNIQ_B892FDFB904F155E'); $this->addSql('DROP INDEX UNIQ_378C2FE4904F155E'); $this->addSql('DROP INDEX UNIQ_2B219D70904F155E'); $this->addSql('DROP INDEX UNIQ_5A8A6C8D904F155E'); $this->addSql('DROP INDEX UNIQ_8D93D649904F155E'); $this->addSql('ALTER TABLE "user" ALTER last_active SET DEFAULT \'now()\''); $this->addSql('DROP INDEX UNIQ_A99CE55F904F155E'); } } ================================================ FILE: migrations/Version20221109161753.php ================================================ addSql('ALTER TABLE magazine ADD tags JSONB DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE magazine DROP tags'); } } ================================================ FILE: migrations/Version20221116150037.php ================================================ addSql('ALTER TABLE "user" ALTER show_profile_subscriptions SET DEFAULT true'); $this->addSql('ALTER TABLE "user" ALTER show_profile_followings SET DEFAULT true'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" ALTER show_profile_subscriptions SET DEFAULT false'); $this->addSql('ALTER TABLE "user" ALTER show_profile_followings SET DEFAULT false'); } } ================================================ FILE: migrations/Version20221121125723.php ================================================ addSql('ALTER TABLE magazine ALTER name TYPE VARCHAR(255)'); $this->addSql('ALTER TABLE magazine ALTER title TYPE VARCHAR(255)'); $this->addSql('ALTER TABLE "user" ALTER email TYPE VARCHAR(255)'); $this->addSql('ALTER TABLE "user" ALTER username TYPE VARCHAR(255)'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE magazine ALTER name TYPE VARCHAR(25)'); $this->addSql('ALTER TABLE magazine ALTER title TYPE VARCHAR(50)'); $this->addSql('ALTER TABLE "user" ALTER email TYPE VARCHAR(500)'); $this->addSql('ALTER TABLE "user" ALTER username TYPE VARCHAR(500)'); } } ================================================ FILE: migrations/Version20221124162526.php ================================================ addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C51255CD1D'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C54B89032C'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C560C33421'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5BA364942'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5DB1174D2'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C51255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C54B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C560C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476caa76ed395'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476caa76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CAA76ED395'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CAA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c5ba364942'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c560c33421'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c54b89032c'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c5db1174d2'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c51255cd1d'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c5ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c560c33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c54b89032c FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c5db1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c51255cd1d FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476caa76ed395'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476caa76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); } } ================================================ FILE: migrations/Version20221128212959.php ================================================ addSql('ALTER TABLE "user" ALTER theme SET DEFAULT \'dark\''); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" ALTER theme SET DEFAULT \'light\''); } } ================================================ FILE: migrations/Version20221202114605.php ================================================ addSql('ALTER TABLE entry ADD tags_tmp JSONB DEFAULT NULL'); $this->addSql('ALTER TABLE entry_comment ADD tags_tmp JSONB DEFAULT NULL'); $this->addSql('ALTER TABLE post ADD tags_tmp JSONB DEFAULT NULL'); $this->addSql('ALTER TABLE post_comment ADD tags_tmp JSONB DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE entry_comment DROP tags_tmp'); $this->addSql('ALTER TABLE entry DROP tags_tmp'); $this->addSql('ALTER TABLE post DROP tags_tmp'); $this->addSql('ALTER TABLE post_comment DROP tags_tmp'); } } ================================================ FILE: migrations/Version20221202134944.php ================================================ addSql('ALTER TABLE entry DROP tags'); $this->addSql('ALTER TABLE entry_comment DROP tags'); $this->addSql('ALTER TABLE post DROP tags'); $this->addSql('ALTER TABLE post_comment DROP tags'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE post_comment ADD tags TEXT DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN post_comment.tags IS \'(DC2Type:array)\''); $this->addSql('ALTER TABLE entry ADD tags TEXT DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN entry.tags IS \'(DC2Type:array)\''); $this->addSql('ALTER TABLE post ADD tags TEXT DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN post.tags IS \'(DC2Type:array)\''); $this->addSql('ALTER TABLE entry_comment ADD tags TEXT DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN entry_comment.tags IS \'(DC2Type:array)\''); } } ================================================ FILE: migrations/Version20221202140020.php ================================================ addSql('ALTER TABLE entry RENAME COLUMN tags_tmp TO tags'); $this->addSql('ALTER TABLE entry_comment RENAME COLUMN tags_tmp TO tags'); $this->addSql('ALTER TABLE post RENAME COLUMN tags_tmp TO tags'); $this->addSql('ALTER TABLE post_comment RENAME COLUMN tags_tmp TO tags'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE entry RENAME COLUMN tags TO tags_tmp'); $this->addSql('ALTER TABLE entry_comment RENAME COLUMN tags TO tags_tmp'); $this->addSql('ALTER TABLE post RENAME COLUMN tags TO tags_tmp'); $this->addSql('ALTER TABLE post_comment RENAME COLUMN tags TO tags_tmp'); } } ================================================ FILE: migrations/Version20221214153611.php ================================================ addSql('ALTER INDEX domain_subsription_idx RENAME TO domain_subscription_idx'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C51255CD1D'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C51255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE92908829462F'); $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE9290A76ED395'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE92908829462F FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE9290A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476cadb1174d2'); $this->addSql('ALTER TABLE "user" ALTER hide_user_avatars SET DEFAULT false'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476cadb1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER INDEX domain_subscription_idx RENAME TO domain_subsription_idx'); $this->addSql('ALTER TABLE "user" ALTER hide_user_avatars SET DEFAULT true'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c51255cd1d'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c51255cd1d FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT fk_f2de92908829462f'); $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT fk_f2de9290a76ed395'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT fk_f2de92908829462f FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT fk_f2de9290a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); } } ================================================ FILE: migrations/Version20221222124812.php ================================================ addSql('ALTER TABLE magazine ADD ap_deleted_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_deleted_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP ap_deleted_at'); $this->addSql('ALTER TABLE magazine DROP ap_deleted_at'); } } ================================================ FILE: migrations/Version20221229160511.php ================================================ addSql('ALTER TABLE site DROP domain'); $this->addSql('ALTER TABLE site DROP title'); $this->addSql('ALTER TABLE site DROP enabled'); $this->addSql('ALTER TABLE site DROP registration_open'); $this->addSql('ALTER TABLE site DROP description'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE site ADD domain VARCHAR(255) NOT NULL'); $this->addSql('ALTER TABLE site ADD title VARCHAR(255) NOT NULL'); $this->addSql('ALTER TABLE site ADD enabled BOOLEAN NOT NULL'); $this->addSql('ALTER TABLE site ADD registration_open BOOLEAN NOT NULL'); $this->addSql('ALTER TABLE site ADD description TEXT DEFAULT NULL'); } } ================================================ FILE: migrations/Version20221229162448.php ================================================ addSql('ALTER TABLE site ADD faq TEXT DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE site DROP faq'); } } ================================================ FILE: migrations/Version20230125123959.php ================================================ addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CADB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment ADD root_id INT DEFAULT NULL'); $this->addSql('ALTER TABLE post_comment ADD update_mark BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55F79066886 FOREIGN KEY (root_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE INDEX IDX_A99CE55F79066886 ON post_comment (root_id)'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CADB1174D2'); $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55F79066886'); $this->addSql('DROP INDEX IDX_A99CE55F79066886'); $this->addSql('ALTER TABLE post_comment DROP root_id'); $this->addSql('ALTER TABLE post_comment DROP update_mark'); } } ================================================ FILE: migrations/Version20230306134010.php ================================================ addSql('ALTER TABLE "user" DROP theme'); $this->addSql('ALTER TABLE "user" DROP mode'); $this->addSql('ALTER TABLE "user" DROP right_pos_images'); $this->addSql('ALTER TABLE "user" DROP hide_user_avatars'); $this->addSql('ALTER TABLE "user" DROP hide_magazine_avatars'); $this->addSql('ALTER TABLE "user" DROP entry_popup'); $this->addSql('ALTER TABLE "user" DROP post_popup'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" ADD theme VARCHAR(255) DEFAULT \'dark\' NOT NULL'); $this->addSql('ALTER TABLE "user" ADD mode VARCHAR(255) DEFAULT \'normal\' NOT NULL'); $this->addSql('ALTER TABLE "user" ADD right_pos_images BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE "user" ADD hide_user_avatars BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE "user" ADD hide_magazine_avatars BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE "user" ADD entry_popup BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE "user" ADD post_popup BOOLEAN DEFAULT false NOT NULL'); } } ================================================ FILE: migrations/Version20230314134010.php ================================================ addSql("INSERT INTO \"public\".\"award_type\" (\"id\", \"name\", \"category\", \"count\", \"attributes\") VALUES (1, 'bronze_autobiographer', 'bronze', 0, 'a:0:{}'), (2, 'bronze_personality', 'bronze', 0, 'a:0:{}'), (3, 'bronze_commentator', 'bronze', 0, 'a:0:{}'), (4, 'bronze_scout', 'bronze', 0, 'a:0:{}'), (5, 'bronze_redactor', 'bronze', 0, 'a:0:{}'), (6, 'bronze_poster', 'bronze', 0, 'a:0:{}'), (7, 'bronze_link', 'bronze', 0, 'a:0:{}'), (8, 'bronze_article', 'bronze', 0, 'a:0:{}'), (9, 'bronze_photo', 'bronze', 0, 'a:0:{}'), (10, 'bronze_comment', 'bronze', 0, 'a:0:{}'), (11, 'bronze_post', 'bronze', 0, 'a:0:{}'), (12, 'bronze_ranking', 'bronze', 0, 'a:0:{}'), (13, 'bronze_popular_entry', 'bronze', 0, 'a:0:{}'), (14, 'bronze_magazine', 'bronze', 0, 'a:0:{}'), (15, 'silver_personality', 'silver', 0, 'a:0:{}'), (16, 'silver_commentator', 'silver', 0, 'a:0:{}'), (17, 'silver_scout', 'silver', 0, 'a:0:{}'), (18, 'silver_redactor', 'silver', 0, 'a:0:{}'), (19, 'silver_poster', 'silver', 0, 'a:0:{}'), (20, 'silver_link', 'silver', 0, 'a:0:{}'), (21, 'silver_article', 'silver', 0, 'a:0:{}'), (22, 'silver_photo', 'silver', 0, 'a:0:{}'), (23, 'silver_comment', 'silver', 0, 'a:0:{}'), (24, 'silver_post', 'silver', 0, 'a:0:{}'), (25, 'silver_ranking', 'silver', 0, 'a:0:{}'), (26, 'silver_popular_entry', 'silver', 0, 'a:0:{}'), (27, 'silver_magazine', 'silver', 0, 'a:0:{}'), (28, 'silver_entry_week', 'silver', 0, 'a:0:{}'), (29, 'silver_comment_week', 'silver', 0, 'a:0:{}'), (30, 'silver_post_week', 'silver', 0, 'a:0:{}'), (31, 'gold_personality', 'gold', 0, 'a:0:{}'), (32, 'gold_commentator', 'gold', 0, 'a:0:{}'), (33, 'gold_scout', 'gold', 0, 'a:0:{}'), (34, 'gold_redactor', 'gold', 0, 'a:0:{}'), (35, 'gold_poster', 'gold', 0, 'a:0:{}'), (36, 'gold_link', 'gold', 0, 'a:0:{}'), (37, 'gold_article', 'gold', 0, 'a:0:{}'), (38, 'gold_photo', 'gold', 0, 'a:0:{}'), (39, 'gold_comment', 'gold', 0, 'a:0:{}'), (40, 'gold_post', 'gold', 0, 'a:0:{}'), (41, 'gold_ranking', 'gold', 0, 'a:0:{}'), (42, 'gold_popular_entry', 'gold', 0, 'a:0:{}'), (43, 'gold_magazine', 'gold', 0, 'a:0:{}'), (44, 'gold_entry_month', 'gold', 0, 'a:0:{}'), (45, 'gold_comment_month', 'gold', 0, 'a:0:{}'), (46, 'gold_post_month', 'gold', 0, 'a:0:{}');"); } public function down(Schema $schema): void { } } ================================================ FILE: migrations/Version20230323160934.php ================================================ addSql('ALTER TABLE entry ALTER lang SET NOT NULL'); $this->addSql('ALTER TABLE entry_comment ADD lang VARCHAR(255) DEFAULT \'pl\' NOT NULL'); $this->addSql('ALTER TABLE post ADD lang VARCHAR(255) DEFAULT \'pl\' NOT NULL'); $this->addSql('ALTER TABLE post_comment ADD lang VARCHAR(255) DEFAULT \'pl\' NOT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE post_comment DROP lang'); $this->addSql('ALTER TABLE entry_comment DROP lang'); $this->addSql('ALTER TABLE entry ALTER lang DROP NOT NULL'); $this->addSql('ALTER TABLE post DROP lang'); } } ================================================ FILE: migrations/Version20230323170745.php ================================================ addSql('ALTER TABLE entry_comment ADD is_adult BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE entry_comment ALTER lang DROP DEFAULT'); $this->addSql('ALTER TABLE post ALTER lang DROP DEFAULT'); $this->addSql('ALTER TABLE post_comment ADD is_adult BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE post_comment ALTER lang DROP DEFAULT'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE post_comment DROP is_adult'); $this->addSql('ALTER TABLE post_comment ALTER lang SET DEFAULT \'pl\''); $this->addSql('ALTER TABLE entry_comment DROP is_adult'); $this->addSql('ALTER TABLE entry_comment ALTER lang SET DEFAULT \'pl\''); $this->addSql('ALTER TABLE post ALTER lang SET DEFAULT \'pl\''); } } ================================================ FILE: migrations/Version20230325084833.php ================================================ addSql('ALTER TABLE entry_comment ALTER is_adult DROP DEFAULT'); $this->addSql('ALTER TABLE magazine DROP CONSTRAINT fk_378c2fe4922726e9'); $this->addSql('DROP INDEX idx_378c2fe4922726e9'); $this->addSql('ALTER TABLE magazine RENAME COLUMN cover_id TO icon_id'); $this->addSql('ALTER TABLE magazine ADD CONSTRAINT FK_378C2FE454B9D732 FOREIGN KEY (icon_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE INDEX IDX_378C2FE454B9D732 ON magazine (icon_id)'); $this->addSql('ALTER TABLE post_comment ALTER is_adult DROP DEFAULT'); $this->addSql('ALTER TABLE "user" DROP hide_images'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE entry_comment ALTER is_adult SET DEFAULT false'); $this->addSql('ALTER TABLE magazine DROP CONSTRAINT FK_378C2FE454B9D732'); $this->addSql('DROP INDEX IDX_378C2FE454B9D732'); $this->addSql('ALTER TABLE magazine RENAME COLUMN icon_id TO cover_id'); $this->addSql('ALTER TABLE magazine ADD CONSTRAINT fk_378c2fe4922726e9 FOREIGN KEY (cover_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE INDEX idx_378c2fe4922726e9 ON magazine (cover_id)'); $this->addSql('ALTER TABLE post_comment ALTER is_adult SET DEFAULT false'); $this->addSql('ALTER TABLE "user" ADD hide_images BOOLEAN DEFAULT false NOT NULL'); } } ================================================ FILE: migrations/Version20230325101955.php ================================================ addSql('CREATE UNIQUE INDEX badge_magazine_name_idx ON badge (name, magazine_id)'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP INDEX badge_magazine_name_idx'); } } ================================================ FILE: migrations/Version20230404080956.php ================================================ addSql('ALTER TABLE "user" ADD add_mentions_entries BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE "user" ADD add_mentions_posts BOOLEAN DEFAULT true NOT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP add_mentions_entries'); $this->addSql('ALTER TABLE "user" DROP add_mentions_posts'); } } ================================================ FILE: migrations/Version20230411133416.php ================================================ addSql('ALTER TABLE site ADD about TEXT DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE site DROP about'); } } ================================================ FILE: migrations/Version20230411143354.php ================================================ addSql('ALTER TABLE site ADD contact TEXT DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE site DROP contact'); } } ================================================ FILE: migrations/Version20230412211534.php ================================================ addSql('CREATE SEQUENCE magazine_subscription_request_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE magazine_subscription_request (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_38501651A76ED395 ON magazine_subscription_request (user_id)'); $this->addSql('CREATE INDEX IDX_385016513EB84A1D ON magazine_subscription_request (magazine_id)'); $this->addSql('CREATE UNIQUE INDEX magazine_subscription_requests_idx ON magazine_subscription_request (user_id, magazine_id)'); $this->addSql('COMMENT ON COLUMN magazine_subscription_request.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE magazine_subscription_request ADD CONSTRAINT FK_38501651A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_subscription_request ADD CONSTRAINT FK_385016513EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP SEQUENCE magazine_subscription_request_id_seq CASCADE'); $this->addSql('ALTER TABLE magazine_subscription_request DROP CONSTRAINT FK_38501651A76ED395'); $this->addSql('ALTER TABLE magazine_subscription_request DROP CONSTRAINT FK_385016513EB84A1D'); $this->addSql('DROP TABLE magazine_subscription_request'); } } ================================================ FILE: migrations/Version20230425103236.php ================================================ addSql('ALTER TABLE image ALTER alt_text TYPE TEXT'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE image ALTER alt_text TYPE VARCHAR(255)'); } } ================================================ FILE: migrations/Version20230428130129.php ================================================ addSql('ALTER TABLE magazine ADD ap_inbox_url VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE magazine ADD ap_domain VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_inbox_url VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_domain VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP ap_inbox_url'); $this->addSql('ALTER TABLE "user" DROP ap_domain'); $this->addSql('ALTER TABLE magazine DROP ap_inbox_url'); $this->addSql('ALTER TABLE magazine DROP ap_domain'); } } ================================================ FILE: migrations/Version20230429053840.php ================================================ addSql('ALTER TABLE magazine ADD ap_timeout_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_timeout_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP ap_timeout_at'); $this->addSql('ALTER TABLE magazine DROP ap_timeout_at'); } } ================================================ FILE: migrations/Version20230429143017.php ================================================ addSql('ALTER TABLE site ADD private_key TEXT DEFAULT NULL'); $this->addSql('ALTER TABLE site ADD public_key TEXT DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE site DROP private_key'); $this->addSql('ALTER TABLE site DROP public_key'); } } ================================================ FILE: migrations/Version20230504124307.php ================================================ addSql('ALTER TABLE "user" ALTER homepage SET DEFAULT \'front\''); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" ALTER homepage SET DEFAULT \'front_subscribed\''); } } ================================================ FILE: migrations/Version20230514143119.php ================================================ addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE92908829462F'); $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE9290A76ED395'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE92908829462F FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE9290A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE settings ADD json JSONB DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE settings DROP json'); $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT fk_f2de92908829462f'); $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT fk_f2de9290a76ed395'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT fk_f2de92908829462f FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT fk_f2de9290a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } } ================================================ FILE: migrations/Version20230521145244.php ================================================ addSql('ALTER TABLE "user" ADD is_deleted BOOLEAN DEFAULT false NOT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE "user" DROP is_deleted'); } } ================================================ FILE: migrations/Version20230522135602.php ================================================ addSql('CREATE INDEX entry_visibility_adult_idx ON entry (visibility, is_adult)'); $this->addSql('CREATE INDEX entry_visibility_idx ON entry (visibility)'); $this->addSql('CREATE INDEX entry_adult_idx ON entry (is_adult)'); $this->addSql('CREATE INDEX entry_ranking_idx ON entry (ranking)'); $this->addSql('CREATE INDEX entry_created_at_idx ON entry (created_at)'); $this->addSql('CREATE INDEX magazine_visibility_adult_idx ON magazine (visibility, is_adult)'); $this->addSql('CREATE INDEX magazine_visibility_idx ON magazine (visibility)'); $this->addSql('CREATE INDEX magazine_adult_idx ON magazine (is_adult)'); $this->addSql('CREATE INDEX post_visibility_adult_idx ON post (visibility, is_adult)'); $this->addSql('CREATE INDEX post_visibility_idx ON post (visibility)'); $this->addSql('CREATE INDEX post_adult_idx ON post (is_adult)'); $this->addSql('CREATE INDEX post_ranking_idx ON post (ranking)'); $this->addSql('CREATE INDEX post_created_at_idx ON post (created_at)'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP INDEX post_visibility_adult_idx'); $this->addSql('DROP INDEX post_visibility_idx'); $this->addSql('DROP INDEX post_adult_idx'); $this->addSql('DROP INDEX post_ranking_idx'); $this->addSql('DROP INDEX post_created_at_idx'); $this->addSql('DROP INDEX magazine_visibility_adult_idx'); $this->addSql('DROP INDEX magazine_visibility_idx'); $this->addSql('DROP INDEX magazine_adult_idx'); $this->addSql('DROP INDEX entry_visibility_adult_idx'); $this->addSql('DROP INDEX entry_visibility_idx'); $this->addSql('DROP INDEX entry_adult_idx'); $this->addSql('DROP INDEX entry_ranking_idx'); $this->addSql('DROP INDEX entry_created_at_idx'); } } ================================================ FILE: migrations/Version20230525203803.php ================================================ addSql("ALTER TABLE entry ADD COLUMN title_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED;"); $this->addSql("ALTER TABLE entry ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;"); $this->addSql("ALTER TABLE post ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;"); $this->addSql("ALTER TABLE post_comment ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;"); $this->addSql("ALTER TABLE entry_comment ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;"); $this->addSql('CREATE INDEX entry_title_ts_idx ON entry USING GIN (title_ts);'); $this->addSql('CREATE INDEX entry_body_ts_idx ON entry USING GIN (body_ts);'); $this->addSql('CREATE INDEX post_body_ts_idx ON post USING GIN (body_ts);'); $this->addSql('CREATE INDEX post_comment_body_ts_idx ON post_comment USING GIN (body_ts);'); $this->addSql('CREATE INDEX entry_comment_body_ts_idx ON entry_comment USING GIN (body_ts);'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE entry DROP title_ts'); $this->addSql('ALTER TABLE entry DROP body_ts'); $this->addSql('ALTER TABLE post DROP body_ts'); $this->addSql('ALTER TABLE post_comment DROP body_ts'); $this->addSql('ALTER TABLE entry_comment DROP body_ts'); $this->addSql('DROP INDEX entry_title_ts_idx'); $this->addSql('DROP INDEX entry_body_ts_idx'); $this->addSql('DROP INDEX post_body_ts_idx'); $this->addSql('DROP INDEX post_comment_body_ts_idx'); $this->addSql('DROP INDEX entry_comment_body_ts_idx'); } } ================================================ FILE: migrations/Version20230615085154.php ================================================ addSql('DROP INDEX entry_title_ts_idx'); $this->addSql('DROP INDEX entry_body_ts_idx'); $this->addSql('ALTER TABLE entry DROP title_ts'); $this->addSql('ALTER TABLE entry DROP body_ts'); $this->addSql('CREATE INDEX entry_score_idx ON entry (score)'); $this->addSql('CREATE INDEX entry_comment_count_idx ON entry (comment_count)'); $this->addSql('DROP INDEX entry_comment_body_ts_idx'); $this->addSql('ALTER TABLE entry_comment DROP body_ts'); $this->addSql('DROP INDEX post_body_ts_idx'); $this->addSql('ALTER TABLE post DROP body_ts'); $this->addSql('CREATE INDEX post_score_idx ON post (score)'); $this->addSql('CREATE INDEX post_comment_count_idx ON post (comment_count)'); $this->addSql('DROP INDEX post_comment_body_ts_idx'); $this->addSql('ALTER TABLE post_comment DROP body_ts'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP INDEX entry_score_idx'); $this->addSql('DROP INDEX entry_comment_count_idx'); $this->addSql('ALTER TABLE entry ADD title_ts TEXT DEFAULT \'english\''); $this->addSql('ALTER TABLE entry ADD body_ts TEXT DEFAULT \'english\''); $this->addSql('CREATE INDEX entry_title_ts_idx ON entry (title_ts)'); $this->addSql('CREATE INDEX entry_body_ts_idx ON entry (body_ts)'); $this->addSql('DROP INDEX post_score_idx'); $this->addSql('DROP INDEX post_comment_count_idx'); $this->addSql('ALTER TABLE post ADD body_ts TEXT DEFAULT \'english\''); $this->addSql('CREATE INDEX post_body_ts_idx ON post (body_ts)'); $this->addSql('ALTER TABLE post_comment ADD body_ts TEXT DEFAULT \'english\''); $this->addSql('CREATE INDEX post_comment_body_ts_idx ON post_comment (body_ts)'); $this->addSql('ALTER TABLE entry_comment ADD body_ts TEXT DEFAULT \'english\''); $this->addSql('CREATE INDEX entry_comment_body_ts_idx ON entry_comment (body_ts)'); } } ================================================ FILE: migrations/Version20230615091124.php ================================================ addSql('CREATE INDEX entry_last_active_at_idx ON entry (last_active)'); $this->addSql('CREATE INDEX entry_comment_up_votes_idx ON entry_comment (up_votes)'); $this->addSql('CREATE INDEX entry_comment_last_active_at_idx ON entry_comment (last_active)'); $this->addSql('CREATE INDEX entry_comment_created_at_idx ON entry_comment (created_at)'); $this->addSql('CREATE INDEX post_last_active_at_idx ON post (last_active)'); $this->addSql('CREATE INDEX post_comment_up_votes_idx ON post_comment (up_votes)'); $this->addSql('CREATE INDEX post_comment_last_active_at_idx ON post_comment (last_active)'); $this->addSql('CREATE INDEX post_comment_created_at_idx ON post_comment (created_at)'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); $this->addSql('DROP INDEX post_last_active_at_idx'); $this->addSql('DROP INDEX entry_last_active_at_idx'); $this->addSql('DROP INDEX post_comment_up_votes_idx'); $this->addSql('DROP INDEX post_comment_last_active_at_idx'); $this->addSql('DROP INDEX post_comment_created_at_idx'); $this->addSql('DROP INDEX entry_comment_up_votes_idx'); $this->addSql('DROP INDEX entry_comment_last_active_at_idx'); $this->addSql('DROP INDEX entry_comment_created_at_idx'); } } ================================================ FILE: migrations/Version20230615203020.php ================================================ addSql("ALTER TABLE entry ADD COLUMN title_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED;"); $this->addSql("ALTER TABLE entry ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;"); $this->addSql("ALTER TABLE post ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;"); $this->addSql("ALTER TABLE post_comment ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;"); $this->addSql("ALTER TABLE entry_comment ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;"); $this->addSql('CREATE INDEX entry_title_ts_idx ON entry USING GIN (title_ts);'); $this->addSql('CREATE INDEX entry_body_ts_idx ON entry USING GIN (body_ts);'); $this->addSql('CREATE INDEX post_body_ts_idx ON post USING GIN (body_ts);'); $this->addSql('CREATE INDEX post_comment_body_ts_idx ON post_comment USING GIN (body_ts);'); $this->addSql('CREATE INDEX entry_comment_body_ts_idx ON entry_comment USING GIN (body_ts);'); } public function down(Schema $schema): void { $this->addSql('CREATE SCHEMA public'); $this->addSql('ALTER TABLE entry DROP title_ts'); $this->addSql('ALTER TABLE entry DROP body_ts'); $this->addSql('ALTER TABLE post DROP body_ts'); $this->addSql('ALTER TABLE post_comment DROP body_ts'); $this->addSql('ALTER TABLE entry_comment DROP body_ts'); $this->addSql('DROP INDEX entry_title_ts_idx'); $this->addSql('DROP INDEX entry_body_ts_idx'); $this->addSql('DROP INDEX post_body_ts_idx'); $this->addSql('DROP INDEX post_comment_body_ts_idx'); $this->addSql('DROP INDEX entry_comment_body_ts_idx'); } } ================================================ FILE: migrations/Version20230701125418.php ================================================ addSql('ALTER TABLE "user" ADD preferred_languages JSONB NOT NULL DEFAULT \'[]\'::jsonb'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP preferred_languages'); } } ================================================ FILE: migrations/Version20230712132025.php ================================================ addSql('ALTER TABLE magazine DROP custom_js'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE magazine ADD custom_js TEXT DEFAULT NULL'); } } ================================================ FILE: migrations/Version20230715034515.php ================================================ addSql('CREATE TABLE rememberme_token (series VARCHAR(88) NOT NULL, value VARCHAR(88) NOT NULL, lastUsed TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, class VARCHAR(100) NOT NULL, username VARCHAR(200) NOT NULL, PRIMARY KEY(series))'); } public function down(Schema $schema): void { $this->addSql('DROP TABLE rememberme_token'); } } ================================================ FILE: migrations/Version20230718160422.php ================================================ addSql('ALTER TABLE "user" ADD oauth_keycloak_id VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE "user" DROP oauth_keycloak_id'); } } ================================================ FILE: migrations/Version20230719060447.php ================================================ addSql('CREATE SEQUENCE oauth2_user_consent_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE oauth2_access_token (identifier CHAR(80) NOT NULL, client VARCHAR(32) NOT NULL, expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_identifier VARCHAR(128) DEFAULT NULL, scopes TEXT DEFAULT NULL, revoked BOOLEAN NOT NULL, PRIMARY KEY(identifier))'); $this->addSql('CREATE INDEX IDX_454D9673C7440455 ON oauth2_access_token (client)'); $this->addSql('COMMENT ON COLUMN oauth2_access_token.expiry IS \'(DC2Type:datetime_immutable)\''); $this->addSql('COMMENT ON COLUMN oauth2_access_token.scopes IS \'(DC2Type:oauth2_scope)\''); $this->addSql('CREATE TABLE oauth2_authorization_code (identifier CHAR(80) NOT NULL, client VARCHAR(32) NOT NULL, expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_identifier VARCHAR(128) DEFAULT NULL, scopes TEXT DEFAULT NULL, revoked BOOLEAN NOT NULL, PRIMARY KEY(identifier))'); $this->addSql('CREATE INDEX IDX_509FEF5FC7440455 ON oauth2_authorization_code (client)'); $this->addSql('COMMENT ON COLUMN oauth2_authorization_code.expiry IS \'(DC2Type:datetime_immutable)\''); $this->addSql('COMMENT ON COLUMN oauth2_authorization_code.scopes IS \'(DC2Type:oauth2_scope)\''); $this->addSql('CREATE TABLE "oauth2_client" (identifier VARCHAR(32) NOT NULL, name VARCHAR(128) NOT NULL, secret VARCHAR(128) DEFAULT NULL, redirect_uris TEXT DEFAULT NULL, grants TEXT DEFAULT NULL, scopes TEXT DEFAULT NULL, active BOOLEAN NOT NULL, allow_plain_text_pkce BOOLEAN DEFAULT false NOT NULL, description TEXT DEFAULT NULL, PRIMARY KEY(identifier))'); $this->addSql('COMMENT ON COLUMN "oauth2_client".redirect_uris IS \'(DC2Type:oauth2_redirect_uri)\''); $this->addSql('COMMENT ON COLUMN "oauth2_client".grants IS \'(DC2Type:oauth2_grant)\''); $this->addSql('COMMENT ON COLUMN "oauth2_client".scopes IS \'(DC2Type:oauth2_scope)\''); $this->addSql('CREATE TABLE oauth2_refresh_token (identifier CHAR(80) NOT NULL, access_token CHAR(80) DEFAULT NULL, expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, revoked BOOLEAN NOT NULL, PRIMARY KEY(identifier))'); $this->addSql('CREATE INDEX IDX_4DD90732B6A2DD68 ON oauth2_refresh_token (access_token)'); $this->addSql('COMMENT ON COLUMN oauth2_refresh_token.expiry IS \'(DC2Type:datetime_immutable)\''); $this->addSql('CREATE TABLE oauth2_user_consent (id INT NOT NULL, user_id INT NOT NULL, client_identifier VARCHAR(32) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, scopes JSON NOT NULL, ip_address VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_C8F05D01A76ED395 ON oauth2_user_consent (user_id)'); $this->addSql('CREATE INDEX IDX_C8F05D01E77ABE2B ON oauth2_user_consent (client_identifier)'); $this->addSql('COMMENT ON COLUMN oauth2_user_consent.created_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('COMMENT ON COLUMN oauth2_user_consent.expires_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('ALTER TABLE oauth2_access_token ADD CONSTRAINT FK_454D9673C7440455 FOREIGN KEY (client) REFERENCES "oauth2_client" (identifier) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE oauth2_authorization_code ADD CONSTRAINT FK_509FEF5FC7440455 FOREIGN KEY (client) REFERENCES "oauth2_client" (identifier) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE oauth2_refresh_token ADD CONSTRAINT FK_4DD90732B6A2DD68 FOREIGN KEY (access_token) REFERENCES oauth2_access_token (identifier) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE oauth2_user_consent ADD CONSTRAINT FK_C8F05D01A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE oauth2_user_consent ADD CONSTRAINT FK_C8F05D01E77ABE2B FOREIGN KEY (client_identifier) REFERENCES "oauth2_client" (identifier) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ALTER considered_at TYPE TIMESTAMP(0) WITH TIME ZONE'); $this->addSql('COMMENT ON COLUMN report.considered_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE oauth2_client ADD user_id INT DEFAULT NULL'); $this->addSql('ALTER TABLE oauth2_client ADD contact_email VARCHAR(255) NOT NULL'); $this->addSql('ALTER TABLE oauth2_client ADD CONSTRAINT FK_669FF9C9A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE UNIQUE INDEX UNIQ_669FF9C9A76ED395 ON oauth2_client (user_id)'); $this->addSql('ALTER TABLE "user" ADD is_bot BOOLEAN DEFAULT false NOT NULL'); $this->addSql('CREATE SEQUENCE oauth2_client_access_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE oauth2_client_access (id INT NOT NULL, client_id VARCHAR(32) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, path VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_D959464019EB6921 ON oauth2_client_access (client_id)'); $this->addSql('COMMENT ON COLUMN oauth2_client_access.created_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('ALTER TABLE oauth2_client_access ADD CONSTRAINT FK_D959464019EB6921 FOREIGN KEY (client_id) REFERENCES "oauth2_client" (identifier) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE oauth2_client ADD created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL DEFAULT now()'); $this->addSql('COMMENT ON COLUMN oauth2_client.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE oauth2_client ALTER COLUMN created_at DROP DEFAULT'); $this->addSql('ALTER TABLE oauth2_client ADD image_id INT DEFAULT NULL'); $this->addSql('ALTER TABLE oauth2_client ADD CONSTRAINT FK_669FF9C93DA5256D FOREIGN KEY (image_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE UNIQUE INDEX UNIQ_669FF9C93DA5256D ON oauth2_client (image_id)'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE "oauth2_client" DROP CONSTRAINT FK_669FF9C93DA5256D'); $this->addSql('DROP INDEX UNIQ_669FF9C93DA5256D'); $this->addSql('ALTER TABLE "oauth2_client" DROP image_id'); $this->addSql('ALTER TABLE "oauth2_client" DROP created_at'); $this->addSql('DROP SEQUENCE oauth2_client_access_id_seq CASCADE'); $this->addSql('ALTER TABLE oauth2_client_access DROP CONSTRAINT FK_D959464019EB6921'); $this->addSql('DROP TABLE oauth2_client_access'); $this->addSql('ALTER TABLE "user" DROP is_bot'); $this->addSql('ALTER TABLE "oauth2_client" DROP CONSTRAINT FK_669FF9C9A76ED395'); $this->addSql('DROP INDEX UNIQ_669FF9C9A76ED395'); $this->addSql('ALTER TABLE "oauth2_client" DROP user_id'); $this->addSql('ALTER TABLE "oauth2_client" DROP contact_email'); $this->addSql('DROP SEQUENCE oauth2_user_consent_id_seq CASCADE'); $this->addSql('ALTER TABLE oauth2_access_token DROP CONSTRAINT FK_454D9673C7440455'); $this->addSql('ALTER TABLE oauth2_authorization_code DROP CONSTRAINT FK_509FEF5FC7440455'); $this->addSql('ALTER TABLE oauth2_refresh_token DROP CONSTRAINT FK_4DD90732B6A2DD68'); $this->addSql('ALTER TABLE oauth2_user_consent DROP CONSTRAINT FK_C8F05D01A76ED395'); $this->addSql('ALTER TABLE oauth2_user_consent DROP CONSTRAINT FK_C8F05D01E77ABE2B'); $this->addSql('DROP TABLE oauth2_access_token'); $this->addSql('DROP TABLE oauth2_authorization_code'); $this->addSql('DROP TABLE "oauth2_client"'); $this->addSql('DROP TABLE oauth2_refresh_token'); $this->addSql('DROP TABLE oauth2_user_consent'); $this->addSql('ALTER TABLE report ALTER considered_at TYPE TIMESTAMP(0) WITH TIME ZONE'); $this->addSql('COMMENT ON COLUMN report.considered_at IS NULL'); } } ================================================ FILE: migrations/Version20230729063543.php ================================================ addSql('ALTER TABLE post ADD sticky BOOLEAN NOT NULL DEFAULT false'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE post DROP sticky'); } } ================================================ FILE: migrations/Version20230812151754.php ================================================ addSql('ALTER TABLE "user" ADD totp_secret VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE "user" DROP totp_secret'); } } ================================================ FILE: migrations/Version20230820234418.php ================================================ addSql('ALTER TABLE "user" ADD custom_css TEXT DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE "user" DROP custom_css'); } } ================================================ FILE: migrations/Version20230902082312.php ================================================ addSql('ALTER TABLE "user" ADD ignore_magazines_custom_css BOOLEAN DEFAULT false NOT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE "user" DROP ignore_magazines_custom_css'); } } ================================================ FILE: migrations/Version20230906095436.php ================================================ addSql('ALTER TABLE "user" ADD totp_backup_codes JSONB DEFAULT \'[]\' NOT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP totp_backup_codes'); } } ================================================ FILE: migrations/Version20231019023030.php ================================================ addSql('DROP SEQUENCE cardano_tx_id_seq CASCADE'); $this->addSql('DROP SEQUENCE cardano_tx_init_id_seq CASCADE'); $this->addSql('ALTER TABLE cardano_tx DROP CONSTRAINT fk_f74c620e3eb84a1d'); $this->addSql('ALTER TABLE cardano_tx DROP CONSTRAINT fk_f74c620ecd53edb6'); $this->addSql('ALTER TABLE cardano_tx DROP CONSTRAINT fk_f74c620ef624b39d'); $this->addSql('ALTER TABLE cardano_tx DROP CONSTRAINT fk_f74c620eba364942'); $this->addSql('ALTER TABLE cardano_tx_init DROP CONSTRAINT fk_973316583eb84a1d'); $this->addSql('ALTER TABLE cardano_tx_init DROP CONSTRAINT fk_97331658a76ed395'); $this->addSql('ALTER TABLE cardano_tx_init DROP CONSTRAINT fk_97331658ba364942'); $this->addSql('DROP TABLE cardano_tx'); $this->addSql('DROP TABLE cardano_tx_init'); $this->addSql('ALTER TABLE "user" DROP cardano_wallet_id'); $this->addSql('ALTER TABLE "user" DROP cardano_wallet_address'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SEQUENCE cardano_tx_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE cardano_tx_init_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE cardano_tx (id INT NOT NULL, magazine_id INT DEFAULT NULL, receiver_id INT DEFAULT NULL, sender_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, amount INT NOT NULL, tx_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, ctx_type TEXT NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX idx_f74c620eba364942 ON cardano_tx (entry_id)'); $this->addSql('CREATE INDEX idx_f74c620ef624b39d ON cardano_tx (sender_id)'); $this->addSql('CREATE INDEX idx_f74c620ecd53edb6 ON cardano_tx (receiver_id)'); $this->addSql('CREATE INDEX idx_f74c620e3eb84a1d ON cardano_tx (magazine_id)'); $this->addSql('COMMENT ON COLUMN cardano_tx.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE cardano_tx_init (id INT NOT NULL, magazine_id INT DEFAULT NULL, user_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, session_id VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, cpi_type TEXT NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX idx_97331658ba364942 ON cardano_tx_init (entry_id)'); $this->addSql('CREATE INDEX idx_97331658a76ed395 ON cardano_tx_init (user_id)'); $this->addSql('CREATE INDEX idx_973316583eb84a1d ON cardano_tx_init (magazine_id)'); $this->addSql('COMMENT ON COLUMN cardano_tx_init.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT fk_f74c620e3eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT fk_f74c620ecd53edb6 FOREIGN KEY (receiver_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT fk_f74c620ef624b39d FOREIGN KEY (sender_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT fk_f74c620eba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_tx_init ADD CONSTRAINT fk_973316583eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_tx_init ADD CONSTRAINT fk_97331658a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE cardano_tx_init ADD CONSTRAINT fk_97331658ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE "user" ADD cardano_wallet_id VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD cardano_wallet_address VARCHAR(255) DEFAULT NULL'); } } ================================================ FILE: migrations/Version20231019190634.php ================================================ addSql('CREATE TYPE user_type AS ENUM (\'Person\', \'Service\', \'Organization\', \'Application\')'); $this->addSql('ALTER TABLE "user" ADD COLUMN "type" user_type NOT NULL DEFAULT \'Person\''); $this->addSql('ALTER TABLE "user" DROP is_bot'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP COLUMN "type"'); $this->addSql('DROP TYPE IF EXISTS user_type'); $this->addSql('ALTER TABLE "user" ADD is_bot BOOLEAN DEFAULT false NOT NULL'); } } ================================================ FILE: migrations/Version20231103004800.php ================================================ addSql('UPDATE entry SET score=favourite_count + up_votes - down_votes'); $this->addSql('UPDATE post SET score=favourite_count + up_votes - down_votes'); } public function down(Schema $schema): void { $this->addSql('UPDATE entry SET score=up_votes - down_votes'); $this->addSql('UPDATE post SET score=up_votes - down_votes'); } } ================================================ FILE: migrations/Version20231103070928.php ================================================ addSql('ALTER TABLE magazine ADD marked_for_deletion_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD marked_for_deletion_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD visibility TEXT DEFAULT \'visible\' NOT NULL'); $this->addSql('CREATE INDEX user_visibility_idx ON "user" (visibility)'); } public function down(Schema $schema): void { $this->addSql('DROP INDEX user_visibility_idx'); $this->addSql('ALTER TABLE "user" DROP marked_for_deletion_at'); $this->addSql('ALTER TABLE "user" DROP visibility'); $this->addSql('ALTER TABLE magazine DROP marked_for_deletion_at'); } } ================================================ FILE: migrations/Version20231107204142.php ================================================ addSql('ALTER TABLE "user" ADD ap_followers_count INT DEFAULT NULL'); $this->addSql('ALTER TABLE magazine ADD ap_followers_count INT DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_attributed_to_url VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE magazine ADD ap_attributed_to_url VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP ap_followers_count'); $this->addSql('ALTER TABLE magazine DROP ap_followers_count'); $this->addSql('ALTER TABLE "user" DROP ap_attributed_to_url'); $this->addSql('ALTER TABLE magazine DROP ap_attributed_to_url'); } } ================================================ FILE: migrations/Version20231108084451.php ================================================ addSql('CREATE SEQUENCE magazine_ownership_request_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE moderator_request_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE magazine_ownership_request (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_A7160C65A76ED395 ON magazine_ownership_request (user_id)'); $this->addSql('CREATE INDEX IDX_A7160C653EB84A1D ON magazine_ownership_request (magazine_id)'); $this->addSql('CREATE UNIQUE INDEX magazine_ownership_magazine_user_idx ON magazine_ownership_request (magazine_id, user_id)'); $this->addSql('COMMENT ON COLUMN magazine_ownership_request.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE moderator_request (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_2CC3E324A76ED395 ON moderator_request (user_id)'); $this->addSql('CREATE INDEX IDX_2CC3E3243EB84A1D ON moderator_request (magazine_id)'); $this->addSql('CREATE UNIQUE INDEX moderator_request_magazine_user_idx ON moderator_request (magazine_id, user_id)'); $this->addSql('COMMENT ON COLUMN moderator_request.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE magazine_ownership_request ADD CONSTRAINT FK_A7160C65A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_ownership_request ADD CONSTRAINT FK_A7160C653EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE moderator_request ADD CONSTRAINT FK_2CC3E324A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE moderator_request ADD CONSTRAINT FK_2CC3E3243EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('DROP SEQUENCE magazine_ownership_request_id_seq CASCADE'); $this->addSql('DROP SEQUENCE moderator_request_id_seq CASCADE'); $this->addSql('ALTER TABLE magazine_ownership_request DROP CONSTRAINT FK_A7160C65A76ED395'); $this->addSql('ALTER TABLE magazine_ownership_request DROP CONSTRAINT FK_A7160C653EB84A1D'); $this->addSql('ALTER TABLE moderator_request DROP CONSTRAINT FK_2CC3E324A76ED395'); $this->addSql('ALTER TABLE moderator_request DROP CONSTRAINT FK_2CC3E3243EB84A1D'); $this->addSql('DROP TABLE magazine_ownership_request'); $this->addSql('DROP TABLE moderator_request'); } } ================================================ FILE: migrations/Version20231112133420.php ================================================ addSql('ALTER TABLE moderator ADD added_by_user_id INT DEFAULT NULL'); $this->addSql('ALTER TABLE moderator ADD CONSTRAINT FK_6A30B268CA792C6B FOREIGN KEY (added_by_user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE INDEX IDX_6A30B268CA792C6B ON moderator (added_by_user_id)'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE moderator DROP CONSTRAINT FK_6A30B268CA792C6B'); $this->addSql('DROP INDEX IDX_6A30B268CA792C6B'); $this->addSql('ALTER TABLE moderator DROP added_by_user_id'); } } ================================================ FILE: migrations/Version20231113165549.php ================================================ addSql('ALTER TABLE site ADD announcement TEXT DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE site DROP announcement'); } } ================================================ FILE: migrations/Version20231119012320.php ================================================ addSql('DROP SEQUENCE award_id_seq CASCADE'); $this->addSql('DROP SEQUENCE award_type_id_seq CASCADE'); $this->addSql('ALTER TABLE award DROP CONSTRAINT fk_8a5b2ee7a76ed395'); $this->addSql('ALTER TABLE award DROP CONSTRAINT fk_8a5b2ee73eb84a1d'); $this->addSql('ALTER TABLE award DROP CONSTRAINT fk_8a5b2ee7c54c8c93'); $this->addSql('DROP TABLE award'); $this->addSql('DROP TABLE award_type'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SEQUENCE award_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE award_type_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE award (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT DEFAULT NULL, type_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX idx_8a5b2ee7c54c8c93 ON award (type_id)'); $this->addSql('CREATE INDEX idx_8a5b2ee73eb84a1d ON award (magazine_id)'); $this->addSql('CREATE INDEX idx_8a5b2ee7a76ed395 ON award (user_id)'); $this->addSql('COMMENT ON COLUMN award.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE award_type (id INT NOT NULL, name VARCHAR(255) NOT NULL, category VARCHAR(255) NOT NULL, count INT DEFAULT 0 NOT NULL, attributes TEXT DEFAULT NULL, PRIMARY KEY(id))'); $this->addSql('COMMENT ON COLUMN award_type.attributes IS \'(DC2Type:array)\''); $this->addSql('ALTER TABLE award ADD CONSTRAINT fk_8a5b2ee7a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE award ADD CONSTRAINT fk_8a5b2ee73eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE award ADD CONSTRAINT fk_8a5b2ee7c54c8c93 FOREIGN KEY (type_id) REFERENCES award_type (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } } ================================================ FILE: migrations/Version20231120164429.php ================================================ addSql('ALTER TABLE entry DROP ada_amount'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE entry ADD ada_amount INT DEFAULT 0 NOT NULL'); } } ================================================ FILE: migrations/Version20231121010453.php ================================================ ['table' => 'entry', 'column' => 'lower(ap_id)'], 'entry_comment_ap_id_lower_idx' => ['table' => 'entry_comment', 'column' => 'lower(ap_id)'], 'magazine_ap_id_lower_idx' => ['table' => 'magazine', 'column' => 'lower(ap_id)'], 'magazine_ap_profile_id_lower_idx' => ['table' => 'magazine', 'column' => 'lower(ap_profile_id)'], 'magazine_name_lower_idx' => ['table' => 'magazine', 'column' => 'lower(name)'], 'magazine_title_lower_idx' => ['table' => 'magazine', 'column' => 'lower(title)'], 'post_ap_id_lower_idx' => ['table' => 'post', 'column' => 'lower(ap_id)'], 'post_comment_ap_id_lower_idx' => ['table' => 'post_comment', 'column' => 'lower(ap_id)'], 'user_ap_id_lower_idx' => ['table' => 'user', 'column' => 'lower(ap_id)'], 'user_ap_profile_id_lower_idx' => ['table' => 'user', 'column' => 'lower(ap_profile_id)'], 'user_email_lower_idx' => ['table' => 'user', 'column' => 'lower(email)'], 'user_username_lower_idx' => ['table' => 'user', 'column' => 'lower(username)'], ]; public function getDescription(): string { return 'Introduce db optimizations - sync with /kbin, Mbin specific changes'; } public function up(Schema $schema): void { foreach (self::INDEXES as $index => $details) { $this->addSql('CREATE INDEX '.$index.' ON "'.$details['table'].'" ('.$details['column'].')'); } } public function down(Schema $schema): void { foreach (self::INDEXES as $index => $details) { $this->addSql('DROP INDEX '.$index); } } } ================================================ FILE: migrations/Version20231130203400.php ================================================ addSql('ALTER TABLE report ADD uuid VARCHAR(255) DEFAULT NULL'); $this->addSql('CREATE UNIQUE INDEX report_uuid_idx ON report (uuid)'); } public function down(Schema $schema): void { $this->addSql('DROP INDEX report_uuid_idx'); $this->addSql('ALTER TABLE report DROP uuid'); } } ================================================ FILE: migrations/Version20240113214751.php ================================================ addSql('ALTER TABLE "user" ADD oauth_zitadel_id VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE "user" DROP oauth_zitadel_id'); } } ================================================ FILE: migrations/Version20240216110804.php ================================================ addSql('ALTER TABLE entry DROP CONSTRAINT FK_2B219D70A76ED395'); $this->addSql('ALTER TABLE entry ADD CONSTRAINT FK_2B219D70A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFBA76ED395'); $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFB79066886'); $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFBA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFB79066886 FOREIGN KEY (root_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT FK_9E561267A76ED395'); $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT FK_9E561267F675F31B'); $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT FK_9E561267A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT FK_9E561267F675F31B FOREIGN KEY (author_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT FK_FE32FD77A76ED395'); $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT FK_FE32FD77F675F31B'); $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT FK_FE32FD77A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT FK_FE32FD77F675F31B FOREIGN KEY (author_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite DROP CONSTRAINT FK_62A2CA193EB84A1D'); $this->addSql('ALTER TABLE favourite DROP CONSTRAINT FK_62A2CA19A76ED395'); $this->addSql('ALTER TABLE favourite DROP CONSTRAINT FK_62A2CA19BA364942'); $this->addSql('ALTER TABLE favourite DROP CONSTRAINT FK_62A2CA1960C33421'); $this->addSql('ALTER TABLE favourite DROP CONSTRAINT FK_62A2CA194B89032C'); $this->addSql('ALTER TABLE favourite DROP CONSTRAINT FK_62A2CA19DB1174D2'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA193EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA19A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA19BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA1960C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA194B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA19DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE53EB84A1D'); $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE5A76ED395'); $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE5386B8E7'); $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE53EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE5A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE5386B8E7 FOREIGN KEY (banned_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C53EB84A1D'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5A76ED395'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C51255CD1D'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C53EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C51255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_ownership_request DROP CONSTRAINT FK_A7160C65A76ED395'); $this->addSql('ALTER TABLE magazine_ownership_request ADD CONSTRAINT FK_A7160C65A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_subscription DROP CONSTRAINT FK_ACCE935A76ED395'); $this->addSql('ALTER TABLE magazine_subscription DROP CONSTRAINT FK_ACCE9353EB84A1D'); $this->addSql('ALTER TABLE magazine_subscription ADD CONSTRAINT FK_ACCE935A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_subscription ADD CONSTRAINT FK_ACCE9353EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message DROP CONSTRAINT FK_B6BD307FF624B39D'); $this->addSql('ALTER TABLE message ADD CONSTRAINT FK_B6BD307FF624B39D FOREIGN KEY (sender_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE moderator DROP CONSTRAINT FK_6A30B268A76ED395'); $this->addSql('ALTER TABLE moderator DROP CONSTRAINT FK_6A30B268CA792C6B'); $this->addSql('ALTER TABLE moderator ADD CONSTRAINT FK_6A30B268A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE moderator ADD CONSTRAINT FK_6A30B268CA792C6B FOREIGN KEY (added_by_user_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE moderator_request DROP CONSTRAINT FK_2CC3E324A76ED395'); $this->addSql('ALTER TABLE moderator_request ADD CONSTRAINT FK_2CC3E324A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA4B89032C'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA537A1329'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA1255CD1D'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CADB1174D2'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA537A1329 FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA1255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CADB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE oauth2_client DROP CONSTRAINT FK_669FF9C9A76ED395'); $this->addSql('ALTER TABLE oauth2_client ADD CONSTRAINT FK_669FF9C9A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE oauth2_user_consent DROP CONSTRAINT FK_C8F05D01A76ED395'); $this->addSql('ALTER TABLE oauth2_user_consent ADD CONSTRAINT FK_C8F05D01A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post DROP CONSTRAINT FK_5A8A6C8DA76ED395'); $this->addSql('ALTER TABLE post ADD CONSTRAINT FK_5A8A6C8DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55FA76ED395'); $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55FA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT FK_D71B5A5BA76ED395'); $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT FK_D71B5A5BF675F31B'); $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT FK_D71B5A5BA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT FK_D71B5A5BF675F31B FOREIGN KEY (author_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT FK_9345E26FA76ED395'); $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT FK_9345E26FF675F31B'); $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT FK_9345E26FA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT FK_9345E26FF675F31B FOREIGN KEY (author_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F77843EB84A1D'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F778427EE0E60'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F778494BDEEB6'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F7784BA364942'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F778460C33421'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F77844B89032C'); $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F7784DB1174D2'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F77843EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778427EE0E60 FOREIGN KEY (reporting_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778494BDEEB6 FOREIGN KEY (reported_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F7784BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778460C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F77844B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F7784DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE reset_password_request DROP CONSTRAINT FK_7CE748AA76ED395'); $this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE view_counter DROP CONSTRAINT FK_E87F8182BA364942'); $this->addSql('ALTER TABLE view_counter ADD CONSTRAINT FK_E87F8182BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE moderator DROP CONSTRAINT fk_6a30b268a76ed395'); $this->addSql('ALTER TABLE moderator DROP CONSTRAINT fk_6a30b268ca792c6b'); $this->addSql('ALTER TABLE moderator ADD CONSTRAINT fk_6a30b268a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE moderator ADD CONSTRAINT fk_6a30b268ca792c6b FOREIGN KEY (added_by_user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT fk_d71b5a5ba76ed395'); $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT fk_d71b5a5bf675f31b'); $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT fk_d71b5a5ba76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT fk_d71b5a5bf675f31b FOREIGN KEY (author_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE view_counter DROP CONSTRAINT fk_e87f8182ba364942'); $this->addSql('ALTER TABLE view_counter ADD CONSTRAINT fk_e87f8182ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT fk_9345e26fa76ed395'); $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT fk_9345e26ff675f31b'); $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT fk_9345e26fa76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT fk_9345e26ff675f31b FOREIGN KEY (author_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT fk_a99ce55fa76ed395'); $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT fk_a99ce55fa76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT fk_b892fdfba76ed395'); $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT fk_b892fdfb79066886'); $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT fk_b892fdfba76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT fk_b892fdfb79066886 FOREIGN KEY (root_id) REFERENCES entry_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE post DROP CONSTRAINT fk_5a8a6c8da76ed395'); $this->addSql('ALTER TABLE post ADD CONSTRAINT fk_5a8a6c8da76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f77843eb84a1d'); $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f778427ee0e60'); $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f778494bdeeb6'); $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f7784ba364942'); $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f778460c33421'); $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f77844b89032c'); $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f7784db1174d2'); $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f77843eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f778427ee0e60 FOREIGN KEY (reporting_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f778494bdeeb6 FOREIGN KEY (reported_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f7784ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f778460c33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f77844b89032c FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f7784db1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_ownership_request DROP CONSTRAINT fk_a7160c65a76ed395'); $this->addSql('ALTER TABLE magazine_ownership_request ADD CONSTRAINT fk_a7160c65a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE reset_password_request DROP CONSTRAINT fk_7ce748aa76ed395'); $this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT fk_7ce748aa76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT fk_9e561267a76ed395'); $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT fk_9e561267f675f31b'); $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT fk_9e561267a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT fk_9e561267f675f31b FOREIGN KEY (author_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite DROP CONSTRAINT fk_62a2ca193eb84a1d'); $this->addSql('ALTER TABLE favourite DROP CONSTRAINT fk_62a2ca19a76ed395'); $this->addSql('ALTER TABLE favourite DROP CONSTRAINT fk_62a2ca19ba364942'); $this->addSql('ALTER TABLE favourite DROP CONSTRAINT fk_62a2ca1960c33421'); $this->addSql('ALTER TABLE favourite DROP CONSTRAINT fk_62a2ca194b89032c'); $this->addSql('ALTER TABLE favourite DROP CONSTRAINT fk_62a2ca19db1174d2'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT fk_62a2ca193eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT fk_62a2ca19a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT fk_62a2ca19ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT fk_62a2ca1960c33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT fk_62a2ca194b89032c FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE favourite ADD CONSTRAINT fk_62a2ca19db1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT fk_fe32fd77a76ed395'); $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT fk_fe32fd77f675f31b'); $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT fk_fe32fd77a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT fk_fe32fd77f675f31b FOREIGN KEY (author_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT fk_6a126ce53eb84a1d'); $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT fk_6a126ce5a76ed395'); $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT fk_6a126ce5386b8e7'); $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT fk_6a126ce53eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT fk_6a126ce5a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT fk_6a126ce5386b8e7 FOREIGN KEY (banned_by_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE oauth2_user_consent DROP CONSTRAINT fk_c8f05d01a76ed395'); $this->addSql('ALTER TABLE oauth2_user_consent ADD CONSTRAINT fk_c8f05d01a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c53eb84a1d'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c5a76ed395'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c51255cd1d'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c53eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c5a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c51255cd1d FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE moderator_request DROP CONSTRAINT fk_2cc3e324a76ed395'); $this->addSql('ALTER TABLE moderator_request ADD CONSTRAINT fk_2cc3e324a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE "oauth2_client" DROP CONSTRAINT fk_669ff9c9a76ed395'); $this->addSql('ALTER TABLE "oauth2_client" ADD CONSTRAINT fk_669ff9c9a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_subscription DROP CONSTRAINT fk_acce935a76ed395'); $this->addSql('ALTER TABLE magazine_subscription DROP CONSTRAINT fk_acce9353eb84a1d'); $this->addSql('ALTER TABLE magazine_subscription ADD CONSTRAINT fk_acce935a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_subscription ADD CONSTRAINT fk_acce9353eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message DROP CONSTRAINT fk_b6bd307ff624b39d'); $this->addSql('ALTER TABLE message ADD CONSTRAINT fk_b6bd307ff624b39d FOREIGN KEY (sender_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca4b89032c'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476cadb1174d2'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca537a1329'); $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca1255cd1d'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca4b89032c FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476cadb1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca537a1329 FOREIGN KEY (message_id) REFERENCES message (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca1255cd1d FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry DROP CONSTRAINT fk_2b219d70a76ed395'); $this->addSql('ALTER TABLE entry ADD CONSTRAINT fk_2b219d70a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); } } ================================================ FILE: migrations/Version20240217103834.php ================================================ addSql('ALTER TABLE image ALTER file_path DROP NOT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE image ALTER file_path SET NOT NULL'); } } ================================================ FILE: migrations/Version20240217141231.php ================================================ addSql('ALTER TABLE magazine ADD last_origin_update TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE magazine DROP last_origin_update'); } } ================================================ FILE: migrations/Version20240313222328.php ================================================ addSql('DELETE FROM favourite a USING favourite b WHERE a.id > b.id AND a.entry_id = b.entry_id AND a.user_id = b.user_id'); $this->addSql('DELETE FROM favourite a USING favourite b WHERE a.id > b.id AND a.entry_comment_id = b.entry_comment_id AND a.user_id = b.user_id'); $this->addSql('DELETE FROM favourite a USING favourite b WHERE a.id > b.id AND a.post_id = b.post_id AND a.user_id = b.user_id'); $this->addSql('DELETE FROM favourite a USING favourite b WHERE a.id > b.id AND a.post_comment_id = b.post_comment_id AND a.user_id = b.user_id'); $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS favourite_user_entry_unique_idx ON favourite (entry_id, user_id)'); $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS favourite_user_entry_comment_unique_idx ON favourite (entry_comment_id, user_id)'); $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS favourite_user_post_unique_idx ON favourite (post_id, user_id)'); $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS favourite_user_post_comment_unique_idx ON favourite (post_comment_id, user_id)'); } public function down(Schema $schema): void { $this->addSql('DROP INDEX IF EXISTS favourite_user_entry_unique_idx'); $this->addSql('DROP INDEX IF EXISTS favourite_user_entry_comment_unique_idx'); $this->addSql('DROP INDEX IF EXISTS favourite_user_post_unique_idx'); $this->addSql('DROP INDEX IF EXISTS favourite_user_post_comment_unique_idx'); } } ================================================ FILE: migrations/Version20240315124130.php ================================================ addSql('CREATE UNIQUE INDEX IF NOT EXISTS user_ap_profile_id_idx ON "user" (ap_profile_id)'); $this->addSql('ALTER INDEX IF EXISTS uniq_8d93d649e7927c74 RENAME TO user_email_idx'); $this->addSql('ALTER INDEX IF EXISTS uniq_8d93d649f85e0677 RENAME TO user_username_idx'); $this->addSql('ALTER INDEX IF EXISTS uniq_8d93d649904f155e RENAME TO user_ap_id_idx'); } public function down(Schema $schema): void { $this->addSql('DROP INDEX IF EXISTS user_ap_profile_id_idx'); $this->addSql('ALTER INDEX IF EXISTS user_username_idx RENAME TO uniq_8d93d649f85e0677'); $this->addSql('ALTER INDEX IF EXISTS user_email_idx RENAME TO uniq_8d93d649e7927c74'); $this->addSql('ALTER INDEX IF EXISTS user_ap_id_idx RENAME TO uniq_8d93d649904f155e'); } } ================================================ FILE: migrations/Version20240317163312.php ================================================ addSql('ALTER TABLE report DROP CONSTRAINT IF EXISTS FK_C42F778427EE0E60'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778427EE0E60 FOREIGN KEY (reporting_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE report DROP CONSTRAINT IF EXISTS FK_C42F778427EE0E60'); $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f778427ee0e60 FOREIGN KEY (reporting_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); } } ================================================ FILE: migrations/Version20240330101300.php ================================================ addSql('CREATE EXTENSION IF NOT EXISTS citext'); $this->addSql('CREATE SEQUENCE hashtag_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE hashtag_link_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE hashtag (id INT NOT NULL, tag citext NOT NULL, banned BOOLEAN DEFAULT false NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE UNIQUE INDEX UNIQ_5AB52A61389B783 ON hashtag (tag)'); $this->addSql('CREATE TABLE hashtag_link (id INT NOT NULL, hashtag_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_83957168FB34EF56 ON hashtag_link (hashtag_id)'); $this->addSql('CREATE INDEX IDX_83957168BA364942 ON hashtag_link (entry_id)'); $this->addSql('CREATE INDEX IDX_8395716860C33421 ON hashtag_link (entry_comment_id)'); $this->addSql('CREATE INDEX IDX_839571684B89032C ON hashtag_link (post_id)'); $this->addSql('CREATE INDEX IDX_83957168DB1174D2 ON hashtag_link (post_comment_id)'); $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_83957168FB34EF56 FOREIGN KEY (hashtag_id) REFERENCES hashtag (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_83957168BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_8395716860C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_839571684B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_83957168DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); // migrate entry tags $select = "SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM entry e JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(e.tags)) as keys ON TRUE WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'array' UNION ALL SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM entry e JOIN LATERAL (SELECT * FROM jsonb_each_text(e.tags)) as keys ON TRUE WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'object' ORDER BY created_at DESC"; $foreachStatement = "IF NOT EXISTS (SELECT id FROM hashtag WHERE hashtag.tag = temprow.hashtag) THEN INSERT INTO hashtag(id, tag) VALUES(NEXTVAL('hashtag_id_seq'), temprow.hashtag); END IF; IF NOT EXISTS (SELECT l.id FROM hashtag_link l INNER JOIN hashtag def ON def.id=l.hashtag_id WHERE l.entry_id = temprow.id AND def.tag = temprow.hashtag) THEN INSERT INTO hashtag_link (id, entry_id, hashtag_id) VALUES (NEXTVAL('hashtag_link_id_seq'), temprow.id, (SELECT id FROM hashtag WHERE tag = temprow.hashtag)); END IF;"; $this->addSql('DO $do$ declare temprow record; BEGIN FOR temprow IN '.$select.' LOOP '.$foreachStatement.' END LOOP; END $do$;'); // migrate entry comments tags $select = "SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM entry_comment e JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(e.tags)) as keys ON TRUE WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'array' UNION ALL SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM entry_comment e JOIN LATERAL (SELECT * FROM jsonb_each_text(e.tags)) as keys ON TRUE WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'object' ORDER BY created_at DESC"; $foreachStatement = "IF NOT EXISTS (SELECT id FROM hashtag WHERE hashtag.tag = temprow.hashtag) THEN INSERT INTO hashtag(id, tag) VALUES(NEXTVAL('hashtag_id_seq'), temprow.hashtag); END IF; IF NOT EXISTS (SELECT l.id FROM hashtag_link l INNER JOIN hashtag def ON def.id=l.hashtag_id WHERE l.entry_comment_id = temprow.id AND def.tag = temprow.hashtag) THEN INSERT INTO hashtag_link (id, entry_comment_id, hashtag_id) VALUES (NEXTVAL('hashtag_link_id_seq'), temprow.id, (SELECT id FROM hashtag WHERE tag=temprow.hashtag)); END IF;"; $this->addSql('DO $do$ declare temprow record; BEGIN FOR temprow IN '.$select.' LOOP '.$foreachStatement.' END LOOP; END $do$;'); // migrate post tags $select = "SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM post e JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(e.tags)) as keys ON TRUE WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'array' UNION ALL SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM post e JOIN LATERAL (SELECT * FROM jsonb_each_text(e.tags)) as keys ON TRUE WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'object' ORDER BY created_at DESC"; $foreachStatement = "IF NOT EXISTS (SELECT id FROM hashtag WHERE hashtag.tag = temprow.hashtag) THEN INSERT INTO hashtag(id, tag) VALUES(NEXTVAL('hashtag_id_seq'), temprow.hashtag); END IF; IF NOT EXISTS (SELECT l.id FROM hashtag_link l INNER JOIN hashtag def ON def.id=l.hashtag_id WHERE l.post_id = temprow.id AND def.tag = temprow.hashtag) THEN INSERT INTO hashtag_link (id, post_id, hashtag_id) VALUES (NEXTVAL('hashtag_link_id_seq'), temprow.id, (SELECT id FROM hashtag WHERE tag=temprow.hashtag)); END IF;"; $this->addSql('DO $do$ declare temprow record; BEGIN FOR temprow IN '.$select.' LOOP '.$foreachStatement.' END LOOP; END $do$;'); // migrate post comment tags $select = "SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM post_comment e JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(e.tags)) as keys ON TRUE WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'array' UNION ALL SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM post_comment e JOIN LATERAL (SELECT * FROM jsonb_each_text(e.tags)) as keys ON TRUE WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'object' ORDER BY created_at DESC"; $foreachStatement = "IF NOT EXISTS (SELECT id FROM hashtag WHERE hashtag.tag = temprow.hashtag) THEN INSERT INTO hashtag(id, tag) VALUES(NEXTVAL('hashtag_id_seq'), temprow.hashtag); END IF; IF NOT EXISTS (SELECT l.id FROM hashtag_link l INNER JOIN hashtag def ON def.id=l.hashtag_id WHERE l.post_comment_id = temprow.id AND def.tag = temprow.hashtag) THEN INSERT INTO hashtag_link (id, post_comment_id, hashtag_id) VALUES (NEXTVAL('hashtag_link_id_seq'), temprow.id, (SELECT id FROM hashtag WHERE tag=temprow.hashtag)); END IF;"; $this->addSql('DO $do$ declare temprow record; BEGIN FOR temprow IN '.$select.' LOOP '.$foreachStatement.' END LOOP; END $do$;'); $this->addSql('ALTER TABLE entry DROP COLUMN tags'); $this->addSql('ALTER TABLE entry_comment DROP COLUMN tags'); $this->addSql('ALTER TABLE post DROP COLUMN tags'); $this->addSql('ALTER TABLE post_comment DROP COLUMN tags'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE entry_comment ADD tags JSONB DEFAULT NULL'); $this->addSql('ALTER TABLE post_comment ADD tags JSONB DEFAULT NULL'); $this->addSql('ALTER TABLE post ADD tags JSONB DEFAULT NULL'); $this->addSql('ALTER TABLE entry ADD tags JSONB DEFAULT NULL'); $this->addSql('DO $do$ declare temprow record; BEGIN FOR temprow IN SELECT hl.entry_id, hl.entry_comment_id, hl.post_id, hl.post_comment_id, h.tag FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id LOOP IF temprow.entry_id IS NOT NULL THEN IF NOT EXISTS (SELECT id FROM entry e WHERE e.id = temprow.entry_id AND e.tags IS NOT NULL) THEN UPDATE entry SET tags = \'[]\'::jsonb WHERE entry.id = temprow.entry_id; END IF; UPDATE entry SET tags = tags || to_jsonb(temprow.tag) WHERE entry.id = temprow.entry_id; END IF; IF temprow.entry_comment_id IS NOT NULL THEN IF NOT EXISTS (SELECT id FROM entry_comment ec WHERE ec.id = temprow.entry_comment_id AND ec.tags IS NOT NULL) THEN UPDATE entry_comment SET tags = \'[]\'::jsonb WHERE entry_comment.id = temprow.entry_comment_id; END IF; UPDATE entry_comment SET tags = tags || to_jsonb(temprow.tag) WHERE entry_comment.id = temprow.entry_comment_id; END IF; IF temprow.post_id IS NOT NULL THEN IF NOT EXISTS (SELECT id FROM post p WHERE p.id = temprow.post_id AND p.tags IS NOT NULL) THEN UPDATE post SET tags = \'[]\'::jsonb WHERE post.id = temprow.post_id; END IF; UPDATE post SET tags = tags || to_jsonb(temprow.tag) WHERE post.id = temprow.post_id; END IF; IF temprow.post_comment_id IS NOT NULL THEN IF NOT EXISTS (SELECT id FROM post_comment pc WHERE pc.id = temprow.post_comment_id AND pc.tags IS NOT NULL) THEN UPDATE post_comment SET tags = \'[]\'::jsonb WHERE post_comment.id = temprow.post_comment_id; END IF; UPDATE post_comment SET tags = tags || to_jsonb(temprow.tag) WHERE post_comment.id = temprow.post_comment_id; END IF; END LOOP; END $do$;'); $this->addSql('DROP SEQUENCE hashtag_id_seq CASCADE'); $this->addSql('DROP SEQUENCE hashtag_link_id_seq CASCADE'); $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_83957168FB34EF56'); $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_83957168BA364942'); $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_8395716860C33421'); $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_839571684B89032C'); $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_83957168DB1174D2'); $this->addSql('DROP TABLE hashtag'); $this->addSql('DROP TABLE hashtag_link'); } } ================================================ FILE: migrations/Version20240402190028.php ================================================ addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE92908829462F'); $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE9290A76ED395'); $this->addSql('ALTER TABLE message DROP CONSTRAINT FK_B6BD307FE2904019'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE92908829462F FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE9290A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message ADD CONSTRAINT FK_B6BD307FE2904019 FOREIGN KEY (thread_id) REFERENCES message_thread (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE message DROP CONSTRAINT fk_b6bd307fe2904019'); $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT fk_f2de92908829462f'); $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT fk_f2de9290a76ed395'); $this->addSql('ALTER TABLE message ADD CONSTRAINT fk_b6bd307fe2904019 FOREIGN KEY (thread_id) REFERENCES message_thread (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT fk_f2de92908829462f FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT fk_f2de9290a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); } } ================================================ FILE: migrations/Version20240405131611.php ================================================ addSql('DROP SEQUENCE view_counter_id_seq CASCADE'); $this->addSql('ALTER TABLE view_counter DROP CONSTRAINT fk_e87f8182ba364942'); $this->addSql('DROP TABLE view_counter'); $this->addSql('ALTER TABLE entry DROP views'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SEQUENCE view_counter_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE view_counter (id INT NOT NULL, entry_id INT DEFAULT NULL, ip TEXT NOT NULL, view_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX idx_e87f8182ba364942 ON view_counter (entry_id)'); $this->addSql('ALTER TABLE view_counter ADD CONSTRAINT fk_e87f8182ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE entry ADD views INT DEFAULT NULL'); } } ================================================ FILE: migrations/Version20240405134821.php ================================================ addSql('ALTER TABLE "user" ADD oauth_azure_id VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP oauth_azure_id'); } } ================================================ FILE: migrations/Version20240409072525.php ================================================ addSql('ALTER TABLE image ALTER source_url TYPE TEXT'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE image ALTER source_url TYPE VARCHAR(255)'); } } ================================================ FILE: migrations/Version20240412010024.php ================================================ addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE5386B8E7'); $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE5386B8E7 FOREIGN KEY (banned_by_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5A76ED395'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c5a76ed395'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c5a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT fk_6a126ce5386b8e7'); $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT fk_6a126ce5386b8e7 FOREIGN KEY (banned_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); } } ================================================ FILE: migrations/Version20240503224350.php ================================================ addSql('ALTER TABLE "user" ADD oauth_simple_login_id VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP oauth_simple_login_id'); } } ================================================ FILE: migrations/Version20240515122858.php ================================================ addSql('ALTER TABLE notification ADD report_id INT DEFAULT NULL'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA4BD2A4C0 FOREIGN KEY (report_id) REFERENCES report (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE INDEX IDX_BF5476CA4BD2A4C0 ON notification (report_id)'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA4BD2A4C0'); $this->addSql('DROP INDEX IDX_BF5476CA4BD2A4C0'); $this->addSql('ALTER TABLE notification DROP report_id'); } } ================================================ FILE: migrations/Version20240528172429.php ================================================ addSql('ALTER TABLE magazine_log ADD acting_user_id INT DEFAULT NULL'); $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C53EAD8611 FOREIGN KEY (acting_user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE INDEX IDX_87D3D4C53EAD8611 ON magazine_log (acting_user_id)'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C53EAD8611'); $this->addSql('DROP INDEX IDX_87D3D4C53EAD8611'); $this->addSql('ALTER TABLE magazine_log DROP acting_user_id'); } } ================================================ FILE: migrations/Version20240529115400.php ================================================ addSql('DELETE FROM moderator mod WHERE mod.is_owner = true AND EXISTS (SELECT * FROM magazine m WHERE mod.magazine_id = m.id AND m.ap_id IS NOT NULL);'); } public function down(Schema $schema): void { } } ================================================ FILE: migrations/Version20240603190838.php ================================================ addSql('ALTER TABLE message ADD uuid UUID NOT NULL DEFAULT gen_random_uuid()'); $this->addSql('ALTER TABLE message ADD ap_id VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE message ADD edited_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); $this->addSql('COMMENT ON COLUMN message.uuid IS \'(DC2Type:uuid)\''); $this->addSql('COMMENT ON COLUMN message.edited_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE UNIQUE INDEX UNIQ_B6BD307FD17F50A6 ON message (uuid)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_B6BD307F904F155E ON message (ap_id)'); } public function down(Schema $schema): void { $this->addSql('DROP INDEX UNIQ_B6BD307FD17F50A6'); $this->addSql('DROP INDEX UNIQ_B6BD307F904F155E'); $this->addSql('ALTER TABLE message DROP uuid'); $this->addSql('ALTER TABLE message DROP ap_id'); $this->addSql('ALTER TABLE message DROP edited_at'); } } ================================================ FILE: migrations/Version20240603230734.php ================================================ addSql('ALTER TABLE "user" ADD oauth_authentik_id VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP oauth_authentik_id'); } } ================================================ FILE: migrations/Version20240612234046.php ================================================ addSql('ALTER TABLE "user" ADD oauth_privacyportal_id VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP oauth_privacyportal_id'); } } ================================================ FILE: migrations/Version20240614120443.php ================================================ addSql('ALTER TABLE entry ADD ap_like_count INT DEFAULT NULL'); $this->addSql('ALTER TABLE entry ADD ap_dislike_count INT DEFAULT NULL'); $this->addSql('ALTER TABLE entry ADD ap_share_count INT DEFAULT NULL'); $this->addSql('ALTER TABLE entry_comment ADD ap_like_count INT DEFAULT NULL'); $this->addSql('ALTER TABLE entry_comment ADD ap_dislike_count INT DEFAULT NULL'); $this->addSql('ALTER TABLE entry_comment ADD ap_share_count INT DEFAULT NULL'); $this->addSql('ALTER TABLE post ADD ap_like_count INT DEFAULT NULL'); $this->addSql('ALTER TABLE post ADD ap_dislike_count INT DEFAULT NULL'); $this->addSql('ALTER TABLE post ADD ap_share_count INT DEFAULT NULL'); $this->addSql('ALTER TABLE post_comment ADD ap_like_count INT DEFAULT NULL'); $this->addSql('ALTER TABLE post_comment ADD ap_dislike_count INT DEFAULT NULL'); $this->addSql('ALTER TABLE post_comment ADD ap_share_count INT DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE entry_comment DROP ap_like_count'); $this->addSql('ALTER TABLE entry_comment DROP ap_dislike_count'); $this->addSql('ALTER TABLE entry_comment DROP ap_share_count'); $this->addSql('ALTER TABLE post_comment DROP ap_like_count'); $this->addSql('ALTER TABLE post_comment DROP ap_dislike_count'); $this->addSql('ALTER TABLE post_comment DROP ap_share_count'); $this->addSql('ALTER TABLE post DROP ap_like_count'); $this->addSql('ALTER TABLE post DROP ap_dislike_count'); $this->addSql('ALTER TABLE post DROP ap_share_count'); $this->addSql('ALTER TABLE entry DROP ap_like_count'); $this->addSql('ALTER TABLE entry DROP ap_dislike_count'); $this->addSql('ALTER TABLE entry DROP ap_share_count'); } } ================================================ FILE: migrations/Version20240615225744.php ================================================ addSql('ALTER TABLE magazine ADD ap_featured_url VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_featured_url VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP ap_featured_url'); $this->addSql('ALTER TABLE magazine DROP ap_featured_url'); } } ================================================ FILE: migrations/Version20240625162714.php ================================================ addSql('CREATE UNIQUE INDEX user_ap_public_url_idx ON "user" (ap_public_url)'); } public function down(Schema $schema): void { $this->addSql('DROP INDEX user_ap_public_url_idx'); } } ================================================ FILE: migrations/Version20240628142700.php ================================================ addSql('DROP INDEX user_ap_public_url_idx'); } public function down(Schema $schema): void { $this->addSql('CREATE UNIQUE INDEX user_ap_public_url_idx ON "user" (ap_public_url)'); } } ================================================ FILE: migrations/Version20240628145441.php ================================================ addSql('CREATE SEQUENCE instance_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE instance (id INT NOT NULL, software VARCHAR(255) DEFAULT NULL, version VARCHAR(255) DEFAULT NULL, domain VARCHAR(255) NOT NULL, last_successful_deliver TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, last_successful_receive TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, last_failed_deliver TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, failed_delivers INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITH TIME ZONE, PRIMARY KEY(id))'); $this->addSql('COMMENT ON COLUMN instance.last_successful_deliver IS \'(DC2Type:datetime_immutable)\''); $this->addSql('COMMENT ON COLUMN instance.last_failed_deliver IS \'(DC2Type:datetime_immutable)\''); $this->addSql('COMMENT ON COLUMN instance.last_successful_receive IS \'(DC2Type:datetime_immutable)\''); $this->addSql('COMMENT ON COLUMN instance.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('COMMENT ON COLUMN instance.updated_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE UNIQUE INDEX UNIQ_4230B1DEA7A91E0B ON instance (domain)'); } public function down(Schema $schema): void { $this->addSql('DROP INDEX UNIQ_4230B1DEA7A91E0B'); $this->addSql('DROP SEQUENCE instance_id_seq CASCADE'); $this->addSql('DROP TABLE instance'); } } ================================================ FILE: migrations/Version20240701113000.php ================================================ addSql("UPDATE entry SET type = '$type', has_embed = true WHERE image_id IS NOT NULL AND url IS NULL"); } public function down(Schema $schema): void { } } ================================================ FILE: migrations/Version20240706005744.php ================================================ addSql('ALTER TABLE "user" ADD oauth_discord_id VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE "user" DROP oauth_discord_id'); } } ================================================ FILE: migrations/Version20240715181419.php ================================================ addSql('CREATE SEQUENCE user_push_subscription_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE user_push_subscription (id INT NOT NULL, user_id INT DEFAULT NULL, api_token CHAR(80) DEFAULT NULL, locale VARCHAR(255) DEFAULT NULL, endpoint TEXT NOT NULL, content_encryption_public_key TEXT NOT NULL, device_key UUID DEFAULT NULL, server_auth_key TEXT NOT NULL, notification_types JSON NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_AE378BD8A76ED395 ON user_push_subscription (user_id)'); $this->addSql('COMMENT ON COLUMN user_push_subscription.device_key IS \'(DC2Type:uuid)\''); $this->addSql('ALTER TABLE user_push_subscription ADD CONSTRAINT FK_AE378BD8A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE user_push_subscription ADD CONSTRAINT FK_AE378BD87BA2F5EB FOREIGN KEY (api_token) REFERENCES oauth2_access_token (identifier) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE UNIQUE INDEX UNIQ_AE378BD87BA2F5EB ON user_push_subscription (api_token)'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE user_push_subscription DROP CONSTRAINT FK_AE378BD8A76ED395'); $this->addSql('ALTER TABLE user_push_subscription DROP CONSTRAINT FK_AE378BD87BA2F5EB'); $this->addSql('DROP INDEX UNIQ_AE378BD87BA2F5EB'); $this->addSql('DROP INDEX IDX_AE378BD8A76ED395'); $this->addSql('DROP INDEX IDX_AE378BD8A76ED395'); $this->addSql('DROP TABLE user_push_subscription'); $this->addSql('DROP SEQUENCE user_push_subscription_id_seq'); } } ================================================ FILE: migrations/Version20240718232800.php ================================================ addSql('ALTER TABLE site ADD COLUMN push_private_key text DEFAULT NULL'); $this->addSql('ALTER TABLE site ADD COLUMN push_public_key text DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE site DROP COLUMN push_private_key'); $this->addSql('ALTER TABLE site DROP COLUMN push_public_key'); } } ================================================ FILE: migrations/Version20240729174207.php ================================================ addSql('ALTER TABLE magazine ADD posting_restricted_to_mods BOOLEAN NOT NULL DEFAULT FALSE'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE magazine DROP posting_restricted_to_mods'); } } ================================================ FILE: migrations/Version20240815162107.php ================================================ addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F7784607E02EB'); $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F7784607E02EB FOREIGN KEY (considered_by_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f7784607e02eb'); $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f7784607e02eb FOREIGN KEY (considered_by_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); } } ================================================ FILE: migrations/Version20240820201944.php ================================================ addSql('CREATE TABLE activity (uuid UUID NOT NULL, user_actor_id INT DEFAULT NULL, magazine_actor_id INT DEFAULT NULL, audience_id INT DEFAULT NULL, inner_activity_id UUID DEFAULT NULL, object_entry_id INT DEFAULT NULL, object_entry_comment_id INT DEFAULT NULL, object_post_id INT DEFAULT NULL, object_post_comment_id INT DEFAULT NULL, object_message_id INT DEFAULT NULL, object_user_id INT DEFAULT NULL, object_magazine_id INT DEFAULT NULL, type VARCHAR(255) NOT NULL, inner_activity_url TEXT DEFAULT NULL, object_generic TEXT DEFAULT NULL, target_string TEXT DEFAULT NULL, content_string TEXT DEFAULT NULL, activity_json TEXT DEFAULT NULL, is_remote BOOL NOT NULL DEFAULT FALSE, PRIMARY KEY(uuid))'); $this->addSql('CREATE INDEX IDX_AC74095AF057164A ON activity (user_actor_id)'); $this->addSql('CREATE INDEX IDX_AC74095A2F5FA0A4 ON activity (magazine_actor_id)'); $this->addSql('CREATE INDEX IDX_AC74095A848CC616 ON activity (audience_id)'); $this->addSql('CREATE INDEX IDX_AC74095A1B4C3858 ON activity (inner_activity_id)'); $this->addSql('CREATE INDEX IDX_AC74095A6CE0A42A ON activity (object_entry_id)'); $this->addSql('CREATE INDEX IDX_AC74095AC3683D33 ON activity (object_entry_comment_id)'); $this->addSql('CREATE INDEX IDX_AC74095A4BC7838C ON activity (object_post_id)'); $this->addSql('CREATE INDEX IDX_AC74095ACC1812B0 ON activity (object_post_comment_id)'); $this->addSql('CREATE INDEX IDX_AC74095A20E5BA95 ON activity (object_message_id)'); $this->addSql('CREATE INDEX IDX_AC74095AA7205335 ON activity (object_user_id)'); $this->addSql('CREATE INDEX IDX_AC74095AFC1C2A13 ON activity (object_magazine_id)'); $this->addSql('COMMENT ON COLUMN activity.uuid IS \'(DC2Type:uuid)\''); $this->addSql('COMMENT ON COLUMN activity.inner_activity_id IS \'(DC2Type:uuid)\''); $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095AF057164A FOREIGN KEY (user_actor_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A2F5FA0A4 FOREIGN KEY (magazine_actor_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A848CC616 FOREIGN KEY (audience_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A1B4C3858 FOREIGN KEY (inner_activity_id) REFERENCES activity (uuid) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A6CE0A42A FOREIGN KEY (object_entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095AC3683D33 FOREIGN KEY (object_entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A4BC7838C FOREIGN KEY (object_post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095ACC1812B0 FOREIGN KEY (object_post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A20E5BA95 FOREIGN KEY (object_message_id) REFERENCES message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095AA7205335 FOREIGN KEY (object_user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095AFC1C2A13 FOREIGN KEY (object_magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095AF057164A'); $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A2F5FA0A4'); $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A848CC616'); $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A1B4C3858'); $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A6CE0A42A'); $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095AC3683D33'); $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A4BC7838C'); $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095ACC1812B0'); $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A20E5BA95'); $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095AA7205335'); $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095AFC1C2A13'); $this->addSql('DROP TABLE activity'); } } ================================================ FILE: migrations/Version20240831151328.php ================================================ addSql('CREATE SEQUENCE bookmark_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE bookmark_list_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE bookmark (id INT NOT NULL, list_id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_DA62921D3DAE168B ON bookmark (list_id)'); $this->addSql('CREATE INDEX IDX_DA62921DA76ED395 ON bookmark (user_id)'); $this->addSql('CREATE INDEX IDX_DA62921DBA364942 ON bookmark (entry_id)'); $this->addSql('CREATE INDEX IDX_DA62921D60C33421 ON bookmark (entry_comment_id)'); $this->addSql('CREATE INDEX IDX_DA62921D4B89032C ON bookmark (post_id)'); $this->addSql('CREATE INDEX IDX_DA62921DDB1174D2 ON bookmark (post_comment_id)'); $this->addSql('CREATE UNIQUE INDEX bookmark_list_entry_entryComment_post_postComment_idx ON bookmark (list_id, entry_id, entry_comment_id, post_id, post_comment_id)'); $this->addSql('COMMENT ON COLUMN bookmark.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('CREATE TABLE bookmark_list (id INT NOT NULL, user_id INT NOT NULL, name VARCHAR(255) NOT NULL, is_default BOOLEAN NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_A650C0C4A76ED395 ON bookmark_list (user_id)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_A650C0C4A76ED3955E237E06 ON bookmark_list (user_id, name)'); $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D3DAE168B FOREIGN KEY (list_id) REFERENCES bookmark_list (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DBA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D60C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DDB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE bookmark_list ADD CONSTRAINT FK_A650C0C4A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { $this->addSql('DROP SEQUENCE bookmark_id_seq CASCADE'); $this->addSql('DROP SEQUENCE bookmark_list_id_seq CASCADE'); $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D3DAE168B'); $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DA76ED395'); $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DBA364942'); $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D60C33421'); $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D4B89032C'); $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DDB1174D2'); $this->addSql('ALTER TABLE bookmark_list DROP CONSTRAINT FK_A650C0C4A76ED395'); $this->addSql('DROP TABLE bookmark'); $this->addSql('DROP TABLE bookmark_list'); } } ================================================ FILE: migrations/Version20240923164233.php ================================================ addSql('CREATE TABLE sessions (sess_id VARCHAR(128) NOT NULL, sess_data BYTEA NOT NULL, sess_lifetime INT NOT NULL, sess_time INT NOT NULL, PRIMARY KEY(sess_id))'); $this->addSql('CREATE INDEX sess_lifetime_idx ON sessions (sess_lifetime)'); } public function down(Schema $schema): void { $this->addSql('DROP TABLE sessions'); } } ================================================ FILE: migrations/Version20241104162329.php ================================================ addSql('CREATE TYPE enumApplicationStatus AS ENUM (\'Approved\', \'Rejected\', \'Pending\')'); $this->addSql('ALTER TABLE "user" ADD application_text TEXT DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD application_status enumApplicationStatus DEFAULT \'Approved\' NOT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP application_text'); $this->addSql('ALTER TABLE "user" DROP application_status'); $this->addSql('DROP TYPE enumApplicationStatus'); } } ================================================ FILE: migrations/Version20241124155724.php ================================================ addSql('ALTER TABLE notification ADD new_user_id INT DEFAULT NULL'); $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA7C2D807B FOREIGN KEY (new_user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE INDEX IDX_BF5476CA7C2D807B ON notification (new_user_id)'); $this->addSql('ALTER TABLE "user" ADD notify_on_user_signup BOOLEAN DEFAULT TRUE'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA7C2D807B'); $this->addSql('DROP INDEX IDX_BF5476CA7C2D807B'); $this->addSql('ALTER TABLE notification DROP new_user_id'); $this->addSql('ALTER TABLE "user" DROP notify_on_user_signup'); } } ================================================ FILE: migrations/Version20241125210454.php ================================================ addSql('CREATE TYPE enumNotificationStatus AS ENUM(\'Default\', \'Muted\', \'Loud\')'); $this->addSql('CREATE SEQUENCE notification_settings_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE notification_settings (id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, post_id INT DEFAULT NULL, magazine_id INT DEFAULT NULL, target_user_id INT DEFAULT NULL, notification_status enumNotificationStatus DEFAULT \'Default\' NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_B0559860A76ED395 ON notification_settings (user_id)'); $this->addSql('CREATE INDEX IDX_B0559860BA364942 ON notification_settings (entry_id)'); $this->addSql('CREATE INDEX IDX_B05598604B89032C ON notification_settings (post_id)'); $this->addSql('CREATE INDEX IDX_B05598603EB84A1D ON notification_settings (magazine_id)'); $this->addSql('CREATE INDEX IDX_B05598606C066AFE ON notification_settings (target_user_id)'); $this->addSql('CREATE UNIQUE INDEX notification_settings_user_target ON notification_settings (user_id, entry_id, post_id, magazine_id, target_user_id)'); $this->addSql('COMMENT ON COLUMN notification_settings.notification_status IS \'(DC2Type:EnumNotificationStatus)\''); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598604B89032C FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598603EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598606C066AFE FOREIGN KEY (target_user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { $this->addSql('DROP SEQUENCE notification_settings_id_seq CASCADE'); $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B0559860A76ED395'); $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B0559860BA364942'); $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B05598604B89032C'); $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B05598603EB84A1D'); $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B05598606C066AFE'); $this->addSql('DROP TABLE notification_settings'); $this->addSql('DROP TYPE enumNotificationStatus'); } } ================================================ FILE: migrations/Version20250128125727.php ================================================ addSql('CREATE TYPE enumSortOptions AS ENUM(\'hot\', \'top\', \'newest\', \'active\', \'oldest\', \'commented\')'); $this->addSql('ALTER TABLE "user" ADD front_default_sort enumSortOptions DEFAULT \'hot\' NOT NULL'); $this->addSql('ALTER TABLE "user" ADD comment_default_sort enumSortOptions DEFAULT \'hot\' NOT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP front_default_sort'); $this->addSql('ALTER TABLE "user" DROP comment_default_sort'); $this->addSql('DROP TYPE enumSortOptions'); } } ================================================ FILE: migrations/Version20250203232039.php ================================================ addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B0559860A76ED395'); $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B0559860BA364942'); $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B05598604B89032C'); $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B05598603EB84A1D'); $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B05598606C066AFE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598604B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598603EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598606C066AFE FOREIGN KEY (target_user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B0559860A76ED395'); $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B0559860BA364942'); $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B05598604B89032C'); $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B05598603EB84A1D'); $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B05598606C066AFE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598604B89032C FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598603EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598606C066AFE FOREIGN KEY (target_user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); } } ================================================ FILE: migrations/Version20250204152300.php ================================================ addSql('COMMENT ON COLUMN notification_settings.notification_status IS NULL'); } public function down(Schema $schema): void { $this->addSql('COMMENT ON COLUMN notification_settings.notification_status IS \'(DC2Type:EnumNotificationStatus)\''); } } ================================================ FILE: migrations/Version20250706115844.php ================================================ addSql('ALTER TABLE magazine ADD name_ts tsvector GENERATED ALWAYS AS (to_tsvector(\'english\', name)) STORED'); $this->addSql('ALTER TABLE magazine ADD title_ts tsvector GENERATED ALWAYS AS (to_tsvector(\'english\', title)) STORED'); $this->addSql('ALTER TABLE magazine ADD description_ts tsvector GENERATED ALWAYS AS (to_tsvector(\'english\', description)) STORED'); $this->addSql('CREATE INDEX magazine_name_ts ON magazine USING GIN (name_ts)'); $this->addSql('CREATE INDEX magazine_title_ts ON magazine USING GIN (title_ts)'); $this->addSql('CREATE INDEX magazine_description_ts ON magazine USING GIN (description_ts)'); $this->addSql('ALTER TABLE "user" ADD username_ts tsvector GENERATED ALWAYS AS (to_tsvector(\'english\', username)) STORED'); $this->addSql('ALTER TABLE "user" ADD about_ts tsvector GENERATED ALWAYS AS (to_tsvector(\'english\', about)) STORED'); $this->addSql('CREATE INDEX user_username_ts ON "user" USING GIN (username_ts)'); $this->addSql('CREATE INDEX user_about_ts ON "user" USING GIN (about_ts)'); } public function down(Schema $schema): void { $this->addSql('DROP INDEX user_username_ts'); $this->addSql('DROP INDEX user_about_ts'); $this->addSql('ALTER TABLE "user" DROP username_ts'); $this->addSql('ALTER TABLE "user" DROP about_ts'); $this->addSql('DROP INDEX magazine_name_ts'); $this->addSql('DROP INDEX magazine_title_ts'); $this->addSql('DROP INDEX magazine_description_ts'); $this->addSql('ALTER TABLE magazine DROP name_ts'); $this->addSql('ALTER TABLE magazine DROP title_ts'); $this->addSql('ALTER TABLE magazine DROP description_ts'); } } ================================================ FILE: migrations/Version20250723183702.php ================================================ addSql('ALTER TABLE activity ADD object_magazine_ban_id INT DEFAULT NULL'); $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095AE490E490 FOREIGN KEY (object_magazine_ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE INDEX IDX_AC74095AE490E490 ON activity (object_magazine_ban_id)'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095AE490E490'); $this->addSql('DROP INDEX IDX_AC74095AE490E490'); $this->addSql('ALTER TABLE activity DROP object_magazine_ban_id'); } } ================================================ FILE: migrations/Version20250802102904.php ================================================ addSql('ALTER TABLE "user" ADD ban_reason VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP ban_reason'); } } ================================================ FILE: migrations/Version20250812194529.php ================================================ addSql('ALTER TABLE activity ADD created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL default CURRENT_TIMESTAMP(0)'); $this->addSql('COMMENT ON COLUMN activity.created_at IS \'(DC2Type:datetimetz_immutable)\''); $this->addSql('ALTER TABLE activity ALTER COLUMN created_at DROP DEFAULT'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE activity DROP created_at'); } } ================================================ FILE: migrations/Version20250813132233.php ================================================ addSql('ALTER TABLE instance ADD IF NOT EXISTS is_banned BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE instance ADD IF NOT EXISTS is_explicitly_allowed BOOLEAN DEFAULT false NOT NULL'); $this->addSql("DO \$do\$ declare tempRow record; BEGIN FOR tempRow IN SELECT keys.value FROM settings s JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(s.json)) as keys ON TRUE WHERE s.name = 'KBIN_BANNED_INSTANCES' LOOP IF NOT EXISTS (SELECT * FROM instance i WHERE i.domain = tempRow.value) THEN INSERT INTO instance(id, domain, created_at, failed_delivers, updated_at, is_banned) VALUES (NEXTVAL('instance_id_seq'), tempRow.value, current_timestamp(0), 0, current_timestamp(0), true); ELSE UPDATE instance SET is_banned = true WHERE domain = tempRow.value; END IF; END LOOP; END \$do\$;"); $this->addSql('DELETE FROM settings WHERE name=\'KBIN_BANNED_INSTANCES\''); } public function down(Schema $schema): void { $this->addSql("DO \$do\$ declare tempRow record; BEGIN FOR tempRow IN SELECT i.domain FROM instance i WHERE i.is_banned = true LOOP IF NOT EXISTS (SELECT * FROM settings s WHERE s.name = 'KBIN_BANNED_INSTANCES') THEN INSERT INTO settings (id, name, json) VALUES (NEXTVAL('settings_id_seq'), 'KBIN_BANNED_INSTANCES', '[]'::jsonb); END IF; UPDATE settings SET json = json || to_jsonb(tempRow.domain) WHERE name = 'KBIN_BANNED_INSTANCES'; END LOOP; END \$do\$;"); $this->addSql('ALTER TABLE instance DROP is_banned'); $this->addSql('ALTER TABLE instance DROP is_explicitly_allowed'); } } ================================================ FILE: migrations/Version20250907112001.php ================================================ addSql('ALTER TABLE magazine ADD old_private_key TEXT DEFAULT NULL'); $this->addSql('ALTER TABLE magazine ADD old_public_key TEXT DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD old_private_key TEXT DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD old_public_key TEXT DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE magazine DROP old_private_key'); $this->addSql('ALTER TABLE magazine DROP old_public_key'); $this->addSql('ALTER TABLE "user" DROP old_private_key'); $this->addSql('ALTER TABLE "user" DROP old_public_key'); } } ================================================ FILE: migrations/Version20250924105525.php ================================================ addSql('CREATE TYPE enumDirectMessageSettings AS ENUM(\'everyone\', \'followers_only\', \'nobody\')'); $this->addSql('ALTER TABLE "user" ADD direct_message_setting enumDirectMessageSettings DEFAULT \'everyone\' NOT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP direct_message_setting'); $this->addSql('DROP TYPE enumDirectMessageSettings'); } } ================================================ FILE: migrations/Version20251022104152.php ================================================ addSql('ALTER TABLE magazine ADD last_key_rotation_date TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD last_key_rotation_date TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE magazine DROP last_key_rotation_date'); $this->addSql('ALTER TABLE "user" DROP last_key_rotation_date'); } } ================================================ FILE: migrations/Version20251022115254.php ================================================ addSql('ALTER TABLE magazine ADD banner_id INT DEFAULT NULL'); $this->addSql('ALTER TABLE magazine ADD CONSTRAINT FK_378C2FE4684EC833 FOREIGN KEY (banner_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('CREATE INDEX IDX_378C2FE4684EC833 ON magazine (banner_id)'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE magazine DROP CONSTRAINT FK_378C2FE4684EC833'); $this->addSql('DROP INDEX IDX_378C2FE4684EC833'); $this->addSql('ALTER TABLE magazine DROP banner_id'); } } ================================================ FILE: migrations/Version20251031174052.php ================================================ addSql('ALTER TABLE entry ADD is_locked BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE post ADD is_locked BOOLEAN DEFAULT false NOT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE post DROP is_locked'); $this->addSql('ALTER TABLE entry DROP is_locked'); } } ================================================ FILE: migrations/Version20251118112235.php ================================================ addSql('CREATE TYPE enumFrontContentOptions AS ENUM(\'all\', \'threads\', \'microblog\')'); $this->addSql('ALTER TABLE "user" ADD front_default_content enumFrontContentOptions DEFAULT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP front_default_content'); $this->addSql('DROP TYPE enumFrontContentOptions'); } } ================================================ FILE: migrations/Version20251129140919.php ================================================ addSql('ALTER TYPE enumFrontContentOptions RENAME VALUE \'all\' TO \'combined\''); } public function down(Schema $schema): void { $this->addSql('ALTER TYPE enumFrontContentOptions RENAME VALUE \'combined\' TO \'all\''); } } ================================================ FILE: migrations/Version20251206145724.php ================================================ addSql('DELETE FROM settings WHERE name=\'MAX_IMAGE_BYTES\''); $this->addSql('DELETE FROM settings WHERE name=\'MBIN_MAX_IMAGE_BYTES\''); } public function down(Schema $schema): void { } } ================================================ FILE: migrations/Version20251214111055.php ================================================ addSql('ALTER TABLE user_push_subscription DROP CONSTRAINT FK_AE378BD8A76ED395'); $this->addSql('ALTER TABLE user_push_subscription ALTER user_id SET NOT NULL'); $this->addSql('ALTER TABLE user_push_subscription ADD CONSTRAINT FK_AE378BD8A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE user_push_subscription DROP CONSTRAINT fk_ae378bd8a76ed395'); $this->addSql('ALTER TABLE user_push_subscription ALTER user_id DROP NOT NULL'); $this->addSql('ALTER TABLE user_push_subscription ADD CONSTRAINT fk_ae378bd8a76ed395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); } } ================================================ FILE: migrations/Version20260113103210.php ================================================ addSql('ALTER TABLE magazine ADD ap_indexable BOOLEAN DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD ap_indexable BOOLEAN DEFAULT NULL'); // The column should be nullable so that we know whether other software simply does not set this value, // but for local users and magazines we should only have true and false as options $this->addSql('UPDATE "user" SET ap_indexable = true WHERE ap_id IS NULL'); $this->addSql('UPDATE magazine SET ap_indexable = true WHERE ap_id IS NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP ap_indexable'); $this->addSql('ALTER TABLE magazine DROP ap_indexable'); } } ================================================ FILE: migrations/Version20260113151625.php ================================================ addSql('UPDATE "user" SET ap_discoverable = true WHERE ap_id IS NULL'); $this->addSql('UPDATE magazine SET ap_discoverable = true WHERE ap_id IS NULL'); } public function down(Schema $schema): void { } } ================================================ FILE: migrations/Version20260118131639.php ================================================ addSql('CREATE UNIQUE INDEX IF NOT EXISTS user_ap_public_url_idx ON "user" (ap_public_url)'); } public function down(Schema $schema): void { } } ================================================ FILE: migrations/Version20260118142727.php ================================================ addSql('CREATE UNIQUE INDEX magazine_ap_profile_id_idx ON magazine (ap_profile_id)'); $this->addSql('CREATE UNIQUE INDEX magazine_ap_public_url_idx ON magazine (ap_public_url)'); $this->addSql('ALTER INDEX uniq_378c2fe4904f155e RENAME TO magazine_ap_id_idx'); } public function down(Schema $schema): void { $this->addSql('DROP INDEX magazine_ap_profile_id_idx'); $this->addSql('DROP INDEX magazine_ap_public_url_idx'); $this->addSql('ALTER INDEX magazine_ap_id_idx RENAME TO uniq_378c2fe4904f155e'); } } ================================================ FILE: migrations/Version20260120175744.php ================================================ addSql('CREATE SEQUENCE monitoring_curl_request_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE monitoring_query_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE monitoring_twig_render_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE monitoring_curl_request (id INT NOT NULL, context_id UUID DEFAULT NULL, url VARCHAR(255) NOT NULL, method VARCHAR(255) NOT NULL, was_successful BOOLEAN NOT NULL, exception VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, started_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, started_at_microseconds DOUBLE PRECISION NOT NULL, ended_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ended_at_microseconds DOUBLE PRECISION NOT NULL, duration_milliseconds DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_19A4B8546B00C1CF ON monitoring_curl_request (context_id)'); $this->addSql('CREATE TABLE monitoring_execution_context (uuid UUID NOT NULL, execution_type VARCHAR(255) NOT NULL, path VARCHAR(255) NOT NULL, handler VARCHAR(255) NOT NULL, user_type VARCHAR(255) NOT NULL, status_code INT DEFAULT NULL, exception VARCHAR(255) DEFAULT NULL, stacktrace VARCHAR(255) DEFAULT NULL, response_sending_duration_milliseconds DOUBLE PRECISION DEFAULT NULL, query_duration_milliseconds DOUBLE PRECISION NOT NULL, twig_render_duration_milliseconds DOUBLE PRECISION NOT NULL, curl_request_duration_milliseconds DOUBLE PRECISION NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, started_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, started_at_microseconds DOUBLE PRECISION NOT NULL, ended_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ended_at_microseconds DOUBLE PRECISION NOT NULL, duration_milliseconds DOUBLE PRECISION NOT NULL, PRIMARY KEY(uuid))'); $this->addSql('CREATE TABLE monitoring_query (id INT NOT NULL, context_id UUID DEFAULT NULL, query_string_id VARCHAR(40) DEFAULT NULL, parameters JSONB DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, started_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, started_at_microseconds DOUBLE PRECISION NOT NULL, ended_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ended_at_microseconds DOUBLE PRECISION NOT NULL, duration_milliseconds DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_760D8AF36B00C1CF ON monitoring_query (context_id)'); $this->addSql('CREATE INDEX IDX_760D8AF3BCAEFD40 ON monitoring_query (query_string_id)'); $this->addSql('CREATE TABLE monitoring_query_string (query_hash VARCHAR(40) NOT NULL, query TEXT NOT NULL, PRIMARY KEY(query_hash))'); $this->addSql('CREATE TABLE monitoring_twig_render (id INT NOT NULL, context_id UUID DEFAULT NULL, parent_id INT DEFAULT NULL, short_description TEXT NOT NULL, template_name VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, type VARCHAR(255) DEFAULT NULL, memory_usage INT DEFAULT NULL, peak_memory_usage INT DEFAULT NULL, profiler_duration DOUBLE PRECISION DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, started_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, started_at_microseconds DOUBLE PRECISION NOT NULL, ended_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ended_at_microseconds DOUBLE PRECISION NOT NULL, duration_milliseconds DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_55BA2A536B00C1CF ON monitoring_twig_render (context_id)'); $this->addSql('CREATE INDEX IDX_55BA2A53727ACA70 ON monitoring_twig_render (parent_id)'); $this->addSql('ALTER TABLE monitoring_curl_request ADD CONSTRAINT FK_19A4B8546B00C1CF FOREIGN KEY (context_id) REFERENCES monitoring_execution_context (uuid) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE monitoring_query ADD CONSTRAINT FK_760D8AF36B00C1CF FOREIGN KEY (context_id) REFERENCES monitoring_execution_context (uuid) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE monitoring_query ADD CONSTRAINT FK_760D8AF3BCAEFD40 FOREIGN KEY (query_string_id) REFERENCES monitoring_query_string (query_hash) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE monitoring_twig_render ADD CONSTRAINT FK_55BA2A536B00C1CF FOREIGN KEY (context_id) REFERENCES monitoring_execution_context (uuid) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE monitoring_twig_render ADD CONSTRAINT FK_55BA2A53727ACA70 FOREIGN KEY (parent_id) REFERENCES monitoring_twig_render (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } public function down(Schema $schema): void { $this->addSql('DROP SEQUENCE monitoring_curl_request_id_seq CASCADE'); $this->addSql('DROP SEQUENCE monitoring_query_id_seq CASCADE'); $this->addSql('DROP SEQUENCE monitoring_twig_render_id_seq CASCADE'); $this->addSql('ALTER TABLE monitoring_curl_request DROP CONSTRAINT FK_19A4B8546B00C1CF'); $this->addSql('ALTER TABLE monitoring_query DROP CONSTRAINT FK_760D8AF36B00C1CF'); $this->addSql('ALTER TABLE monitoring_query DROP CONSTRAINT FK_760D8AF3BCAEFD40'); $this->addSql('ALTER TABLE monitoring_twig_render DROP CONSTRAINT FK_55BA2A536B00C1CF'); $this->addSql('ALTER TABLE monitoring_twig_render DROP CONSTRAINT FK_55BA2A53727ACA70'); $this->addSql('DROP TABLE monitoring_curl_request'); $this->addSql('DROP TABLE monitoring_execution_context'); $this->addSql('DROP TABLE monitoring_query'); $this->addSql('DROP TABLE monitoring_query_string'); $this->addSql('DROP TABLE monitoring_twig_render'); } } ================================================ FILE: migrations/Version20260127111110.php ================================================ addSql('DROP INDEX IF EXISTS idx_da62921d3dae168b'); // bookmark (list_id) -> covered by bookmark_list_entry_entrycomment_post_postcomment_idx (list_id, entry_id, entry_comment_id, post_id, post_comment_id) $this->addSql('DROP INDEX IF EXISTS idx_a650c0c4a76ed395'); // bookmark_list (user_id) -> covered by uniq_a650c0c4a76ed3955e237e06 (user_id, name) $this->addSql('DROP INDEX IF EXISTS idx_5060bff4a76ed395'); // domain_block (user_id) -> covered by domain_block_idx (user_id, domain_id) $this->addSql('DROP INDEX IF EXISTS idx_3ac9125ea76ed395'); // domain_subscription (user_id) -> covered by domain_subscription_idx (user_id, domain_id) $this->addSql('DROP INDEX IF EXISTS entry_visibility_idx'); // entry (visibility) -> covered by entry_visibility_adult_idx (visibility, is_adult) $this->addSql('DROP INDEX IF EXISTS idx_9e561267a76ed395'); // entry_comment_vote (user_id) -> covered by user_entry_comment_vote_idx (user_id, comment_id) $this->addSql('DROP INDEX IF EXISTS idx_fe32fd77a76ed395'); // entry_vote (user_id) -> covered by user_entry_vote_idx (user_id, entry_id) $this->addSql('DROP INDEX IF EXISTS idx_62a2ca1960c33421'); // favourite (entry_comment_id) -> covered by favourite_user_entry_comment_unique_idx (entry_comment_id, user_id) $this->addSql('DROP INDEX IF EXISTS idx_62a2ca19ba364942'); // favourite (entry_id) -> covered by favourite_user_entry_unique_idx (entry_id, user_id) $this->addSql('DROP INDEX IF EXISTS idx_62a2ca19db1174d2'); // favourite (post_comment_id) -> covered by favourite_user_post_comment_unique_idx (post_comment_id, user_id) $this->addSql('DROP INDEX IF EXISTS idx_62a2ca194b89032c'); // favourite (post_id) -> covered by favourite_user_post_unique_idx (post_id, user_id) $this->addSql('DROP INDEX IF EXISTS magazine_visibility_idx'); // magazine (visibility) -> covered by magazine_visibility_adult_idx (visibility, is_adult) $this->addSql('DROP INDEX IF EXISTS idx_41cc6069a76ed395'); // magazine_block (user_id) -> covered by magazine_block_idx (user_id, magazine_id) $this->addSql('DROP INDEX IF EXISTS idx_a7160c653eb84a1d'); // magazine_ownership_request (magazine_id) -> covered by magazine_ownership_magazine_user_idx (magazine_id, user_id) $this->addSql('DROP INDEX IF EXISTS idx_acce935a76ed395'); // magazine_subscription (user_id) -> covered by magazine_subsription_idx (user_id, magazine_id) $this->addSql('DROP INDEX IF EXISTS idx_38501651a76ed395'); // magazine_subscription_request (user_id) -> covered by magazine_subscription_requests_idx (user_id, magazine_id) $this->addSql('DROP INDEX IF EXISTS idx_f2de92908829462f'); // message_thread_participants (message_thread_id) -> covered by message_thread_participants_pkey (message_thread_id, user_id) $this->addSql('DROP INDEX IF EXISTS idx_6a30b2683eb84a1d'); // moderator (magazine_id) -> covered by moderator_magazine_user_idx (magazine_id, user_id) $this->addSql('DROP INDEX IF EXISTS idx_2cc3e3243eb84a1d'); // moderator_request (magazine_id) -> covered by moderator_request_magazine_user_idx (magazine_id, user_id) $this->addSql('DROP INDEX IF EXISTS idx_b0559860a76ed395'); // notification_settings (user_id) -> covered by notification_settings_user_target (user_id, entry_id, post_id, magazine_id, target_user_id) $this->addSql('DROP INDEX IF EXISTS post_visibility_idx'); // post (visibility) -> covered by post_visibility_adult_idx (visibility, is_adult) $this->addSql('DROP INDEX IF EXISTS idx_d71b5a5ba76ed395'); // post_comment_vote (user_id) -> covered by user_post_comment_vote_idx (user_id, comment_id) $this->addSql('DROP INDEX IF EXISTS idx_9345e26fa76ed395'); // post_vote (user_id) -> covered by user_post_vote_idx (user_id, post_id) $this->addSql('DROP INDEX IF EXISTS idx_61d96c7a548d5975'); // user_block (blocker_id) -> covered by user_block_idx (blocker_id, blocked_id) $this->addSql('DROP INDEX IF EXISTS idx_d665f4dac24f853'); // user_follow (follower_id) -> covered by user_follows_idx (follower_id, following_id) $this->addSql('DROP INDEX IF EXISTS idx_ee70876ac24f853'); // user_follow_request (follower_id) -> covered by user_follow_requests_idx (follower_id, following_id) $this->addSql('DROP INDEX IF EXISTS idx_b53cb6dda76ed395'); // user_note (user_id) -> covered by user_noted_idx (user_id, target_id) } public function down(Schema $schema): void { $this->addSql('CREATE INDEX IF NOT EXISTS IDX_DA62921D3DAE168B ON bookmark (list_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_A650C0C4A76ED395 ON bookmark_list (user_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_5060BFF4A76ED395 ON domain_block (user_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_3AC9125EA76ED395 ON domain_subscription (user_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS entry_visibility_idx ON entry (visibility)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_9E561267A76ED395 ON entry_comment_vote (user_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_62A2CA1960C33421 ON favourite (entry_comment_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_62A2CA19BA364942 ON favourite (entry_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_62A2CA19DB1174D2 ON favourite (post_comment_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_62A2CA194B89032C ON favourite (post_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS magazine_visibility_idx ON magazine (visibility)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_41CC6069A76ED395 ON magazine_block (user_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_A7160C653EB84A1D ON magazine_ownership_request (magazine_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_ACCE935A76ED395 ON magazine_subscription (user_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_38501651A76ED395 ON magazine_subscription_request (user_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_F2DE92908829462F ON message_thread_participants (message_thread_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_6A30B2683EB84A1D ON moderator (magazine_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_2CC3E3243EB84A1D ON moderator_request (magazine_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_B0559860A76ED395 ON notification_settings (user_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS post_visibility_idx ON post (visibility)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_D71B5A5BA76ED395 ON post_comment_vote (user_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_9345E26FA76ED395 ON post_vote (user_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_61D96C7A548D5975 ON user_block (blocker_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_D665F4DAC24F853 ON user_follow (follower_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_EE70876AC24F853 ON user_follow_request (follower_id)'); $this->addSql('CREATE INDEX IF NOT EXISTS IDX_B53CB6DDA76ED395 ON user_note (user_id)'); } } ================================================ FILE: migrations/Version20260201131000.php ================================================ addSql('UPDATE magazine SET tags = tags || jsonb_build_array(name) WHERE ap_id IS NULL AND NOT (tags @> jsonb_build_array(name));'); // set it where tags IS NULL $this->addSql('UPDATE magazine SET tags = jsonb_build_array(name) WHERE ap_id IS NULL AND tags IS NULL'); } public function down(Schema $schema): void { $this->addSql('UPDATE magazine SET tags = tags - name WHERE ap_id IS NULL;'); } } ================================================ FILE: migrations/Version20260224224633.php ================================================ addSql('ALTER TABLE "user" ADD title VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE "user" ADD title_ts tsvector GENERATED ALWAYS AS (to_tsvector(\'english\', title)) STORED'); $this->addSql('CREATE INDEX user_title_ts ON "user" USING GIN (title_ts)'); } public function down(Schema $schema): void { $this->addSql('DROP INDEX user_title_ts'); $this->addSql('ALTER TABLE "user" DROP title_ts'); $this->addSql('ALTER TABLE "user" DROP title'); } } ================================================ FILE: migrations/Version20260303103217.php ================================================ addSql('ALTER TABLE rememberme_token ALTER class SET DEFAULT \'\''); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE rememberme_token ALTER class DROP DEFAULT'); } } ================================================ FILE: migrations/Version20260303142852.php ================================================ addSql('ALTER TABLE image ADD is_compressed BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE image ADD source_too_big BOOLEAN DEFAULT false NOT NULL'); $this->addSql('ALTER TABLE image ADD downloaded_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); // init the column for all existing images, it gets overwritten in the big loop underneath $this->addSql('ALTER TABLE image ADD created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL DEFAULT current_timestamp'); $this->addSql('ALTER TABLE image ALTER created_at DROP DEFAULT;'); $this->addSql('ALTER TABLE image ADD original_size BIGINT DEFAULT 0 NOT NULL'); $this->addSql('ALTER TABLE image ADD local_size BIGINT DEFAULT 0 NOT NULL'); // set the downloaded at value to something realistically $this->addSql('DO $do$ declare tempRow record; BEGIN FOR tempRow IN SELECT i.id, e.created_at as ec, ec.created_at as ecc, p.created_at as pc, pc.created_at as pcc, u.created_at as uc, u2.created_at as u2c, m.created_at as mc, m2.created_at as m2c FROM image i LEFT JOIN entry e ON i.id = e.image_id LEFT JOIN entry_comment ec ON i.id = ec.image_id LEFT JOIN post p ON i.id = p.image_id LEFT JOIN post_comment pc ON i.id = pc.image_id LEFT JOIN "user" u ON i.id = u.avatar_id LEFT JOIN "user" u2 ON i.id = u2.cover_id LEFT JOIN magazine m ON i.id = m.icon_id LEFT JOIN magazine m2 ON i.id = m2.banner_id LOOP IF tempRow.ec IS NOT NULL THEN UPDATE image SET downloaded_at = tempRow.ec, created_at = tempRow.ec WHERE id = tempRow.id; ELSIF tempRow.ecc IS NOT NULL THEN UPDATE image SET downloaded_at = tempRow.ecc, created_at = tempRow.ecc WHERE id = tempRow.id; ELSIF tempRow.pc IS NOT NULL THEN UPDATE image SET downloaded_at = tempRow.pc, created_at = tempRow.pc WHERE id = tempRow.id; ELSIF tempRow.pcc IS NOT NULL THEN UPDATE image SET downloaded_at = tempRow.pcc, created_at = tempRow.pcc WHERE id = tempRow.id; ELSIF tempRow.uc IS NOT NULL THEN UPDATE image SET downloaded_at = tempRow.uc, created_at = tempRow.uc WHERE id = tempRow.id; ELSIF tempRow.u2c IS NOT NULL THEN UPDATE image SET downloaded_at = tempRow.u2c, created_at = tempRow.u2c WHERE id = tempRow.id; ELSIF tempRow.mc IS NOT NULL THEN UPDATE image SET downloaded_at = tempRow.mc, created_at = tempRow.mc WHERE id = tempRow.id; ELSIF tempRow.m2c IS NOT NULL THEN UPDATE image SET downloaded_at = tempRow.m2c, created_at = tempRow.m2c WHERE id = tempRow.id; END IF; END LOOP; END $do$;'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE image DROP is_compressed'); $this->addSql('ALTER TABLE image DROP source_too_big'); $this->addSql('ALTER TABLE image DROP downloaded_at'); $this->addSql('ALTER TABLE image DROP created_at'); $this->addSql('ALTER TABLE image DROP original_size'); $this->addSql('ALTER TABLE image DROP local_size'); } } ================================================ FILE: migrations/Version20260315190023.php ================================================ addSql('ALTER TABLE "user" ADD show_boosts_of_following BOOLEAN DEFAULT false NOT NULL'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE "user" DROP show_boosts_of_following'); } } ================================================ FILE: migrations/Version20260330132857.php ================================================ addSql('CREATE SEQUENCE user_filter_list_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE user_filter_list (id INT NOT NULL, name VARCHAR(255) NOT NULL, expiration_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, feeds BOOLEAN NOT NULL, profile BOOLEAN NOT NULL, comments BOOLEAN NOT NULL, words JSONB NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, user_id INT NOT NULL, PRIMARY KEY (id))'); $this->addSql('CREATE INDEX IDX_85E956F4A76ED395 ON user_filter_list (user_id)'); $this->addSql('ALTER TABLE user_filter_list ADD CONSTRAINT FK_85E956F4A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE'); } public function down(Schema $schema): void { $this->addSql('DROP SEQUENCE user_filter_list_id_seq CASCADE'); $this->addSql('ALTER TABLE user_filter_list DROP CONSTRAINT FK_85E956F4A76ED395'); $this->addSql('DROP TABLE user_filter_list'); } } ================================================ FILE: package.json ================================================ { "devDependencies": { "@babel/core": "^7.29.0", "@babel/preset-env": "^7.29.2", "@eslint/js": "^9.39.4", "@floating-ui/dom": "^1.7.6", "@fortawesome/fontawesome-free": "^6.7.2", "@github/markdown-toolbar-element": "^2.2.3", "@hotwired/stimulus": "^3.2.2", "@stylistic/eslint-plugin": "^2.13.0", "@symfony/stimulus-bridge": "^3.2.3", "@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets", "@symfony/ux-autocomplete": "file:vendor/symfony/ux-autocomplete/assets", "@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/assets", "@symfony/webpack-encore": "^5.3.1", "chart.js": "^3.8.2", "core-js": "^3.49.0", "emoji-picker-element": "^1.29.1", "eslint": "^9.39.4", "file-loader": "^6.2.0", "glightbox": "^3.3.1", "globals": "^15.15.0", "hotkeys-js": "^3.13.15", "regenerator-runtime": "^0.14.1", "sass": "^1.98.0", "sass-loader": "^16.0.7", "simple-icons-font": "^14.15.0", "stimulus-textarea-autogrow": "^4.1.0", "stimulus-use": "^0.52.3", "timeago.js": "^4.0.2", "tom-select": "^2.5.2", "typescript": "^5.9.3", "webpack": "^5.105.4", "webpack-cli": "^5.1.4", "webpack-notifier": "^1.15.0" }, "name": "mbin", "license": "AGPL-3.0", "private": true, "scripts": { "dev-server": "encore dev-server", "dev": "encore dev", "watch": "encore dev --watch", "build": "encore production --progress", "lint": "eslint .", "lint-fix": "eslint --fix ." } } ================================================ FILE: phpstan.dist.neon ================================================ parameters: level: 6 paths: - bin/ - config/ - public/ - src/ - tests/ ================================================ FILE: phpunit.xml.dist ================================================ tests src src/DataFixtures ================================================ FILE: public/index.php ================================================ { /** @var {PushMessageData} data */ const data = e.data const json = data.json() console.log("received push notification", json) const promiseChain = self.registration.showNotification(json.title, { body: json.message, data: json, icon: json.avatarUrl ?? json.iconUrl, badge: json.badgeUrl }) e.waitUntil(promiseChain); }) self.addEventListener("notificationclick", (/** @var {NotificationEvent} event */ event) => { let n = event.notification console.log("clicked on notification", event) if (!event.action || event.action === "") { const url = n.data.actionUrl if (url) { const promiseChain = self.clients.matchAll({type: "window"}) .then((clientList) => { if (clientList.length > 0) { const client = clientList.at(0) console.log("got a windowclient", client) return client.navigate(url) .then(client => { console.log("navigated to url", url) if (client && client.focus) { console.log("focusing to client") return client.focus(); } }) } if (self.clients.openWindow) { console.log("opening new window") return self.clients.openWindow(url); } }) event.waitUntil(promiseChain) } } n.close() }) self.addEventListener('install', (event) => { console.log('Inside the install handler:', event); event.waitUntil(self.skipWaiting()) }); self.addEventListener('activate', (event) => { console.log('Inside the activate handler:', event); }); // This is your Service Worker, you can put any of your custom Service Worker // code in this file, above the `precacheAndRoute` line. workbox.precaching.precacheAndRoute(self.__WB_MANIFEST || []); ================================================ FILE: src/ActivityPub/ActorHandle.php ================================================ [@!])?(?P[\w\-\.]+)@(?P[\w\.\-]+)(?P:[\d]+)?$/'; public function __construct( public ?string $prefix = null, public ?string $name = null, public ?string $host = null, public ?int $port = null, ) { } public function __toString(): string { return $this->formatWithPrefix($this->prefix); } public static function parse(string $handle): ?static { if (preg_match(static::HANDLE_PATTERN, $handle, $match)) { $new = new static( $match['prefix'] ?? null, $match['name'], $match['host'] ); $new->setPort($match['port'] ?? null); return $new; } return null; } public static function isHandle(string $handle): bool { if (preg_match(static::HANDLE_PATTERN, $handle, $matches)) { return !empty($matches['name']) && !empty($matches['host']); } return false; } public function isValid(): bool { return static::isHandle((string) $this); } /** @return string port as string in the format ':9000' or empty string if it's null */ public function getPortString(): string { return !empty($this->port) ? ':'.$this->port : ''; } /** @param int|string|null $port port as either plain int or string formatted like ':9000' */ public function setPort(int|string|null $port): static { if (\is_string($port)) { $this->port = \intval(ltrim($port, ':')); } else { $this->port = $port; } return $this; } /** @return string the handle's domain and optionally port in the format `host[:port]` */ public function getDomain(): string { return $this->host.$this->getPortString(); } /** @param ?string $domain the domain in the format `host[:port]` to set both handle's host and port */ public function setDomain(?string $domain): static { $url = parse_url($domain ?? ''); $this->host = $url['host'] ?? null; $this->port = $url['port'] ?? null; return $this; } public function formatWithPrefix(?string $prefix): string { return "{$prefix}{$this->name}@{$this->getDomain()}"; } /** @return string handle in the form `name@domain` */ public function plainHandle(): string { return $this->formatWithPrefix(''); } /** @return string handle in the form `@name@domain` */ public function atHandle(): string { return $this->formatWithPrefix('@'); } /** @return string handle in the form `!name@domain` */ public function bangHandle(): string { return $this->formatWithPrefix('!'); } } ================================================ FILE: src/ActivityPub/JsonRd.php ================================================ */ protected $properties = []; /** * The "links" array has any number of member objects, each of which * represents a link [4]. * * @var JsonRdLink[] */ protected $links = []; public function addAlias(string $uri): static { array_push($this->aliases, $uri); return $this; } public function removeAlias(string $uri): static { $key = array_search($uri, $this->aliases); if (false !== $key) { unset($this->aliases[$key]); } return $this; } public function addProperty(string $uri, ?string $value = null): static { $this->properties[$uri] = $value; return $this; } public function removeProperty(string $uri): static { if (!\array_key_exists($uri, $this->properties)) { return $this; } unset($this->properties[$uri]); return $this; } public function addLink(JsonRdLink $link): static { array_push($this->links, $link); return $this; } public function removeLink(JsonRdLink $link): static { $serialized_link = serialize($link); foreach ($this->links as $key => $_link) { $_serialized_link = serialize($_link); if ($_serialized_link === $serialized_link) { unset($this->links[$key]); break; } } return $this; } public function toJSON(): string { return json_encode($this->toArray()); } /** * @return array */ public function toArray(): array { $data = []; if (!empty($this->getSubject())) { $data['subject'] = $this->getSubject(); } !empty($this->getAliases()) && $data['aliases'] = $this->getAliases(); !empty($this->getLinks()) && $data['links'] = array_map(function (JsonRdLink $jsonRdLink) { return $jsonRdLink->toArray(); }, $this->getLinks()); !empty($this->getProperties()) && $data['properties'] = $this->getProperties(); return $data; } /** * @return string */ public function getSubject() { return $this->subject; } public function setSubject(string $subject): static { $this->subject = $subject; return $this; } /** * @return string[] */ public function getAliases(): array { return $this->aliases; } /** * @param string[] $aliases */ protected function setAliases(array $aliases): static { $this->aliases = $aliases; return $this; } /** * @return JsonRdLink[] */ public function getLinks(): array { return $this->links; } /** * @param JsonRdLink[] $links */ protected function setLinks(array $links): static { $this->links = $links; return $this; } /** * @return array */ public function getProperties(): array { return $this->properties; } /** * @param array $properties */ protected function setProperties(array $properties): static { $this->properties = $properties; return $this; } } ================================================ FILE: src/ActivityPub/JsonRdLink.php ================================================ */ protected $titles = []; /** * The "properties" object within the link relation object comprises * zero or more name/value pairs whose names are URIs (referred to as * "property identifiers") and whose values are strings or null. * Properties are used to convey additional information about the link * relation. As an example, consider this use of "properties":. * * "properties" : { "http://webfinger.example/mail/port" : "993" } * * The "properties" member is OPTIONAL in the link relation object. * * @var array */ protected $properties = []; public function addTitle(string $locale, string $value): static { if (!\array_key_exists($locale, $this->titles)) { $this->titles[$locale] = $value; } return $this; } public function removeTitle(string $locale): static { if (!\array_key_exists($locale, $this->titles)) { return $this; } unset($this->titles[$locale]); return $this; } public function addProperty(string $url, string $value): static { $this->properties[$url] = $value; return $this; } public function removeProperty(string $url): static { if (!\array_key_exists($url, $this->properties)) { return $this; } unset($this->properties[$url]); return $this; } /** * @return array */ public function toArray(): array { $data = []; $data['rel'] = $this->getRel(); $data['href'] = $this->getHref(); !empty($this->getType()) && $data['type'] = $this->getType(); !empty($this->getTitles()) && $data['titles'] = $this->getTitles(); !empty($this->getProperties()) && $data['properties'] = $this->getProperties(); return $data; } public function getRel(): string { return $this->rel; } /** * @throws \Exception */ public function setRel(string $relation): static { if (\in_array($relation, self::REGISTERED_RELATION_TYPES)) { $this->rel = $relation; return $this; } preg_match("/^http(s)?\:\/\/[a-z]+\.[a-z]+/", $relation, $match); if (isset($match[0]) && !empty($match[0])) { $this->rel = $relation; return $this; } throw new \Exception('The value of the `rel` member MUST contain exactly one URI or registered relation type.'); } public function getHref(): ?string { return $this->href; } /** * @todo we need to write for url validation for $href argument. */ public function setHref(string $href): static { $this->href = $href; return $this; } public function getType(): ?string { return $this->type; } public function setType(string $type): static { $this->type = $type; return $this; } /** * @return array */ public function getTitles(): array { return $this->titles; } /** * @param array $titles */ protected function setTitles(array $titles): static { $this->titles = $titles; return $this; } /** * @return array */ public function getProperties(): array { return $this->properties; } /** * @param array $properties */ protected function setProperties(array $properties): static { $this->properties = $properties; return $this; } } ================================================ FILE: src/ArgumentValueResolver/FavouriteResolver.php ================================================ getType() && !$argument->isVariadic() && is_a($request->attributes->get('entityClass'), FavouriteInterface::class, true) && $request->attributes->has('id') ) { ['id' => $id, 'entityClass' => $entityClass] = $request->attributes->all(); /** @var class-string $entityClass */ yield $this->entityManager->find($entityClass, $id); } } } ================================================ FILE: src/ArgumentValueResolver/MagazineResolver.php ================================================ getType()) { return; } $magazineName = $request->attributes->get('magazine_name') ?? $request->attributes->get('name'); yield $this->repository->findOneByName($magazineName); } } ================================================ FILE: src/ArgumentValueResolver/ReportResolver.php ================================================ getType() && !$argument->isVariadic() && is_a($request->attributes->get('entityClass'), ReportInterface::class, true) && $request->attributes->has('id') ) { ['id' => $id, 'entityClass' => $entityClass] = $request->attributes->all(); /** @var class-string $entityClass */ yield $this->entityManager->find($entityClass, $id); } } } ================================================ FILE: src/ArgumentValueResolver/UserResolver.php ================================================ getType()) { return; } if (!$username = $request->attributes->get('username') ?? $request->attributes->get('user')) { return; } // @todo case-insensitive if (!$user = $this->repository->findOneByUsername($username)) { if (str_ends_with($username, '@'.$this->settingsManager->get('KBIN_DOMAIN'))) { $username = ltrim($username, '@'); $username = str_replace('@'.$this->settingsManager->get('KBIN_DOMAIN'), '', $username); $user = $this->repository->findOneByUsername($username); } if (!$user && substr_count($username, '@') > 1) { try { $user = $this->activityPubManager->findActorOrCreate($username); } catch (\Exception $e) { $user = null; } } } if (!$user instanceof User) { throw new NotFoundHttpException(); } yield $user; } } ================================================ FILE: src/ArgumentValueResolver/VotableResolver.php ================================================ getType() && !$argument->isVariadic() && is_a($request->attributes->get('entityClass'), VotableInterface::class, true) && $request->attributes->has('id') ) { ['id' => $id, 'entityClass' => $entityClass] = $request->attributes->all(); /** @var class-string $entityClass */ yield $this->entityManager->find($entityClass, $id); } } } ================================================ FILE: src/Command/ActorUpdateCommand.php ================================================ addArgument('user', InputArgument::OPTIONAL, 'AP url of the actor to update') ->addOption('users', null, InputOption::VALUE_NONE, 'update *all* known users that needs updating') ->addOption('magazines', null, InputOption::VALUE_NONE, 'update *all* known magazines that needs updating') ->addOption('force', null, InputOption::VALUE_NONE, 'force actor update even if they are recently updated'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $userArg = $input->getArgument('user'); $force = (bool) $input->getOption('force'); if ($userArg) { $this->bus->dispatch(new UpdateActorMessage($userArg, $force), [new TransportNamesStamp('sync')]); } elseif ($input->getOption('users')) { foreach ($this->repository->findRemoteForUpdate() as $u) { $this->bus->dispatch(new UpdateActorMessage($u->apProfileId, $force), [new TransportNamesStamp('sync')]); $io->info($u->username); } } elseif ($input->getOption('magazines')) { foreach ($this->magazineRepository->findRemoteForUpdate() as $u) { $this->bus->dispatch(new UpdateActorMessage($u->apProfileId, $force), [new TransportNamesStamp('sync')]); $io->info($u->name); } } $io->success('Done.'); return Command::SUCCESS; } } ================================================ FILE: src/Command/AdminCommand.php ================================================ addArgument('username', InputArgument::REQUIRED) ->addOption('remove', 'r', InputOption::VALUE_NONE, 'Remove privileges'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $remove = $input->getOption('remove'); $user = $this->repository->findOneByUsername($input->getArgument('username')); if (!$user) { $io->error('User not found.'); return Command::FAILURE; } $user->setOrRemoveAdminRole($remove); $this->entityManager->flush(); $remove ? $io->success('Administrator privileges have been revoked.') : $io->success('Administrator privileges have been granted.'); return Command::SUCCESS; } } ================================================ FILE: src/Command/ApImportObject.php ================================================ addArgument('url', InputArgument::REQUIRED); } protected function execute(InputInterface $input, OutputInterface $output): int { $body = $this->client->getActivityObject($input->getArgument('url'), false); $this->bus->dispatch(new ActivityMessage($body)); return Command::SUCCESS; } } ================================================ FILE: src/Command/AwesomeBot/AwesomeBotEntries.php ================================================ setDescription('This command allows you to create awesome-bot entries.') ->addArgument('username', InputArgument::REQUIRED) ->addArgument('magazine_name', InputArgument::REQUIRED) ->addArgument('url', InputArgument::REQUIRED) ->addArgument('tags', InputArgument::REQUIRED); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $user = $this->userRepository->findOneByUsername($input->getArgument('username')); $magazine = $this->magazineRepository->findOneByName($input->getArgument('magazine_name')); $tags = $input->getArgument('tags') ? explode(',', $input->getArgument('tags')) : []; if (!$user) { $io->error('User not exist.'); return Command::FAILURE; } elseif (!$magazine) { $io->error('Magazine not exist.'); return Command::FAILURE; } $browser = new HttpBrowser(HttpClient::create()); $crawler = $browser->request('GET', $input->getArgument('url')); $content = $crawler->filter('.markdown-body')->first()->children(); $tags = array_flip($tags); $result = []; foreach ($content as $elem) { if (\array_key_exists($elem->nodeName, $tags)) { $tags[$elem->nodeName] = $elem->nodeValue; } if ('ul' === $elem->nodeName) { foreach ($elem->childNodes as $li) { /** * @var \DOMElement $li */ if ('li' !== $li->nodeName) { continue; } if ('a' !== $li->firstChild->nodeName) { continue; } $result[] = [ 'title' => $li->nodeValue, 'url' => $li->firstChild->getAttribute('href'), 'badges' => new ArrayCollection(array_filter($tags, fn ($v) => \is_string($v))), ]; } } } foreach ($result as $item) { if (false === filter_var($item['url'], FILTER_VALIDATE_URL)) { continue; } if ($this->entryRepository->findOneByUrl($item['url'])) { continue; } $dto = new EntryDto(); $dto->magazine = $magazine; $dto->user = $user; $dto->title = substr($item['title'], 0, 255); $dto->url = $item['url']; $dto->badges = $item['badges']; $this->entryManager->create($dto, $user); } return Command::SUCCESS; } } ================================================ FILE: src/Command/AwesomeBot/AwesomeBotFixtures.php ================================================ addOption('prepare', null, InputOption::VALUE_OPTIONAL) ->setDescription('This command allows you to create awesome-bot entries.'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); /** @var array[] */ $result = []; foreach ($this->getEntries() as $entry) { if ($input->getOption('prepare')) { $this->prepareMagazines($output, $entry); continue; } $user = $this->userRepository->findOneByUsername($entry['username']); $magazine = $this->magazineRepository->findOneByName($entry['magazine_name']); $tags = $entry['tags'] ? explode(',', $entry['tags']) : []; if (!$user) { $io->error("User {$entry['username']} not exist."); return Command::FAILURE; } elseif (!$magazine) { $io->error("Magazine {$entry['magazine_name']} not exist."); return Command::FAILURE; } $browser = new HttpBrowser(HttpClient::create()); $crawler = $browser->request('GET', $entry['url']); $content = $crawler->filter('.markdown-body')->first()->children(); $tags = array_flip($tags); foreach ($content as $elem) { if (\array_key_exists($elem->nodeName, $tags)) { $tags[$elem->nodeName] = $elem->nodeValue; } if ('ul' === $elem->nodeName) { foreach ($elem->childNodes as $li) { /** * @var \DOMElement $li */ if ('li' !== $li->nodeName) { continue; } if (!$li->firstChild) { var_dump('a'); continue; } if ('a' !== $li->firstChild->nodeName) { continue; } $result[] = [ 'magazine' => $magazine, 'user' => $user, 'title' => $li->nodeValue, 'url' => $li->firstChild->getAttribute('href'), 'badges' => new ArrayCollection(array_filter($tags, fn ($v) => \is_string($v))), ]; } } } } shuffle($result); foreach ($result as $item) { if (false === filter_var($item['url'], FILTER_VALIDATE_URL)) { continue; } if ($this->entryRepository->findOneByUrl($item['url'])) { continue; } $dto = new EntryDto(); $dto->magazine = $item['magazine']; $dto->user = $item['user']; $dto->title = substr($item['title'], 0, 255); $dto->url = $item['url']; $dto->badges = $item['badges']; $entry = $this->entryManager->create($dto, $item['user']); $io->info("(m/{$entry->magazine->name}) {$entry->title}"); // sleep(rand(2,30)); } return Command::SUCCESS; } /** * @return array[] */ private function getEntries(): array { return [ [ 'username' => 'awesome-rust-bot', 'magazine_name' => 'rust', 'magazine_title' => 'Rust', 'url' => 'https://github.com/rust-unofficial/awesome-rust', 'tags' => 'h2,h3', ], [ 'username' => 'awesome-vue-bot', 'magazine_name' => 'vue', 'magazine_title' => 'Vue', 'url' => 'https://github.com/vuejs/awesome-vue', 'tags' => 'h3', ], [ 'username' => 'awesome-svelte-bot', 'magazine_name' => 'svelte', 'magazine_title' => 'Svelte', 'url' => 'https://github.com/TheComputerM/awesome-svelte', 'tags' => 'h2,h3', ], [ 'username' => 'awesome-react-bot', 'magazine_name' => 'react', 'magazine_title' => 'React', 'url' => 'https://github.com/enaqx/awesome-react', 'tags' => 'h4,h5', ], [ 'username' => 'awesome-ethereum-bot', 'magazine_name' => 'ethereum', 'magazine_title' => 'Ethereum', 'url' => 'https://github.com/bekatom/awesome-ethereum', 'tags' => '', ], [ 'username' => 'awesome-golang-bot', 'magazine_name' => 'golang', 'magazine_title' => 'Golang', 'url' => 'https://github.com/avelino/awesome-go', 'tags' => 'h2', ], [ 'username' => 'awesome-haskell-bot', 'magazine_name' => 'haskell', 'magazine_title' => 'Haskell', 'url' => 'https://github.com/krispo/awesome-haskell', 'tags' => 'h2', ], [ 'username' => 'awesome-flutter-bot', 'magazine_name' => 'flutter', 'magazine_title' => 'Flutter', 'url' => 'https://github.com/Solido/awesome-flutter', 'tags' => 'h3, h4', ], [ 'username' => 'awesome-erlang-bot', 'magazine_name' => 'erlang', 'magazine_title' => 'Erlang', 'url' => 'https://github.com/drobakowski/awesome-erlang', 'tags' => 'h2', ], [ 'username' => 'awesome-php-bot', 'magazine_name' => 'php', 'magazine_title' => 'PHP', 'url' => 'https://github.com/ziadoz/awesome-php', 'tags' => 'h3', ], [ 'username' => 'awesome-testing-bot', 'magazine_name' => 'testing', 'magazine_title' => 'Testing', 'url' => 'https://github.com/TheJambo/awesome-testing', 'tags' => 'h3', ], [ 'username' => 'awesome-code-review-bot', 'magazine_name' => 'codeReview', 'magazine_title' => 'Code review', 'url' => 'https://github.com/joho/awesome-code-review', 'tags' => 'h2', ], [ 'username' => 'awesome-bitcoin-bot', 'magazine_name' => 'bitcoin', 'magazine_title' => 'Bitcoin', 'url' => 'https://github.com/igorbarinov/awesome-bitcoin', 'tags' => 'h2', ], [ 'username' => 'awesome-fediverse-bot', 'magazine_name' => 'fediverse', 'magazine_title' => 'Fediverse', 'url' => 'https://github.com/emilebosch/awesome-fediverse', 'tags' => '', ], [ 'username' => 'awesome-eventstorming-bot', 'magazine_name' => 'eventstorming', 'magazine_title' => 'Eventstorming', 'url' => 'https://github.com/mariuszgil/awesome-eventstorming', 'tags' => 'h2', ], [ 'username' => 'awesome-javascript-bot', 'magazine_name' => 'javascript', 'magazine_title' => 'Javascript', 'url' => 'https://github.com/sorrycc/awesome-javascript', 'tags' => 'h2,h3', ], [ 'username' => 'awesome-unity-bot', 'magazine_name' => 'unity', 'magazine_title' => 'Unity 3D', 'url' => 'https://github.com/RyanNielson/awesome-unity', 'tags' => 'h2', ], [ 'username' => 'awesome-selfhosted-bot', 'magazine_name' => 'selfhosted', 'magazine_title' => 'selfhosted', 'url' => 'https://github.com/awesome-selfhosted/awesome-selfhosted', 'tags' => 'h3', ], [ 'username' => 'awesome-dotnet-bot', 'magazine_name' => 'dotnet', 'magazine_title' => 'dotnet', 'url' => 'https://github.com/quozd/awesome-dotnet', 'tags' => 'h2', ], [ 'username' => 'awesome-java-bot', 'magazine_name' => 'java', 'magazine_title' => 'Java', 'url' => 'https://github.com/akullpp/awesome-java', 'tags' => 'h3', ], [ 'username' => 'awesome-macos-bot', 'magazine_name' => 'macOS', 'magazine_title' => 'macOS', 'url' => 'https://github.com/iCHAIT/awesome-macOS', 'tags' => 'h3', ], [ 'username' => 'awesome-laravel-bot', 'magazine_name' => 'laravel', 'magazine_title' => 'Laravel', 'url' => 'https://github.com/chiraggude/awesome-laravel', 'tags' => 'h2,h5', ], [ 'username' => 'awesome-ux-bot', 'magazine_name' => 'ux', 'magazine_title' => 'UX', 'url' => 'https://github.com/netoguimaraes/awesome-ux', 'tags' => 'h2,h3', ], [ 'username' => 'awesome-symfony-bot', 'magazine_name' => 'symfony', 'magazine_title' => 'Symfony', 'url' => 'https://github.com/sitepoint-editors/awesome-symfony', 'tags' => 'h2', ], [ 'username' => 'awesome-design-bot', 'magazine_name' => 'design', 'magazine_title' => 'Design', 'url' => 'https://github.com/gztchan/awesome-design', 'tags' => 'h2', ], [ 'username' => 'awesome-wordpress-bot', 'magazine_name' => 'wordpress', 'magazine_title' => 'wordpress', 'url' => 'https://github.com/miziomon/awesome-wordpress', 'tags' => 'h2,h4', ], [ 'username' => 'awesome-drupal-bot', 'magazine_name' => 'drupal', 'magazine_title' => 'Drupal', 'url' => 'https://github.com/mrsinguyen/awesome-drupal', 'tags' => 'h2,h3', ], ]; } /** * @param array $entry */ private function prepareMagazines(OutputInterface $output, array $entry): void { try { $command = $this->getApplication()->find('mbin:user:create'); $arguments = [ 'username' => $entry['username'], 'email' => $entry['username'].'@karab.in', 'password' => md5((string) rand()), ]; $input = new ArrayInput($arguments); $command->run($input, $output); } catch (\Exception $e) { } try { $command = $this->getApplication()->find('mbin:awesome-bot:magazine:create'); $arguments = [ 'username' => 'demo', 'magazine_name' => $entry['magazine_name'], 'magazine_title' => $entry['magazine_title'], 'url' => $entry['url'], 'tags' => $entry['tags'], ]; $input = new ArrayInput($arguments); $command->run($input, $output); } catch (\Exception $e) { } } } ================================================ FILE: src/Command/AwesomeBot/AwesomeBotMagazine.php ================================================ setDescription('This command allows you to create awesome-bot magazine.') ->addArgument('username', InputArgument::REQUIRED) ->addArgument('magazine_name', InputArgument::REQUIRED) ->addArgument('magazine_title', InputArgument::REQUIRED) ->addArgument('url', InputArgument::REQUIRED) ->addArgument('tags', InputArgument::REQUIRED); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $user = $this->repository->findOneByUsername($input->getArgument('username')); if (!$user) { $io->error('User doesn\'t exist.'); return Command::FAILURE; } try { $dto = new MagazineDto(); $dto->name = $input->getArgument('magazine_name'); $dto->title = $input->getArgument('magazine_title'); $dto->description = 'Powered by '.$input->getArgument('url'); $dto->setOwner($user); $magazine = $this->magazineManager->create($dto, $user); $this->createBadges( $magazine, $input->getArgument('url'), $input->getArgument('tags') ? explode(',', $input->getArgument('tags')) : [] ); } catch (\Exception $e) { $io->error('Can\'t create magazine'); return Command::FAILURE; } return Command::SUCCESS; } /** * @param string[] $tags */ #[Pure] private function createBadges(Magazine $magazine, string $url, array $tags): Collection { $browser = new HttpBrowser(HttpClient::create()); $crawler = $browser->request('GET', $url); $content = $crawler->filter('.markdown-body')->first()->children(); $labels = []; foreach ($content as $elem) { if (\in_array($elem->nodeName, $tags)) { $labels[] = $elem->nodeValue; } } $badges = []; foreach ($labels as $label) { $this->badgeManager->create( BadgeDto::create($magazine, $label) ); } return new ArrayCollection($badges); } } ================================================ FILE: src/Command/CheckDuplicatesUsersMagazines.php ================================================ setDescription('Check for duplicate users and magazines with interactive deletion options.'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $io->title('Duplicate Users and Magazines Checker'); $dryRun = \boolval($input->getOption('dry-run')); // Let user choose entity type $entity = $io->choice( 'What would you like to check for duplicates?', ['users' => 'Users', 'magazines' => 'Magazines'], 'users' ); if ('users' === $entity) { $userType = $io->choice('Solve duplicates by handle or by profile URL?', ['handle', 'profileUrl']); if ('handle' === $userType) { $this->fixDuplicatesByHandle($io, $dryRun); return Command::SUCCESS; } } // Check for duplicates $duplicates = $this->findDuplicates($io, $entity); if (empty($duplicates)) { $entityName = ucfirst(substr($entity, 0, -1)); $io->success("No duplicate {$entityName}s found."); return Command::SUCCESS; } // Display duplicates table $entityName = ucfirst($entity); $nameField = 'users' === $entity ? 'username' : 'name'; $this->displayDuplicatesTable($io, $duplicates, $entityName, $nameField); // Ask if user wants to delete any duplicates $deleteChoice = $io->confirm('Would you like to delete any of these duplicates?', false); if (!$deleteChoice) { $io->success('Operation completed. No deletions performed.'); return Command::SUCCESS; } // Get IDs to delete $idsInput = $io->ask( 'Enter the IDs to delete (comma-separated, e.g., 1,2,3)', null, function ($input) { if (empty($input)) { throw new \InvalidArgumentException('Please provide at least one ID'); } $ids = array_map('trim', explode(',', $input)); foreach ($ids as $id) { if (!is_numeric($id)) { throw new \InvalidArgumentException("Invalid ID: $id"); } } return $ids; } ); return $this->deleteEntities($io, $entity, $idsInput); } private function findDuplicates(SymfonyStyle $io, string $entity): array { $conn = $this->entityManager->getConnection(); if ('users' === $entity) { $sql = ' SELECT id, username, ap_public_url, created_at, last_active FROM "user" WHERE ap_public_url IN (SELECT ap_public_url FROM "user" WHERE ap_public_url IS NOT NULL GROUP BY ap_public_url HAVING COUNT(*) > 1) ORDER BY ap_public_url; '; } else { // magazines $sql = ' SELECT id, name, ap_public_url, created_at, last_active FROM "magazine" WHERE ap_public_url IN (SELECT ap_public_url FROM "magazine" WHERE ap_public_url IS NOT NULL GROUP BY ap_public_url HAVING COUNT(*) > 1) ORDER BY ap_public_url; '; } $stmt = $conn->prepare($sql); $stmt = $stmt->executeQuery(); $results = $stmt->fetchAllAssociative(); return $results; } private function displayDuplicatesTable(SymfonyStyle $io, array $results, string $entityName, string $nameField): void { $io->section("Duplicate {$entityName}s Found"); // Group by ap_public_url $duplicates = []; foreach ($results as $item) { $url = $item['ap_public_url']; if (!isset($duplicates[$url])) { $duplicates[$url] = []; } $duplicates[$url][] = $item; } foreach ($duplicates as $url => $items) { $io->text("\n".str_repeat('=', 30)); $io->text('Duplicate Group: '.$url); // Prepare table data $headers = ['ID', ucfirst($nameField), 'Created At', 'Last Active']; $rows = []; foreach ($items as $item) { $rows[] = [ $item['id'], $item[$nameField], $item['created_at'] ? substr($item['created_at'], 0, 19) : 'N/A', $item['last_active'] ? substr($item['last_active'], 0, 19) : 'N/A', ]; } $io->table($headers, $rows); } $io->text(\sprintf("\nTotal duplicate {$entityName}s: %d", \count($results))); } private function deleteEntities(SymfonyStyle $io, string $entity, array $ids): int { try { foreach ($ids as $id) { if ('users' === $entity) { // Check if user exists first $existingUser = $this->entityManager->getRepository(User::class)->find($id); if (!$existingUser) { $io->warning("User with ID $id not found, skipping..."); continue; } $this->userManager->delete($existingUser); $io->success("Deleted user: {$existingUser->getUsername()} (ID: $id)"); } else { // magazines // Check if magazine exists first $magazine = $this->entityManager->getRepository(Magazine::class)->find($id); if (!$magazine) { $io->warning("Magazine with ID $id not found, skipping..."); continue; } $this->magazineManager->purge($magazine); $io->success("Deleted magazine: {$magazine->getApName()} (ID: $id)"); } } $entityName = ucfirst(substr($entity, 0, -1)); $io->success("{$entityName} deletion completed successfully."); } catch (\Exception $e) { $io->error('Error during deletion: '.$e->getMessage()); return Command::FAILURE; } return Command::SUCCESS; } protected function fixDuplicatesByHandle(SymfonyStyle $io, bool $dryRun): int { $sql = 'SELECT LOWER(username) AS normalized_username, COUNT(*) AS duplicate_count, json_agg(id) AS user_ids, json_agg(username) AS usernames, json_agg(ap_profile_id) as urls FROM "user" WHERE application_status = \'Approved\' AND ap_id IS NOT NULL GROUP BY LOWER(username) HAVING COUNT(*) > 1 ORDER BY duplicate_count DESC'; $io->writeln('Gathering duplicate users'); $result = $this->entityManager->getConnection()->prepare($sql)->executeQuery()->fetchAllAssociative(); $usersToUpdate = []; $usersToMerge = []; $io->writeln('Determining which users to update'); foreach ($result as $row) { $username = $this->mentionManager->getUsername($row['normalized_username']); $urls = json_decode($row['urls']); // if the URL contains the string (after removing the host), the username is probably right $urlsToUpdate = array_filter($urls, fn (string $url) => !str_contains(str_replace('https://'.parse_url($url, PHP_URL_HOST), '', strtolower($url)), $username)); foreach ($urlsToUpdate as $url) { $usersToUpdate = array_merge($usersToUpdate, $this->getUsersToUpdateFromUrl($url, $io, $dryRun)); } $referenceUrl = strtolower($urls[0]); $pairShouldBeMerged = true; foreach ($urls as $url) { if ($referenceUrl !== strtolower($url)) { $pairShouldBeMerged = false; break; } } if ($pairShouldBeMerged) { $usersToMerge[] = [json_decode($row['user_ids'])]; } } $io->writeln('Updating users from URLs: '.implode(', ', $usersToUpdate)); foreach ($usersToUpdate as $url) { if (!$dryRun) { $io->writeln("Updating actor $url"); $this->activityPubManager->updateActor($url); } else { $io->writeln("Would have updated actor $url"); } } foreach ($usersToMerge as $userPairs) { $users = $this->userRepository->findBy(['id' => $userPairs]); $userString = implode(', ', array_map(fn (User $user) => "'$user->username' ($user->apProfileId)", $users)); $answer = $io->ask("Should these users get merged: $userString", 'yes'); if ('yes' === $answer) { $this->mergeRemoteUsers($users, $io, $dryRun); } } return Command::SUCCESS; } private function getUsersToUpdateFromUrl(string $url, SymfonyStyle $io, bool $dryRun): array { $user = $this->userRepository->findOneBy(['apProfileId' => $url]); if (!$user) { return []; } if ($user->isDeleted || $user->isSoftDeleted() || $user->isTrashed() || $user->markedForDeletionAt) { // deleted users do not get updated, thus they can cause non-unique violations if they also have a wrong username if (!$dryRun) { $answer = $io->ask("Do you want to purge user '$user->username' ($user->apProfileId)? They are already deleted.", 'yes'); if ('yes' === strtolower($answer)) { $this->purgeUser($user); } } else { $io->writeln("Would have asked whether '$user->username' ($user->apProfileId) should be purged"); } return []; } $instance = $this->instanceRepository->findOneBy(['domain' => $user->apDomain]); if ($instance && ($instance->isBanned || $instance->isDead())) { if (!$dryRun) { $answer = $io->ask("The instance $instance->domain is either dead or banned, should the user '$user->username' ($user->apProfileId) be purged?", 'yes'); if ('yes' === strtolower($answer)) { $this->purgeUser($user); } } else { $io->writeln("Would have asked whether '$user->username' ($user->apProfileId) should be purged"); } return []; } $io->writeln("fetching remote object for '$user->username' ($user->apProfileId)"); $actorObject = $this->apHttpClient->getActorObject($url); if (!$actorObject) { if (!$dryRun) { $io->writeln(\sprintf('Purging "%s", because it does not exist on the remote server', $user->username)); $this->purgeUser($user); } else { $io->writeln("Would have purged user '$user->username' ('$user->apProfileId'), because we didn't get a response from the server"); } return []; } elseif ('Tombstone' === $actorObject['type']) { if (!$dryRun) { $io->writeln(\sprintf('Purging "%s", because it was deleted on the remote server', $user->username)); $this->purgeUser($user); } else { $io->writeln("Would have purged user '$user->username' ('$user->apProfileId'), because it is deleted on the remote server"); } return []; } $domain = parse_url($url, PHP_URL_HOST); $newUsername = '@'.$actorObject['preferredUsername'].'@'.$domain; // if there already is a user with the username that is supposed to be on the current one, // we have to update that user before the current one to avoid non-unique violations $existingUsers = $this->userRepository->findBy(['username' => $newUsername]); $result = []; foreach ($existingUsers as $existingUser) { if ($existingUser) { if ($user->getId() === $existingUser->getId()) { continue; } $additionalUrls = $this->getUsersToUpdateFromUrl($existingUser->apProfileId, $io, $dryRun); $result = array_merge($additionalUrls, $result); } } $result[] = $url; return $result; } /** * @throws \Doctrine\DBAL\Exception */ private function purgeUser(User $user): void { $stmt = $this->entityManager->getConnection() ->prepare('DELETE FROM "user" WHERE id = :id'); $stmt->bindValue('id', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); } /** * This replaces all the references of one user with all the others. The main user the others are merged into * is determined by the exact match of the 'id' gathered from the URL in `$user->apProfileId`. * * @param User[] $users * * @throws InvalidApPostException * @throws \Doctrine\DBAL\Exception * @throws InvalidArgumentException */ private function mergeRemoteUsers(array $users, SymfonyStyle $io, bool $dryRun): void { if (0 === \count($users)) { return; } $actorObject = $this->apHttpClient->getActorObject($users[0]->apProfileId); $mainUser = array_first(array_filter($users, fn (User $user) => $user->apProfileId === $actorObject['id'])); if (!$mainUser) { $io->warning(\sprintf('Could not find an exact match for %s in the users %s', $actorObject['id'], implode(', ', array_map(fn (User $user) => $user->apProfileId, $users)))); return; } foreach ($users as $user) { if ($mainUser->getId() === $user->getId()) { continue; } if ($dryRun) { $io->writeln("Would have merged '$user->username' ('$user->apProfileId') into '$mainUser->username' ('$mainUser->apProfileId')"); continue; } $io->writeln("Merging '$user->username' ('$user->apProfileId') into '$mainUser->username' ('$mainUser->apProfileId')"); $conn = $this->entityManager->getConnection(); $conn->transactional(function () use ($conn, $mainUser, $user) { $stmt = $conn->prepare('UPDATE activity SET user_actor_id = :main WHERE user_actor_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE activity SET object_user_id = :main WHERE object_user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE entry SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE entry_comment SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE post SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE post_comment SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE entry_vote SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE entry_comment_vote SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE post_vote SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE post_comment_vote SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE favourite SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE message_thread_participants SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE message SET sender_id = :main WHERE sender_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE magazine_ban SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE magazine_ban SET banned_by_id = :main WHERE banned_by_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE magazine_block SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE magazine_log SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE magazine_log SET acting_user_id = :main WHERE acting_user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE magazine_subscription SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE moderator SET user_id = :main WHERE user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE moderator SET added_by_user_id = :main WHERE added_by_user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE notification_settings SET target_user_id = :main WHERE target_user_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE report SET reported_id = :main WHERE reported_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE report SET reporting_id = :main WHERE reporting_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE user_block SET blocker_id = :main WHERE blocker_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE user_block SET blocked_id = :main WHERE blocked_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE user_follow SET follower_id = :main WHERE follower_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE user_follow SET following_id = :main WHERE following_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE user_follow_request SET follower_id = :main WHERE follower_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); $stmt = $conn->prepare('UPDATE user_follow_request SET following_id = :main WHERE following_id = :oldId'); $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER); $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER); $stmt->executeStatement(); }); $io->writeln("Purging user '$user->username' ('$user->apProfileId')"); $this->purgeUser($user); } } } ================================================ FILE: src/Command/DeleteMonitoringDataCommand.php ================================================ addOption('all', 'a', InputOption::VALUE_NONE, 'Delete all monitoring data'); $this->addOption('queries', null, InputOption::VALUE_NONE, 'Delete all query data'); $this->addOption('twig', null, InputOption::VALUE_NONE, 'Delete all twig data'); $this->addOption('requests', null, InputOption::VALUE_NONE, 'Delete all request data'); $this->addOption('before', null, InputOption::VALUE_OPTIONAL, 'Limit the deletion to contexts before the date'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $beforeString = $input->getOption('before'); try { $before = $beforeString ? new \DateTimeImmutable($beforeString) : new \DateTimeImmutable(); } catch (\Exception $e) { $io->error(\sprintf('%s is not in a valid form', $input->getOption('before'))); return Command::FAILURE; } if ($input->getOption('all')) { $stmt = $this->entityManager->getConnection()->prepare('DELETE FROM monitoring_execution_context WHERE created_at < :before'); $stmt->bindValue('before', $before, 'datetime_immutable'); $stmt->executeStatement(); $io->success('Deleted monitoring data before '.$before->format(DATE_ATOM)); } else { if ($input->getOption('queries')) { $stmt = $this->entityManager->getConnection()->prepare('DELETE FROM monitoring_query WHERE created_at < :before'); $stmt->bindValue('before', $before, 'datetime_immutable'); $stmt->executeStatement(); $io->success('Deleted query data before '.$before->format(DATE_ATOM)); } if ($input->getOption('twig')) { $stmt = $this->entityManager->getConnection()->prepare('DELETE FROM monitoring_twig_render WHERE created_at < :before'); $stmt->bindValue('before', $before, 'datetime_immutable'); $stmt->executeStatement(); $io->success('Deleted twig data before '.$before->format(DATE_ATOM)); } if ($input->getOption('requests')) { $this->entityManager->getConnection()->prepare('DELETE FROM monitoring_curl_request WHERE created_at < :before'); $stmt->bindValue('before', $before, 'datetime_immutable'); $stmt->executeStatement(); $io->success('Deleted request data before '.$before->format(DATE_ATOM)); } } return Command::SUCCESS; } } ================================================ FILE: src/Command/DeleteOrphanedImagesCommand.php ================================================ addOption( 'ignored-paths', null, InputArgument::OPTIONAL, 'A comma seperated list of paths to be ignored in this process. If the path starts with one of the supplied string it will be skipped. e.g. "/cache"', '' ) ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry run, don\'t delete anything') ; } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $totalFiles = 0; $totalDeletedSize = 0; $totalDeletedFiles = 0; $errors = 0; $dryRun = $input->getOption('dry-run'); $ignoredPaths = array_filter( array_map(fn (string $item) => trim($item), explode(',', $input->getOption('ignored-paths'))), fn (string $item) => '' !== $item ); if (\sizeof($ignoredPaths)) { $io->info(\sprintf('Ignoring files in: %s', implode(', ', $ignoredPaths))); } ProgressBar::setFormatDefinition('custom_orphaned', '%deleted% deleted file(s) | %current% checked file(s) (in %elapsed%) - %message%'); $progress = $io->createProgressBar(); $progress->setFormat('custom_orphaned'); $progress->setMessage(''); $progress->start(); try { foreach ($this->imageManager->deleteOrphanedFiles($this->imageRepository, $dryRun, $ignoredPaths) as $file) { $progress->advance(); if ($file['deleted']) { if ($file['successful']) { if ($dryRun) { $progress->setMessage(\sprintf('Would have deleted "%s"', $file['path'])); } else { $progress->setMessage(\sprintf('Deleted "%s"', $file['path'])); } if ($file['fileSize']) { $totalDeletedSize += $file['fileSize']; } ++$totalDeletedFiles; $progress->setMessage($totalDeletedFiles.'', 'deleted'); $progress->display(); } else { if (null !== $file['exception']) { $io->warning(\sprintf('Failed to delete "%s". Message: "%s"', $file['path'], $file['exception']->getMessage())); } else { $io->warning(\sprintf('Failed to delete "%s".', $file['path'])); } ++$errors; } } } } catch (\Exception $e) { $progress->finish(); $io->error(\sprintf('There was an error deleting the files: "%s" - %s', \get_class($e), $e->getMessage())); return Command::FAILURE; } $progress->finish(); $megaBytes = round($totalDeletedSize / pow(1000, 2), 2); if ($dryRun) { $io->info(\sprintf('Would have deleted %s of %s images, and freed up %sMB', $totalDeletedFiles, $totalFiles, $megaBytes)); } else { $io->info(\sprintf('Deleted %s of %s images, and freed up %sMB', $totalDeletedFiles, $totalFiles, $megaBytes)); } if ($errors) { $io->warning(\sprintf('There were %s errors', $errors)); } return Command::SUCCESS; } } ================================================ FILE: src/Command/DeleteUserCommand.php ================================================ addArgument('user', InputArgument::REQUIRED, 'The name of the user that should be deleted'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $userArg = $input->getArgument('user'); $user = $this->repository->findOneByUsername($userArg); if (null !== $user) { $this->bus->dispatch(new DeleteUserMessage($user->getId())); $io->success('Dispatched a user delete message, the user will be deleted shortly'); return Command::SUCCESS; } else { $io->error("There is no user with the username '$userArg'"); return Command::INVALID; } } } ================================================ FILE: src/Command/DocumentationGenerateFederationCommand.php ================================================ addArgument('target', InputArgument::REQUIRED, 'the target file the generated markdown should be saved to'); $this->addOption('overwrite', 'o', InputOption::VALUE_NONE, 'should the target file be overwritten in case it exists'); } protected function execute(InputInterface $input, OutputInterface $output): int { // do everything in a transaction so we can roll that back afterward $this->entityManager->beginTransaction(); $this->settingsManager->set('KBIN_FEDERATION_ENABLED', false); $this->settingsManager->set('KBIN_DOMAIN', 'mbin.example'); $context = $this->router->getContext(); $context->setHost('mbin.example'); $io = new SymfonyStyle($input, $output); $file = './docs/05-fediverse_developers/README.md'; if (!file_exists($file)) { $io->error('File "'.$file.'" not found'); return Command::FAILURE; } $content = file_get_contents($file); if (false === $content) { $io->error('File "'.$file.'" could not be read'); return Command::FAILURE; } $target = $input->getArgument('target'); $overwrite = $input->getOption('overwrite'); if (file_exists($target) && !$overwrite) { $io->error('File "'.$target.'" already exists'); return Command::FAILURE; } $content = $this->generateMarkdown($content); $this->entityManager->rollback(); if (false === file_put_contents($target, $content)) { $io->error('File "'.$target.'" could not be written'); return Command::FAILURE; } $io->success('Markdown has been generated and saved to "'.$target.'"'); return Command::SUCCESS; } private function generateMarkdown(string $content): string { $image = $this->createImage(); $imageDto = $this->imageFactory->createDto($image); $dto = UserDto::create('BentiGorlich', 'a@b.test', avatar: $imageDto, cover: $imageDto); $dto->plainPassword = 'secret'; $user = $this->userManager->create($dto, verifyUserEmail: false, preApprove: true); $user = $this->userManager->edit($user, $dto); $dto = UserDto::create('Melroy', 'a2@b.test', avatar: $imageDto, cover: $imageDto); $dto->plainPassword = 'secret'; $user2 = $this->userManager->create($dto, verifyUserEmail: false, preApprove: true); $user2 = $this->userManager->edit($user2, $dto); $this->userManager->follow($user, $user2); $this->userManager->follow($user2, $user); $dto = new MagazineDto(); $dto->name = 'melroyMag'; $dto->title = 'Melroys Magazine'; $dto->description = 'Melroys wonderful magazine'; $dto->icon = $image; $magazine = $this->magazineManager->create($dto, $user); $dto = new EntryDto(); $dto->user = $user; $dto->magazine = $magazine; $dto->title = 'Bentis thread'; $dto->body = 'Bentis thread in melroys magazine'; $dto->lang = 'en'; $entry = $this->entryManager->create($dto, $user, rateLimit: false, stickyIt: true); $entryCreate = $this->createWrapper->build($entry); $dto = new EntryCommentDto(); $dto->user = $user; $dto->magazine = $magazine; $dto->entry = $entry; $dto->body = 'melroys comment'; $dto->lang = 'en'; $entryComment = $this->entryCommentManager->create($dto, $user2, rateLimit: false); $entryCommentCreate = $this->createWrapper->build($entryComment); $dto = new PostDto(); $dto->user = $user; $dto->magazine = $magazine; $dto->lang = 'en'; $dto->body = 'Melroys post'; $post = $this->postManager->create($dto, $user, rateLimit: false); $postCreate = $this->createWrapper->build($post); $dto = new PostCommentDto(); $dto->user = $user; $dto->magazine = $magazine; $dto->lang = 'en'; $dto->body = 'Bentis post comment'; $dto->post = $post; $postComment = $this->postCommentManager->create($dto, $user, rateLimit: false); $postCommentCreate = $this->createWrapper->build($postComment); $dto = new MessageDto(); $dto->body = 'Bentis message'; $thread = $this->messageManager->toThread($dto, $user, $user2); $message = $thread->getLastMessage(); $userOutboxCollectionInfo = $this->collectionFactory->getUserOutboxCollection($user, false); $userOutboxCollectionItems = $this->collectionFactory->getUserOutboxCollectionItems($user, 1, false); $userFollowerCollection = $this->collectionInfoWrapper->build('ap_user_followers', ['username' => $user->username], $this->userRepository->findFollowers(1, $user)->getNbResults()); unset($userFollowerCollection['@context']); $userFollowingCollection = $this->collectionInfoWrapper->build('ap_user_following', ['username' => $user->username], $this->userRepository->findFollowing(1, $user)->getNbResults()); unset($userFollowingCollection['@context']); $moderatorCollection = $this->collectionFactory->getMagazineModeratorCollection($magazine, false); $pinnedCollection = $this->collectionFactory->getMagazinePinnedCollection($magazine, false); $magazineFollowersCollections = $this->collectionInfoWrapper->build('ap_magazine_followers', ['name' => $magazine->name], $magazine->subscriptionsCount); $magazineFollowersCollectionItems = []; $dto = ReportDto::create($entry, 'Spam'); $report = $this->reportManager->report($dto, $user2); $activityUserFollow = $this->followWrapper->build($user, $user2); $activityUserUndoFollow = $this->undoWrapper->build($activityUserFollow); $activityUserAccept = $this->followResponseWrapper->build($user2, $activityUserFollow); $activityUserCreate = $entryCreate; $activityUserFlag = $this->flagFactory->build($report); $activityUserLike = $this->likeWrapper->build($user2, $entry); $activityUserUndoLike = $this->undoWrapper->build($activityUserLike); $activityUserAnnounce = $this->announceWrapper->build($user2, $entry, true); $activityUserUpdate = $this->updateWrapper->buildForActor($user2); $activityUserEdit = $this->updateWrapper->buildForActivity($entry); $activityUserDelete = $this->deleteWrapper->build($entry, includeContext: false); $activityUserDeleteAccount = $this->deleteWrapper->buildForUser($user); $activityUserLock = $this->lockFactory->build($user2, $entry); $magazineBan = new MagazineBan($magazine, $user, $user2, 'A very specific reason', \DateTimeImmutable::createFromFormat('Y-m-d', '2025-01-01')); $this->entityManager->persist($magazineBan); $activityModAddMod = $this->addRemoveFactory->buildAddModerator($user, $user2, $magazine); $activityModRemoveMod = $this->addRemoveFactory->buildRemoveModerator($user, $user2, $magazine); $activityModAddPin = $this->addRemoveFactory->buildAddPinnedPost($user, $entry); $activityModRemovePin = $this->addRemoveFactory->buildRemovePinnedPost($user, $entry); $activityModDelete = $this->deleteWrapper->adjustDeletePayload($user, $entryComment, false); $activityModBan = $this->blockFactory->createActivityFromMagazineBan($magazineBan); $activityModLock = $this->lockFactory->build($user, $entry); $activityMagAnnounce = $this->announceWrapper->build($magazine, $entryCreate); $activityAdminBan = $this->blockFactory->createActivityFromInstanceBan($user2, $user); $activityAdminDeleteAccount = $this->deleteWrapper->buildForUser($user); $jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES; $replaceVariables = [ '%@context%' => json_encode($this->contextsProvider->referencedContexts(), $jsonFlags), '%@context_additional%' => json_encode(ContextsProvider::embeddedContexts(), $jsonFlags), '%actor_instance%' => json_encode($this->instanceFactory->create(false), $jsonFlags), '%actor_user%' => json_encode($this->personFactory->create($user, false), $jsonFlags), '%actor_magazine%' => json_encode($this->groupFactory->create($magazine, false), $jsonFlags), '%object_entry%' => json_encode($this->entryPageFactory->create($entry, []), $jsonFlags), '%object_entry_comment%' => json_encode($this->entryCommentNoteFactory->create($entryComment, []), $jsonFlags), '%object_post%' => json_encode($this->postNoteFactory->create($post, []), $jsonFlags), '%object_post_comment%' => json_encode($this->postCommentNoteFactory->create($postComment, []), $jsonFlags), '%object_message%' => json_encode($this->messageFactory->build($message, false), $jsonFlags), '%collection_user_outbox%' => json_encode($userOutboxCollectionInfo, $jsonFlags), '%collection_items_user_outbox%' => json_encode($userOutboxCollectionItems, $jsonFlags), '%collection_user_followers%' => json_encode($userFollowerCollection, $jsonFlags), '%collection_user_followings%' => json_encode($userFollowingCollection, $jsonFlags), '%collection_magazine_outbox%' => json_encode(new \stdClass(), $jsonFlags), '%collection_magazine_followers%' => json_encode($magazineFollowersCollections, $jsonFlags), '%collection_items_magazine_followers%' => json_encode($magazineFollowersCollectionItems, $jsonFlags), '%collection_magazine_moderators%' => json_encode($moderatorCollection, $jsonFlags), '%collection_magazine_featured%' => json_encode($pinnedCollection, $jsonFlags), '%activity_user_follow%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserFollow, false), $jsonFlags), '%activity_user_undo_follow%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserUndoFollow, false), $jsonFlags), '%activity_user_accept%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserAccept, false), $jsonFlags), '%activity_user_create%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserCreate, false), $jsonFlags), '%activity_user_flag%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserFlag, false), $jsonFlags), '%activity_user_like%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserLike, false), $jsonFlags), '%activity_user_undo_like%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserUndoLike, false), $jsonFlags), '%activity_user_announce%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserAnnounce, false), $jsonFlags), '%activity_user_update_user%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserUpdate, false), $jsonFlags), '%activity_user_update_content%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserEdit, false), $jsonFlags), '%activity_user_delete%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserDelete, false), $jsonFlags), '%activity_user_delete_account%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserDeleteAccount, false), $jsonFlags), '%activity_user_lock%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserLock, false), $jsonFlags), '%activity_mod_add_mod%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModAddMod, false), $jsonFlags), '%activity_mod_remove_mod%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModRemoveMod, false), $jsonFlags), '%activity_mod_add_pin%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModAddPin, false), $jsonFlags), '%activity_mod_remove_pin%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModRemovePin, false), $jsonFlags), '%activity_mod_lock%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModLock, false), $jsonFlags), '%activity_mod_delete%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModDelete, false), $jsonFlags), '%activity_mod_ban%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModBan, false), $jsonFlags), '%activity_mag_announce%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityMagAnnounce, false), $jsonFlags), '%activity_admin_ban%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityAdminBan, false), $jsonFlags), '%activity_admin_delete_account%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityAdminDeleteAccount, false), $jsonFlags), ]; foreach ($replaceVariables as $key => $value) { $content = str_replace($key, $value, $content); } return $content; } protected function createImage(): Image { $fileName = hash('sha256', 'random'); $image = new Image($fileName, $fileName, $fileName, 100, 100, null); $this->entityManager->persist($image); return $image; } } ================================================ FILE: src/Command/ImageCacheCommand.php ================================================ buildUsersCache(); $this->buildEntriesCache(); $this->buildEntryCommentsCache(); $this->buildPostsCache(); $this->buildPostCommentsCache(); $this->buildMagazinesCache(); return 1; } private function buildUsersCache(): void { $repo = $this->entityManager->getRepository(User::class); $res = $repo->createQueryBuilder('u')->select('i.filePath') ->join('u.avatar', 'i') ->getQuery() ->getArrayResult(); foreach ($res as $image) { if (!$image['filePath']) { continue; } $command = $this->getApplication()->find('liip:imagine:cache:resolve'); $arguments = [ 'paths' => [$image['filePath']], '--filter' => ['avatar_thumb'], ]; $input = new ArrayInput($arguments); $returnCode = $command->run($input, new NullOutput()); } } private function buildEntriesCache(): void { $repo = $this->entityManager->getRepository(Entry::class); $res = $repo->createQueryBuilder('e')->select('i.filePath') ->join('e.image', 'i') ->getQuery() ->getArrayResult(); foreach ($res as $image) { if (!$image['filePath']) { continue; } $command = $this->getApplication()->find('liip:imagine:cache:resolve'); $arguments = [ 'paths' => [$image['filePath']], '--filter' => ['entry_thumb'], ]; $input = new ArrayInput($arguments); $returnCode = $command->run($input, new NullOutput()); } } private function buildEntryCommentsCache(): void { $repo = $this->entityManager->getRepository(EntryComment::class); $res = $repo->createQueryBuilder('c')->select('i.filePath') ->join('c.image', 'i') ->getQuery() ->getArrayResult(); foreach ($res as $image) { if (!$image['filePath']) { continue; } $command = $this->getApplication()->find('liip:imagine:cache:resolve'); $arguments = [ 'paths' => [$image['filePath']], '--filter' => ['post_thumb'], ]; $input = new ArrayInput($arguments); $returnCode = $command->run($input, new NullOutput()); } } private function buildPostsCache(): void { $repo = $this->entityManager->getRepository(Post::class); $res = $repo->createQueryBuilder('p')->select('i.filePath') ->join('p.image', 'i') ->getQuery() ->getArrayResult(); foreach ($res as $image) { if (!$image['filePath']) { continue; } $command = $this->getApplication()->find('liip:imagine:cache:resolve'); $arguments = [ 'paths' => [$image['filePath']], '--filter' => ['post_thumb'], ]; $input = new ArrayInput($arguments); $returnCode = $command->run($input, new NullOutput()); } } private function buildPostCommentsCache(): void { $repo = $this->entityManager->getRepository(PostComment::class); $res = $repo->createQueryBuilder('c')->select('i.filePath') ->join('c.image', 'i') ->getQuery() ->getArrayResult(); foreach ($res as $image) { if (!$image['filePath']) { continue; } $command = $this->getApplication()->find('liip:imagine:cache:resolve'); $arguments = [ 'paths' => [$image['filePath']], '--filter' => ['post_thumb'], ]; $input = new ArrayInput($arguments); $returnCode = $command->run($input, new NullOutput()); } } private function buildMagazinesCache(): void { $repo = $this->entityManager->getRepository(Magazine::class); $res = $repo->createQueryBuilder('m')->select('i.filePath') ->join('m.icon', 'i') ->getQuery() ->getArrayResult(); foreach ($res as $image) { if (!$image['filePath']) { continue; } $command = $this->getApplication()->find('liip:imagine:cache:resolve'); $arguments = [ 'paths' => [$image['filePath']], '--filter' => ['post_thumb'], ]; $input = new ArrayInput($arguments); $returnCode = $command->run($input, new NullOutput()); } } } ================================================ FILE: src/Command/MagazineCreateCommand.php ================================================ addArgument('name', InputArgument::REQUIRED) ->addOption('owner', 'o', InputOption::VALUE_REQUIRED, 'the owner of the magazine') ->addOption('remove', 'r', InputOption::VALUE_NONE, 'Remove the magazine') ->addOption('purge', null, InputOption::VALUE_NONE, 'Purge the magazine') ->addOption('restricted', null, InputOption::VALUE_NONE, 'Restrict the creation of threads to moderators') ->addOption('title', 't', InputOption::VALUE_REQUIRED, 'the title of the magazine') ->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'the description of the magazine') ; } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $remove = $input->getOption('remove'); $purge = $input->getOption('purge'); $restricted = $input->getOption('restricted'); $ownerInput = $input->getOption('owner'); if ($ownerInput) { $user = $this->userRepository->findOneByUsername($ownerInput); if (null === $user) { $io->error(\sprintf('There is no user named: "%s"', $input->getArgument('owner'))); return Command::FAILURE; } } else { $user = $this->userRepository->findAdmin(); } $magazineName = $input->getArgument('name'); $existing = $this->magazineRepository->findOneBy(['name' => $magazineName, 'apId' => null]); if ($remove || $purge) { if (null !== $existing) { if ($remove) { $this->magazineManager->delete($existing); $io->success(\sprintf('The magazine "%s" has been removed.', $magazineName)); return Command::SUCCESS; } else { $this->magazineManager->purge($existing); $io->success(\sprintf('The magazine "%s" has been purged.', $magazineName)); return Command::SUCCESS; } } else { $io->error(\sprintf('There is no magazine named: "%s"', $magazineName)); return Command::FAILURE; } } if (null !== $existing) { $io->error(\sprintf('There already is a magazine called "%s"', $magazineName)); return Command::FAILURE; } $dto = new MagazineDto(); $dto->name = $magazineName; $dto->title = $input->getOption('title') ?? $magazineName; $dto->description = $input->getOption('description'); $dto->isPostingRestrictedToMods = $restricted; $magazine = $this->magazineManager->create($dto, $user, rateLimit: false); $io->success(\sprintf('The magazine "%s" was created successfully', $magazine->name)); return Command::SUCCESS; } } ================================================ FILE: src/Command/MagazineUnsubCommand.php ================================================ addArgument('magazine', InputArgument::REQUIRED); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $magazine = $this->repository->findOneByName($input->getArgument('magazine')); if ($magazine) { foreach ($magazine->subscriptions as $sub) { $this->manager->unsubscribe($magazine, $sub->user); } $io->success('User unsubscribed'); return Command::SUCCESS; } return Command::FAILURE; } } ================================================ FILE: src/Command/ModeratorCommand.php ================================================ addArgument('username', InputArgument::REQUIRED) ->addOption('remove', 'r', InputOption::VALUE_NONE, 'Remove privileges'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $remove = $input->getOption('remove'); $user = $this->repository->findOneByUsername($input->getArgument('username')); if (!$user) { $io->error('User not found.'); return Command::FAILURE; } $user->setOrRemoveModeratorRole($remove); $this->entityManager->flush(); $remove ? $io->success('Global moderator privileges have been revoked.') : $io->success('Global moderator privileges have been granted.'); return Command::SUCCESS; } } ================================================ FILE: src/Command/MoveEntriesByTagCommand.php ================================================ addArgument('magazine', InputArgument::REQUIRED) ->addArgument('tag', InputArgument::REQUIRED); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $magazine = $this->magazineRepository->findOneByName($input->getArgument('magazine')); $tag = $input->getArgument('tag'); if (!$magazine) { $io->error('The magazine does not exist.'); return Command::FAILURE; } $entries = $this->entryRepository->createQueryBuilder('e') ->where('t.tag = :tag') ->join('e.hashtags', 'h') ->join('h.hashtag', 't') ->setParameter('tag', $tag) ->getQuery() ->getResult(); foreach ($entries as $entry) { /* * @var Entry $entry */ $entry->magazine = $magazine; $this->moveComments($entry->comments, $magazine); $this->moveReports($entry->reports, $magazine); $this->moveFavourites($entry->favourites, $magazine); $entry->badges->clear(); $tags = array_diff($entry->tags, [$tag]); $entry->tags = \count($tags) ? array_values($tags) : null; $this->entityManager->persist($entry); } $this->entityManager->flush(); return Command::SUCCESS; } /** * @param ArrayCollection|Collection $comments */ private function moveComments(ArrayCollection|Collection $comments, Magazine $magazine): void { foreach ($comments as $comment) { /* * @var EntryComment $comment */ $comment->magazine = $magazine; $this->moveReports($comment->reports, $magazine); $this->moveFavourites($comment->favourites, $magazine); $this->entityManager->persist($comment); } } /** * @param ArrayCollection|Collection $reports */ private function moveReports(ArrayCollection|Collection $reports, Magazine $magazine): void { foreach ($reports as $report) { /* * @var Report $report */ $report->magazine = $magazine; $this->entityManager->persist($report); } } /** * @param ArrayCollection|Collection $favourites */ private function moveFavourites(ArrayCollection|Collection $favourites, Magazine $magazine): void { foreach ($favourites as $favourite) { /* * @var Favourite $favourite */ $favourite->magazine = $magazine; $this->entityManager->persist($favourite); } } } ================================================ FILE: src/Command/MovePostsByTagCommand.php ================================================ addArgument('magazine', InputArgument::REQUIRED) ->addArgument('tag', InputArgument::REQUIRED); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $magazine = $this->magazineRepository->findOneByName($input->getArgument('magazine')); $tag = $input->getArgument('tag'); if (!$magazine) { $io->error('The magazine does not exist.'); return Command::FAILURE; } $qb = $this->postRepository->createQueryBuilder('p'); $qb->andWhere('t.tag = :tag') ->join('p.hashtags', 'h') ->join('h.hashtag', 't') ->setParameter('tag', $tag); $posts = $qb->getQuery()->getResult(); foreach ($posts as $post) { $output->writeln((string) $post->getId()); $this->postManager->changeMagazine($post, $magazine); } return Command::SUCCESS; } /** * @param ArrayCollection|Collection $comments */ private function moveComments(ArrayCollection|Collection $comments, Magazine $magazine): void { foreach ($comments as $comment) { /* * @var EntryComment $comment */ $comment->magazine = $magazine; $this->moveReports($comment->reports, $magazine); $this->moveFavourites($comment->favourites, $magazine); $this->entityManager->persist($comment); } } /** * @param ArrayCollection|Collection $reports */ private function moveReports(ArrayCollection|Collection $reports, Magazine $magazine): void { foreach ($reports as $report) { /* * @var Report $report */ $report->magazine = $magazine; $this->entityManager->persist($report); } } /** * @param ArrayCollection|Collection $favourites */ private function moveFavourites(ArrayCollection|Collection $favourites, Magazine $magazine): void { foreach ($favourites as $favourite) { /* * @var Favourite $favourite */ $favourite->magazine = $magazine; $this->entityManager->persist($favourite); } } } ================================================ FILE: src/Command/PostMagazinesUpdateCommand.php ================================================ postRepository->findTaggedFederatedInRandomMagazine(); foreach ($posts as $post) { $this->handleMagazine($post, $output); } return Command::SUCCESS; } private function handleMagazine(Post $post, OutputInterface $output): void { $tags = $this->tagLinkRepository->getTagsOfContent($post); $output->writeln((string) $post->getId()); foreach ($tags as $tag) { if ($magazine = $this->magazineRepository->findOneByName($tag)) { $output->writeln($magazine->name); $this->postManager->changeMagazine($post, $magazine); break; } if ($magazine = $this->magazineRepository->findByTag($tag)) { $output->writeln($magazine->name); $this->postManager->changeMagazine($post, $magazine); break; } } } } ================================================ FILE: src/Command/RefreshImageMetaDataCommand.php ================================================ addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'The number of images to handle at once, the higher the number the faster the command, but it also takes more memory', '10000'); $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do a trial without removing any media'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); GeneralUtil::useProgressbarFormatsWithMessage(); $dryRun = \boolval($input->getOption('dry-run')); $batchSize = \intval($input->getOption('batch-size')); $images = $this->imageRepository->findSavedImagesPaginated($batchSize); $count = $images->count(); $progressBar = $io->createProgressBar($count); $progressBar->setMessage(''); $progressBar->start(); $totalCheckedFiles = 0; $totalUpdateFiles = 0; for ($i = 0; $i < $images->getNbPages(); ++$i) { $progressBar->setMessage(\sprintf('Fetching images %s - %s', ($i * $batchSize) + 1, ($i + 1) * $batchSize)); $progressBar->display(); foreach ($images->getCurrentPageResults() as $image) { $progressBar->advance(); ++$totalCheckedFiles; try { if ($this->publicUploadsFilesystem->has($image->filePath)) { ++$totalUpdateFiles; $fileSize = $this->publicUploadsFilesystem->fileSize($image->filePath); if (!$dryRun) { $image->localSize = $fileSize; $progressBar->setMessage(\sprintf('Refreshed meta data of "%s" (%s)', $image->filePath, $image->getId())); $this->logger->debug('Refreshed meta data of "{path}" ({id})', ['path' => $image->filePath, 'id' => $image->getId()]); } else { $progressBar->setMessage(\sprintf('Would have refreshed meta data of "%s" (%s)', $image->filePath, $image->getId())); } $progressBar->display(); } else { $previousPath = $image->filePath; // mark it as not present on the media storage if (!$dryRun) { $image->filePath = null; $image->localSize = 0; $image->downloadedAt = null; $progressBar->setMessage(\sprintf('Marked "%s" (%s) as not present on the media storage', $previousPath, $image->getId())); } else { $progressBar->setMessage(\sprintf('Would have marked "%s" (%s) as not present on the media storage', $image->filePath, $image->getId())); } $progressBar->display(); } } catch (FilesystemException $e) { $this->logger->error('There was an exception refreshing the meta data of "{path}" ({id}): {exClass} - {message}', [ 'path' => $image->filePath, 'id' => $image->getId(), 'exClass' => \get_class($image), 'message' => $e->getMessage(), 'exception' => $e, ]); $progressBar->setMessage(\sprintf('Error checking meta data of "%s" (%s)', $image->filePath, $image->getId())); $progressBar->display(); } } if (!$dryRun) { $this->entityManager->flush(); } if ($images->hasNextPage()) { $images->setCurrentPage($images->getNextPage()); } } $io->writeln(''); if (!$dryRun) { $io->success(\sprintf('Refreshed %s files', $totalUpdateFiles)); } else { $io->success(\sprintf('Would have refreshed %s files', $totalUpdateFiles)); } return Command::SUCCESS; } } ================================================ FILE: src/Command/RemoveAccountsMarkedForDeletion.php ================================================ userManager->getUsersMarkedForDeletionBefore(); $deletedUsers = 0; foreach ($users as $user) { $output->writeln("deleting $user->username"); try { $this->bus->dispatch(new DeleteUserMessage($user->getId())); ++$deletedUsers; } catch (\Exception|\Error $e) { $output->writeln('an error occurred during the deletion of '.$user->username.': '.\get_class($e).' - '.$e->getMessage()); } } $output->writeln("deleted $deletedUsers user"); return 0; } } ================================================ FILE: src/Command/RemoveDMAndBanCommand.php ================================================ addArgument('body', InputArgument::REQUIRED, 'Search query for direct message body.'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $this->bodySearch = (string) $input->getArgument('body'); try { // Search and display messages $this->searchMessages($io); // Confirm? if (!$io->confirm('Do you want to remove *all* found messages and ban sender users? This action is irreversible !!!', false)) { // If not confirmed, exit return Command::FAILURE; } // Ban sender users $io->note('Banning sender users...'); $this->banSenders(); // Remove messages $io->note('Removing direct messages...'); $this->removeMessages(); $io->success('Done!'); // Ban sender user } catch (\Exception $e) { $io->error($e->getMessage()); return Command::FAILURE; } return Command::SUCCESS; } /** * Search for direct messages matching the search query. */ private function searchMessages(SymfonyStyle $io): void { $resultSet = $this->entityManager->getConnection()->executeQuery(' select m.id ,u.username, m.body FROM message m JOIN public.user u ON m.sender_id = u.id WHERE body LIKE :body', ['body' => '%'.$this->bodySearch.'%']); $results = $resultSet->fetchAllAssociative(); if (0 === \count($results)) { throw new \Exception('No direct messages found.'); } $io->text('Found '.\count($results).' direct messages.'); // Display results $table = new Table(new ConsoleOutput()); $table ->setHeaders(['DM ID', 'Sender username', 'Body direct message']) ->setRows(array_map(fn ($item) => [ $item['id'], $item['username'], wordwrap(str_replace(["\r\n", "\r", "\n"], ' ', $item['body']), 60, PHP_EOL, true), ], $results)); $table->render(); } /** * Ban sender users based on the found messages. */ private function banSenders(): void { $this->entityManager->getConnection()->executeQuery(' UPDATE public.user SET is_banned = TRUE WHERE id IN ( SELECT DISTINCT m.sender_id FROM message m JOIN public.user u ON m.sender_id = u.id WHERE body LIKE :body )', ['body' => '%'.$this->bodySearch.'%']); } /** * Remove messages by removing message threads (message_thread table). * * Which will automatically do a cascade delete on the messages table and * the message participants table. */ private function removeMessages(): void { $this->entityManager->getConnection()->executeQuery(' DELETE FROM message_thread WHERE id IN ( SELECT DISTINCT thread_id FROM message WHERE body LIKE :body )', ['body' => '%'.$this->bodySearch.'%']); } } ================================================ FILE: src/Command/RemoveDeadMessagesCommand.php ================================================ removeDeadMessages(); return Command::SUCCESS; } /** * Remove all dead messages from database. */ private function removeDeadMessages(): void { $this->entityManager->getConnection()->executeQuery( 'DELETE FROM messenger_messages WHERE queue_name = ?', ['dead'] ); // Followed by vacuuming the messenger_messages table. $this->entityManager->getConnection()->executeQuery('VACUUM messenger_messages'); } } ================================================ FILE: src/Command/RemoveDuplicatesCommand.php ================================================ removePosts(); $this->removeActors(); return Command::SUCCESS; } private function removePosts(): void { $conn = $this->entityManager->getConnection(); $sql = ' SELECT * FROM post WHERE ap_id IN ( SELECT ap_id FROM post GROUP BY ap_id HAVING COUNT(*) > 1 ) '; $stmt = $conn->prepare($sql); $stmt = $stmt->executeQuery(); $results = $stmt->fetchAllAssociative(); foreach ($results as $item) { try { $post = $this->entityManager->getRepository(Post::class)->find($item['id']); $this->entityManager->remove($post); $this->entityManager->flush(); } catch (\Exception $e) { } } } private function removeActors(): void { $conn = $this->entityManager->getConnection(); $sql = ' SELECT * FROM "user" WHERE ap_id IN ( SELECT ap_id FROM "user" GROUP BY ap_id HAVING COUNT(*) > 1 ) '; $stmt = $conn->prepare($sql); $stmt = $stmt->executeQuery(); $results = $stmt->fetchAllAssociative(); foreach ($results as $item) { // $this->entityManager->beginTransaction(); try { $user = $this->entityManager->getRepository(User::class)->find($item['id']); if ($user->posts->count() || $user->postComments->count() || $user->follows->count( ) || $user->followers->count()) { continue; } $this->entityManager->remove($user); $this->entityManager->flush(); } catch (\Exception $e) { // $this->entityManager->rollback(); var_dump($e->getMessage()); } } } } ================================================ FILE: src/Command/RemoveFailedMessagesCommand.php ================================================ removeFailedMessages(); return Command::SUCCESS; } /** * Remove all failed messages from database. */ private function removeFailedMessages(): void { $this->entityManager->getConnection()->executeQuery( 'DELETE FROM messenger_messages WHERE queue_name = ?', ['failed'] ); // Followed by vacuuming the messenger_messages table. $this->entityManager->getConnection()->executeQuery('VACUUM messenger_messages'); } } ================================================ FILE: src/Command/RemoveOldImagesCommand.php ================================================ addArgument('type', InputArgument::OPTIONAL, 'Type of images to delete either: "all" (except for users), "threads", "thread_comments", "posts", "post_comments" or "users"', 'all') ->addArgument('monthsAgo', InputArgument::OPTIONAL, 'Delete images older than x months', $this->monthsAgo) ->addOption('noActivity', null, InputOption::VALUE_OPTIONAL, 'Delete image that doesn\'t have recorded activity (comments, upvotes, boosts)', false) ->addOption('batchSize', null, InputOption::VALUE_OPTIONAL, 'Number of images to delete at a time (for each type)', $this->batchSize); } /** * Starting point, switch what image will get deleted based on the type input arg. */ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $type = $input->getArgument('type'); $this->monthsAgo = (int) $input->getArgument('monthsAgo'); if ($input->getOption('noActivity')) { $this->noActivity = (bool) $input->getOption('noActivity'); } $this->batchSize = (int) $input->getOption('batchSize'); if ('all' === $type) { $nrDeletedImages = $this->deleteAllImages($output); // Except for user avatars and covers } elseif ('threads' === $type) { $nrDeletedImages = $this->deleteThreadsImages($output); } elseif ('thread_comments' === $type) { $nrDeletedImages = $this->deleteThreadCommentsImages($output); } elseif ('posts' === $type) { $nrDeletedImages = $this->deletePostsImages($output); } elseif ('post_comments' === $type) { $nrDeletedImages = $this->deletePostCommentsImages($output); } elseif ('users' === $type) { $nrDeletedImages = $this->deleteUsersImages($output); } else { $io->error('Invalid type of images to delete. Try \'all\', \'threads\', \'thread_comments\', \'posts\', \'post_comments\' or \'users\'.'); return Command::FAILURE; } $this->entityManager->clear(); $output->writeln(''); // New line $output->writeln(\sprintf('Total images deleted during this run: %d', $nrDeletedImages)); return Command::SUCCESS; } /** * Call all delete methods below, _except_ for the delete users images. * Since users on the instance can be several years old and not getting fetched, * however we shouldn't remove their avatar/cover images just like that. * * @return number Total number of removed records from database */ private function deleteAllImages($output): int { $threadsImagesRemoved = $this->deleteThreadsImages($output); $threadCommentsImagesRemoved = $this->deleteThreadCommentsImages($output); $postsImagesRemoved = $this->deletePostsImages($output); $postCommentsImagesRemoved = $this->deletePostCommentsImages($output); return $threadsImagesRemoved + $threadCommentsImagesRemoved + $postsImagesRemoved + $postCommentsImagesRemoved; } /** * Delete thread images, check on created_at database column for the age. * Limit by batch size. * * @return number Number of removed records from database */ private function deleteThreadsImages(OutputInterface $output): int { $queryBuilder = $this->entityManager->createQueryBuilder(); $timeAgo = new \DateTime("-{$this->monthsAgo} months"); $query = $queryBuilder ->select('e') ->from(Entry::class, 'e') ->where( $queryBuilder->expr()->andX( $queryBuilder->expr()->lt('e.createdAt', ':timeAgo'), $queryBuilder->expr()->neq('i.id', 1), $queryBuilder->expr()->isNotNull('e.apId'), $this->noActivity ? $queryBuilder->expr()->eq('e.upVotes', 0) : null, $this->noActivity ? $queryBuilder->expr()->eq('e.commentCount', 0) : null, $this->noActivity ? $queryBuilder->expr()->eq('e.favouriteCount', 0) : null ) ) ->innerJoin('e.image', 'i') ->orderBy('e.id', 'ASC') ->setParameter('timeAgo', $timeAgo) ->setMaxResults($this->batchSize) ->getQuery(); $entries = $query->getResult(); foreach ($entries as $entry) { $output->writeln(\sprintf('Deleting image from thread ID: %d, with ApId: %s', $entry->getId(), $entry->getApId())); $this->entryManager->detachImage($entry); } // Return total number of elements deleted return \count($entries); } /** * Delete thread comment images, check on created_at database column for the age. * Limit by batch size. * * @return number Number of removed records from database */ private function deleteThreadCommentsImages(OutputInterface $output): int { $queryBuilder = $this->entityManager->createQueryBuilder(); $timeAgo = new \DateTime("-{$this->monthsAgo} months"); $query = $queryBuilder ->select('c') ->from(EntryComment::class, 'c') ->where( $queryBuilder->expr()->andX( $queryBuilder->expr()->lt('c.createdAt', ':timeAgo'), $queryBuilder->expr()->neq('i.id', 1), $queryBuilder->expr()->isNotNull('c.apId'), $this->noActivity ? $queryBuilder->expr()->eq('c.upVotes', 0) : null, $this->noActivity ? $queryBuilder->expr()->eq('c.favouriteCount', 0) : null ) ) ->innerJoin('c.image', 'i') ->orderBy('c.id', 'ASC') ->setParameter('timeAgo', $timeAgo) ->setMaxResults($this->batchSize) ->getQuery(); $comments = $query->getResult(); foreach ($comments as $comment) { $output->writeln(\sprintf('Deleting image from thread comment ID: %d, with ApId: %s', $comment->getId(), $comment->getApId())); $this->entryCommentManager->detachImage($comment); } // Return total number of elements deleted return \count($comments); } /** * Delete post images, check on created_at database column for the age. * Limit by batch size. * * @return number Number of removed records from database */ private function deletePostsImages(OutputInterface $output): int { $queryBuilder = $this->entityManager->createQueryBuilder(); $timeAgo = new \DateTime("-{$this->monthsAgo} months"); $query = $queryBuilder ->select('p') ->from(Post::class, 'p') ->where( $queryBuilder->expr()->andX( $queryBuilder->expr()->lt('p.createdAt', ':timeAgo'), $queryBuilder->expr()->neq('i.id', 1), $queryBuilder->expr()->isNotNull('p.apId'), $this->noActivity ? $queryBuilder->expr()->eq('p.upVotes', 0) : null, $this->noActivity ? $queryBuilder->expr()->eq('p.commentCount', 0) : null, $this->noActivity ? $queryBuilder->expr()->eq('p.favouriteCount', 0) : null ) ) ->innerJoin('p.image', 'i') ->orderBy('p.id', 'ASC') ->setParameter('timeAgo', $timeAgo) ->setMaxResults($this->batchSize) ->getQuery(); $posts = $query->getResult(); foreach ($posts as $post) { $output->writeln(\sprintf('Deleting image from post ID: %d, with ApId: %s', $post->getId(), $post->getApId())); $this->postManager->detachImage($post); } // Return total number of elements deleted return \count($posts); } /** * Delete post comment images, check on created_at database column for the age. * Limit by batch size. * * @return number Number of removed records from database */ private function deletePostCommentsImages(OutputInterface $output): int { $queryBuilder = $this->entityManager->createQueryBuilder(); $timeAgo = new \DateTime("-{$this->monthsAgo} months"); $query = $queryBuilder ->select('c') ->from(PostComment::class, 'c') ->where( $queryBuilder->expr()->andX( $queryBuilder->expr()->lt('c.createdAt', ':timeAgo'), $queryBuilder->expr()->neq('i.id', 1), $queryBuilder->expr()->isNotNull('c.apId'), $this->noActivity ? $queryBuilder->expr()->eq('c.upVotes', 0) : null, $this->noActivity ? $queryBuilder->expr()->eq('c.favouriteCount', 0) : null ) ) ->innerJoin('c.image', 'i') ->orderBy('c.id', 'ASC') ->setParameter('timeAgo', $timeAgo) ->setMaxResults($this->batchSize) ->getQuery(); $comments = $query->getResult(); foreach ($comments as $comment) { $output->writeln(\sprintf('Deleting image from post comment ID: %d, with ApId: %s', $comment->getId(), $comment->getApId())); $this->postCommentManager->detachImage($comment); } // Return total number of elements deleted return \count($comments); } /** * Delete user avatar and user cover images. Check ap_fetched_at column for the age. * Limit by batch size. * * @return number Number of removed records from database */ private function deleteUsersImages(OutputInterface $output): int { $queryBuilder = $this->entityManager->createQueryBuilder(); $timeAgo = new \DateTime("-{$this->monthsAgo} months"); $query = $queryBuilder ->select('u') ->from(User::class, 'u') ->where( $queryBuilder->expr()->andX( $queryBuilder->expr()->orX( $queryBuilder->expr()->isNotNull('u.avatar'), $queryBuilder->expr()->isNotNull('u.cover') ), $queryBuilder->expr()->lt('u.apFetchedAt', ':timeAgo'), $queryBuilder->expr()->isNotNull('u.apId') ) ) ->orderBy('u.apFetchedAt', 'ASC') ->setParameter('timeAgo', $timeAgo) ->setMaxResults($this->batchSize) ->getQuery(); $users = $query->getResult(); foreach ($users as $user) { $output->writeln(\sprintf('Deleting image from username: %s', $user->getUsername())); if (null !== $user->cover) { $this->userManager->detachCover($user); } if (null !== $user->avatar) { $this->userManager->detachAvatar($user); } } // Return total number of elements deleted return \count($users) * 2; } } ================================================ FILE: src/Command/RemoveRemoteMediaCommand.php ================================================ addOption('days', 'd', InputOption::VALUE_REQUIRED, 'Delete media that is older than x days, if you omit this parameter or set it to 0 it will remove all cached remote media'); $this->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'The number of images to handle at once, the higher the number the faster the command, but it also takes more memory', '10000'); $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do a trial without removing any media'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $days = \intval($input->getOption('days')); if ($days < 0) { $io->error('Days must be at least 0'); return Command::FAILURE; } GeneralUtil::useProgressbarFormatsWithMessage(); $dryRun = \boolval($input->getOption('dry-run')); $batchSize = \intval($input->getOption('batch-size')); $images = $this->imageRepository->findOldRemoteMediaPaginated($days, $batchSize); $count = $images->count(); $progressBar = $io->createProgressBar($count); $progressBar->setMessage(''); $progressBar->start(); $totalDeletedFiles = 0; $totalDeletedSize = 0; for ($i = 0; $i < $images->getNbPages(); ++$i) { $progressBar->setMessage(\sprintf('Fetching images %s - %s', ($i * $batchSize) + 1, ($i + 1) * $batchSize)); $progressBar->display(); foreach ($images->getCurrentPageResults() as $image) { $progressBar->advance(); ++$totalDeletedFiles; $totalDeletedSize += $image->localSize; if (!$dryRun) { $filePath = $image->filePath; if ($this->imageManager->removeCachedImage($image)) { $progressBar->setMessage(\sprintf('Removed "%s" (%s)', $filePath, $image->getId())); $progressBar->display(); $this->logger->debug('Removed "{path}" ({id})', ['path' => $filePath, 'id' => $image->getId()]); } } else { $progressBar->setMessage(\sprintf('Would have removed "%s" (%s)', $image->filePath, $image->getId())); $this->logger->debug('Would have removed "{path}" ({id})', ['path' => $image->filePath, 'id' => $image->getId()]); } } if ($images->hasNextPage()) { $images->setCurrentPage($images->getNextPage()); } } $io->writeln(''); if (!$dryRun) { $io->success(\sprintf('Removed %s files (~%sB)', $totalDeletedFiles, $this->formatter->abbreviateNumber($totalDeletedSize))); } else { $io->success(\sprintf('Would have removed %s files (~%sB)', $totalDeletedFiles, $this->formatter->abbreviateNumber($totalDeletedSize))); } return Command::SUCCESS; } } ================================================ FILE: src/Command/SubMagazineCommand.php ================================================ addArgument('magazine', InputArgument::REQUIRED) ->addArgument('username', InputArgument::REQUIRED) ->addOption('unsub', 'u', InputOption::VALUE_NONE, 'Unsubscribe magazine.'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $user = $this->userRepository->findOneByUsername($input->getArgument('username')); $magazine = $this->magazineRepository->findOneByName($input->getArgument('magazine')); if (!$user) { $io->error('User not found.'); return Command::FAILURE; } if (!$magazine) { $io->error('Magazine not found.'); return Command::FAILURE; } if (!$input->getOption('unsub')) { $this->manager->subscribe($magazine, $user); } else { $this->manager->unsubscribe($magazine, $user); } return Command::SUCCESS; } } ================================================ FILE: src/Command/Update/ApKeysUpdateCommand.php ================================================ generate($this->userRepository->findWithoutKeys()); $this->generate($this->magazineRepository->findWithoutKeys()); $site = $this->siteRepository->findAll(); if (empty($site)) { $site = new Site(); $this->entityManager->persist($site); $this->entityManager->flush(); } $site = $this->siteRepository->findAll()[0]; $privateKey = RSA::createKey(4096); $site->publicKey = (string) $privateKey->getPublicKey(); $site->privateKey = (string) $privateKey; $this->entityManager->flush(); return Command::SUCCESS; } /** * @param ActivityPubActorInterface[] $actors */ private function generate(array $actors): void { foreach ($actors as $actor) { $actor = KeysGenerator::generate($actor); $this->entityManager->persist($actor); } $this->entityManager->flush(); } } ================================================ FILE: src/Command/Update/Async/ImageBlurhashHandler.php ================================================ entityManager->getRepository(Image::class); $image = $repo->find($message->id); $image->blurhash = $repo->blurhash($this->manager->getPath($image)); $this->entityManager->persist($image); $this->entityManager->flush(); return true; } } ================================================ FILE: src/Command/Update/Async/ImageBlurhashMessage.php ================================================ entityManager->getRepository($message->class); $entity = $repo->find($message->id); $req = $this->client->request('GET', $entity->apId, [ 'headers' => [ 'Accept' => 'application/activity+json,application/ld+json,application/json', ], ]); if (Response::HTTP_NOT_FOUND === $req->getStatusCode()) { $entity->visibility = VisibilityInterface::VISIBILITY_PRIVATE; $this->entityManager->flush(); } } } ================================================ FILE: src/Command/Update/Async/NoteVisibilityMessage.php ================================================ $class */ public function __construct(public int $id, public string $class) { } } ================================================ FILE: src/Command/Update/ImageBlurhashUpdateCommand.php ================================================ repository->findAll(); foreach ($images as $image) { $this->bus->dispatch(new ImageBlurhashMessage($image->getId())); } return Command::SUCCESS; } } ================================================ FILE: src/Command/Update/LocalMagazineApProfile.php ================================================ repository->createQueryBuilder('m') ->where('m.apId IS NULL') ->getQuery() ->getResult(); foreach ($magazines as $magazine) { $magazine->apProfileId = $this->urlGenerator->generate( 'ap_magazine', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); $io->info($magazine->name); $this->repository->save($magazine, true); } return Command::SUCCESS; } } ================================================ FILE: src/Command/Update/NoteVisibilityUpdateCommand.php ================================================ repository->findAllRemote() as $user) { $this->bus->dispatch(new UpdateActorMessage($user->apProfileId)); } return Command::SUCCESS; } } ================================================ FILE: src/Command/Update/PostCommentRootUpdateCommand.php ================================================ repository->createQueryBuilder('c') ->select('c.id') ->where('c.parent IS NOT NULL') ->andWhere('c.root IS NULL') ->andWhere('c.updateMark = false') ->orderBy('c.id', 'ASC') ->getQuery() ->getResult(); foreach ($queryBuilder as $comment) { echo $comment['id'].PHP_EOL; $this->update($this->repository->find($comment['id'])); } return Command::SUCCESS; } private function update(PostComment $comment): void { if (null === $comment->parent->root) { $this->entityManager->getConnection()->executeQuery( 'UPDATE post_comment SET root_id = :root_id, update_mark = true WHERE id = :id', [ 'root_id' => $comment->parent->getId(), 'id' => $comment->getId(), ] ); return; } $this->entityManager->getConnection()->executeQuery( 'UPDATE post_comment SET root_id = :root_id, update_mark = true WHERE id = :id', [ 'root_id' => $comment->parent->root->getId(), 'id' => $comment->getId(), ] ); } } ================================================ FILE: src/Command/Update/PushKeysUpdateCommand.php ================================================ siteRepository->findAll(); if (empty($site)) { $site = new Site(); $this->entityManager->persist($site); $this->entityManager->flush(); } $site = $this->siteRepository->findAll()[0]; if (null === $site->pushPrivateKey && null === $site->pushPublicKey) { $keys = VAPID::createVapidKeys(); $site->pushPublicKey = (string) $keys['publicKey']; $site->pushPrivateKey = (string) $keys['privateKey']; $this->entityManager->flush(); } return Command::SUCCESS; } } ================================================ FILE: src/Command/Update/RemoveMagazineNameFromTagsCommand.php ================================================ magazineRepository->findAll() as $magazine) { if ($tags = $magazine->tags) { $magazine->tags = array_values(array_filter($tags, fn ($val) => $val !== $magazine->name)); if (empty($magazine->tags)) { $magazine->tags = null; } } } $this->entityManager->flush(); return Command::SUCCESS; } } ================================================ FILE: src/Command/Update/RemoveRemoteEntriesFromLocalDomainCommand.php ================================================ settingsManager->get('KBIN_DOMAIN'); $domainName = preg_replace('/^www\./i', '', parse_url($domainName)['host']); $domain = $this->repository->findOneByName($domainName); if (!$domain) { $io->warning(\sprintf('There is no local domain like %s', $domainName)); return Command::SUCCESS; } $countBeforeSql = 'SELECT COUNT(*) as ctn FROM entry WHERE domain_id = :dId'; $stmt1 = $this->entityManager->getConnection()->prepare($countBeforeSql); $stmt1->bindValue('dId', $domain->getId(), ParameterType::INTEGER); $countBefore = \intval($stmt1->executeQuery()->fetchOne()); $sql = 'UPDATE entry SET domain_id = NULL WHERE domain_id = :dId AND ap_id IS NOT NULL'; $stmt2 = $this->entityManager->getConnection()->prepare($sql); $stmt2->bindValue('dId', $domain->getId(), ParameterType::INTEGER); $stmt2->executeStatement(); $countAfterSql = 'SELECT COUNT(*) as ctn FROM entry WHERE domain_id = :dId'; $stmt3 = $this->entityManager->getConnection()->prepare($countAfterSql); $stmt3->bindValue('dId', $domain->getId(), ParameterType::INTEGER); $countAfter = \intval($stmt3->executeQuery()->fetchOne()); $sql = 'UPDATE domain SET entry_count = :c WHERE id = :dId'; $stmt4 = $this->entityManager->getConnection()->prepare($sql); $stmt4->bindValue('dId', $domain->getId(), ParameterType::INTEGER); $stmt4->bindValue('c', $countAfter, ParameterType::INTEGER); $stmt4->executeStatement(); $io->success(\sprintf('Removed %d entries from the domain %s, now only %d entries are left', $countBefore - $countAfter, $domainName, $countAfter)); return Command::SUCCESS; } } ================================================ FILE: src/Command/Update/SlugUpdateCommand.php ================================================ entityManager->getRepository(Entry::class)->findAll(); foreach ($entries as $entry) { $entry->slug = $this->slugger->slug($entry->title); $this->entityManager->persist($entry); } $posts = $this->entityManager->getRepository(Post::class)->findAll(); foreach ($posts as $post) { $post->slug = $this->slugger->slug($post->body); $this->entityManager->persist($post); } $this->entityManager->flush(); return Command::SUCCESS; } } ================================================ FILE: src/Command/Update/TagsUpdateCommand.php ================================================ entityManager->getRepository(EntryComment::class)->findAll(); foreach ($comments as $comment) { // TODO: $comment->tags is undefined; should it be ->hashtags? $comment->tags = $this->tagExtractor->extract($comment->body, $comment->magazine->name); $this->entityManager->persist($comment); } $posts = $this->entityManager->getRepository(Post::class)->findAll(); foreach ($posts as $post) { // TODO: $post->tags is undefined; should it be ->hashtags? $post->tags = $this->tagExtractor->extract($post->body, $post->magazine->name); $this->entityManager->persist($post); } $comments = $this->entityManager->getRepository(PostComment::class)->findAll(); foreach ($comments as $comment) { // TODO: $comment->tags is undefined; should it be ->hashtags? $comment->tags = $this->tagExtractor->extract($comment->body, $comment->magazine->name); $this->entityManager->persist($comment); } $this->entityManager->flush(); return Command::SUCCESS; } } ================================================ FILE: src/Command/Update/UserLastActiveUpdateCommand.php ================================================ entityManager->getRepository(User::class); $hideAdult = false; foreach ($repo->findAll() as $user) { $activity = $this->searchRepository->findUserPublicActivity(1, $user, $hideAdult); if ($activity->count()) { $user->lastActive = $activity->getCurrentPageResults()[0]->lastActive; } } $this->entityManager->flush(); return Command::SUCCESS; } } ================================================ FILE: src/Command/UserCommand.php ================================================ addArgument('username', InputArgument::REQUIRED) ->addArgument('email', InputArgument::REQUIRED) ->addArgument('password', InputArgument::REQUIRED) ->addOption('applicationText', 'a', InputOption::VALUE_REQUIRED, 'The application text of the user, if set the user will not be pre-approved') ->addOption('remove', 'r', InputOption::VALUE_NONE, 'Remove user') ->addOption('admin', null, InputOption::VALUE_NONE, 'Grant administrator privileges') ->addOption('moderator', null, InputOption::VALUE_NONE, 'Grant global moderator privileges'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $remove = $input->getOption('remove'); $user = $this->repository->findOneByUsername($input->getArgument('username')); if ($user && !$remove) { $io->error('User exists.'); return Command::FAILURE; } if ($user) { // @todo publish delete user message $this->entityManager->remove($user); $this->entityManager->flush(); $io->success('The user deletion process has started.'); return Command::SUCCESS; } $this->createUser($input, $io); return Command::SUCCESS; } private function createUser(InputInterface $input, SymfonyStyle $io): void { $applicationText = $input->getOption('applicationText'); if ('' === $applicationText) { $applicationText = null; } $dto = (new UserDto())->create($input->getArgument('username'), $input->getArgument('email'), applicationText: $applicationText); $dto->plainPassword = $input->getArgument('password'); $user = $this->manager->create($dto, false, false, preApprove: null === $applicationText); if ($input->getOption('admin')) { $user->setOrRemoveAdminRole(); } if ($input->getOption('moderator')) { $user->setOrRemoveModeratorRole(); } $user->isVerified = true; $this->entityManager->flush(); $io->success('A user has been created. It is recommended to change the password after the first login.'); } } ================================================ FILE: src/Command/UserPasswordCommand.php ================================================ addArgument('username', InputArgument::REQUIRED) ->addArgument('password', InputArgument::REQUIRED); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $password = $input->getArgument('password'); $user = $this->repository->findOneByUsername($input->getArgument('username')); if (!$user) { $io->error('User does not exist!'); return Command::FAILURE; } if ($user->apId) { $io->error('The specified account is not a local user!'); return Command::FAILURE; } // Encode(hash) the plain password, and set it. $encodedPassword = $this->userPasswordHasher->hashPassword( $user, $password ); $user->setPassword($encodedPassword); $this->entityManager->flush(); return Command::SUCCESS; } } ================================================ FILE: src/Command/UserRotatePrivateKeys.php ================================================ addArgument('username', InputArgument::OPTIONAL) ->addOption('all-local-users', 'a') ->addOption('revert', 'r', description: 'revert a previous rotation of private keys'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $username = $input->getArgument('username'); $all = $input->getOption('all-local-users'); $revert = $input->getOption('revert'); if (!$username && !$all) { $io->error('You must provide a username or execute the command for all local users!'); return Command::FAILURE; } if ($username) { $user = $this->userRepository->findOneByUsername($username); if (!$user) { $io->error('The username "'.$username.'" does not exist!'); return Command::FAILURE; } elseif ($user->apId) { $io->error('The user "'.$username.'" is not a local user!'); return Command::FAILURE; } $users = [$user]; } elseif ($all) { // all local users, including suspended, banned and marked for deletion, but excluding deleted ones $users = $this->userRepository->findBy(['apId' => null, 'isDeleted' => false]); } else { // unreachable because of the first if throw new \LogicException('no username is set and it should not run for all local users!'); } $userCount = \count($users); $action = $revert ? 'reverted' : 'rotated'; $io->confirm("This command will $action the private and public key of $userCount users. " .'After running this command it can take up to 24 hours for other instances to update their stored public keys. ' .'In this timeframe federation might be impacted by this, as those services cannot successfully verify the identity of your users. ' .'Please inform your users about this when you\'re running this command. Do you want to continue?'); $ignoreCount = 0; $progressBar = $io->createProgressBar($userCount); foreach ($users as $user) { $this->entityManager->beginTransaction(); if ($revert && (null === $user->oldPrivateKey || null === $user->oldPublicKey)) { ++$ignoreCount; $progressBar->advance(); continue; } $user->rotatePrivateKey(revert: $revert); $update = $this->updateWrapper->buildForActor($user); $this->entityManager->flush(); $this->entityManager->commit(); $updateJson = $this->activityJsonBuilder->buildActivityJson($update); $inboxes = $this->userManager->getAllInboxesOfInteractions($user); // send one signed with the old private key and one signed with the new // some software will fetch the newest public key and some will have cached the old one $this->deliverManager->deliver($inboxes, $updateJson, useOldPrivateKey: true); $this->deliverManager->deliver($inboxes, $updateJson, useOldPrivateKey: false); $progressBar->advance(); } $progressBar->finish(); $ignoreText = $revert && $ignoreCount > 0 ? " $ignoreCount users have been ignored, because their keys were never rotated." : ''; $io->info("Successfully $action the private key for $userCount users. $ignoreText"); return Command::SUCCESS; } } ================================================ FILE: src/Command/UserUnsubCommand.php ================================================ addArgument('username', InputArgument::REQUIRED); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $user = $this->repository->findOneByUsername($input->getArgument('username')); if ($user) { foreach ($user->followers as $follower) { $this->manager->unfollow($follower->follower, $user); } $io->success('User unsubscribed'); return Command::SUCCESS; } return Command::FAILURE; } } ================================================ FILE: src/Command/VerifyCommand.php ================================================ addArgument('username', InputArgument::REQUIRED) ->addOption('activate', 'a', InputOption::VALUE_NONE, 'Activate user, bypass email verification.') ->addOption('deactivate', 'd', InputOption::VALUE_NONE, 'Deactivate user, require email (re)verification.'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $activate = $input->getOption('activate'); $deactivate = $input->getOption('deactivate'); $user = $this->repository->findOneByUsername($input->getArgument('username')); if (!$user) { $io->error('User does not exist!'); return Command::FAILURE; } if ($activate) { $user->isVerified = true; $this->entityManager->flush(); $io->success('The user has been activated and can login.'); } elseif ($deactivate) { $user->isVerified = false; $this->entityManager->flush(); $io->success('The user has been deactivated and cannot login.'); } else { if ($user->isVerified) { $io->success('The user is verified and can login.'); } else { $io->success('The user is unverified and cannot login.'); } } return Command::SUCCESS; } } ================================================ FILE: src/Controller/.gitignore ================================================ ================================================ FILE: src/Controller/AboutController.php ================================================ findAll(); return $this->render( 'page/about.html.twig', [ 'body' => $site[0]->about ?? '', ] ); } } ================================================ FILE: src/Controller/AbstractController.php ================================================ getUser(); if (!$user) { throw new \BadMethodCallException('User is not logged in'); } return $user; } protected function validateCsrf(string $id, $token): void { if (!\is_string($token) || !$this->isCsrfTokenValid($id, $token)) { throw new BadRequestHttpException("Invalid CSRF token, with ID: $id."); } } protected function redirectToRefererOrHome(Request $request, ?string $element = null): Response { if (!$request->headers->has('Referer')) { return $this->redirectToRoute('front'.($element ? '#'.$element : '')); } return $this->redirect($request->headers->get('Referer').($element ? '#'.$element : '')); } protected function getJsonSuccessResponse(): JsonResponse { return new JsonResponse( [ 'success' => true, 'html' => '
    Reported
    ', ] ); } protected function getJsonFormResponse( FormInterface $form, string $template, ?array $variables = null, ): JsonResponse { return new JsonResponse( [ 'form' => $this->renderView( $template, [ 'form' => $form->createView(), ] + ($variables ?? []) ), ] ); } protected function getPageNb(Request $request): int { return (int) $request->get('p', 1); } protected function redirectToEntry(Entry $entry): Response { return $this->redirectToRoute( 'entry_single', [ 'magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'slug' => $entry->slug, ] ); } protected function redirectToPost(Post $post): Response { return $this->redirectToRoute( 'post_single', [ 'magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => $post->slug, ] ); } protected function redirectToMagazine(Magazine $magazine, ?string $sortBy = null): Response { return $this->redirectToRoute( 'front_magazine', [ 'name' => $magazine->name, 'sortBy' => $sortBy, ] ); } } ================================================ FILE: src/Controller/ActivityPub/ContextsController.php ================================================ $context->embeddedContexts()], 200, [ 'Content-Type' => 'application/ld+json', 'Access-Control-Allow-Origin' => '*', ] ); } } ================================================ FILE: src/Controller/ActivityPub/EntryCommentController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'comment_id')] EntryComment $comment, Request $request, ): Response { if ($comment->apId) { return $this->redirect($comment->apId); } $this->handlePrivateContent($comment); $response = new JsonResponse($this->commentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment), true)); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/EntryController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { if ($entry->apId) { return $this->redirect($entry->apId); } $response = new JsonResponse($this->pageFactory->create($entry, $this->tagLinkRepository->getTagsOfContent($entry), true)); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/HostMetaController.php ================================================ openMemory(); $document->startDocument('1.0', 'UTF-8'); $document->startElement('XRD'); $document->writeAttribute('xmlns', 'http://docs.oasis-open.org/ns/xri/xrd-1.0'); $document->startElement('Link'); $document->writeAttribute('rel', 'lrdd'); $document->writeAttribute('type', 'application/jrd+json'); $document->writeAttribute( 'template', $this->urlGenerator->generate( 'ap_webfinger', [], $this->urlGenerator::ABSOLUTE_URL, ).'?resource={uri}' ); $document->endElement(); // Link $document->endElement(); // XRD $document->endDocument(); return new Response( $document->outputMemory(), Response::HTTP_OK, [ 'Content-Type' => 'application/xrd+xml', ], ); } } ================================================ FILE: src/Controller/ActivityPub/InstanceController.php ================================================ get('instance_actor', function (ItemInterface $item) use ($instanceFactory) { $item->expiresAfter(7200); return $instanceFactory->create(); }); return new JsonResponse($instance, 200, [ 'Content-Type' => 'application/activity+json', ]); } } ================================================ FILE: src/Controller/ActivityPub/InstanceOutboxController.php ================================================ apId) { throw $this->createNotFoundException(); } $response = new JsonResponse($this->groupFactory->create($magazine)); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/Magazine/MagazineFollowersController.php ================================================ apId) { throw $this->createNotFoundException(); } if (!$request->get('page')) { $data = $this->collectionInfoWrapper->build('ap_magazine_followers', ['name' => $magazine->name], $magazine->subscriptionsCount); } else { $data = $this->getCollectionItems($magazine, (int) $request->get('page')); } $response = new JsonResponse($data); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } #[ArrayShape([ '@context' => 'string', 'type' => 'string', 'partOf' => 'string', 'id' => 'string', 'totalItems' => 'int', 'orderedItems' => 'array', 'next' => 'string', ])] private function getCollectionItems(Magazine $magazine, int $page): array { $subscriptions = $this->magazineSubscriptionRepository->findMagazineSubscribers(1, $magazine); $actors = array_map(fn ($sub) => $sub->user, iterator_to_array($subscriptions->getCurrentPageResults())); $items = []; foreach ($actors as $actor) { $items[] = $this->manager->getActorProfileId($actor); } return $this->collectionItemsWrapper->build( 'ap_magazine_followers', ['name' => $magazine->name], $subscriptions, $items, $page ); } } ================================================ FILE: src/Controller/ActivityPub/Magazine/MagazineInboxController.php ================================================ $request->getHost(), 'method' => $request->getMethod(), 'uri' => $request->getRequestUri(), 'client_ip' => $request->getClientIp(), ] ); $this->logger->debug('MagazineInboxController:request: '.$requestInfo['method'].' '.$requestInfo['uri']); $this->logger->debug('MagazineInboxController:headers: '.$request->headers); $this->logger->debug('MagazineInboxController:content: '.$request->getContent()); $this->bus->dispatch( new ActivityMessage( $request->getContent(), $requestInfo, $request->headers->all(), ) ); $response = new JsonResponse(); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/Magazine/MagazineModeratorsController.php ================================================ collectionFactory->getMagazineModeratorCollection($magazine); $response = new JsonResponse($data); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/Magazine/MagazineOutboxController.php ================================================ collectionFactory->getMagazinePinnedCollection($magazine); $response = new JsonResponse($data); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/MessageController.php ================================================ 'uuid'])] Message $message, Request $request, ): Response { $json = $this->factory->build($message); $response = new JsonResponse($json); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/NodeInfoController.php ================================================ getLinks()); } /** * Returning NodeInfo JSON response for path: nodeinfo/2.x. * * @param string $version version number of NodeInfo */ public function nodeInfoV2(string $version): JsonResponse { return new JsonResponse($this->nodeInfoFactory->create($version)); } /** * Get list of links for well-known nodeinfo. * * @return array[]> */ private function getLinks(): array { return [ 'links' => [ [ 'rel' => self::NODE_REL_v21, 'href' => $this->urlGenerator->generate('ap_node_info_v2', ['version' => '2.1'], UrlGeneratorInterface::ABSOLUTE_URL), ], [ 'rel' => self::NODE_REL_v20, 'href' => $this->urlGenerator->generate('ap_node_info_v2', ['version' => '2.0'], UrlGeneratorInterface::ABSOLUTE_URL), ], ], ]; } } ================================================ FILE: src/Controller/ActivityPub/ObjectController.php ================================================ activityRepository->findOneBy(['uuid' => $uuid, 'isRemote' => false]); if (null === $activity) { return new JsonResponse(status: 404); } $response = new JsonResponse($this->activityJsonBuilder->buildActivityJson($activity)); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/PostCommentController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, #[MapEntity(id: 'comment_id')] PostComment $comment, Request $request, ): Response { if ($comment->apId) { return $this->redirect($comment->apId); } $this->handlePrivateContent($post); $response = new JsonResponse($this->commentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment), true)); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/PostController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, ): Response { if ($post->apId) { return $this->redirect($post->apId); } $response = new JsonResponse($this->postNoteFactory->create($post, $this->tagLinkRepository->getTagsOfContent($post), true)); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/ReportController.php ================================================ 'uuid'])] Report $report, Request $request, ): Response { if (!$report) { throw new ArgumentException('there is no such report'); } $json = $this->factory->build($report, $this->factory->getPublicUrl($report->getSubject())); $response = new JsonResponse($json); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/SharedInboxController.php ================================================ $request->getHost(), 'method' => $request->getMethod(), 'uri' => $request->getRequestUri(), 'client_ip' => $request->getClientIp(), ] ); $this->logger->debug('SharedInboxController:request: '.$requestInfo['method'].' '.$requestInfo['uri']); $this->logger->debug('SharedInboxController:headers: '.$request->headers); $this->logger->debug('SharedInboxController:body: '.$request->getContent()); $this->bus->dispatch( new ActivityMessage( $request->getContent(), $requestInfo, $request->headers->all(), ) ); $response = new JsonResponse(); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/User/UserController.php ================================================ apId) { throw $this->createNotFoundException(); } if (EApplicationStatus::Approved !== $user->getApplicationStatus()) { throw $this->createNotFoundException(); } if (!$user->isDeleted || null !== $user->markedForDeletionAt) { $response = new JsonResponse($this->personFactory->create($user, true)); } else { $response = new JsonResponse($this->tombstoneFactory->createForUser($user)); $response->setStatusCode(410); } $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/User/UserFollowersController.php ================================================ get($user, $request, ActivityPubActivityInterface::FOLLOWERS); } public function get(User $user, Request $request, string $type): JsonResponse { if (!$request->get('page')) { $data = $this->getCollectionInfo($user, $type); } else { $data = $this->getCollectionItems($user, (int) $request->get('page'), $type); } $response = new JsonResponse($data); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } #[ArrayShape([ '@context' => 'string', 'type' => 'string', 'id' => 'string', 'first' => 'string', 'totalItems' => 'int', ])] private function getCollectionInfo(User $user, string $type): array { $routeName = "ap_user_{$type}"; if (ActivityPubActivityInterface::FOLLOWING === $type) { $count = $this->userRepository->findFollowing(1, $user)->getNbResults(); } else { $count = $this->userRepository->findFollowers(1, $user)->getNbResults(); } return $this->collectionInfoWrapper->build($routeName, ['username' => $user->username], $count); } #[ArrayShape([ '@context' => 'string', 'type' => 'string', 'partOf' => 'string', 'id' => 'string', 'totalItems' => 'int', 'orderedItems' => 'array', ])] private function getCollectionItems(User $user, int $page, string $type): array { $routeName = "ap_user_{$type}"; $items = []; if (ActivityPubActivityInterface::FOLLOWING === $type) { $actors = $this->userRepository->findFollowing($page, $user); foreach ($actors as $actor) { $items[] = $this->manager->getActorProfileId($actor->following); } } else { $actors = $this->userRepository->findFollowers($page, $user); foreach ($actors as $actor) { $items[] = $this->manager->getActorProfileId($actor->follower); } } return $this->collectionItemsWrapper->build( $routeName, ['username' => $user->username], $actors, $items, $page ); } public function following(User $user, Request $request): JsonResponse { return $this->get($user, $request, ActivityPubActivityInterface::FOLLOWING); } } ================================================ FILE: src/Controller/ActivityPub/User/UserInboxController.php ================================================ $request->getHost(), 'method' => $request->getMethod(), 'uri' => $request->getRequestUri(), 'client_ip' => $request->getClientIp(), ] ); $this->logger->debug('UserInboxController:request: '.$requestInfo['method'].' '.$requestInfo['uri']); $this->logger->debug('UserInboxController:headers: '.$request->headers); $this->logger->debug('UserInboxController:content: '.$request->getContent()); $this->bus->dispatch( new ActivityMessage( $request->getContent(), $requestInfo, $request->headers->all(), ) ); $response = new JsonResponse(); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/User/UserOutboxController.php ================================================ apId) { throw $this->createNotFoundException(); } if (!$request->get('page')) { $data = $this->collectionFactory->getUserOutboxCollection($user); } else { $data = $this->collectionFactory->getUserOutboxCollectionItems($user, (int) $request->get('page')); } $response = new JsonResponse($data); $response->headers->set('Content-Type', 'application/activity+json'); return $response; } } ================================================ FILE: src/Controller/ActivityPub/WebFingerController.php ================================================ query->get('resource') ?: '', $this->webFingerParameters->getParams($request), ); $this->eventDispatcher->dispatch($event); if (!empty($event->jsonRd->getLinks())) { $response = new JsonResponse($event->jsonRd->toArray()); } else { $response = new JsonResponse(); $response->setStatusCode(404); $response->headers->set('Status', '404 Not Found'); } $response->headers->set('Content-Type', 'application/jrd+json; charset=utf-8'); $response->headers->set('Access-Control-Allow-Origin', '*'); return $response; } } ================================================ FILE: src/Controller/Admin/AdminClearCacheController.php ================================================ setAutoExit(false); $input = new ArrayInput([ 'command' => 'cache:clear', ]); $application->run($input); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Admin/AdminDashboardController.php ================================================ render('admin/dashboard.html.twig', [ 'period' => $statsPeriod, 'withFederated' => $withFederated, ] + $this->counter->count($statsPeriod ? "-$statsPeriod days" : null, $withFederated)); } } ================================================ FILE: src/Controller/Admin/AdminDeletionController.php ================================================ render('admin/deletion_users.html.twig', [ 'users' => $this->userRepository->findForDeletionPaginated($request->get('page', 1)), ]); } #[IsGranted('ROLE_ADMIN')] public function magazines(Request $request): Response { return $this->render('admin/deletion_magazines.html.twig', [ 'magazines' => $this->magazineRepository->findForDeletionPaginated($request->get('page', 1)), ]); } } ================================================ FILE: src/Controller/Admin/AdminFederationController.php ================================================ settingsManager->getDto(); $dto = new FederationSettingsDto( $settings->KBIN_FEDERATION_ENABLED, $settings->MBIN_USE_FEDERATION_ALLOW_LIST, $settings->KBIN_FEDERATION_PAGE_ENABLED, ); $form = $this->createForm(FederationSettingsType::class, $dto); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { /** @var FederationSettingsDto $dto */ $dto = $form->getData(); $settings->KBIN_FEDERATION_ENABLED = $dto->federationEnabled; $settings->MBIN_USE_FEDERATION_ALLOW_LIST = $dto->federationUsesAllowList; $settings->KBIN_FEDERATION_PAGE_ENABLED = $dto->federationPageEnabled; $this->settingsManager->save($settings); return $this->redirectToRoute('admin_federation'); } $useAllowList = $this->settingsManager->getUseAllowList(); return $this->render( 'admin/federation.html.twig', [ 'form' => $form->createView(), 'useAllowList' => $useAllowList, 'instances' => $useAllowList ? $this->settingsManager->getAllowedInstances() : $this->settingsManager->getBannedInstances(), 'allInstances' => $this->instanceRepository->findAllOrdered(), ] ); } #[IsGranted('ROLE_ADMIN')] public function banInstance(#[MapQueryParameter] string $instanceDomain, Request $request): Response { $instance = $this->instanceRepository->getOrCreateInstance($instanceDomain); $form = $this->createForm(ConfirmDefederationType::class, new ConfirmDefederationDto()); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { /** @var ConfirmDefederationDto $dto */ $dto = $form->getData(); if ($dto->confirm) { $this->instanceManager->banInstance($instance); return $this->redirectToRoute('admin_federation'); } else { $this->addFlash('error', 'flash_error_defederation_must_confirm'); } } return $this->render('admin/federation_defederate_instance.html.twig', [ 'form' => $form->createView(), 'instance' => $instance, 'counts' => $this->instanceRepository->getInstanceCounts($instance), 'useAllowList' => $this->settingsManager->getUseAllowList(), ], new Response(status: $form->isSubmitted() && !$form->isValid() ? 422 : 200)); } #[IsGranted('ROLE_ADMIN')] public function unbanInstance(#[MapQueryParameter] string $instanceDomain): Response { $instance = $this->instanceRepository->getOrCreateInstance($instanceDomain); $this->instanceManager->unbanInstance($instance); return $this->redirectToRoute('admin_federation'); } #[IsGranted('ROLE_ADMIN')] public function allowInstance(#[MapQueryParameter] string $instanceDomain): Response { $instance = $this->instanceRepository->getOrCreateInstance($instanceDomain); $this->instanceManager->allowInstanceFederation($instance); return $this->redirectToRoute('admin_federation'); } #[IsGranted('ROLE_ADMIN')] public function denyInstance(#[MapQueryParameter] string $instanceDomain, Request $request): Response { $instance = $this->instanceRepository->getOrCreateInstance($instanceDomain); $form = $this->createForm(ConfirmDefederationType::class, new ConfirmDefederationDto()); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { /** @var ConfirmDefederationDto $dto */ $dto = $form->getData(); if ($dto->confirm) { $this->instanceManager->denyInstanceFederation($instance); return $this->redirectToRoute('admin_federation'); } else { $this->addFlash('error', 'flash_error_defederation_must_confirm'); } } return $this->render('admin/federation_defederate_instance.html.twig', [ 'form' => $form->createView(), 'instance' => $instance, 'counts' => $this->instanceRepository->getInstanceCounts($instance), 'useAllowList' => $this->settingsManager->getUseAllowList(), ], new Response(status: $form->isSubmitted() && !$form->isValid() ? 422 : 200)); } } ================================================ FILE: src/Controller/Admin/AdminMagazineOwnershipRequestController.php ================================================ render('admin/magazine_ownership.html.twig', [ 'requests' => $this->repository->findAllPaginated($request->get('page', 1)), ]); } #[IsGranted('ROLE_ADMIN')] public function accept(Magazine $magazine, User $user, Request $request): Response { $this->validateCsrf('admin_magazine_ownership_requests_accept', $request->getPayload()->get('token')); $this->manager->acceptOwnershipRequest($magazine, $user, $this->getUserOrThrow()); return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_ADMIN')] public function reject(Magazine $magazine, User $user, Request $request): Response { $this->validateCsrf('admin_magazine_ownership_requests_reject', $request->getPayload()->get('token')); $this->manager->toggleOwnershipRequest($magazine, $user); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Admin/AdminModeratorController.php ================================================ createForm(ModeratorType::class, $dto); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $dto->addedBy = $this->getUserOrThrow(); $this->manager->addModerator($dto); } $moderators = $this->repository->findModerators($this->getPageNb($request)); return $this->render( 'admin/moderators.html.twig', [ 'moderators' => $moderators, 'form' => $form->createView(), ] ); } #[IsGranted('ROLE_ADMIN')] public function removeModerator(User $user, Request $request): Response { $this->validateCsrf('remove_moderator', $request->getPayload()->get('token')); $this->manager->removeModerator($user); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Admin/AdminMonitoringController.php ================================================ formFactory->createNamed('filter', MonitoringExecutionContextFilterType::class, $dto, ['method' => 'GET']); try { $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $dto = $form->getData(); } } catch (\Exception) { } $contexts = $this->monitoringRepository->getFilteredContextsPaginated($dto); $contexts->setCurrentPage($p); $contexts->setMaxPerPage(50); if (1 === $p) { $chart = $this->getOverViewChart($dto); } return $this->render('admin/monitoring/monitoring.html.twig', [ 'page' => $p, 'executionContexts' => $contexts, 'chart' => $chart ?? null, 'form' => $form, 'configuration' => $this->monitoringRepository->getConfiguration(), ]); } private function getOverViewChart(MonitoringExecutionContextFilterDto $dto): Chart { $chart = $this->chartBuilder->createChart(Chart::TYPE_BAR); $chart->setData($this->getOverviewChartData($dto)); $chart->setOptions([ 'scales' => [ 'y' => [ 'label' => '<%=value%>ms', ], ], 'interaction' => [ 'mode' => 'index', 'axis' => 'xy', ], 'plugins' => [ 'tooltip' => [ 'enabled' => true, ], ], ]); return $chart; } public function getOverviewChartData(MonitoringExecutionContextFilterDto $dto): array { $rawData = $this->monitoringRepository->getOverviewRouteCalls($dto); $labels = []; $overallDurationRemaining = []; $queryDurations = []; $twigRenderDuration = []; $curlRequestDuration = []; $sendingDuration = []; foreach ($rawData as $data) { $labels[] = $data['path']; $total = round(\floatval($data['total_duration']), 2); $query = round(\floatval($data['query_duration']), 2); $twig = round(\floatval($data['twig_render_duration']), 2); $curl = round(\floatval($data['curl_request_duration']), 2); $sending = round(\floatval($data['response_duration']), 2); $overallDurationRemaining[] = max(0, round($total - $query - $twig - $curl - $sending, 2)); $queryDurations[] = $query; $twigRenderDuration[] = $twig; $curlRequestDuration[] = $curl; $sendingDuration[] = $sending; } return [ 'labels' => $labels, 'datasets' => [ [ 'label' => $this->translator->trans('monitoring_duration_overall'), 'data' => $overallDurationRemaining, 'stack' => '1', 'backgroundColor' => 'gray', 'borderRadius' => 5, ], [ 'label' => $this->translator->trans('monitoring_duration_query'), 'data' => $queryDurations, 'stack' => '1', 'backgroundColor' => '#a3067c', 'borderRadius' => 5, ], [ 'label' => $this->translator->trans('monitoring_duration_twig_render'), 'data' => $twigRenderDuration, 'stack' => '1', 'backgroundColor' => 'green', 'borderRadius' => 5, ], [ 'label' => $this->translator->trans('monitoring_duration_curl_request'), 'data' => $curlRequestDuration, 'stack' => '1', 'backgroundColor' => '#07abaf', 'borderRadius' => 5, ], [ 'label' => $this->translator->trans('monitoring_duration_sending_response'), 'data' => $sendingDuration, 'stack' => '1', 'backgroundColor' => 'lightgray', 'borderRadius' => 5, ], ], ]; } #[IsGranted('ROLE_ADMIN')] public function single(string $id, string $page, #[MapQueryParameter] bool $groupSimilar = true, #[MapQueryParameter] bool $formatQuery = false, #[MapQueryParameter] bool $showParameters = false, #[MapQueryParameter] bool $compareToParent = true): Response { $context = $this->monitoringRepository->findOneBy(['uuid' => $id]); if (!$context) { throw $this->createNotFoundException(); } return $this->render('admin/monitoring/monitoring_single.html.twig', [ 'context' => $context, 'page' => $page, 'groupSimilar' => $groupSimilar, 'formatQuery' => $formatQuery, 'showParameters' => $showParameters, 'compareToParent' => $compareToParent, ]); } } ================================================ FILE: src/Controller/Admin/AdminPagesController.php ================================================ repository->findAll(); if (!\count($entity)) { $entity = new Site(); } else { $entity = $entity[0]; } $form = $this->createForm(PageType::class, (new PageDto())->create($entity->{$page} ?? '')); try { $form->handleRequest($request); } catch (\Exception $e) { $entity->{$page} = ''; $this->entityManager->persist($entity); $this->entityManager->flush(); return $this->redirectToRefererOrHome($request); } if ($form->isSubmitted() && $form->isValid()) { $entity->{$page} = $form->getData()->body; $this->entityManager->persist($entity); $this->entityManager->flush(); return $this->redirectToRefererOrHome($request); } return $this->render('admin/pages.html.twig', [ 'form' => $form->createView(), ]); } } ================================================ FILE: src/Controller/Admin/AdminReportController.php ================================================ get('p', 1); $reports = $this->repository->findAllPaginated($page, $status); $this->notificationRepository->markReportNotificationsAsRead($this->getUserOrThrow()); return $this->render( 'admin/reports.html.twig', [ 'reports' => $reports, ] ); } } ================================================ FILE: src/Controller/Admin/AdminSettingsController.php ================================================ settings->getDto(); $form = $this->createForm(SettingsType::class, $dto); if ($dto->MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY) { // See: https://github.com/MbinOrg/mbin/issues/1868 $form->get('MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY')->addError(new FormError($this->translator->trans('random_local_only_performance_warning'))); } $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->settings->save($dto); return $this->redirectToRefererOrHome($request); } return $this->render('admin/settings.html.twig', [ 'form' => $form->createView(), ]); } } ================================================ FILE: src/Controller/Admin/AdminSignupRequestsController.php ================================================ repository->findAllSignupRequestsPaginated($page); } else { $requests = []; if ($signupRequest = $this->repository->findSignupRequest($username)) { $requests[] = $signupRequest; } $user = $this->repository->findOneBy(['username' => $username]); // Always mark the notifications as read, even if the user does not have any signup requests anymore $this->notificationRepository->markUserSignupNotificationsAsRead($this->getUserOrThrow(), $user); } return $this->render('admin/signup_requests.html.twig', [ 'requests' => $requests, 'page' => $page, 'username' => $username, ]); } #[IsGranted(new Expression('is_granted("ROLE_ADMIN") or is_granted("ROLE_MODERATOR")'))] public function approve(#[MapQueryParameter] int $page, #[MapEntity(id: 'id')] User $user): Response { $this->userManager->approveUserApplication($user); return $this->redirectToRoute('admin_signup_requests', ['page' => $page]); } #[IsGranted(new Expression('is_granted("ROLE_ADMIN") or is_granted("ROLE_MODERATOR")'))] public function reject(#[MapQueryParameter] int $page, #[MapEntity(id: 'id')] User $user): Response { $this->userManager->rejectUserApplication($user); return $this->redirectToRoute('admin_signup_requests', ['page' => $page]); } } ================================================ FILE: src/Controller/Admin/AdminUsersController.php ================================================ repository ->findAllActivePaginated( page: $p, onlyLocal: !($withFederated ?? false), searchTerm: $search, orderBy: new OrderBy("u.$field", $sort), ); $userIds = array_map(fn (User $user) => $user->getId(), [...$users]); $attitudes = $this->reputationRepository->getUserAttitudes(...$userIds); return $this->render( 'admin/users.html.twig', [ 'users' => $users, 'withFederated' => $withFederated, 'sortField' => $field, 'order' => $sort, 'searchTerm' => $search, 'attitudes' => $attitudes, ] ); } #[IsGranted('ROLE_ADMIN')] public function inactive( #[MapQueryParameter] int $p = 1, #[MapQueryParameter] string $sort = 'ASC', #[MapQueryParameter] string $field = 'createdAt', #[MapQueryParameter] ?string $search = null, ): Response { return $this->render( 'admin/users.html.twig', [ 'users' => $this->repository->findAllInactivePaginated( $p, searchTerm: $search, orderBy: new OrderBy("u.$field", $sort) ), 'sortField' => $field, 'order' => $sort, 'searchTerm' => $search, ] ); } #[IsGranted('ROLE_ADMIN')] public function suspended( ?bool $withFederated = null, #[MapQueryParameter] int $p = 1, #[MapQueryParameter] string $sort = 'ASC', #[MapQueryParameter] string $field = 'createdAt', #[MapQueryParameter] ?string $search = null, ): Response { return $this->render( 'admin/users.html.twig', [ 'users' => $this->repository->findAllSuspendedPaginated( $p, onlyLocal: !($withFederated ?? false), searchTerm: $search, orderBy: new OrderBy("u.$field", $sort) ), 'withFederated' => $withFederated, 'sortField' => $field, 'order' => $sort, 'searchTerm' => $search, ] ); } #[IsGranted('ROLE_ADMIN')] public function banned( ?bool $withFederated = null, #[MapQueryParameter] int $p = 1, #[MapQueryParameter] string $sort = 'ASC', #[MapQueryParameter] string $field = 'createdAt', #[MapQueryParameter] ?string $search = null, ): Response { return $this->render( 'admin/users.html.twig', [ 'users' => $this->repository->findAllBannedPaginated( $p, onlyLocal: !($withFederated ?? false), searchTerm: $search, orderBy: new OrderBy("u.$field", $sort), ), 'withFederated' => $withFederated, 'sortField' => $field, 'order' => $sort, 'searchTerm' => $search, ] ); } } ================================================ FILE: src/Controller/AgentController.php ================================================ render('page/agent.html.twig'); } } ================================================ FILE: src/Controller/AjaxController.php ================================================ getContent())->url; $embed = $embed->fetch($url); return new JsonResponse( [ 'title' => $embed->title, 'description' => $embed->description, 'image' => $embed->image, ] ); } public function fetchDuplicates(EntryRepository $repository, Request $request): JsonResponse { $url = json_decode($request->getContent())->url; $entries = $repository->findBy(['url' => $url]); return new JsonResponse( [ 'total' => \count($entries), 'html' => $this->renderView('entry/_list.html.twig', ['entries' => $entries]), ] ); } /** * Returns an embeded objects html value, to be used for front-end insertion. */ public function fetchEmbed(Embed $embed, Request $request): JsonResponse { $data = $embed->fetch($request->get('url')); // only wrap embed link for image embed as it doesn't make much sense for any other type for embed if ($data->isImageUrl()) { $html = \sprintf( '%s', $data->url, $data->html ); } else { $html = $data->html; } return new JsonResponse( [ 'html' => \sprintf('
    %s
    ', $html), ] ); } public function fetchEntry(#[MapEntity(id: 'id')] Entry $entry, Request $request): JsonResponse { return new JsonResponse( [ 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'entry', 'attributes' => [ 'entry' => $entry, ], ] ), ] ); } public function fetchEntryComment(#[MapEntity(id: 'id')] EntryComment $comment): JsonResponse { return new JsonResponse( [ 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'entry_comment', 'attributes' => [ 'comment' => $comment, 'showEntryTitle' => false, 'showMagazineName' => false, ], ] ), ] ); } public function fetchPost(#[MapEntity(id: 'id')] Post $post): JsonResponse { return new JsonResponse( [ 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'post', 'attributes' => [ 'post' => $post, ], ] ), ] ); } public function fetchPostComment(#[MapEntity(id: 'id')] PostComment $comment): JsonResponse { return new JsonResponse( [ 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'post_comment', 'attributes' => [ 'comment' => $comment, ], ] ), ] ); } public function fetchPostComments(#[MapEntity(id: 'id')] Post $post, PostCommentRepository $repository): JsonResponse { $criteria = new PostCommentPageView(1, $this->security); $criteria->post = $post; $criteria->sortOption = Criteria::SORT_OLD; $criteria->perPage = 500; $comments = $repository->findByCriteria($criteria); return new JsonResponse( [ 'html' => $this->renderView( 'post/comment/_preview.html.twig', ['comments' => $comments, 'post' => $post, 'criteria' => $criteria] ), ] ); } public function fetchOnline( string $topic, string $mercurePublicUrl, string $mercureSubscriptionsToken, HttpClientInterface $httpClient, CacheInterface $cache, ): JsonResponse { $resp = $httpClient->request('GET', $mercurePublicUrl.'/subscriptions/'.$topic, [ 'auth_bearer' => $mercureSubscriptionsToken, ]); // @todo cloudflare bug $online = $cache->get($topic, function (ItemInterface $item) use ($resp) { $item->expiresAfter(45); return \count($resp->toArray()['subscriptions']) + 1; }); return new JsonResponse([ 'online' => $online, ]); } public function fetchUserPopup(#[MapEntity(mapping: ['username' => 'username'])] User $user, UserNoteManager $manager): JsonResponse { if ($this->getUser()) { $dto = $manager->createDto($this->getUserOrThrow(), $user); } else { $dto = new UserNoteDto(); $dto->target = $user; } $form = $this->createForm(UserNoteType::class, $dto, [ 'action' => $this->generateUrl('user_note', ['username' => $dto->target->username]), ]); return new JsonResponse([ 'html' => $this->renderView('user/_user_popover.html.twig', ['user' => $user, 'form' => $form->createView()] ), ]); } public function fetchUsersSuggestions(string $username, Request $request, UserRepository $repository): JsonResponse { return new JsonResponse( [ 'html' => $this->renderView( 'search/_user_suggestion.html.twig', [ 'users' => $repository->findUsersSuggestions(ltrim($username, '@')), ] ), ] ); } public function fetchEmojiSuggestions(#[MapQueryParameter] string $query): JsonResponse { $trans = EmojiTransliterator::create('text-emoji'); $class = new \ReflectionClass($trans); $emojis = $class->getProperty('map')->getValue($trans); $codes = array_keys($emojis); $matches = array_filter($codes, fn ($emoji) => str_contains($emoji, $query)); $results = array_map(function ($code) use ($emojis) { $std = new \stdClass(); $std->shortCode = $code; $std->emoji = $emojis[$code]; return $std; }, $matches); return new JsonResponse( [ 'html' => $this->renderView( 'search/_emoji_suggestion.html.twig', [ 'emojis' => \array_slice($results, 0, 5), ] ), ] ); } #[IsGranted('ROLE_USER')] public function fetchNotificationsCount(): JsonResponse { $user = $this->getUserOrThrow(); return new JsonResponse(new NotificationsCountResponsePayload($user->countNewNotifications(), $user->countNewMessages())); } public function registerPushNotifications(#[MapRequestPayload] RegisterPushRequestPayload $payload): JsonResponse { $user = $this->getUserOrThrow(); $pushSubscription = $this->repository->findOneBy(['apiToken' => null, 'deviceKey' => $payload->deviceKey, 'user' => $user]); if (!$pushSubscription) { $pushSubscription = new UserPushSubscription($user, $payload->endpoint, $payload->contentPublicKey, $payload->serverKey, []); $pushSubscription->deviceKey = $payload->deviceKey; $pushSubscription->locale = $this->settingsManager->getLocale(); } else { $pushSubscription->endpoint = $payload->endpoint; $pushSubscription->serverAuthKey = $payload->serverKey; $pushSubscription->contentEncryptionPublicKey = $payload->contentPublicKey; } $this->entityManager->persist($pushSubscription); $this->entityManager->flush(); try { $testNotification = new PushNotification(null, '', $this->translator->trans('test_push_message', locale: $pushSubscription->locale)); $this->pushSubscriptionManager->sendTextToUser($user, $testNotification, specificDeviceKey: $payload->deviceKey); return new JsonResponse(); } catch (\ErrorException $e) { $this->logger->error('[AjaxController::handle] There was an exception while deleting a UserPushSubscription: {e} - {m}. {o}', [ 'e' => \get_class($e), 'm' => $e->getMessage(), 'o' => json_encode($e), ]); return new JsonResponse(status: 500); } } public function unregisterPushNotifications(#[MapRequestPayload] UnRegisterPushRequestPayload $payload): JsonResponse { try { $conn = $this->entityManager->getConnection(); $stmt = $conn->prepare('DELETE FROM user_push_subscription WHERE user_id = :user AND device_key = :device'); $stmt->bindValue('user', $this->getUserOrThrow()->getId(), ParameterType::INTEGER); $stmt->bindValue('device', $payload->deviceKey, ParameterType::STRING); $stmt->executeQuery(); return new JsonResponse(); } catch (\Exception $e) { $this->logger->error('[AjaxController::unregisterPushNotifications] There was an exception while deleting a UserPushSubscription: {e} - {m}. {o}', [ 'e' => \get_class($e), 'm' => $e->getMessage(), 'o' => json_encode($e), ]); return new JsonResponse(status: 500); } } public function testPushNotification(#[MapRequestPayload] TestPushRequestPayload $payload): JsonResponse { $user = $this->getUserOrThrow(); try { $this->pushSubscriptionManager->sendTextToUser($user, new PushNotification(null, '', $this->translator->trans('test_push_message')), specificDeviceKey: $payload->deviceKey); return new JsonResponse(); } catch (\ErrorException $e) { $this->logger->error('[AjaxController::testPushNotification] There was an exception while deleting a UserPushSubscription: {e} - {m}. {o}', [ 'e' => \get_class($e), 'm' => $e->getMessage(), 'o' => json_encode($e), ]); return new JsonResponse(status: 500); } } } ================================================ FILE: src/Controller/Api/BaseApi.php ================================================ An array of headers describing the current rate limit status to the client * * @throws AccessDeniedHttpException if the user is not authenticated and no anonymous rate limiter factory is provided, access to the resource will be denied * @throws TooManyRequestsHttpException If the limit is hit, rate limit the connection */ protected function rateLimit( ?RateLimiterFactoryInterface $limiterFactory = null, ?RateLimiterFactoryInterface $anonLimiterFactory = null, ): array { $this->logAccess(); if (null === $limiterFactory && null === $anonLimiterFactory) { throw new \LogicException('No rate limiter factory provided!'); } $limiter = null; if ( $limiterFactory && $this->isGranted('ROLE_USER') ) { $limiter = $limiterFactory->create($this->getUserOrThrow()->getUserIdentifier()); } elseif ($anonLimiterFactory) { $limiter = $anonLimiterFactory->create($this->ipResolver->resolve()); } else { // non-API_USER without an anonymous rate limiter? Not allowed. throw new AccessDeniedHttpException(); } $limit = $limiter->consume(); $headers = [ 'X-RateLimit-Remaining' => $limit->getRemainingTokens(), 'X-RateLimit-Retry-After' => $limit->getRetryAfter()->getTimestamp(), 'X-RateLimit-Limit' => $limit->getLimit(), ]; if (false === $limit->isAccepted()) { throw new TooManyRequestsHttpException(headers: $headers); } return $headers; } /** * Logs timestamp, client, and route name of authenticated API access for admin * to track how API clients are being (ab)used and for stat creation. * * This might be better to have as a cache entry, with an aggregate in the database * created periodically */ private function logAccess(): void { /** @var ?OAuth2Token $token */ $token = $this->container->get('security.token_storage')->getToken(); if (null !== $token && $token instanceof OAuth2Token) { $clientId = $token->getOAuthClientId(); /** @var Client $client */ $client = $this->entityManager->getReference(Client::class, $clientId); $access = new OAuth2ClientAccess(); $access->setClient($client); $access->setCreatedAt(new \DateTimeImmutable()); $access->setPath($this->request->getCurrentRequest()->get('_route')); $this->clientAccessRepository->save($access, flush: true); } } public function getOAuthToken(): ?OAuth2Token { try { /** @var ?OAuth2Token $token */ $token = $this->container->get('security.token_storage')->getToken(); if ($token instanceof OAuth2Token) { return $token; } } catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) { $this->logger->warning('there was an error getting the access token: {e} - {m}, {stack}', [ 'e' => \get_class($e), 'm' => $e->getMessage(), 'stack' => $e->getTraceAsString(), ]); } return null; } public function getAccessToken(?OAuth2Token $oAuth2Token): ?AccessToken { if (!$oAuth2Token) { return null; } return $this->entityManager ->getRepository(AccessToken::class) ->findOneBy(['identifier' => $oAuth2Token->getAttribute('access_token_id')]); } public function serializePaginated(array $serializedItems, PagerfantaInterface $pagerfanta): array { return [ 'items' => $serializedItems, 'pagination' => new PaginationSchema($pagerfanta), ]; } public function serializeCursorPaginated(array $serializedItems, CursorPaginationInterface $pagerfanta): array { return [ 'items' => $serializedItems, 'pagination' => new CursorPaginationSchema($pagerfanta), ]; } public function serializeContentInterface(ContentInterface $content, bool $forceVisible = false): mixed { $toReturn = null; if ($content instanceof Entry) { $cross = $this->entryRepository->findCross($content); $crossDtos = array_map(fn ($entry) => $this->entryFactory->createResponseDto($entry, []), $cross); $dto = $this->entryFactory->createResponseDto($content, $this->tagLinkRepository->getTagsOfContent($content), $crossDtos); $dto->visibility = $forceVisible ? VisibilityInterface::VISIBILITY_VISIBLE : $dto->visibility; $toReturn = $dto->jsonSerialize(); $toReturn['itemType'] = 'entry'; } elseif ($content instanceof EntryComment) { $dto = $this->entryCommentFactory->createResponseDto($content, $this->tagLinkRepository->getTagsOfContent($content)); $dto->visibility = $forceVisible ? VisibilityInterface::VISIBILITY_VISIBLE : $dto->visibility; $toReturn = $dto->jsonSerialize(); $toReturn['itemType'] = 'entry_comment'; } elseif ($content instanceof Post) { $dto = $this->postFactory->createResponseDto($content, $this->tagLinkRepository->getTagsOfContent($content)); $dto->visibility = $forceVisible ? VisibilityInterface::VISIBILITY_VISIBLE : $dto->visibility; $toReturn = $dto->jsonSerialize(); $toReturn['itemType'] = 'post'; } elseif ($content instanceof PostComment) { $dto = $this->postCommentFactory->createResponseDto($content, $this->tagLinkRepository->getTagsOfContent($content)); $dto->visibility = $forceVisible ? VisibilityInterface::VISIBILITY_VISIBLE : $dto->visibility; $toReturn = $dto->jsonSerialize(); $toReturn['itemType'] = 'post_comment'; } else { throw new \LogicException('Invalid contentInterface classname "'.$this->entityManager->getClassMetadata(\get_class($content))->rootEntityName.'"'); } if ($forceVisible) { $toReturn['visibility'] = $content->visibility; } return $toReturn; } /** * Serialize a single log item to JSON. */ protected function serializeLogItem(MagazineLog $log): array { /** @var ?ContentVisibilityInterface $subject */ $subject = $log->getSubject(); $response = $this->magazineFactory->createLogDto($log); $response->setSubject( $subject, $this->entryFactory, $this->entryCommentFactory, $this->postFactory, $this->postCommentFactory, $this->tagLinkRepository, ); return $response->jsonSerialize(); } /** * Serialize a single magazine to JSON. * * @param MagazineDto $dto The MagazineDto to serialize * * @return MagazineResponseDto An associative array representation of the entry's safe fields, to be used as JSON */ protected function serializeMagazine(MagazineDto $dto): MagazineResponseDto { $response = $this->magazineFactory->createResponseDto($dto); if ($user = $this->getUser()) { $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto)?->getStatus() ?? ENotificationStatus::Default; } return $response; } /** * Serialize a single user to JSON. * * @param UserDto $dto The UserDto to serialize * * @return UserResponseDto A JsonSerializable representation of the user */ protected function serializeUser(UserDto $dto): UserResponseDto { $response = new UserResponseDto($dto); if ($user = $this->getUser()) { $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto)?->getStatus() ?? ENotificationStatus::Default; } return $response; } protected function serializeFilterList(UserFilterList $list): UserFilterListResponseDto { return UserFilterListResponseDto::fromList($list); } public static function constrainPerPage(mixed $value, int $min = self::MIN_PER_PAGE, int $max = self::MAX_PER_PAGE): int { return min(max(\intval($value), $min), $max); } /** * Alias for constrainPerPage with different defaults. */ public static function constrainDepth(mixed $value, int $min = self::MIN_DEPTH, int $max = self::MAX_DEPTH): int { return self::constrainPerPage($value, $min, $max); } public function handleLanguageCriteria(Criteria $criteria): void { $usePreferred = filter_var($this->request->getCurrentRequest()->get('usePreferredLangs', false), FILTER_VALIDATE_BOOL); if ($usePreferred && null === $this->getUser()) { // Debating between AccessDenied and BadRequest exceptions for this throw new AccessDeniedHttpException('You must be logged in to use your preferred languages'); } $languages = $usePreferred ? $this->getUserOrThrow()->preferredLanguages : $this->request->getCurrentRequest()->get('lang'); if (null !== $languages) { if (\is_string($languages)) { $languages = explode(',', $languages); } $criteria->languages = $languages; } } /** * @throws BadRequestHttpException|\Exception */ public function handleUploadedImage(): Image { $img = $this->handleUploadedImageOptional(); if (null === $img) { throw new BadRequestHttpException('Uploaded file not found!'); } return $img; } /** * @throws BadRequestHttpException|\Exception */ public function handleUploadedImageOptional(): ?Image { try { /** * @var UploadedFile $uploaded */ $uploaded = $this->request->getCurrentRequest()->files->get('uploadImage'); if (null === $uploaded) { return null; } if (null === self::$constraint) { self::$constraint = ImageConstraint::default(); } if (self::$constraint->maxSize < $uploaded->getSize()) { throw new BadRequestHttpException('File cannot exceed '.(string) self::$constraint->maxSize.' bytes'); } if (false === array_search($uploaded->getMimeType(), self::$constraint->mimeTypes)) { throw new BadRequestHttpException('Mimetype of "'.$uploaded->getMimeType().'" not allowed!'); } $image = $this->imageRepository->findOrCreateFromUpload($uploaded); if (null === $image) { throw new BadRequestHttpException('Failed to create file'); } $image->altText = $this->request->getCurrentRequest()->get('alt', null); } catch (\Exception $e) { if (null !== $uploaded && file_exists($uploaded->getPathname())) { unlink($uploaded->getPathname()); } throw $e; } return $image; } protected function reportContent(ReportInterface $reportable): void { /** @var ReportRequestDto $dto */ $dto = $this->serializer->deserialize($this->request->getCurrentRequest()->getContent(), ReportRequestDto::class, 'json'); $errors = $this->validator->validate($dto); if (0 < \count($errors)) { throw new BadRequestHttpException((string) $errors); } $reportDto = ReportDto::create($reportable, $dto->reason); try { $this->reportManager->report($reportDto, $this->getUserOrThrow()); } catch (SubjectHasBeenReportedException $e) { // Do nothing } } /** * Serialize a single entry to JSON. * * @param Entry[]|null $crosspostedEntries */ protected function serializeEntry(EntryDto|Entry $dto, array $tags, ?array $crosspostedEntries = null): EntryResponseDto { $crosspostedEntryDtos = null; if (null !== $crosspostedEntries) { $crosspostedEntryDtos = array_map(fn (Entry $item) => $this->entryFactory->createResponseDto($item, []), $crosspostedEntries); } $response = $this->entryFactory->createResponseDto($dto, $tags, $crosspostedEntryDtos); if ($this->isGranted('ROLE_OAUTH2_ENTRY:VOTE')) { $response->isFavourited = $dto instanceof EntryDto ? $dto->isFavourited : $dto->isFavored($this->getUserOrThrow()); $response->userVote = $dto instanceof EntryDto ? $dto->userVote : $dto->getUserChoice($this->getUserOrThrow()); } if ($user = $this->getUser()) { $response->canAuthUserModerate = $dto->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin(); $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto)?->getStatus() ?? ENotificationStatus::Default; } return $response; } /** * Serialize a single entry comment to JSON. */ protected function serializeEntryComment(EntryCommentDto $comment, array $tags): EntryCommentResponseDto { $response = $this->entryCommentFactory->createResponseDto($comment, $tags); if ($this->isGranted('ROLE_OAUTH2_ENTRY_COMMENT:VOTE')) { $response->isFavourited = $comment->isFavourited; $response->userVote = $comment->userVote; } if ($user = $this->getUser()) { $response->canAuthUserModerate = $comment->magazine->userIsModerator($user) || $user->isModerator() || $user->isAdmin(); } return $response; } /** * Serialize a single post to JSON. */ protected function serializePost(Post|PostDto $dto, array $tags): PostResponseDto { if (null === $dto) { return []; } $response = $this->postFactory->createResponseDto($dto, $tags); if ($this->isGranted('ROLE_OAUTH2_POST:VOTE')) { $response->isFavourited = $dto instanceof PostDto ? $dto->isFavourited : $dto->isFavored($this->getUserOrThrow()); $response->userVote = $dto instanceof PostDto ? $dto->userVote : $dto->getUserChoice($this->getUserOrThrow()); } if ($user = $this->getUser()) { $response->canAuthUserModerate = $dto->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin(); $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto)?->getStatus() ?? ENotificationStatus::Default; } return $response; } /** * Serialize a single comment to JSON. */ protected function serializePostComment(PostCommentDto $comment, array $tags): PostCommentResponseDto { $response = $this->postCommentFactory->createResponseDto($comment, $tags); if ($this->isGranted('ROLE_OAUTH2_POST_COMMENT:VOTE')) { $response->isFavourited = $comment instanceof PostCommentDto ? $comment->isFavourited : $comment->isFavored($this->getUserOrThrow()); $response->userVote = $comment instanceof PostCommentDto ? $comment->userVote : $comment->getUserChoice($this->getUserOrThrow()); } if ($user = $this->getUser()) { $response->canAuthUserModerate = $comment->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin(); } return $response; } } ================================================ FILE: src/Controller/Api/Bookmark/BookmarkApiController.php ================================================ getUserOrThrow(); $headers = $this->rateLimit($apiUpdateLimiter); $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); if (null === $subject) { throw new NotFoundHttpException(code: 404, headers: $headers); } $this->bookmarkManager->addBookmarkToDefaultList($user, $subject); $dto = new BookmarksDto(); $dto->bookmarks = $this->bookmarkListRepository->getBookmarksOfContentInterface($subject); return new JsonResponse($dto, status: 200, headers: $headers); } #[OA\Response( response: 200, description: 'Add a bookmark for the subject in the specified list', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: BookmarksDto::class) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'The specified subject or list does not exist', content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Parameter( name: 'subject_id', description: 'The id of the subject to be added to the specified list', in: 'path', schema: new OA\Schema(type: 'integer') )] #[OA\Parameter( name: 'subject_type', description: 'the type of the subject', in: 'path', schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment']) )] #[OA\Tag(name: 'bookmark')] #[Security(name: 'oauth2', scopes: ['bookmark:add'])] #[IsGranted('ROLE_OAUTH2_BOOKMARK:ADD')] public function subjectBookmarkToList(string $list_name, int $subject_id, string $subject_type, RateLimiterFactoryInterface $apiUpdateLimiter): JsonResponse { $user = $this->getUserOrThrow(); $headers = $this->rateLimit($apiUpdateLimiter); $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); if (null === $subject) { throw new NotFoundHttpException(code: 404, headers: $headers); } $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); if (null === $list) { throw new NotFoundHttpException(code: 404, headers: $headers); } $this->bookmarkManager->addBookmark($user, $list, $subject); $dto = new BookmarksDto(); $dto->bookmarks = $this->bookmarkListRepository->getBookmarksOfContentInterface($subject); return new JsonResponse($dto, status: 200, headers: $headers); } #[OA\Response( response: 200, description: 'Remove bookmark for the subject from the specified list', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: BookmarksDto::class) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'The specified subject or list does not exist', content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Parameter( name: 'subject_id', description: 'The id of the subject to be removed', in: 'path', schema: new OA\Schema(type: 'integer') )] #[OA\Parameter( name: 'subject_type', description: 'the type of the subject', in: 'path', schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment']) )] #[OA\Tag(name: 'bookmark')] #[Security(name: 'oauth2', scopes: ['bookmark:remove'])] #[IsGranted('ROLE_OAUTH2_BOOKMARK:REMOVE')] public function subjectRemoveBookmarkFromList(string $list_name, int $subject_id, string $subject_type, RateLimiterFactoryInterface $apiUpdateLimiter): JsonResponse { $user = $this->getUserOrThrow(); $headers = $this->rateLimit($apiUpdateLimiter); $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); if (null === $subject) { throw new NotFoundHttpException(code: 404, headers: $headers); } $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); if (null === $list) { throw new NotFoundHttpException(code: 404, headers: $headers); } $this->bookmarkRepository->removeBookmarkFromList($user, $list, $subject); $dto = new BookmarksDto(); $dto->bookmarks = $this->bookmarkListRepository->getBookmarksOfContentInterface($subject); return new JsonResponse($dto, status: 200, headers: $headers); } #[OA\Response( response: 200, description: 'Remove all bookmarks for the subject', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: BookmarksDto::class) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'The specified subject does not exist', content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Parameter( name: 'subject_id', description: 'The id of the subject to be removed', in: 'path', schema: new OA\Schema(type: 'integer') )] #[OA\Parameter( name: 'subject_type', description: 'the type of the subject', in: 'path', schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment']) )] #[OA\Tag(name: 'bookmark')] #[Security(name: 'oauth2', scopes: ['bookmark:remove'])] #[IsGranted('ROLE_OAUTH2_BOOKMARK:REMOVE')] public function subjectRemoveBookmarks(int $subject_id, string $subject_type, RateLimiterFactoryInterface $apiUpdateLimiter): JsonResponse { $user = $this->getUserOrThrow(); $headers = $this->rateLimit($apiUpdateLimiter); $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); if (null === $subject) { throw new NotFoundHttpException(code: 404, headers: $headers); } $this->bookmarkRepository->removeAllBookmarksForContent($user, $subject); $dto = new BookmarksDto(); $dto->bookmarks = $this->bookmarkListRepository->getBookmarksOfContentInterface($subject); return new JsonResponse($dto, status: 200, headers: $headers); } } ================================================ FILE: src/Controller/Api/Bookmark/BookmarkListApiController.php ================================================ getUserOrThrow(); $headers = $this->rateLimit($apiReadLimiter); $criteria = new EntryPageView($p ?? 1, $security); $criteria->setTime($criteria->resolveTime($time ?? Criteria::TIME_ALL)); $criteria->setType($criteria->resolveType($type ?? 'all')); $criteria->showSortOption($criteria->resolveSort($sort ?? Criteria::SORT_NEW)); $criteria->setFederation($federation ?? Criteria::AP_ALL); if (null !== $list) { $bookmarkList = $this->bookmarkListRepository->findOneBy(['name' => $list, 'user' => $user]); if (null === $bookmarkList) { return new JsonResponse(status: 404, headers: $headers); } } else { $bookmarkList = $this->bookmarkListRepository->findOneByUserDefault($user); } $pagerfanta = $this->bookmarkRepository->findPopulatedByList($bookmarkList, $criteria, $perPage); $objects = $pagerfanta->getCurrentPageResults(); $items = array_map(fn (ContentInterface $item) => $this->serializeContentInterface($item), $objects); $result = $this->serializePaginated($items, $pagerfanta); return new JsonResponse($result, status: 200, headers: $headers); } #[OA\Response( response: 200, description: 'Returns all bookmark lists from the user', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent( properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: BookmarkListDto::class)) ), ], type: 'object' ) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Tag(name: 'bookmark_list')] #[Security(name: 'oauth2', scopes: ['bookmark_list:read'])] #[IsGranted('ROLE_OAUTH2_BOOKMARK_LIST:READ')] public function list(RateLimiterFactoryInterface $apiReadLimiter): JsonResponse { $user = $this->getUserOrThrow(); $headers = $this->rateLimit($apiReadLimiter); $items = array_map(fn (BookmarkList $list) => BookmarkListDto::fromList($list), $this->bookmarkListRepository->findByUser($user)); $response = [ 'items' => $items, ]; return new JsonResponse($response, status: 200, headers: $headers); } #[OA\Response( response: 200, description: 'Sets the provided list as the default', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: BookmarkListDto::class), )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'The requested list does not exist', content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Parameter( name: 'list_name', description: 'The name of the list to be made the default', in: 'path', schema: new OA\Schema(type: 'string') )] #[OA\Tag(name: 'bookmark_list')] #[Security(name: 'oauth2', scopes: ['bookmark_list:edit'])] #[IsGranted('ROLE_OAUTH2_BOOKMARK_LIST:EDIT')] public function makeDefault(string $list_name, RateLimiterFactoryInterface $apiUpdateLimiter): JsonResponse { $user = $this->getUserOrThrow(); $headers = $this->rateLimit($apiUpdateLimiter); $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); if (null === $list) { throw new NotFoundHttpException(headers: $headers); } $this->bookmarkListRepository->makeListDefault($user, $list); return new JsonResponse(BookmarkListDto::fromList($list), status: 200, headers: $headers); } #[OA\Response( response: 200, description: 'Edits the supplied list', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: BookmarkListDto::class), )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'The requested list does not exist', content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Parameter( name: 'list_name', description: 'The name of the list to be edited', in: 'path', schema: new OA\Schema(type: 'string') )] #[OA\RequestBody(content: new Model(type: BookmarkListDto::class))] #[OA\Tag(name: 'bookmark_list')] #[Security(name: 'oauth2', scopes: ['bookmark_list:edit'])] #[IsGranted('ROLE_OAUTH2_BOOKMARK_LIST:EDIT')] public function editList(string $list_name, #[MapRequestPayload] BookmarkListDto $dto, RateLimiterFactoryInterface $apiUpdateLimiter): JsonResponse { $user = $this->getUserOrThrow(); $headers = $this->rateLimit($apiUpdateLimiter); $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); if (null === $list) { throw new NotFoundHttpException(headers: $headers); } $this->bookmarkListRepository->editList($user, $list, $dto); return new JsonResponse(BookmarkListDto::fromList($list), status: 200, headers: $headers); } #[OA\Response( response: 200, description: 'Creates a list with the supplied name', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: BookmarkListDto::class), )] #[OA\Response( response: 400, description: 'The requested list already exists', content: new OA\JsonContent(ref: new Model(type: BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Parameter( name: 'list_name', description: 'The name of the list to be created', in: 'path', schema: new OA\Schema(type: 'string') )] #[OA\Tag(name: 'bookmark_list')] #[Security(name: 'oauth2', scopes: ['bookmark_list:edit'])] #[IsGranted('ROLE_OAUTH2_BOOKMARK_LIST:EDIT')] public function createList(string $list_name, RateLimiterFactoryInterface $apiUpdateLimiter): JsonResponse { $user = $this->getUserOrThrow(); $headers = $this->rateLimit($apiUpdateLimiter); $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); if (null !== $list) { throw new BadRequestException(); } $list = $this->bookmarkManager->createList($user, $list_name); return new JsonResponse(BookmarkListDto::fromList($list), status: 200, headers: $headers); } #[OA\Response( response: 200, description: 'Deletes the provided list', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: null )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'The requested list does not exist', content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Parameter( name: 'list_name', description: 'The name of the list to be deleted', in: 'path', schema: new OA\Schema(type: 'string') )] #[OA\Tag(name: 'bookmark_list')] #[Security(name: 'oauth2', scopes: ['bookmark_list:delete'])] #[IsGranted('ROLE_OAUTH2_BOOKMARK_LIST:DELETE')] public function deleteList(string $list_name, RateLimiterFactoryInterface $apiDeleteLimiter): JsonResponse { $user = $this->getUserOrThrow(); $headers = $this->rateLimit($apiDeleteLimiter); $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); if (null === $list) { throw new NotFoundHttpException(headers: $headers); } $this->bookmarkListRepository->deleteList($list); return new JsonResponse(status: 200, headers: $headers); } } ================================================ FILE: src/Controller/Api/Combined/CombinedRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = $this->getCriteria($p, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, null); $content = $contentRepository->findByCriteria($criteria); return $this->serializeContent($content, $headers); } #[OA\Response( response: 200, description: 'A paginated list of combined entries and posts from subscribed magazines and users filtered by the query parameters', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent( properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: ContentResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ], type: 'object' ) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Parameter( name: 'collectionType', description: 'the type of collection to get', in: 'path', schema: new OA\Schema(type: 'string', enum: ['subscribed', 'moderated', 'favourited']) )] #[OA\Parameter( name: 'p', description: 'Page of content to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of content items to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: ContentRepository::PER_PAGE, maximum: self::MAX_PER_PAGE, minimum: self::MIN_PER_PAGE) )] #[OA\Parameter( name: 'sort', description: 'Sort method to use when retrieving content', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS) )] #[OA\Parameter( name: 'time', description: 'Max age of retrieved content', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN) )] #[OA\Parameter( name: 'lang[]', description: 'Language(s) of content to return', in: 'query', schema: new OA\Schema( type: 'array', items: new OA\Items(type: 'string', default: null, maxLength: 3, minLength: 2) ), explode: true, allowReserved: true )] #[OA\Parameter( name: 'usePreferredLangs', description: 'Filter by a user\'s preferred languages? (Requires authentication and takes precedence over lang[])', in: 'query', schema: new OA\Schema(type: 'boolean', default: false), )] #[OA\Parameter( name: 'federation', description: 'What type of federated entries to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Parameter( name: 'includeBoosts', description: 'if true then boosted content from followed users are included', in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] #[OA\Tag(name: 'combined')] #[\Nelmio\ApiDocBundle\Attribute\Security(name: 'oauth2', scopes: ['read'])] #[IsGranted('ROLE_OAUTH2_READ')] public function userCollection( RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, Security $security, ContentRepository $contentRepository, SqlHelpers $sqlHelpers, #[MapQueryParameter] ?int $p, #[MapQueryParameter] ?int $perPage, #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, string $collectionType, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = $this->getCriteria($p, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, $collectionType); $content = $contentRepository->findByCriteria($criteria); return $this->serializeContent($content, $headers); } #[OA\Response( response: 200, description: 'A cursor paginated list of combined entries and posts filtered by the query parameters', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent( properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: ContentResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: CursorPaginationSchema::class) ), ], type: 'object' ) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Parameter( name: 'cursor', description: 'The cursor', in: 'query', schema: new OA\Schema(type: 'string', default: null) )] #[OA\Parameter( name: 'cursor2', description: 'The secondary cursor, always a datetime', in: 'query', schema: new OA\Schema(type: 'string', default: null) )] #[OA\Parameter( name: 'perPage', description: 'Number of content items to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: ContentRepository::PER_PAGE, maximum: self::MAX_PER_PAGE, minimum: self::MIN_PER_PAGE) )] #[OA\Parameter( name: 'sort', description: 'Sort method to use when retrieving content', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS) )] #[OA\Parameter( name: 'time', description: 'Max age of retrieved content', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN) )] #[OA\Parameter( name: 'lang[]', description: 'Language(s) of content to return', in: 'query', schema: new OA\Schema( type: 'array', items: new OA\Items(type: 'string', default: null, maxLength: 3, minLength: 2) ), explode: true, allowReserved: true )] #[OA\Parameter( name: 'usePreferredLangs', description: 'Filter by a user\'s preferred languages? (Requires authentication and takes precedence over lang[])', in: 'query', schema: new OA\Schema(type: 'boolean', default: false), )] #[OA\Parameter( name: 'federation', description: 'What type of federated entries to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Parameter( name: 'includeBoosts', description: 'if true then boosted content from followed users are included', in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] #[OA\Tag(name: 'combined')] public function cursorCollection( RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, Security $security, ContentRepository $contentRepository, #[MapQueryParameter] ?string $cursor, #[MapQueryParameter] ?string $cursor2, #[MapQueryParameter] ?int $perPage, #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, SqlHelpers $sqlHelpers, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = $this->getCriteria(1, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, null); $currentCursor = $this->getCursor($contentRepository, $criteria->sortOption, $cursor); $currentCursor2 = $cursor2 ? $this->getCursor($contentRepository, Criteria::SORT_NEW, $cursor2) : null; $content = $contentRepository->findByCriteriaCursored($criteria, $currentCursor, $currentCursor2); return $this->serializeContentCursored($content, $headers); } #[OA\Response( response: 200, description: 'A cursor paginated list of combined entries and posts from subscribed magazines and users filtered by the query parameters', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent( properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: ContentResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: CursorPaginationSchema::class) ), ], type: 'object' ) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Parameter( name: 'cursor', description: 'The cursor', in: 'query', schema: new OA\Schema(type: 'string', default: null) )] #[OA\Parameter( name: 'cursor2', description: 'The secondary cursor, always a datetime', in: 'query', schema: new OA\Schema(type: 'string', default: null) )] #[OA\Parameter( name: 'perPage', description: 'Number of content items to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: ContentRepository::PER_PAGE, maximum: self::MAX_PER_PAGE, minimum: self::MIN_PER_PAGE) )] #[OA\Parameter( name: 'sort', description: 'Sort method to use when retrieving content', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS) )] #[OA\Parameter( name: 'time', description: 'Max age of retrieved content', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN) )] #[OA\Parameter( name: 'lang[]', description: 'Language(s) of content to return', in: 'query', schema: new OA\Schema( type: 'array', items: new OA\Items(type: 'string', default: null, maxLength: 3, minLength: 2) ), explode: true, allowReserved: true )] #[OA\Parameter( name: 'usePreferredLangs', description: 'Filter by a user\'s preferred languages? (Requires authentication and takes precedence over lang[])', in: 'query', schema: new OA\Schema(type: 'boolean', default: false), )] #[OA\Parameter( name: 'federation', description: 'What type of federated entries to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Parameter( name: 'includeBoosts', description: 'if true then boosted content from followed users are included', in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] #[OA\Tag(name: 'combined')] #[\Nelmio\ApiDocBundle\Attribute\Security(name: 'oauth2', scopes: ['read'])] #[IsGranted('ROLE_OAUTH2_READ')] public function cursorUserCollection( RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, Security $security, ContentRepository $contentRepository, #[MapQueryParameter] ?string $cursor, #[MapQueryParameter] ?string $cursor2, #[MapQueryParameter] ?int $perPage, #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, string $collectionType, SqlHelpers $sqlHelpers, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = $this->getCriteria(1, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, $collectionType); $currentCursor = $this->getCursor($contentRepository, $criteria->sortOption, $cursor); $currentCursor2 = $cursor2 ? $this->getCursor($contentRepository, Criteria::SORT_NEW, $cursor2) : null; $content = $contentRepository->findByCriteriaCursored($criteria, $currentCursor, $currentCursor2); return $this->serializeContentCursored($content, $headers); } private function getCriteria(?int $p, Security $security, ?string $sort, ?string $time, ?string $federation, ?bool $includeBoosts, ?int $perPage, SqlHelpers $sqlHelpers, ?string $collectionType): ContentPageView { $criteria = new ContentPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); $criteria->setFederation($federation ?? Criteria::AP_ALL); $this->handleLanguageCriteria($criteria); $criteria->content = Criteria::CONTENT_COMBINED; $criteria->perPage = $perPage; $user = $security->getUser(); if ($user instanceof User) { $criteria->includeBoosts = $includeBoosts ?? $user->showBoostsOfFollowing; $criteria->fetchCachedItems($sqlHelpers, $user); } switch ($collectionType) { case 'subscribed': $criteria->subscribed = true; break; case 'moderated': $criteria->moderated = true; break; case 'favourited': $criteria->favourite = true; break; } return $criteria; } private function serializeContent(PagerfantaInterface $content, array $headers): JsonResponse { $result = []; foreach ($content as $item) { if ($item instanceof Entry) { $this->handlePrivateContent($item); $result[] = new ContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof Post) { $this->handlePrivateContent($item); $result[] = new ContentResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof EntryComment) { $this->handlePrivateContent($item); $result[] = new ContentResponseDto(entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof PostComment) { $this->handlePrivateContent($item); $result[] = new ContentResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } } return new JsonResponse($this->serializePaginated($result, $content), headers: $headers); } private function serializeContentCursored(CursorPaginationInterface $content, array $headers): JsonResponse { $result = []; foreach ($content as $item) { if ($item instanceof Entry) { $this->handlePrivateContent($item); $result[] = new ContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof Post) { $this->handlePrivateContent($item); $result[] = new ContentResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } } return new JsonResponse($this->serializeCursorPaginated($result, $content), headers: $headers); } private function getCursor(ContentRepository $contentRepository, string $sortOption, ?string $cursor): int|\DateTime|\DateTimeImmutable { $initialCursor = $contentRepository->guessInitialCursor($sortOption); if ($initialCursor instanceof \DateTime || $initialCursor instanceof \DateTimeImmutable) { try { $currentCursor = null !== $cursor ? new \DateTimeImmutable($cursor) : $initialCursor; } catch (\DateException) { throw new BadRequestHttpException('The cursor is not a parsable datetime.'); } } elseif (\is_int($initialCursor)) { $currentCursor = null !== $cursor ? \intval($cursor) : $initialCursor; } else { $this->logger->critical('Could not get a cursor from class "{c}"', ['c' => \get_class($initialCursor)]); throw new HttpException(500, 'Could not determine the cursor.'); } return $currentCursor; } } ================================================ FILE: src/Controller/Api/Domain/DomainBaseApi.php ================================================ factory = $factory; } /** * Serialize a domain to JSON. */ protected function serializeDomain(DomainDto|Domain $dto): DomainDto { $response = $dto instanceof Domain ? $this->factory->createDto($dto) : $dto; return $response; } } ================================================ FILE: src/Controller/Api/Domain/DomainBlockApi.php ================================================ rateLimit($apiUpdateLimiter); $manager->block($domain, $this->getUserOrThrow()); return new JsonResponse( $this->serializeDomain($factory->createDto($domain)), headers: $headers ); } #[OA\Response( response: 200, description: 'Domain unblocked', content: new Model(type: DomainDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Domain not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'domain_id', in: 'path', description: 'The domain to unblock', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'domain')] #[Security(name: 'oauth2', scopes: ['domain:block'])] #[IsGranted('ROLE_OAUTH2_DOMAIN:BLOCK')] public function unblock( #[MapEntity(id: 'domain_id')] Domain $domain, DomainManager $manager, DomainFactory $factory, RateLimiterFactoryInterface $apiUpdateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiUpdateLimiter); $manager->unblock($domain, $this->getUserOrThrow()); return new JsonResponse( $this->serializeDomain($factory->createDto($domain)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Domain/DomainRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $dto = $factory->createDto($domain); return new JsonResponse( $this->serializeDomain($dto), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of domains', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: DomainDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of domains to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of domains per page', in: 'query', schema: new OA\Schema(type: 'integer', default: DomainRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'q', description: 'Domain search term', in: 'query', schema: new OA\Schema(type: 'string') )] #[OA\Tag(name: 'domain')] public function collection( DomainRepository $repository, SearchManager $searchManager, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $request = $this->request->getCurrentRequest(); $perPage = self::constrainPerPage($request->get('perPage', DomainRepository::PER_PAGE)); if ($q = $request->get('q')) { $domains = $searchManager->findDomainsPaginated($q, $this->getPageNb($request), $perPage); } else { $domains = $repository->findAllPaginated( $this->getPageNb($request), $perPage ); } $dtos = []; foreach ($domains->getCurrentPageResults() as $value) { \assert($value instanceof Domain); array_push($dtos, $this->serializeDomain($value)); } return new JsonResponse( $this->serializePaginated($dtos, $domains), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of subscribed domains', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: DomainDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of domains to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of domains per page', in: 'query', schema: new OA\Schema(type: 'integer', default: DomainRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Tag(name: 'domain')] #[Security(name: 'oauth2', scopes: ['domain:subscribe'])] #[IsGranted('ROLE_OAUTH2_DOMAIN:SUBSCRIBE')] public function subscribed( DomainRepository $repository, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $request = $this->request->getCurrentRequest(); $domains = $repository->findSubscribedDomains( $this->getPageNb($request), $this->getUserOrThrow(), self::constrainPerPage($request->get('perPage', DomainRepository::PER_PAGE)) ); $dtos = []; foreach ($domains->getCurrentPageResults() as $value) { \assert($value instanceof DomainSubscription); array_push($dtos, $this->serializeDomain($value->domain)); } return new JsonResponse( $this->serializePaginated($dtos, $domains), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of user\'s subscribed domains', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: DomainDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'This user does not allow others to view their subscribed domains', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'user_id', description: 'User from which to retrieve subscribed domains', in: 'path', schema: new OA\Schema(type: 'integer') )] #[OA\Parameter( name: 'p', description: 'Page of domains to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of domains per page', in: 'query', schema: new OA\Schema( type: 'integer', default: DomainRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE ) )] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['read'])] #[IsGranted('ROLE_OAUTH2_READ')] public function subscriptions( #[MapEntity(id: 'user_id')] User $user, DomainRepository $repository, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); if ($user->getId() !== $this->getUserOrThrow()->getId() && !$user->getShowProfileFollowings()) { throw new AccessDeniedHttpException('You are not permitted to view the domains followed by this user'); } $request = $this->request->getCurrentRequest(); $domains = $repository->findSubscribedDomains( $this->getPageNb($request), $user, self::constrainPerPage($request->get('perPage', DomainRepository::PER_PAGE)) ); $dtos = []; foreach ($domains->getCurrentPageResults() as $value) { array_push($dtos, $this->serializeDomain($value->domain)); } return new JsonResponse( $this->serializePaginated($dtos, $domains), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of blocked domains', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: DomainDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of domains to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of domains per page', in: 'query', schema: new OA\Schema(type: 'integer', default: DomainRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Tag(name: 'domain')] #[Security(name: 'oauth2', scopes: ['domain:block'])] #[IsGranted('ROLE_OAUTH2_DOMAIN:BLOCK')] public function blocked( DomainRepository $repository, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $request = $this->request->getCurrentRequest(); $domains = $repository->findBlockedDomains( $this->getPageNb($request), $this->getUserOrThrow(), self::constrainPerPage($request->get('perPage', DomainRepository::PER_PAGE)) ); $dtos = []; foreach ($domains->getCurrentPageResults() as $value) { \assert($value instanceof DomainBlock); array_push($dtos, $this->serializeDomain($value->domain)); } return new JsonResponse( $this->serializePaginated($dtos, $domains), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Domain/DomainSubscribeApi.php ================================================ rateLimit($apiUpdateLimiter); $manager->subscribe($domain, $this->getUserOrThrow()); return new JsonResponse( $this->serializeDomain($factory->createDto($domain)), headers: $headers ); } #[OA\Response( response: 200, description: 'Domain subscription status updated', content: new Model(type: DomainDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Domain not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'domain_id', in: 'path', description: 'The domain to unsubscribe from', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'domain')] #[Security(name: 'oauth2', scopes: ['domain:subscribe'])] #[IsGranted('ROLE_OAUTH2_DOMAIN:SUBSCRIBE')] public function unsubscribe( #[MapEntity(id: 'domain_id')] Domain $domain, DomainManager $manager, DomainFactory $factory, RateLimiterFactoryInterface $apiUpdateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiUpdateLimiter); $manager->unsubscribe($domain, $this->getUserOrThrow()); return new JsonResponse( $this->serializeDomain($factory->createDto($domain)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Admin/EntriesChangeMagazineApi.php ================================================ rateLimit($apiModerateLimiter); $manager->changeMagazine($entry, $target); return new JsonResponse( $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Admin/EntriesPurgeApi.php ================================================ rateLimit($apiModerateLimiter); $manager->purge($this->getUserOrThrow(), $entry); return new JsonResponse( status: 204, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Comments/Admin/EntryCommentsPurgeApi.php ================================================ rateLimit($apiModerateLimiter); $manager->purge($this->getUserOrThrow(), $comment); return new JsonResponse( status: 204, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Comments/DomainEntryCommentsRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = new EntryCommentPageView((int) $request->getCurrentRequest()->get('p', 1), $security); $criteria->sortOption = $request->getCurrentRequest()->get('sort', Criteria::SORT_HOT); $criteria->time = $criteria->resolveTime( $request->getCurrentRequest()->get('time', Criteria::TIME_ALL) ); $this->handleLanguageCriteria($criteria); $criteria->perPage = self::constrainPerPage($request->getCurrentRequest()->get('perPage', EntryRepository::PER_PAGE)); $criteria->domain = $domain->name; $comments = $repository->findByCriteria($criteria); $dtos = []; foreach ($comments->getCurrentPageResults() as $value) { try { $this->handlePrivateContent($value); $dtos[] = $this->serializeCommentTree($value, $criteria); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $comments), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Comments/EntryCommentsActivityApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $this->handlePrivateContent($comment); $dto = $dtoFactory->createActivitiesDto($comment); return new JsonResponse( $dto, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Comments/EntryCommentsCreateApi.php ================================================ rateLimit($apiCommentLimiter); if (!$this->isGranted('create_content', $entry->magazine)) { throw new AccessDeniedHttpException(); } if ($parent && $parent->entry->getId() !== $entry->getId()) { throw new BadRequestHttpException('The parent comment does not belong to that entry!'); } $dto = $this->deserializeComment(); $dto->entry = $entry; $dto->magazine = $entry->magazine; $dto->parent = $parent; $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } // Rate limiting already taken care of $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); $dto = $factory->createDto($comment); $dto->parent = $parent; return new JsonResponse( $this->serializeEntryComment($dto, $this->tagLinkRepository->getTagsOfContent($comment)), status: 201, headers: $headers ); } #[OA\Response( response: 201, description: 'Comment created', content: new Model(type: EntryCommentResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'The request body was invalid or the comment you are replying to does not belong to the entry you are trying to add the new comment to.', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not permitted to add comments to this entry', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Entry or parent comment not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'entry_id', in: 'path', description: 'Entry to which the new comment will belong', schema: new OA\Schema(type: 'integer') )] #[OA\RequestBody(content: new OA\MediaType( 'multipart/form-data', schema: new OA\Schema( ref: new Model( type: EntryCommentRequestDto::class, groups: [ 'common', 'comment', ImageUploadDto::IMAGE_UPLOAD, ] ) ) ))] #[OA\Tag(name: 'entry_comment')] #[Security(name: 'oauth2', scopes: ['entry_comment:create'])] #[IsGranted('ROLE_OAUTH2_ENTRY_COMMENT:CREATE')] #[IsGranted('comment', subject: 'entry')] public function uploadImage( #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'comment_id')] ?EntryComment $parent, EntryCommentManager $manager, EntryCommentFactory $factory, ValidatorInterface $validator, RateLimiterFactoryInterface $apiImageLimiter, ): JsonResponse { $headers = $this->rateLimit($apiImageLimiter); $image = $this->handleUploadedImage(); if (!$this->isGranted('create_content', $entry->magazine)) { throw new AccessDeniedHttpException(); } if ($parent && $parent->entry->getId() !== $entry->getId()) { throw new BadRequestHttpException('The parent comment does not belong to that entry!'); } $dto = $this->deserializeCommentFromForm(); $dto->entry = $entry; $dto->magazine = $entry->magazine; $dto->parent = $parent; $dto->image = $this->imageFactory->createDto($image); $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } // Rate limiting already taken care of $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); $dto = $factory->createDto($comment); $dto->parent = $parent; return new JsonResponse( $this->serializeEntryComment($dto, $this->tagLinkRepository->getTagsOfContent($comment)), status: 201, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Comments/EntryCommentsDeleteApi.php ================================================ rateLimit($apiDeleteLimiter); $manager->delete($this->getUserOrThrow(), $comment); return new JsonResponse(status: 204, headers: $headers); } } ================================================ FILE: src/Controller/Api/Entry/Comments/EntryCommentsFavouriteApi.php ================================================ rateLimit($apiVoteLimiter); $manager->toggle($this->getUserOrThrow(), $comment); return new JsonResponse( $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Comments/EntryCommentsReportApi.php ================================================ rateLimit($apiReportLimiter); $this->reportContent($comment); return new JsonResponse( status: 204, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Comments/EntryCommentsRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $this->handlePrivateContent($entry); $request = $this->request->getCurrentRequest(); $criteria = new EntryCommentPageView($this->getPageNb($request), $security); $sort = $criteria->resolveSort($request->get('sortBy', Criteria::SORT_HOT)); $criteria->showSortOption($sort); $criteria->entry = $entry; $criteria->perPage = self::constrainPerPage($request->get('perPage', EntryCommentRepository::PER_PAGE)); $criteria->setTime($criteria->resolveTime($request->get('time', Criteria::TIME_ALL))); $this->handleLanguageCriteria($criteria); $comments = $commentsRepository->findByCriteria($criteria); $commentsRepository->hydrate(...$comments); $commentsRepository->hydrateChildren(...$comments); $dtos = []; foreach ($comments->getCurrentPageResults() as $value) { try { \assert($value instanceof EntryComment); $this->handlePrivateContent($value); $dtos[] = $this->serializeCommentTree($value, $criteria); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $comments), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns the comment', content: new Model(type: EntryCommentResponseDto::class) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Comment not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Parameter( name: 'comment_id', in: 'path', description: 'The comment to retrieve', schema: new OA\Schema(type: 'integer'), )] #[OA\Parameter( name: 'd', in: 'query', description: 'Comment tree depth to retrieve', schema: new OA\Schema(type: 'integer', default: self::DEPTH, minimum: self::MIN_DEPTH, maximum: self::MAX_DEPTH), )] #[OA\Tag(name: 'entry_comment')] public function single( #[MapEntity(id: 'comment_id')] EntryComment $comment, EntryCommentRepository $commentsRepository, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, Security $security, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $this->handlePrivateContent($comment); return new JsonResponse( $this->serializeCommentTree($comment, new EntryCommentPageView(0, $security)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Comments/EntryCommentsUpdateApi.php ================================================ rateLimit($apiUpdateLimiter); if (!$this->isGranted('create_content', $comment->magazine)) { throw new AccessDeniedHttpException(); } $dto = $this->deserializeComment($factory->createDto($comment)); $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } $comment = $manager->edit($comment, $dto, $this->getUserOrThrow()); return new JsonResponse( $this->serializeCommentTree($comment, new EntryCommentPageView(0, $security)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Comments/EntryCommentsVoteApi.php ================================================ rateLimit($apiVoteLimiter); if (!\in_array($choice, VotableInterface::VOTE_CHOICES)) { throw new BadRequestHttpException('Vote must be either -1, 0, or 1'); } if (DownvotesMode::Disabled === $settingsManager->getDownvotesMode() && VotableInterface::VOTE_DOWN === $choice) { throw new BadRequestHttpException('Downvotes are disabled!'); } // Rate limiting handled above $manager->vote($choice, $comment, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetAdultApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); // Returns true for "1", "true", "on" and "yes". Returns false otherwise. $comment->isAdult = filter_var($request->get('adult', 'true'), FILTER_VALIDATE_BOOL); $manager->flush(); return new JsonResponse( $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetLanguageApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); $newLang = $request->get('lang', ''); $valid = false !== array_search($newLang, Languages::getLanguageCodes()); if (!$valid) { throw new BadRequestHttpException('The given language is not valid!'); } $comment->lang = $newLang; $manager->flush(); return new JsonResponse( $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Comments/Moderate/EntryCommentsTrashApi.php ================================================ rateLimit($apiModerateLimiter); $moderator = $this->getUserOrThrow(); $manager->trash($moderator, $comment); // Force response to have all fields visible $visibility = $comment->visibility; $comment->visibility = VisibilityInterface::VISIBILITY_VISIBLE; $response = $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment))->jsonSerialize(); $response['visibility'] = $visibility; return new JsonResponse( $response, headers: $headers ); } #[OA\Response( response: 200, description: 'Comment restored', content: new Model(type: EntryCommentResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'The comment was not in the trashed state', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not authorized to restore this comment', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Comment not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'comment_id', in: 'path', description: 'The comment to restore', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'moderation/entry_comment')] #[Security(name: 'oauth2', scopes: ['moderate:entry_comment:trash'])] #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY_COMMENT:TRASH')] #[IsGranted('moderate', subject: 'comment')] public function restore( #[MapEntity(id: 'comment_id')] EntryComment $comment, EntryCommentManager $manager, EntryCommentFactory $factory, RateLimiterFactoryInterface $apiModerateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); $moderator = $this->getUserOrThrow(); try { $manager->restore($moderator, $comment); } catch (\Exception $e) { throw new BadRequestHttpException('The comment cannot be restored because it was not trashed!'); } return new JsonResponse( $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Comments/UserEntryCommentsRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = new EntryCommentPageView((int) $request->getCurrentRequest()->get('p', 1), $security); $criteria->sortOption = $request->getCurrentRequest()->get('sort', Criteria::SORT_HOT); $criteria->time = $criteria->resolveTime( $request->getCurrentRequest()->get('time', Criteria::TIME_ALL) ); $this->handleLanguageCriteria($criteria); $criteria->perPage = self::constrainPerPage($request->getCurrentRequest()->get('perPage', EntryRepository::PER_PAGE)); $criteria->user = $user; $criteria->onlyParents = false; $comments = $repository->findByCriteria($criteria); $dtos = []; foreach ($comments->getCurrentPageResults() as $value) { try { $this->handlePrivateContent($value); $dtos[] = $this->serializeCommentTree($value, $criteria); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $comments), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/DomainEntriesRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = new EntryPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); $this->handleLanguageCriteria($criteria); $user = $security->getUser(); if ($user instanceof User) { $criteria->fetchCachedItems($sqlHelpers, $user); } $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE); $criteria->domain = $domain->name; $entries = $repository->findByCriteria($criteria); $dtos = []; foreach ($entries->getCurrentPageResults() as $value) { try { \assert($value instanceof Entry); $this->handlePrivateContent($value); $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $entries), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/EntriesActivityApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $this->handlePrivateContent($entry); $dto = $dtoFactory->createActivitiesDto($entry); return new JsonResponse( $dto, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/EntriesBaseApi.php ================================================ commentsFactory = $commentsFactory; } /** * Deserialize an entry from JSON. * * @param ?EntryDto $dto The EntryDto to modify with new values (default: null to create a new EntryDto) * * @return EntryDto An entry with only certain fields allowed to be modified by the user * * Modifies: * * title * * body * * tags * * isAdult * * isOc * * lang * * imageAlt * * imageUrl */ protected function deserializeEntry(?EntryDto $dto = null, array $context = []): EntryDto { $dto = $dto ? $dto : new EntryDto(); $deserialized = $this->serializer->deserialize($this->request->getCurrentRequest()->getContent(), EntryRequestDto::class, 'json', $context); \assert($deserialized instanceof EntryRequestDto); $dto = $deserialized->mergeIntoDto($dto, $this->settingsManager); $dto->ip = $this->ipResolver->resolve(); return $dto; } protected function deserializeEntryFromForm(): EntryRequestDto { $request = $this->request->getCurrentRequest(); $deserialized = new EntryRequestDto(); $deserialized->title = $request->get('title'); $deserialized->url = $request->get('url'); $deserialized->body = $request->get('body'); $deserialized->tags = $request->get('tags'); $deserialized->body = $request->get('body'); // TODO: Support badges whenever/however they're implemented // $deserialized->badges = $request->get('badges'); $deserialized->isOc = filter_var($request->get('isOc'), FILTER_VALIDATE_BOOL); $deserialized->lang = $request->get('lang'); $deserialized->isAdult = filter_var($request->get('isAdult'), FILTER_VALIDATE_BOOL); return $deserialized; } /** * Deserialize a comment from JSON. * * @param ?EntryCommentDto $dto The EntryCommentDto to modify with new values (default: null to create a new EntryCommentDto) * * @return EntryCommentDto A comment with only certain fields allowed to be modified by the user * * Modifies: * * body * * isAdult * * lang * * imageAlt (currently not working to modify the image) * * imageUrl (currently not working to modify the image) */ protected function deserializeComment(?EntryCommentDto $dto = null): EntryCommentDto { $dto = $dto ? $dto : new EntryCommentDto(); /** * @var EntryCommentRequestDto $deserialized */ $deserialized = $this->serializer->deserialize($this->request->getCurrentRequest()->getContent(), EntryCommentRequestDto::class, 'json', [ 'groups' => [ 'common', 'comment', 'no-upload', ], ]); $dto->ip = $this->ipResolver->resolve(); return $deserialized->mergeIntoDto($dto, $this->settingsManager); } protected function deserializeCommentFromForm(?EntryCommentDto $dto = null): EntryCommentDto { $request = $this->request->getCurrentRequest(); $dto = $dto ? $dto : new EntryCommentDto(); $deserialized = new EntryCommentRequestDto(); $deserialized->body = $request->get('body'); $deserialized->lang = $request->get('lang'); $dto->ip = $this->ipResolver->resolve(); return $deserialized->mergeIntoDto($dto, $this->settingsManager); } /** * Serialize a comment tree to JSON. * * @param ?EntryComment $comment The root comment to base the tree on * @param ?int $depth how many levels of children to include. If null (default), retrieves depth from query parameter 'd'. * * @return array An associative array representation of the comment's hierarchy, to be used as JSON */ protected function serializeCommentTree(?EntryComment $comment, EntryCommentPageView $commentPageView, ?int $depth = null): array { if (null === $comment) { return []; } if (null === $depth) { $depth = self::constrainDepth($this->request->getCurrentRequest()->get('d', self::DEPTH)); } $canModerate = null; if ($user = $this->getUser()) { $canModerate = $comment->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin(); } $commentTree = $this->commentsFactory->createResponseTree($comment, $commentPageView, $depth, $canModerate); $commentTree->canAuthUserModerate = $canModerate; return $commentTree->jsonSerialize(); } public function createEntry(Magazine $magazine, EntryManager $manager, array $context, ?ImageDto $image = null): Entry { $dto = new EntryDto(); $dto->magazine = $magazine; if (null !== $image) { $dto->image = $image; } if (null === $dto->magazine) { throw new NotFoundHttpException('Magazine not found'); } $dto = $this->deserializeEntry($dto, $context); if (!$this->isGranted('create_content', $dto->magazine)) { throw new AccessDeniedHttpException(); } $errors = $this->validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } return $manager->create($dto, $this->getUserOrThrow()); } } ================================================ FILE: src/Controller/Api/Entry/EntriesDeleteApi.php ================================================ rateLimit($apiDeleteLimiter); if ($entry->user->getId() !== $this->getUserOrThrow()->getId()) { throw new AccessDeniedHttpException(); } $manager->delete($this->getUserOrThrow(), $entry); return new JsonResponse(status: 204, headers: $headers); } } ================================================ FILE: src/Controller/Api/Entry/EntriesFavouriteApi.php ================================================ rateLimit($apiVoteLimiter); $manager->toggle($this->getUserOrThrow(), $entry); return new JsonResponse( $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/EntriesReportApi.php ================================================ rateLimit($apiReportLimiter); $this->reportContent($entry); return new JsonResponse( status: 204, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/EntriesRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $this->handlePrivateContent($entry); $dispatcher->dispatch(new EntryHasBeenSeenEvent($entry)); $dto = $factory->createDto($entry); return new JsonResponse( $this->serializeEntry($dto, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a list of Entries', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: EntryResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'sort', in: 'query', description: 'The sorting method to use during entry fetch', schema: new OA\Schema( default: Criteria::SORT_DEFAULT, enum: Criteria::SORT_OPTIONS ) )] #[OA\Parameter( name: 'time', in: 'query', description: 'The maximum age of retrieved entries', schema: new OA\Schema( default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN ) )] #[OA\Parameter( name: 'p', description: 'Page of entries to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of entries to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: EntryRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'lang[]', description: 'Language(s) of entries to return', in: 'query', explode: true, allowReserved: true, schema: new OA\Schema( type: 'array', items: new OA\Items(type: 'string') ) )] #[OA\Parameter( name: 'usePreferredLangs', description: 'Filter by a user\'s preferred languages? (Requires authentication and takes precedence over lang[])', in: 'query', schema: new OA\Schema(type: 'boolean', default: false), )] #[OA\Parameter( name: 'federation', description: 'What type of federated entries to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Tag(name: 'entry')] public function collection( ContentRepository $repository, EntryFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, SymfonySecurity $security, SqlHelpers $sqlHelpers, #[MapQueryParameter] ?int $p, #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = new EntryPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); $criteria->setFederation($federation ?? Criteria::AP_ALL); $this->handleLanguageCriteria($criteria); $criteria->content = Criteria::CONTENT_THREADS; $user = $security->getUser(); if ($user instanceof User) { $criteria->fetchCachedItems($sqlHelpers, $user); } $entries = $repository->findByCriteria($criteria); $dtos = []; foreach ($entries->getCurrentPageResults() as $value) { try { \assert($value instanceof Entry); $this->handlePrivateContent($value); $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); } catch (AccessDeniedException $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $entries), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a list of entries from subscribed magazines', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: EntryResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'sort', in: 'query', description: 'The sorting method to use during entry fetch', schema: new OA\Schema( default: Criteria::SORT_DEFAULT, enum: Criteria::SORT_OPTIONS ) )] #[OA\Parameter( name: 'time', in: 'query', description: 'The maximum age of retrieved entries', schema: new OA\Schema( default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN ) )] #[OA\Parameter( name: 'p', description: 'Page of entries to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of entries to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: EntryRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'federation', description: 'What type of federated entries to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Tag(name: 'entry')] #[Security(name: 'oauth2', scopes: ['read'])] #[IsGranted('ROLE_OAUTH2_READ')] public function subscribed( ContentRepository $repository, EntryFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, SymfonySecurity $security, SqlHelpers $sqlHelpers, #[MapQueryParameter] ?int $p, #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $criteria = new EntryPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); $criteria->setFederation($federation ?? Criteria::AP_ALL); $criteria->subscribed = true; $criteria->content = Criteria::CONTENT_THREADS; $user = $security->getUser(); if ($user instanceof User) { $criteria->fetchCachedItems($sqlHelpers, $user); } $entries = $repository->findByCriteria($criteria); $dtos = []; foreach ($entries->getCurrentPageResults() as $value) { try { \assert($value instanceof Entry); $this->handlePrivateContent($value); $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $entries), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a list of entries from moderated magazines', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: EntryResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'sort', in: 'query', description: 'The sorting method to use during entry fetch', schema: new OA\Schema( default: Criteria::SORT_NEW, enum: Criteria::SORT_OPTIONS ) )] #[OA\Parameter( name: 'time', in: 'query', description: 'The maximum age of retrieved entries', schema: new OA\Schema( default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN ) )] #[OA\Parameter( name: 'p', description: 'Page of entries to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of entries to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: EntryRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'federation', description: 'What type of federated entries to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Tag(name: 'entry')] #[Security(name: 'oauth2', scopes: ['moderate:entry'])] #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY')] public function moderated( ContentRepository $repository, EntryFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, SymfonySecurity $security, SqlHelpers $sqlHelpers, #[MapQueryParameter] ?int $p, #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $criteria = new EntryPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); $criteria->setFederation($federation ?? Criteria::AP_ALL); $criteria->moderated = true; $criteria->content = Criteria::CONTENT_THREADS; $user = $security->getUser(); if ($user instanceof User) { $criteria->fetchCachedItems($sqlHelpers, $user); } $entries = $repository->findByCriteria($criteria); $dtos = []; foreach ($entries->getCurrentPageResults() as $value) { try { \assert($value instanceof Entry); $this->handlePrivateContent($value); $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $entries), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a list of favourited entries', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: EntryResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'sort', in: 'query', description: 'The sorting method to use during entry fetch', schema: new OA\Schema( default: Criteria::SORT_TOP, enum: Criteria::SORT_OPTIONS ) )] #[OA\Parameter( name: 'time', in: 'query', description: 'The maximum age of retrieved entries', schema: new OA\Schema( default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN ) )] #[OA\Parameter( name: 'p', description: 'Page of entries to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of entries to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: EntryRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'federation', description: 'What type of federated entries to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Tag(name: 'entry')] #[Security(name: 'oauth2', scopes: ['entry:vote'])] #[IsGranted('ROLE_OAUTH2_ENTRY:VOTE')] public function favourited( ContentRepository $repository, EntryFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, SymfonySecurity $security, SqlHelpers $sqlHelpers, #[MapQueryParameter] ?int $p, #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $criteria = new EntryPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); $criteria->setFederation($federation ?? Criteria::AP_ALL); $criteria->favourite = true; $user = $security->getUser(); if ($user instanceof User) { $criteria->fetchCachedItems($sqlHelpers, $user); } $entries = $repository->findByCriteria($criteria); $criteria->content = Criteria::CONTENT_THREADS; $dtos = []; foreach ($entries->getCurrentPageResults() as $value) { try { \assert($value instanceof Entry); $this->handlePrivateContent($value); $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $entries), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/EntriesUpdateApi.php ================================================ rateLimit($apiUpdateLimiter); $dto = $this->deserializeEntry($manager->createDto($entry), context: [ 'groups' => [ 'common', Entry::ENTRY_TYPE_ARTICLE, ], ]); $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } $entry = $manager->edit($entry, $dto, $this->getUserOrThrow()); return new JsonResponse( $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/EntriesVoteApi.php ================================================ rateLimit($apiVoteLimiter); if (!\in_array($choice, VotableInterface::VOTE_CHOICES)) { throw new BadRequestHttpException('Vote must be either -1, 0, or 1'); } if (DownvotesMode::Disabled === $settingsManager->getDownvotesMode() && VotableInterface::VOTE_DOWN === $choice) { throw new BadRequestHttpException('Downvotes are disabled!'); } // Rate limiting handled above $manager->vote($choice, $entry, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/MagazineEntriesRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = new EntryPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); $this->handleLanguageCriteria($criteria); $criteria->stickiesFirst = true; $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE); $criteria->magazine = $magazine; $user = $security->getUser(); if ($user instanceof User) { $criteria->fetchCachedItems($sqlHelpers, $user); } $entries = $repository->findByCriteria($criteria); $dtos = []; foreach ($entries->getCurrentPageResults() as $value) { try { \assert($value instanceof Entry); $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $entries), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/MagazineEntryCreateApi.php ================================================ rateLimit($apiEntryLimiter); $entry = $this->createEntry($magazine, $manager, context: [ 'groups' => [ Entry::ENTRY_TYPE_ARTICLE, 'common', ], ]); return new JsonResponse( $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), status: 201, headers: $headers ); } #[OA\Post(deprecated: true)] #[OA\Response( response: 201, description: 'Returns the created Entry', content: new Model(type: EntryResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'An entry must have at least one of URL, body, or image', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'Either the entry:create scope has not been granted, or the user is banned from the magazine', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Magazine not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', in: 'path', description: 'The magazine to create the entry in', schema: new OA\Schema(type: 'integer'), )] #[OA\RequestBody(content: new Model( type: EntryRequestDto::class, groups: [ Entry::ENTRY_TYPE_LINK, 'common', ] ))] #[OA\Tag(name: 'magazine')] #[Security(name: 'oauth2', scopes: ['entry:create'])] #[IsGranted('ROLE_OAUTH2_ENTRY:CREATE')] public function link( #[MapEntity(id: 'magazine_id')] Magazine $magazine, EntryManager $manager, RateLimiterFactoryInterface $apiEntryLimiter, ): JsonResponse { $headers = $this->rateLimit($apiEntryLimiter); $entry = $this->createEntry($magazine, $manager, context: [ 'groups' => [ Entry::ENTRY_TYPE_LINK, 'common', ], ]); return new JsonResponse( $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), status: 201, headers: $headers ); } #[OA\Post(deprecated: true)] #[OA\Response( response: 201, description: 'Returns the created Entry', content: new Model(type: EntryResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'Either the entry:create scope has not been granted, or the user is banned from the magazine', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Magazine not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', in: 'path', description: 'The magazine to create the entry in', schema: new OA\Schema(type: 'integer'), )] #[OA\RequestBody(content: new Model( type: EntryRequestDto::class, groups: [ Entry::ENTRY_TYPE_VIDEO, 'common', ] ))] #[OA\Tag(name: 'magazine')] #[Security(name: 'oauth2', scopes: ['entry:create'])] #[IsGranted('ROLE_OAUTH2_ENTRY:CREATE')] public function video( #[MapEntity(id: 'magazine_id')] Magazine $magazine, EntryManager $manager, RateLimiterFactoryInterface $apiEntryLimiter, ): JsonResponse { $headers = $this->rateLimit($apiEntryLimiter); $entry = $this->createEntry($magazine, $manager, [ 'groups' => [ Entry::ENTRY_TYPE_VIDEO, 'common', ], ]); return new JsonResponse( $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), status: 201, headers: $headers ); } #[OA\Post(deprecated: true)] #[OA\Response( response: 201, description: 'Returns the created image entry', content: new Model(type: EntryResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'Image was too large, not provided, or is not an acceptable file type', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'Either the entry:create scope has not been granted, or the user is banned from the magazine', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Magazine not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', in: 'path', description: 'The magazine to create the entry in', schema: new OA\Schema(type: 'integer'), )] #[OA\RequestBody(content: new OA\MediaType( 'multipart/form-data', schema: new OA\Schema( ref: new Model( type: EntryRequestDto::class, groups: [ ImageUploadDto::IMAGE_UPLOAD, Entry::ENTRY_TYPE_IMAGE, 'common', ] ) ) ))] #[OA\Tag(name: 'magazine')] #[Security(name: 'oauth2', scopes: ['entry:create'])] #[IsGranted('ROLE_OAUTH2_ENTRY:CREATE')] public function uploadImage( #[MapEntity(id: 'magazine_id')] Magazine $magazine, ValidatorInterface $validator, EntryManager $manager, RateLimiterFactoryInterface $apiImageLimiter, ): JsonResponse { $headers = $this->rateLimit($apiImageLimiter); $dto = new EntryDto(); $dto->magazine = $magazine; if (null === $dto->magazine) { throw new NotFoundHttpException('Magazine not found'); } $deserialized = $this->deserializeEntryFromForm(); $dto = $deserialized->mergeIntoDto($dto, $this->settingsManager); if (!$this->isGranted('create_content', $dto->magazine)) { throw new AccessDeniedHttpException(); } $image = $this->handleUploadedImage(); if (null !== $image) { $dto->image = $this->imageFactory->createDto($image); } $errors = $validator->validate($dto); if (0 < \count($errors)) { throw new BadRequestHttpException((string) $errors); } $entry = $manager->create($dto, $this->getUserOrThrow()); return new JsonResponse( $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), status: 201, headers: $headers ); } #[OA\Response( response: 201, description: 'Returns the created entry', content: new Model(type: EntryResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'An entry must have at least one of URL, body, or image; Image was too large or is not an acceptable file type', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'Either the entry:create scope has not been granted, or the user is banned from the magazine', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Magazine not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', in: 'path', description: 'The magazine to create the entry in', schema: new OA\Schema(type: 'integer'), )] #[OA\RequestBody( content: new OA\MediaType( mediaType: 'multipart/form-data', schema: new OA\Schema( ref: new Model( type: EntryRequestDto::class, groups: [ ImageUploadDto::IMAGE_UPLOAD, Entry::ENTRY_TYPE_ARTICLE, Entry::ENTRY_TYPE_LINK, Entry::ENTRY_TYPE_VIDEO, Entry::ENTRY_TYPE_IMAGE, 'common', ], ) ) ) )] #[OA\Tag(name: 'magazine')] #[Security(name: 'oauth2', scopes: ['entry:create'])] #[IsGranted('ROLE_OAUTH2_ENTRY:CREATE')] public function entry( #[MapEntity(id: 'magazine_id')] ?Magazine $magazine, ValidatorInterface $validator, EntryManager $manager, RateLimiterFactoryInterface $apiImageLimiter, ): JsonResponse { $headers = $this->rateLimit($apiImageLimiter); if (null === $magazine) { throw new NotFoundHttpException('Magazine not found'); } if (!$this->isGranted('create_content', $magazine)) { throw new AccessDeniedHttpException(); } $dto = new EntryDto(); $dto->magazine = $magazine; $deserialized = $this->deserializeEntryFromForm(); $dto = $deserialized->mergeIntoDto($dto, $this->settingsManager); $image = $this->handleUploadedImageOptional(); if (null !== $image) { $dto->image = $this->imageFactory->createDto($image); } $errors = $validator->validate($dto); if (0 < \count($errors)) { throw new BadRequestHttpException((string) $errors); } $entry = $manager->create($dto, $this->getUserOrThrow()); $retDto = $manager->createDto($entry); $tags = $this->tagLinkRepository->getTagsOfContent($entry); $crossposts = $this->entryRepository->findCross($entry); return new JsonResponse( $this->serializeEntry($retDto, $tags, $crossposts), status: 201, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Moderate/EntriesLockApi.php ================================================ rateLimit($apiModerateLimiter); $manager->toggleLock($entry, $this->getUserOrThrow()); return new JsonResponse( $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Moderate/EntriesPinApi.php ================================================ rateLimit($apiModerateLimiter); $manager->pin($entry, $this->getUserOrThrow()); return new JsonResponse( $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Moderate/EntriesSetAdultApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); // Returns true for "1", "true", "on" and "yes". Returns false otherwise. $entry->isAdult = filter_var($request->get('adult', 'true'), FILTER_VALIDATE_BOOL); $manager->flush(); return new JsonResponse( $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Moderate/EntriesSetLanguageApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); $newLang = $request->get('lang', ''); $valid = false !== array_search($newLang, Languages::getLanguageCodes()); if (!$valid) { throw new BadRequestHttpException('The given language is not valid!'); } $entry->lang = $newLang; $manager->flush(); return new JsonResponse( $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/Moderate/EntriesTrashApi.php ================================================ rateLimit($apiModerateLimiter); $moderator = $this->getUserOrThrow(); $manager->trash($moderator, $entry); $response = $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)); // Force response to have all fields visible $visibility = $response->visibility; $response->visibility = VisibilityInterface::VISIBILITY_VISIBLE; $response = $response->jsonSerialize(); $response['visibility'] = $visibility; return new JsonResponse( $response, headers: $headers ); } #[OA\Response( response: 200, description: 'Entry restored', content: new Model(type: EntryResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'The entry was not in the trashed state', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not authorized to restore this entry', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Entry not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'entry_id', in: 'path', description: 'The entry to restore', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'moderation/entry')] #[Security(name: 'oauth2', scopes: ['moderate:entry:trash'])] #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY:TRASH')] #[IsGranted('moderate', subject: 'entry')] public function restore( #[MapEntity(id: 'entry_id')] Entry $entry, EntryManager $manager, EntryFactory $factory, RateLimiterFactoryInterface $apiModerateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); $moderator = $this->getUserOrThrow(); try { $manager->restore($moderator, $entry); } catch (\Exception $e) { throw new BadRequestHttpException('The entry cannot be restored because it was not trashed!'); } return new JsonResponse( $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Entry/UserEntriesRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = new EntryPageView((int) $request->getCurrentRequest()->get('p', 1), $security); $criteria->sortOption = $request->getCurrentRequest()->get('sort', Criteria::SORT_HOT); $criteria->time = $criteria->resolveTime( $request->getCurrentRequest()->get('time', Criteria::TIME_ALL) ); $this->handleLanguageCriteria($criteria); $criteria->stickiesFirst = true; $criteria->perPage = self::constrainPerPage($request->getCurrentRequest()->get('perPage', EntryRepository::PER_PAGE)); $criteria->user = $user; $entries = $repository->findByCriteria($criteria); $dtos = []; foreach ($entries->getCurrentPageResults() as $value) { try { \assert($value instanceof Entry); $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $entries), headers: $headers ); } } ================================================ FILE: src/Controller/Api/EntryComments.php ================================================ request->getCurrentRequest()->get('p', 1), $this->security); $criteria->entry = $entry; $criteria->onlyParents = false; $comments = $this->repository->findByCriteria($criteria); } catch (\Exception $e) { return []; } $dtos = array_map(fn ($comment) => $this->factory->createDto($comment), (array) $comments->getCurrentPageResults()); return new DtoPaginator($dtos, 0, EntryCommentRepository::PER_PAGE, $comments->getNbResults()); } } ================================================ FILE: src/Controller/Api/Instance/Admin/InstanceRetrieveSettingsApi.php ================================================ rateLimit($apiModerateLimiter); return new JsonResponse( $settings->getDto(), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Instance/Admin/InstanceUpdateFederationApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); /** @var InstancesDto $dto */ $dto = $serializer->deserialize($request->getContent(), InstancesDto::class, 'json'); $dto->instances = array_map( fn (string $instance) => trim(str_replace('www.', '', $instance)), $dto->instances ); $errors = $validator->validate($dto); if (0 < \count($errors)) { throw new BadRequestHttpException((string) $errors); } $instanceManager->setBannedInstances($dto->instances); $dto = new InstancesDto($instanceRepository->getBannedInstanceUrls()); return new JsonResponse( $dto, headers: $headers ); } #[OA\Response( response: 200, description: 'Instance added to ban list', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: SettingsDto::class) )] #[OA\Response( response: 400, description: 'Instance cannot be banned when an allow list is used', content: new OA\JsonContent(ref: new Model(type: BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You do not have permission to edit the instance settings', content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Tag(name: 'admin/federation')] #[IsGranted('ROLE_ADMIN')] #[Security(name: 'oauth2', scopes: ['admin:federation:update'])] #[IsGranted('ROLE_OAUTH2_ADMIN:FEDERATION:UPDATE')] public function banInstance( RateLimiterFactoryInterface $apiModerateLimiter, string $domain, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); $instance = $this->instanceRepository->getOrCreateInstance($domain); try { $this->instanceManager->banInstance($instance); } catch (\LogicException $exception) { throw new BadRequestHttpException($exception->getMessage()); } return new JsonResponse(headers: $headers); } #[OA\Response( response: 200, description: 'Instance removed from ban list', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: SettingsDto::class) )] #[OA\Response( response: 400, description: 'Instance cannot be unbanned when an allow list is used', content: new OA\JsonContent(ref: new Model(type: BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You do not have permission to edit the instance settings', content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Instance not found', content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Tag(name: 'admin/federation')] #[IsGranted('ROLE_ADMIN')] #[Security(name: 'oauth2', scopes: ['admin:federation:update'])] #[IsGranted('ROLE_OAUTH2_ADMIN:FEDERATION:UPDATE')] public function unbanInstance( RateLimiterFactoryInterface $apiModerateLimiter, #[MapEntity(mapping: ['domain' => 'domain'])] Instance $instance, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); try { $this->instanceManager->unbanInstance($instance); } catch (\LogicException $exception) { throw new BadRequestHttpException($exception->getMessage()); } return new JsonResponse(headers: $headers); } #[OA\Response( response: 200, description: 'Instance added to allowlist', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: SettingsDto::class) )] #[OA\Response( response: 400, description: 'Instance cannot be removed from the allow list when the allow list is not used', content: new OA\JsonContent(ref: new Model(type: BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You do not have permission to edit the instance settings', content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Tag(name: 'admin/federation')] #[IsGranted('ROLE_ADMIN')] #[Security(name: 'oauth2', scopes: ['admin:federation:update'])] #[IsGranted('ROLE_OAUTH2_ADMIN:FEDERATION:UPDATE')] public function allowInstance( RateLimiterFactoryInterface $apiModerateLimiter, string $domain, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); $instance = $this->instanceRepository->getOrCreateInstance($domain); try { $this->instanceManager->allowInstanceFederation($instance); } catch (\LogicException $exception) { throw new BadRequestHttpException($exception->getMessage()); } return new JsonResponse(headers: $headers); } #[OA\Response( response: 200, description: 'Instance removed from allow list', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: SettingsDto::class) )] #[OA\Response( response: 400, description: 'Instance cannot be put on the allow list when the allow list is not used', content: new OA\JsonContent(ref: new Model(type: BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You do not have permission to edit the instance settings', content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Instance not found', content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Tag(name: 'admin/federation')] #[IsGranted('ROLE_ADMIN')] #[Security(name: 'oauth2', scopes: ['admin:federation:update'])] #[IsGranted('ROLE_OAUTH2_ADMIN:FEDERATION:UPDATE')] public function denyInstance( RateLimiterFactoryInterface $apiModerateLimiter, #[MapEntity(mapping: ['domain' => 'domain'])] Instance $instance, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); try { $this->instanceManager->denyInstanceFederation($instance); } catch (\LogicException $exception) { throw new BadRequestHttpException($exception->getMessage()); } return new JsonResponse(headers: $headers); } } ================================================ FILE: src/Controller/Api/Instance/Admin/InstanceUpdatePagesApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); $page = $request->get('page'); if (null === $page || false === array_search($page, SiteResponseDto::PAGES)) { throw new BadRequestHttpException('Page parameter is invalid!'); } /** @var PageDto $dto */ $dto = $serializer->deserialize($request->getContent(), PageDto::class, 'json'); $errors = $validator->validate($dto); if (0 < \count($errors)) { throw new BadRequestHttpException((string) $errors); } $entity = $repository->findAll(); if (!\count($entity)) { $entity = new Site(); } else { $entity = $entity[0]; } $entity->{$page} = $dto->body; $entityManager->persist($entity); $entityManager->flush(); return new JsonResponse( new SiteResponseDto($entity, $settingsManager->getDownvotesMode()), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Instance/Admin/InstanceUpdateSettingsApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); /** @var SettingsDto $dto */ $dto = $serializer->deserialize($request->getContent(), SettingsDto::class, 'json'); $errors = $validator->validate($dto); if (0 < \count($errors)) { throw new BadRequestHttpException((string) $errors); } $settingDto = $dto->mergeIntoDto($settings->getDto()); $settings->save($settingDto); return new JsonResponse( $settingDto, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Instance/InstanceBaseApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $results = $repository->findAll(); $dto = new SiteResponseDto(null, $settingsManager->getDownvotesMode()); if (0 < \count($results)) { $dto = new SiteResponseDto($results[0], $settingsManager->getDownvotesMode()); } return new JsonResponse( $dto, headers: $headers ); } #[OA\Response( response: 200, description: 'Returns the details of a remote instance', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: RemoteInstanceDto::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You do not have permission to view the details for remote instances', content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Remote instance not found', content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[IsGranted('ROLE_ADMIN')] #[Security(name: 'oauth2', scopes: ['admin:federation:read'])] #[IsGranted('ROLE_OAUTH2_ADMIN:FEDERATION:READ')] #[OA\Tag('admin/instance')] public function retrieveRemoteInstanceDetails( RateLimiterFactoryInterface $apiReadLimiter, #[MapEntity(mapping: ['domain' => 'domain'])] Instance $instance, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $dto = RemoteInstanceDto::create($instance, $this->instanceRepository->getInstanceCounts($instance)); return new JsonResponse($dto, headers: $headers); } } ================================================ FILE: src/Controller/Api/Instance/InstanceModLogApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $request = $this->request->getCurrentRequest(); $logs = $repository->findByCustom( $this->getPageNb($request), self::constrainPerPage($request->get('perPage', MagazineLogRepository::PER_PAGE)), types: $types, ); $dtos = []; foreach ($logs->getCurrentPageResults() as $value) { $dtos[] = $this->serializeLogItem($value); } return new JsonResponse( $this->serializePaginated($dtos, $logs), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Instance/InstanceRetrieveFederationApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $dto = new InstancesDto($instanceRepository->getBannedInstanceUrls()); return new JsonResponse( $dto, headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a list of de-federated instances and info about their server software', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: InstancesDtoV2::class) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Tag(name: 'instance')] /** * Get de-federated instances. */ public function getDeFederatedV2( RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, InstanceRepository $instanceRepository, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $instances = array_map(fn (Instance $i) => new InstanceDto($i->domain, $i->software, $i->version), $instanceRepository->getBannedInstances()); $dto = new InstancesDtoV2($instances); return new JsonResponse( $dto, headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a list of federated instances', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: InstancesDtoV2::class) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Tag(name: 'instance')] /** * Get federated instances. */ public function getFederated( RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, InstanceRepository $instanceRepository, SettingsManager $settingsManager, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $instances = array_map(fn (Instance $i) => new InstanceDto($i->domain, $i->software, $i->version), $instanceRepository->getAllowedInstances($settingsManager->getUseAllowList())); $dto = new InstancesDtoV2($instances); return new JsonResponse( $dto, headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a list of dead instances', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: InstancesDtoV2::class) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Tag(name: 'instance')] /** * Get dead instances. */ public function getDead( RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, InstanceRepository $instanceRepository, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $instances = array_map(fn (Instance $i) => new InstanceDto($i->domain, $i->software, $i->version), $instanceRepository->getDeadInstances()); $dto = new InstancesDtoV2($instances); return new JsonResponse( $dto, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Instance/InstanceRetrieveInfoApi.php ================================================ create($admin); unset($json['@context']); return $json; }; $adminUsers = $this->userRepository->findAllAdmins(); $admins = array_map($userToJson, $adminUsers); $moderatorUsers = $this->userRepository->findAllModerators(); $moderators = array_map($userToJson, $moderatorUsers); $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $body = [ 'softwareName' => $projectInfo->getName(), 'softwareVersion' => $projectInfo->getVersion(), 'softwareRepository' => $projectInfo->getRepositoryURL(), 'websiteDomain' => $settings->get('KBIN_DOMAIN'), 'websiteContactEmail' => $settings->get('KBIN_CONTACT_EMAIL'), 'websiteTitle' => $settings->get('KBIN_TITLE'), 'websiteOpenRegistrations' => $settings->get('KBIN_REGISTRATIONS_ENABLED'), 'websiteFederationEnabled' => $settings->get('KBIN_FEDERATION_ENABLED'), 'websiteDefaultLang' => $settings->get('KBIN_DEFAULT_LANG'), 'instanceModerators' => $moderators, 'instanceAdmins' => $admins, ]; return new JsonResponse( $body, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Instance/InstanceRetrieveStatsApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $request = $this->request->getCurrentRequest(); $resolution = $request->get('resolution'); $local = filter_var($request->get('local', false), FILTER_VALIDATE_BOOL); try { $startString = $request->get('start'); if (null === $startString) { $start = null; } else { $start = new \DateTime($startString); } $endString = $request->get('end'); if (null === $endString) { $end = null; } else { $end = new \DateTime($endString); } } catch (\Exception $e) { throw new BadRequestHttpException('Failed to parse start or end time'); } if (null === $resolution) { throw new BadRequestHttpException('Resolution must be provided!'); } try { $stats = $repository->getStats(null, $resolution, $start, $end, $local); } catch (\LogicException $e) { throw new BadRequestHttpException($e->getMessage()); } return new JsonResponse( $stats, headers: $headers ); } #[OA\Response( response: 200, description: 'Submissions by interval retrieved. These are not guaranteed to be continuous.', content: new OA\JsonContent( properties: [ new OA\Property( 'entry', type: 'array', items: new OA\Items(ref: new Model(type: ContentStatsResponseDto::class)) ), new OA\Property( 'entry_comment', type: 'array', items: new OA\Items(ref: new Model(type: ContentStatsResponseDto::class)) ), new OA\Property( 'post', type: 'array', items: new OA\Items(ref: new Model(type: ContentStatsResponseDto::class)) ), new OA\Property( 'post_comment', type: 'array', items: new OA\Items(ref: new Model(type: ContentStatsResponseDto::class)) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'Invalid parameters', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'start', in: 'query', description: 'The start date of the window to retrieve submissions in. If not provided defaults to 1 (resolution) ago', schema: new OA\Schema(type: 'string', format: 'date'), )] #[OA\Parameter( name: 'end', in: 'query', description: 'The end date of the window to retrieve submissions in. If not provided defaults to today', schema: new OA\Schema(type: 'string', format: 'date'), )] #[OA\Parameter( name: 'resolution', required: true, in: 'query', description: 'The size of chunks to aggregate content submissions in', schema: new OA\Schema(type: 'string', enum: ['all', 'year', 'month', 'day', 'hour']), )] #[OA\Parameter( name: 'local', in: 'query', description: 'Exclude federated content?', schema: new OA\Schema(type: 'boolean', default: false), )] #[OA\Tag(name: 'instance/stats')] /** * Retrieve the content stats of the instance over time. */ public function content( StatsContentRepository $repository, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $request = $this->request->getCurrentRequest(); $resolution = $request->get('resolution'); $local = filter_var($request->get('local', false), FILTER_VALIDATE_BOOL); try { $startString = $request->get('start'); if (null === $startString) { $start = null; } else { $start = new \DateTimeImmutable($startString); } $endString = $request->get('end'); if (null === $endString) { $end = null; } else { $end = new \DateTimeImmutable($endString); } } catch (\Exception $e) { throw new BadRequestHttpException('Failed to parse start or end time'); } if (null === $resolution) { throw new BadRequestHttpException('Resolution must be provided!'); } try { $stats = $repository->getStats(null, $resolution, $start, $end, $local); } catch (\LogicException $e) { throw new BadRequestHttpException($e->getMessage()); } return new JsonResponse( $stats, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazineAddBadgesApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); /** * @var BadgeDto $dto */ $dto = $serializer->deserialize($request->getContent(), BadgeDto::class, 'json', ['groups' => ['create-badge']]); $dto->magazine = $magazine; $errors = $validator->validate($dto); if (0 < \count($errors)) { throw new BadRequestHttpException((string) $errors); } $manager->create($dto); return new JsonResponse( $this->serializeMagazine($factory->createDto($magazine)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazineAddModeratorsApi.php ================================================ rateLimit($apiModerateLimiter); $moderator = $magazine->moderators->findFirst(fn (int $index, Moderator $moderator) => $moderator->user->getId() === $user->getId()); if (null !== $moderator) { throw new BadRequestHttpException('The user is already a moderator of this magazine'); } $dto = new ModeratorDto($magazine); $dto->user = $user; $dto->addedBy = $this->getUserOrThrow(); $manager->addModerator($dto); return new JsonResponse( $this->serializeMagazine($factory->createDto($magazine)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazineAddTagsApi.php ================================================ rateLimit($apiModerateLimiter); if (null === $tag) { throw new BadRequestHttpException('Tag is required'); } if (null !== $magazine->tags && false !== array_search($tag, $magazine->tags)) { throw new BadRequestHttpException('Tag exists on magazine already'); } if (1 !== preg_match('/^[a-z]{2,32}$/', $tag)) { throw new BadRequestHttpException('Invalid tag'); } if (null === $magazine->tags) { $magazine->tags = []; } array_push($magazine->tags, $tag); $entityManager->flush(); return new JsonResponse( $this->serializeMagazine($factory->createDto($magazine)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazineCreateApi.php ================================================ rateLimit($apiMagazineLimiter); $magazine = $this->createMagazine(); return new JsonResponse( $this->serializeMagazine($this->manager->createDto($magazine)), status: 201, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazineDeleteApi.php ================================================ rateLimit($apiDeleteLimiter); $manager->delete($magazine); return new JsonResponse(status: 204, headers: $headers); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazineDeleteBannerApi.php ================================================ rateLimit($apiModerateLimiter); $manager->detachBanner($magazine); $iconDto = $magazine->icon ? $this->imageFactory->createDto($magazine->icon) : null; $dto = MagazineThemeResponseDto::create($manager->createDto($magazine), $magazine->customCss, $iconDto, banner: null); return new JsonResponse( $dto, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazineDeleteIconApi.php ================================================ rateLimit($apiModerateLimiter); $manager->detachIcon($magazine); $bannerDto = $magazine->banner ? $this->imageFactory->createDto($magazine->banner) : null; $dto = MagazineThemeResponseDto::create($manager->createDto($magazine), $magazine->customCss, icon: null, banner: $bannerDto); return new JsonResponse( $dto, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazinePurgeApi.php ================================================ rateLimit($apiDeleteLimiter); $manager->purge($magazine); return new JsonResponse(status: 204, headers: $headers); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazineRemoveBadgesApi.php ================================================ rateLimit($apiModerateLimiter); if ($badge->magazine->getId() !== $magazine->getId()) { throw new NotFoundHttpException('Badge not found on magazine'); } $manager->delete($badge); return new JsonResponse( $this->serializeMagazine($factory->createDto($magazine)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazineRemoveModeratorsApi.php ================================================ rateLimit($apiModerateLimiter); $moderator = $magazine->moderators->findFirst(fn (int $index, Moderator $moderator) => $moderator->user->getId() === $user->getId()); if (null === $moderator) { throw new BadRequestHttpException('Given user is not a moderator of this magazine'); } $manager->removeModerator($moderator, $this->getUserOrThrow()); return new JsonResponse( $this->serializeMagazine($factory->createDto($magazine)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazineRemoveTagsApi.php ================================================ rateLimit($apiModerateLimiter); if (!$magazine->userIsOwner($this->getUserOrThrow())) { throw new AccessDeniedHttpException(); } if (null === $tag) { throw new BadRequestHttpException('Tag must be present'); } if (null === $magazine->tags) { $index = false; } else { $index = array_search($tag, $magazine->tags); } if (false === $index) { throw new BadRequestHttpException('Tag is not present on magazine'); } array_splice($magazine->tags, $index, 1); if (0 === \count($magazine->tags)) { $magazine->tags = null; } $entityManager->flush(); return new JsonResponse( $this->serializeMagazine($factory->createDto($magazine)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazineRetrieveStatsApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); $resolution = $request->get('resolution'); $local = filter_var($request->get('local', false), FILTER_VALIDATE_BOOL); try { $startString = $request->get('start'); if (null === $startString) { $start = null; } else { $start = new \DateTime($startString); } $endString = $request->get('end'); if (null === $endString) { $end = null; } else { $end = new \DateTime($endString); } } catch (\Exception $e) { throw new BadRequestHttpException('Failed to parse start or end time'); } if (null === $resolution) { throw new BadRequestHttpException('Resolution must be provided!'); } try { $stats = $repository->getStats($magazine, $resolution, $start, $end, $local); } catch (\LogicException $e) { throw new BadRequestHttpException($e->getMessage()); } return new JsonResponse( $stats, headers: $headers ); } #[OA\Response( response: 200, description: 'Submissions by interval retrieved. These are not guaranteed to be continuous.', content: new OA\JsonContent( properties: [ new OA\Property( 'entry', type: 'array', items: new OA\Items(ref: new Model(type: ContentStatsResponseDto::class)) ), new OA\Property( 'entry_comment', type: 'array', items: new OA\Items(ref: new Model(type: ContentStatsResponseDto::class)) ), new OA\Property( 'post', type: 'array', items: new OA\Items(ref: new Model(type: ContentStatsResponseDto::class)) ), new OA\Property( 'post_comment', type: 'array', items: new OA\Items(ref: new Model(type: ContentStatsResponseDto::class)) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'Invalid parameters', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You do not have permission to view the stats of this magazine', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Magazine not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', in: 'path', description: 'The id of the magazine to retrieve stats from', schema: new OA\Schema(type: 'integer'), )] #[OA\Parameter( name: 'start', in: 'query', description: 'The start date of the window to retrieve submissions in. If not provided defaults to 1 (resolution) ago', schema: new OA\Schema(type: 'string', format: 'date'), )] #[OA\Parameter( name: 'end', in: 'query', description: 'The end date of the window to retrieve submissions in. If not provided defaults to today', schema: new OA\Schema(type: 'string', format: 'date'), )] #[OA\Parameter( name: 'resolution', required: true, in: 'query', description: 'The size of chunks to aggregate content submissions in', schema: new OA\Schema(type: 'string', enum: ['all', 'year', 'month', 'day', 'hour']), )] #[OA\Parameter( name: 'local', in: 'query', description: 'Exclude federated content?', schema: new OA\Schema(type: 'boolean', default: false), )] #[OA\Tag(name: 'moderation/magazine/owner')] #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:stats'])] #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:STATS')] #[IsGranted('edit', subject: 'magazine')] /** * Retrieve the content stats of a magazine over time. */ public function content( #[MapEntity(id: 'magazine_id')] Magazine $magazine, StatsContentRepository $repository, RateLimiterFactoryInterface $apiModerateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); $resolution = $request->get('resolution'); $local = filter_var($request->get('local', false), FILTER_VALIDATE_BOOL); try { $startString = $request->get('start'); if (null === $startString) { $start = null; } else { $start = new \DateTimeImmutable($startString); } $endString = $request->get('end'); if (null === $endString) { $end = null; } else { $end = new \DateTimeImmutable($endString); } } catch (\Exception $e) { throw new BadRequestHttpException('Failed to parse start or end time'); } if (null === $resolution) { throw new BadRequestHttpException('Resolution must be provided!'); } try { $stats = $repository->getStats($magazine, $resolution, $start, $end, $local); } catch (\LogicException $e) { throw new BadRequestHttpException($e->getMessage()); } return new JsonResponse( $stats, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazineUpdateApi.php ================================================ rateLimit($apiUpdateLimiter); if (!$magazine->userIsOwner($this->getUserOrThrow())) { throw new AccessDeniedHttpException(); } $dto = $this->deserializeMagazine($manager->createDto($magazine)); $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } if (!$magazine->rules && $dto->rules) { throw new BadRequestHttpException($translator->trans('magazine_rules_deprecated')); } if ($magazine->name !== $dto->name) { throw new BadRequestHttpException('Magazine name cannot be edited'); } $magazine = $manager->edit($magazine, $dto, $this->getUserOrThrow()); return new JsonResponse( $this->serializeMagazine($factory->createDto($magazine)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Admin/MagazineUpdateThemeApi.php ================================================ rateLimit($apiModerateLimiter); $dto = new MagazineThemeDto($magazine); $dto = $this->deserializeThemeFromForm($dto); try { $icon = $this->handleUploadedImage(); $dto->icon = $imageFactory->createDto($icon); } catch (BadRequestHttpException $e) { $dto->icon = null; // Todo: add an API to remove the icon rather than only replace } $errors = $validator->validate($dto); if (0 < \count($errors)) { throw new BadRequestHttpException((string) $errors); } $manager->changeTheme($dto); $imageDto = $magazine->icon ? $this->imageFactory->createDto($magazine->icon) : null; $bannerDto = $magazine->banner ? $this->imageFactory->createDto($magazine->banner) : null; $dto = MagazineThemeResponseDto::create($manager->createDto($magazine), $magazine->customCss, $imageDto, $bannerDto); return new JsonResponse( $dto, headers: $headers ); } #[OA\Response( response: 200, description: 'Magazine banner updated', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: MagazineThemeResponseDto::class) )] #[OA\Response( response: 400, description: 'The uploaded image was missing or invalid', content: new OA\JsonContent(ref: new Model(type: BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not authorized to update the magazine\'s banner', content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\RequestBody(content: new OA\MediaType( 'multipart/form-data', schema: new OA\Schema( ref: new Model( type: ImageUploadDto::class, groups: [ ImageUploadDto::IMAGE_UPLOAD_NO_ALT, ] ) ) ))] #[OA\Tag(name: 'moderation/magazine/owner')] #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:theme'])] #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:THEME')] #[IsGranted('edit', subject: 'magazine')] public function banner( #[MapEntity(id: 'magazine_id')] Magazine $magazine, MagazineManager $manager, RateLimiterFactoryInterface $apiModerateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); $image = $this->handleUploadedImage(); if (null !== $magazine->banner && $image->getId() !== $magazine->banner->getId()) { $manager->detachBanner($magazine); } $dto = new MagazineThemeDto($magazine); $dto->banner = $image ? $this->imageFactory->createDto($image) : $dto->banner; $magazine = $manager->changeTheme($dto); $iconDto = $magazine->icon ? $this->imageFactory->createDto($magazine->icon) : null; $bannerDto = $magazine->banner ? $this->imageFactory->createDto($magazine->banner) : null; return new JsonResponse( MagazineThemeResponseDto::create($manager->createDto($magazine), $magazine->customCss, $iconDto, $bannerDto), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/MagazineBaseApi.php ================================================ reportFactory = $reportFactory; } #[Required] public function setManager(MagazineManager $manager) { $this->manager = $manager; } protected function serializeReport(Report $report) { $response = $this->reportFactory->createResponseDto($report); return $response; } /** * Deserialize a magazine from JSON. * * @param ?MagazineDto $dto The MagazineDto to modify with new values (default: null to create a new MagazineDto) * * @return MagazineDto A magazine with only certain fields allowed to be modified by the user */ protected function deserializeMagazine(?MagazineDto $dto = null): MagazineDto { $dto = $dto ?? new MagazineDto(); $deserialized = $this->serializer->deserialize($this->request->getCurrentRequest()->getContent(), MagazineRequestDto::class, 'json'); \assert($deserialized instanceof MagazineRequestDto); return $deserialized->mergeIntoDto($dto); } protected function deserializeThemeFromForm(MagazineThemeDto $dto): MagazineThemeDto { $deserialized = new MagazineThemeRequestDto(); $deserialized->customCss = $this->request->getCurrentRequest()->get('customCss'); $deserialized->backgroundImage = $this->request->getCurrentRequest()->get('backgroundImage'); $dto = $deserialized->mergeIntoDto($dto); return $dto; } protected function createMagazine(?ImageDto $image = null): Magazine { $dto = $this->deserializeMagazine(); if ($image) { $dto->icon = $image; } $errors = $this->validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } if (!empty($dto->rules)) { throw new BadRequestHttpException($this->translator->trans('magazine_rules_deprecated')); } // Rate limit handled elsewhere $magazine = $this->manager->create($dto, $this->getUserOrThrow(), rateLimit: false); return $magazine; } } ================================================ FILE: src/Controller/Api/Magazine/MagazineBlockApi.php ================================================ rateLimit($apiUpdateLimiter); $manager->block($magazine, $this->getUserOrThrow()); return new JsonResponse( $this->serializeMagazine($factory->createDto($magazine)), headers: $headers ); } #[OA\Response( response: 200, description: 'Magazine unblocked', content: new Model(type: MagazineResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Magazine not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', in: 'path', description: 'The magazine to unblock', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'magazine')] #[Security(name: 'oauth2', scopes: ['magazine:block'])] #[IsGranted('ROLE_OAUTH2_MAGAZINE:BLOCK')] #[IsGranted('block', subject: 'magazine')] public function unblock( #[MapEntity(id: 'magazine_id')] Magazine $magazine, MagazineManager $manager, MagazineFactory $factory, RateLimiterFactoryInterface $apiUpdateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiUpdateLimiter); $manager->unblock($magazine, $this->getUserOrThrow()); return new JsonResponse( $this->serializeMagazine($factory->createDto($magazine)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/MagazineModLogApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $request = $this->request->getCurrentRequest(); $logs = $repository->findByCustom( $this->getPageNb($request), self::constrainPerPage($request->get('perPage', MagazineLogRepository::PER_PAGE)), types: $types, magazine: $magazine, ); $dtos = []; foreach ($logs->getCurrentPageResults() as $value) { $dtos[] = $this->serializeLogItem($value); } return new JsonResponse( $this->serializePaginated($dtos, $logs), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/MagazineRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $dto = $factory->createDto($magazine); return new JsonResponse( $this->serializeMagazine($dto), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns the magazine for the given name', content: new Model(type: MagazineResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Magazine not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_name', in: 'path', description: 'The magazine to retrieve', schema: new OA\Schema(type: 'string'), )] #[OA\Tag(name: 'magazine')] public function byName( #[MapEntity(mapping: ['magazine_name' => 'name'])] Magazine $magazine, MagazineFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $dto = $factory->createDto($magazine); return new JsonResponse( $this->serializeMagazine($dto), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of magazines', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: MagazineResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of magazines to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of magazines per page', in: 'query', schema: new OA\Schema(type: 'integer', default: MagazineRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'q', description: 'Magazine search term', in: 'query', schema: new OA\Schema(type: 'string') )] #[OA\Parameter( name: 'sort', description: 'Sort method to use when retrieving magazines', in: 'query', schema: new OA\Schema(type: 'string', default: MagazinePageView::SORT_HOT, enum: [...MagazineRepository::SORT_OPTIONS, MagazinePageView::SORT_OWNER_LAST_ACTIVE]) )] #[OA\Parameter( name: 'federation', description: 'What type of federated magazines to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Parameter( name: 'hide_adult', description: 'Options for retrieving adult magazines', in: 'query', schema: new OA\Schema(type: 'string', default: MagazinePageView::ADULT_HIDE, enum: MagazinePageView::ADULT_OPTIONS) )] #[OA\Parameter( name: 'abandoned', description: 'Options for retrieving abandoned magazines (federation must be \''.Criteria::AP_LOCAL.'\')', in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] #[OA\Tag(name: 'magazine')] public function collection( MagazineRepository $repository, MagazineFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $request = $this->request->getCurrentRequest(); $criteria = new MagazinePageView( $this->getPageNb($request), $request->get('sort', MagazinePageView::SORT_HOT), $request->get('federation', Criteria::AP_ALL), $request->get('hide_adult', MagazinePageView::ADULT_HIDE), filter_var($request->get('abandoned', 'false'), FILTER_VALIDATE_BOOL), ); $criteria->perPage = self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE)); if ($q = $request->get('q')) { $criteria->query = $q; } $magazines = $repository->findPaginated($criteria); $dtos = []; foreach ($magazines->getCurrentPageResults() as $value) { \assert($value instanceof Magazine); array_push($dtos, $this->serializeMagazine($factory->createDto($value))); } return new JsonResponse( $this->serializePaginated($dtos, $magazines), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of subscribed magazines', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: MagazineResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of magazines to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of magazines per page', in: 'query', schema: new OA\Schema(type: 'integer', default: MagazineRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Tag(name: 'magazine')] #[Security(name: 'oauth2', scopes: ['magazine:subscribe'])] #[IsGranted('ROLE_OAUTH2_MAGAZINE:SUBSCRIBE')] public function subscribed( MagazineRepository $repository, MagazineFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $request = $this->request->getCurrentRequest(); $magazines = $repository->findSubscribedMagazines( $this->getPageNb($request), $this->getUserOrThrow(), self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE)) ); $dtos = []; foreach ($magazines->getCurrentPageResults() as $value) { \assert($value instanceof MagazineSubscription); array_push($dtos, $this->serializeMagazine($factory->createDto($value->magazine))); } return new JsonResponse( $this->serializePaginated($dtos, $magazines), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of user\'s subscribed magazines', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: MagazineResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'This user does not allow others to view their subscribed magazines', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'user_id', description: 'User from which to retrieve subscribed magazines', in: 'path', schema: new OA\Schema(type: 'integer') )] #[OA\Parameter( name: 'p', description: 'Page of magazines to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of magazines per page', in: 'query', schema: new OA\Schema( type: 'integer', default: MagazineRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE ) )] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['magazine:subscribe'])] #[IsGranted('ROLE_OAUTH2_MAGAZINE:SUBSCRIBE')] public function subscriptions( #[MapEntity(id: 'user_id')] User $user, MagazineRepository $repository, MagazineFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); if ($user->getId() !== $this->getUserOrThrow()->getId() && !$user->getShowProfileSubscriptions()) { throw new AccessDeniedHttpException('You are not permitted to view the magazines this user subscribes to'); } $request = $this->request->getCurrentRequest(); $magazines = $repository->findSubscribedMagazines( $this->getPageNb($request), $user, self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE)) ); $dtos = []; foreach ($magazines->getCurrentPageResults() as $value) { \assert($value instanceof MagazineSubscription); array_push($dtos, $this->serializeMagazine($factory->createDto($value->magazine))); } return new JsonResponse( $this->serializePaginated($dtos, $magazines), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of moderated magazines', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: MagazineResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of magazines to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of magazines per page', in: 'query', schema: new OA\Schema(type: 'integer', default: MagazineRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Tag(name: 'magazine')] #[Security(name: 'oauth2', scopes: ['moderate:magazine:list'])] #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE:LIST')] public function moderated( MagazineRepository $repository, MagazineFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $request = $this->request->getCurrentRequest(); $magazines = $repository->findModeratedMagazines( $this->getUserOrThrow(), $this->getPageNb($request), self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE)) ); $dtos = []; foreach ($magazines->getCurrentPageResults() as $value) { \assert($value instanceof Magazine); array_push($dtos, $this->serializeMagazine($factory->createDto($value))); } return new JsonResponse( $this->serializePaginated($dtos, $magazines), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of blocked magazines', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: MagazineResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of magazines to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of magazines per page', in: 'query', schema: new OA\Schema(type: 'integer', default: MagazineRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Tag(name: 'magazine')] #[Security(name: 'oauth2', scopes: ['magazine:block'])] #[IsGranted('ROLE_OAUTH2_MAGAZINE:BLOCK')] public function blocked( MagazineRepository $repository, MagazineFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $request = $this->request->getCurrentRequest(); $magazines = $repository->findBlockedMagazines( $this->getPageNb($request), $this->getUserOrThrow(), self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE)) ); $dtos = []; foreach ($magazines->getCurrentPageResults() as $value) { \assert($value instanceof MagazineBlock); array_push($dtos, $this->serializeMagazine($factory->createDto($value->magazine))); } return new JsonResponse( $this->serializePaginated($dtos, $magazines), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/MagazineRetrieveThemeApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $imageDto = $magazine->icon ? $this->imageFactory->createDto($magazine->icon) : null; $bannerDto = $magazine->banner ? $this->imageFactory->createDto($magazine->banner) : null; $dto = MagazineThemeResponseDto::create($magazineFactory->createDto($magazine), $magazine->customCss, $imageDto, $bannerDto); return new JsonResponse( $dto, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/MagazineSubscribeApi.php ================================================ rateLimit($apiUpdateLimiter); $manager->subscribe($magazine, $this->getUserOrThrow()); return new JsonResponse( $this->serializeMagazine($factory->createDto($magazine)), headers: $headers ); } #[OA\Response( response: 200, description: 'Magazine subscription status updated', content: new Model(type: MagazineResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Magazine not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', in: 'path', description: 'The magazine to unsubscribe from', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'magazine')] #[Security(name: 'oauth2', scopes: ['magazine:subscribe'])] #[IsGranted('ROLE_OAUTH2_MAGAZINE:SUBSCRIBE')] #[IsGranted('subscribe', subject: 'magazine')] public function unsubscribe( #[MapEntity(id: 'magazine_id')] Magazine $magazine, MagazineManager $manager, MagazineFactory $factory, RateLimiterFactoryInterface $apiUpdateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiUpdateLimiter); $manager->unsubscribe($magazine, $this->getUserOrThrow()); return new JsonResponse( $this->serializeMagazine($factory->createDto($magazine)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Moderate/MagazineBansRetrieveApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); $bans = $repository->findBans( $magazine, $this->getPageNb($request), self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE)) ); $dtos = []; foreach ($bans->getCurrentPageResults() as $value) { \assert($value instanceof MagazineBan); array_push($dtos, $factory->createBanDto($value)); } return new JsonResponse( $this->serializePaginated($dtos, $bans), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Moderate/MagazineModOwnerRequestApi.php ================================================ rateLimit($apiModerateLimiter); // applying to be a moderator is only supported for local magazines if ($magazine->apId) { throw new AccessDeniedException(); } $this->manager->toggleModeratorRequest($magazine, $this->getUserOrThrow()); return new JsonResponse( new ToggleCreatedDto($this->manager->userRequestedModerator($magazine, $this->getUserOrThrow())), headers: $headers, ); } #[OA\Response( response: 204, description: 'Moderator request was accepted', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token or you are no admin', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'mod request not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', in: 'path', description: 'The magazine to manage', schema: new OA\Schema(type: 'integer'), )] #[OA\Parameter( name: 'user_id', in: 'path', description: 'The user to accept', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'moderation/magazine/owner')] #[Security(name: 'oauth2', scopes: ['admin:magazine:moderate'])] #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE')] #[IsGranted('ROLE_ADMIN')] public function acceptModRequest( #[MapEntity(id: 'magazine_id')] Magazine $magazine, #[MapEntity(id: 'user_id')] User $user, RateLimiterFactoryInterface $apiModerateLimiter, ): Response { $headers = $this->rateLimit($apiModerateLimiter); if (!$this->manager->userRequestedModerator($magazine, $user)) { throw new NotFoundHttpException('moderator request does not exist'); } $this->manager->acceptModeratorRequest($magazine, $user, $this->getUserOrThrow()); return new Response( status: Response::HTTP_NO_CONTENT, headers: $headers, ); } #[OA\Response( response: 204, description: 'Moderator request was rejected', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token or you are no admin', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'mod request not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', in: 'path', description: 'The magazine to manage', schema: new OA\Schema(type: 'integer'), )] #[OA\Parameter( name: 'user_id', in: 'path', description: 'The user to reject', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'moderation/magazine/owner')] #[Security(name: 'oauth2', scopes: ['admin:magazine:moderate'])] #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE')] #[IsGranted('ROLE_ADMIN')] public function rejectModRequest( #[MapEntity(id: 'magazine_id')] Magazine $magazine, #[MapEntity(id: 'user_id')] User $user, RateLimiterFactoryInterface $apiModerateLimiter, ): Response { $headers = $this->rateLimit($apiModerateLimiter); if (!$this->manager->userRequestedModerator($magazine, $user)) { throw new NotFoundHttpException('moderator request does not exist'); } $this->manager->toggleModeratorRequest($magazine, $user); return new Response( status: Response::HTTP_NO_CONTENT, headers: $headers, ); } #[OA\Response( response: 200, description: 'returns a list of moderator requests with user and magazine', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token or you are no admin', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Magazine not found or id was invalid', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine', in: 'query', description: 'The magazine to filter for', required: false, schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'moderation/magazine/owner')] #[Security(name: 'oauth2', scopes: ['admin:magazine:moderate'])] #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE')] #[IsGranted('ROLE_ADMIN')] public function getModRequests( #[MapQueryParameter(name: 'magazine')] ?int $magazineId, RateLimiterFactoryInterface $apiModerateLimiter, ): Response { $headers = $this->rateLimit($apiModerateLimiter); $magazine = null; if (null !== $magazineId) { $magazine = $this->entityManager->getRepository(Magazine::class)->find($magazineId); if (null === $magazine) { throw new NotFoundHttpException("magazine with id $magazineId does not exist"); } } $requests = $this->manager->listModeratorRequests($magazine); $requestsDto = array_map(function ($item) { return [ 'user' => $this->userFactory->createSmallDto($item->user), 'magazine' => $this->magazineFactory->createSmallDto($item->magazine), ]; }, $requests); return new JsonResponse( $requestsDto, headers: $headers, ); } #[OA\Response( response: 200, description: 'Owner request created or deleted', content: new Model(type: ToggleCreatedDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token or the magazine is not local', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Magazine not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', in: 'path', description: 'The magazine to apply for owner to', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'moderation/magazine/owner')] #[Security(name: 'oauth2', scopes: ['magazine:subscribe'])] #[IsGranted('ROLE_OAUTH2_MAGAZINE:SUBSCRIBE')] #[IsGranted(MagazineVoter::SUBSCRIBE, subject: 'magazine')] public function toggleOwnerRequest( #[MapEntity(id: 'magazine_id')] Magazine $magazine, RateLimiterFactoryInterface $apiModerateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); // applying to be a owner is only supported for local magazines if ($magazine->apId) { throw new AccessDeniedException(); } $this->manager->toggleOwnershipRequest($magazine, $this->getUserOrThrow()); return new JsonResponse( new ToggleCreatedDto($this->manager->userRequestedOwnership($magazine, $this->getUserOrThrow())), headers: $headers, ); } #[OA\Response( response: 204, description: 'Ownership request was accepted', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token or you are no admin', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'owner request not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', in: 'path', description: 'The magazine to manage', schema: new OA\Schema(type: 'integer'), )] #[OA\Parameter( name: 'user_id', in: 'path', description: 'The user to reject', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'moderation/magazine/owner')] #[Security(name: 'oauth2', scopes: ['admin:magazine:moderate'])] #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE')] #[IsGranted('ROLE_ADMIN')] public function acceptOwnerRequest( #[MapEntity(id: 'magazine_id')] Magazine $magazine, #[MapEntity(id: 'user_id')] User $user, RateLimiterFactoryInterface $apiModerateLimiter, ): Response { $headers = $this->rateLimit($apiModerateLimiter); if (!$this->manager->userRequestedOwnership($magazine, $user)) { throw new NotFoundHttpException('ownership request does not exist'); } $this->manager->acceptOwnershipRequest($magazine, $user, $this->getUserOrThrow()); return new JsonResponse( status: Response::HTTP_NO_CONTENT, headers: $headers, ); } #[OA\Response( response: 204, description: 'Moderator request was rejected', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token or you are no admin', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'owner request not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', in: 'path', description: 'The magazine to manage', schema: new OA\Schema(type: 'integer'), )] #[OA\Parameter( name: 'user_id', in: 'path', description: 'The user to reject', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'moderation/magazine/owner')] #[Security(name: 'oauth2', scopes: ['admin:magazine:moderate'])] #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE')] #[IsGranted('ROLE_ADMIN')] public function rejectOwnerRequest( #[MapEntity(id: 'magazine_id')] Magazine $magazine, #[MapEntity(id: 'user_id')] User $user, RateLimiterFactoryInterface $apiModerateLimiter, ): Response { $headers = $this->rateLimit($apiModerateLimiter); if (!$this->manager->userRequestedOwnership($magazine, $user)) { throw new NotFoundHttpException('ownership request does not exist'); } $this->manager->toggleOwnershipRequest($magazine, $user); return new Response( status: Response::HTTP_NO_CONTENT, headers: $headers, ); } #[OA\Response( response: 200, description: 'returns a list of ownership requests with user and magazine', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token or you are no admin', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Magazine not found or id was invalid', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine', in: 'query', description: 'The magazine filter for', required: false, schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'moderation/magazine/owner')] #[Security(name: 'oauth2', scopes: ['admin:magazine:moderate'])] #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE')] #[IsGranted('ROLE_ADMIN')] public function getOwnerRequests( #[MapQueryParameter(name: 'magazine')] ?int $magazineId, RateLimiterFactoryInterface $apiModerateLimiter, ): Response { $headers = $this->rateLimit($apiModerateLimiter); $magazine = null; if (null !== $magazineId) { $magazine = $this->entityManager->getRepository(Magazine::class)->find($magazineId); if (null === $magazine) { throw new NotFoundHttpException("magazine with id $magazineId does not exist"); } } $requests = $this->manager->listOwnershipRequests($magazine); $requestsDto = array_map(function ($item) { return [ 'user' => $this->userFactory->createSmallDto($item->user), 'magazine' => $this->magazineFactory->createSmallDto($item->magazine), ]; }, $requests); return new JsonResponse( $requestsDto, headers: $headers, ); } } ================================================ FILE: src/Controller/Api/Magazine/Moderate/MagazineReportsAcceptApi.php ================================================ rateLimit($apiModerateLimiter); if ($magazine->getId() !== $report->magazine->getId()) { throw new NotFoundHttpException('Report not found in magazine'); } $manager = $managerFactory->createManager($report->getSubject()); $manager->delete($this->getUserOrThrow(), $report->getSubject()); return new JsonResponse( $this->serializeReport($report), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Moderate/MagazineReportsRejectApi.php ================================================ rateLimit($apiModerateLimiter); if ($magazine->getId() !== $report->magazine->getId()) { throw new NotFoundHttpException('Report not found in magazine'); } $manager->reject($report, $this->getUserOrThrow()); return new JsonResponse( $this->serializeReport($report), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Moderate/MagazineReportsRetrieveApi.php ================================================ rateLimit($apiModerateLimiter); if ($magazine->getId() !== $report->magazine->getId()) { throw new NotFoundHttpException('The report was not found in the magazine'); } return new JsonResponse( $this->serializeReport($report), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of reports', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: ReportResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', description: 'Magazine to retrieve reports from', in: 'path', schema: new OA\Schema(type: 'integer') )] #[OA\Parameter( name: 'p', description: 'Page of reports to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of reports per page', in: 'query', schema: new OA\Schema(type: 'integer', default: MagazineRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'status', description: 'Filter by report status', in: 'query', schema: new OA\Schema(type: 'string', default: Report::STATUS_PENDING, enum: Report::STATUS_OPTIONS) )] #[OA\Tag(name: 'moderation/magazine')] #[Security(name: 'oauth2', scopes: ['moderate:magazine:reports:read'])] #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE:REPORTS:READ')] #[IsGranted('moderate', subject: 'magazine')] public function collection( #[MapEntity(id: 'magazine_id')] Magazine $magazine, MagazineRepository $repository, RateLimiterFactoryInterface $apiModerateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); $status = $request->get('status', Report::STATUS_PENDING); if (false === array_search($status, Report::STATUS_OPTIONS)) { throw new BadRequestHttpException('Invalid status'); } $reports = $repository->findReports( $magazine, $this->getPageNb($request), self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE)), $status ); $dtos = []; foreach ($reports->getCurrentPageResults() as $value) { \assert($value instanceof Report); array_push($dtos, $this->serializeReport($value)); } return new JsonResponse( $this->serializePaginated($dtos, $reports), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Moderate/MagazineTrashedRetrieveApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); $trash = $repository->findTrashed( $magazine, $this->getPageNb($request), self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE)) ); $dtos = []; foreach ($trash->getCurrentPageResults() as $value) { array_push($dtos, $this->serializeContentInterface($value, forceVisible: true)); } return new JsonResponse( $this->serializePaginated($dtos, $trash), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Magazine/Moderate/MagazineUserBanApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); $moderator = $this->getUserOrThrow(); /** @var MagazineBanDto $ban */ $ban = $deserializer->deserialize($request->getContent(), MagazineBanDto::class, 'json'); $errors = $validator->validate($ban); if (0 < \count($errors)) { throw new BadRequestHttpException((string) $errors); } $ban = $manager->ban($magazine, $user, $moderator, $ban); if (!$ban) { throw new BadRequestHttpException('Failed to ban user'); } $response = $factory->createBanDto($ban); return new JsonResponse( $response, headers: $headers ); } #[OA\Response( response: 200, description: 'User unbanned', content: new Model(type: MagazineBanResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not authorized to unban this user', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'User or magazine not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', in: 'path', description: 'The magazine the user is banned in', schema: new OA\Schema(type: 'integer'), )] #[OA\Parameter( name: 'user_id', in: 'path', description: 'The user to unban', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'moderation/magazine')] #[Security(name: 'oauth2', scopes: ['moderate:magazine:ban:delete'])] #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE:BAN:DELETE')] #[IsGranted('moderate', subject: 'magazine')] /** * Remove magazine ban from a user. */ public function unban( #[MapEntity(id: 'magazine_id')] Magazine $magazine, #[MapEntity(id: 'user_id')] User $user, MagazineManager $manager, MagazineFactory $factory, RateLimiterFactoryInterface $apiModerateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); $ban = $manager->unban($magazine, $user); if (!$ban) { throw new BadRequestHttpException('Failed to ban user'); } $response = $factory->createBanDto($ban); return new JsonResponse( $response, headers: $headers ); } } ================================================ FILE: src/Controller/Api/MagazineBadges.php ================================================ $this->factory->createDto($badge), $magazine->badges->toArray()); return new DtoPaginator($dtos, 0, 10, \count($dtos)); } } ================================================ FILE: src/Controller/Api/Message/MessageBaseApi.php ================================================ messageFactory = $messageFactory; } /** * Serialize a single message to JSON. * * @param Message $message The Message to serialize * * @return array An associative array representation of the message's safe fields, to be used as JSON */ protected function serializeMessage(Message $message) { $response = $this->messageFactory->createResponseDto($message); return $response; } /** * Serialize a message thread to JSON. * * @param MessageThread $thread The thread to serialize * * @return array An associative array representation of the message's safe fields, to be used as JSON */ protected function serializeMessageThread(MessageThread $thread) { $depth = $this->constrainPerPage($this->request->getCurrentRequest()->get('d', self::REPLY_DEPTH), self::MIN_REPLY_DEPTH, self::MAX_REPLY_DEPTH); $response = $this->messageFactory->createThreadResponseDto($thread, $depth); return $response; } /** * Deserialize a message from JSON. * * @return MessageDto A message DTO */ protected function deserializeMessage(): MessageDto { $request = $this->request->getCurrentRequest(); $dto = $this->serializer->deserialize($request->getContent(), MessageDto::class, 'json'); return $dto; } } ================================================ FILE: src/Controller/Api/Message/MessageReadApi.php ================================================ isGranted('show', $message->thread)) { throw new AccessDeniedHttpException(); } $headers = $this->rateLimit($apiUpdateLimiter); $manager->readMessage($message, $this->getUserOrThrow(), flush: true); return new JsonResponse( $this->serializeMessage($message), headers: $headers ); } #[OA\Response( response: 200, description: 'Marks the message as new', content: new Model(type: MessageResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Message not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'message_id', in: 'path', description: 'The message to mark as new', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'message')] #[Security(name: 'oauth2', scopes: ['user:message:read'])] #[IsGranted('ROLE_OAUTH2_USER:MESSAGE:READ')] public function unread( #[MapEntity(id: 'message_id')] Message $message, MessageManager $manager, RateLimiterFactoryInterface $apiUpdateLimiter, ): JsonResponse { if (!$this->isGranted('show', $message->thread)) { throw new AccessDeniedHttpException(); } $headers = $this->rateLimit($apiUpdateLimiter); $manager->unreadMessage($message, $this->getUserOrThrow(), flush: true); return new JsonResponse( $this->serializeMessage($message), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Message/MessageRetrieveApi.php ================================================ find((int) $this->request->getCurrentRequest()->get('message_id')); if (null === $message) { throw new NotFoundHttpException(); } if (!$this->isGranted('show', $message->thread)) { throw new AccessDeniedHttpException(); } $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); return new JsonResponse( $this->serializeMessage($message), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of message threads for the current user', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: MessageThreadResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of messages to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of messages per page', in: 'query', schema: new OA\Schema(type: 'integer', default: MessageThreadRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'd', description: 'Number of replies per thread', in: 'query', schema: new OA\Schema(type: 'integer', default: self::REPLY_DEPTH, minimum: self::MIN_REPLY_DEPTH, maximum: self::MAX_REPLY_DEPTH) )] #[OA\Tag(name: 'message')] #[Security(name: 'oauth2', scopes: ['user:message:read'])] #[IsGranted('ROLE_OAUTH2_USER:MESSAGE:READ')] public function collection( MessageThreadRepository $repository, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $request = $this->request->getCurrentRequest(); $messages = $repository->findUserMessages( $this->getUserOrThrow(), $this->getPageNb($request), $this->constrainPerPage($request->get('perPage', MessageThreadRepository::PER_PAGE)) ); $dtos = []; foreach ($messages->getCurrentPageResults() as $value) { array_push($dtos, $this->serializeMessageThread($value)); } return new JsonResponse( $this->serializePaginated($dtos, $messages), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of messages in a thread', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: MessageResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), new OA\Property( property: 'participants', type: 'array', items: new OA\Items(ref: new Model(type: UserResponseDto::class)) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not allowed to view the messages in this thread', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Page not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'thread_id', description: 'Thread from which to retrieve messages', in: 'path', schema: new OA\Schema(type: 'integer') )] #[OA\Parameter( name: 'p', description: 'Page of messages to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of messages per page', in: 'query', schema: new OA\Schema( type: 'integer', default: MessageRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE ) )] #[OA\Parameter( name: 'sort', description: 'Order to retrieve messages by', in: 'path', schema: new OA\Schema(type: 'string', default: Criteria::SORT_NEW, enum: MessageThreadPageView::SORT_OPTIONS) )] #[OA\Tag(name: 'message')] #[Security(name: 'oauth2', scopes: ['user:message:read'])] #[IsGranted('ROLE_OAUTH2_USER:MESSAGE:READ')] #[IsGranted('show', subject: 'thread', statusCode: 403)] public function thread( #[MapEntity(id: 'thread_id')] MessageThread $thread, MessageRepository $repository, UserFactory $userFactory, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $request = $this->request->getCurrentRequest(); $criteria = new MessageThreadPageView($this->getPageNb($request)); $criteria->perPage = $this->constrainPerPage($request->get('perPage', self::MESSAGES_PER_PAGE)); $criteria->thread = $thread; $criteria->sortOption = $request->get('sort', Criteria::SORT_NEW); $messages = $repository->findByCriteria($criteria); $dtos = []; foreach ($messages->getCurrentPageResults() as $value) { array_push($dtos, $this->serializeMessage($value)); } $paginated = $this->serializePaginated($dtos, $messages); $paginated['participants'] = array_map( fn ($participant) => new UserResponseDto($userFactory->createDto($participant)), $thread->participants->toArray() ); return new JsonResponse( $paginated, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Message/MessageThreadCreateApi.php ================================================ rateLimit($apiMessageLimiter); if ($receiver->apId) { throw new AccessDeniedHttpException(); } $dto = $this->deserializeMessage(); $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } $thread = $manager->toThread($dto, $this->getUserOrThrow(), $receiver); return new JsonResponse( $this->serializeMessageThread($thread), status: 201, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Message/MessageThreadReplyApi.php ================================================ rateLimit($apiMessageLimiter); $dto = $this->deserializeMessage(); $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } $manager->toMessage($dto, $thread, $this->getUserOrThrow()); return new JsonResponse( $this->serializeMessageThread($thread), status: 201, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Notification/NotificationBaseApi.php ================================================ messageFactory = $messageFactory; } /** * Serialize a single message to JSON. * * @param Notification $dto The Notification to serialize * * @return array An associative array representation of the message's safe fields, to be used as JSON */ protected function serializeNotification(Notification $dto) { $toReturn = [ 'notificationId' => $dto->getId(), 'status' => $dto->status, 'type' => $dto->getType(), ]; switch ($dto->getType()) { case 'entry_created_notification': case 'entry_edited_notification': case 'entry_deleted_notification': case 'entry_mentioned_notification': /** * @var \App\Entity\EntryMentionedNotification $dto */ $entry = $dto->getSubject(); $toReturn['subject'] = $this->entryFactory->createResponseDto($entry, $this->tagLinkRepository->getTagsOfContent($entry)); break; case 'entry_comment_created_notification': case 'entry_comment_edited_notification': case 'entry_comment_reply_notification': case 'entry_comment_deleted_notification': case 'entry_comment_mentioned_notification': /** * @var \App\Entity\EntryCommentMentionedNotification $dto */ $comment = $dto->getSubject(); $toReturn['subject'] = $this->entryCommentFactory->createResponseDto($comment, $this->tagLinkRepository->getTagsOfContent($comment)); break; case 'post_created_notification': case 'post_edited_notification': case 'post_deleted_notification': case 'post_mentioned_notification': /** * @var \App\Entity\PostMentionedNotification $dto */ $post = $dto->getSubject(); $toReturn['subject'] = $this->postFactory->createResponseDto($post, $this->tagLinkRepository->getTagsOfContent($post)); break; case 'post_comment_created_notification': case 'post_comment_edited_notification': case 'post_comment_reply_notification': case 'post_comment_deleted_notification': case 'post_comment_mentioned_notification': /** * @var \App\Entity\PostCommentMentionedNotification $dto */ $comment = $dto->getSubject(); $toReturn['subject'] = $this->postCommentFactory->createResponseDto($comment, $this->tagLinkRepository->getTagsOfContent($comment)); break; case 'message_notification': if (!$this->isGranted('ROLE_OAUTH2_USER:MESSAGE:READ')) { $toReturn['subject'] = [ 'messageId' => null, 'threadId' => null, 'sender' => null, 'body' => $this->translator->trans('oauth.client_not_granted_message_read_permission'), 'status' => null, 'createdAt' => null, ]; break; } /** * @var \App\Entity\MessageNotification $dto */ $message = $dto->getSubject(); $toReturn['subject'] = $this->messageFactory->createResponseDto($message); break; case 'ban': /** * @var \App\Entity\MagazineBanNotification $dto */ $ban = $dto->getSubject(); $toReturn['subject'] = $this->magazineFactory->createBanDto($ban); break; case 'report_created_notification': /** @var ReportCreatedNotification $n */ $n = $dto; $toReturn['reason'] = $n->report->reason; // no break case 'report_rejected_notification': case 'report_approved_notification': /** @var ReportCreatedNotification|ReportRejectedNotification|ReportApprovedNotification $n */ $n = $dto; $toReturn['subject'] = $this->createResponseDtoForReport($n->report->getSubject()); $toReturn['reportId'] = $n->report->getId(); break; case 'new_signup': /** @var NewSignupNotification $n */ $n = $dto; $toReturn['subject'] = $this->userFactory->createSignupResponseDto($n->getSubject()); break; } return $toReturn; } private function createResponseDtoForReport(ReportInterface $subject): EntryCommentResponseDto|EntryResponseDto|PostCommentResponseDto|PostResponseDto { if ($subject instanceof Entry) { return $this->entryFactory->createResponseDto($subject, $this->tagLinkRepository->getTagsOfContent($subject)); } elseif ($subject instanceof EntryComment) { return $this->entryCommentFactory->createResponseDto($subject, $this->tagLinkRepository->getTagsOfContent($subject)); } elseif ($subject instanceof Post) { return $this->postFactory->createResponseDto($subject, $this->tagLinkRepository->getTagsOfContent($subject)); } elseif ($subject instanceof PostComment) { return $this->postCommentFactory->createResponseDto($subject, $this->tagLinkRepository->getTagsOfContent($subject)); } throw new \InvalidArgumentException("cannot work with: '".\get_class($subject)."'"); } } ================================================ FILE: src/Controller/Api/Notification/NotificationPurgeApi.php ================================================ rateLimit($apiNotificationLimiter); $this->entityManager->remove($notification); $this->entityManager->flush(); return new JsonResponse( status: 204, headers: $headers ); } #[OA\Response( response: 204, description: 'Cleared all notifications', content: null, headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You do not have permission to clear notifications', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Tag(name: 'notification')] #[Security(name: 'oauth2', scopes: ['user:notification:delete'])] #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:DELETE')] public function purgeAll( NotificationManager $manager, RateLimiterFactoryInterface $apiNotificationLimiter, ): JsonResponse { $headers = $this->rateLimit($apiNotificationLimiter); $manager->clear($this->getUserOrThrow()); return new JsonResponse( status: 204, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Notification/NotificationPushApi.php ================================================ rateLimit($apiNotificationLimiter); $user = $this->getUserOrThrow(); $token = $this->getOAuthToken(); $apiToken = $this->getAccessToken($token); $pushSubscription = $repository->findOneBy(['user' => $user, 'apiToken' => $apiToken]); if (!$pushSubscription) { $pushSubscription = new UserPushSubscription($user, $payload->endpoint, $payload->contentPublicKey, $payload->serverKey, [], $apiToken); $pushSubscription->locale = $settingsManager->getLocale(); } else { $pushSubscription->endpoint = $payload->endpoint; $pushSubscription->serverAuthKey = $payload->serverKey; $pushSubscription->contentEncryptionPublicKey = $payload->contentPublicKey; } $this->entityManager->persist($pushSubscription); $this->entityManager->flush(); try { $testNotification = new PushNotification(null, '', $translator->trans('test_push_message', locale: $pushSubscription->locale)); $pushSubscriptionManager->sendTextToUser($user, $testNotification, specificToken: $apiToken); return new JsonResponse(headers: $headers); } catch (\ErrorException $e) { $this->logger->error('There was an exception while deleting a UserPushSubscription: {e} - {m}. {o}', [ 'e' => \get_class($e), 'm' => $e->getMessage(), 'o' => json_encode($e), ]); return new JsonResponse(status: 500, headers: $headers); } } #[OA\Response( response: 200, description: 'Deleted the existing push subscription', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not allowed to create push notifications', content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Notification not found', content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Tag(name: 'notification')] #[Security(name: 'oauth2', scopes: ['user:notification:read'])] #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')] /** * Delete the existing push subscription. */ public function deleteSubscription( RateLimiterFactoryInterface $apiNotificationLimiter, ): JsonResponse { $headers = $this->rateLimit($apiNotificationLimiter); $user = $this->getUserOrThrow(); $token = $this->getOAuthToken(); $apiToken = $this->getAccessToken($token); try { $conn = $this->entityManager->getConnection(); $stmt = $conn->prepare('DELETE FROM user_push_subscription WHERE user_id = :user AND api_token = :token'); $stmt->bindValue('user', $user->getId(), ParameterType::INTEGER); $stmt->bindValue('token', $apiToken->getIdentifier()); $stmt->executeQuery(); return new JsonResponse(headers: $headers); } catch (\Exception $e) { $this->logger->error('There was an exception while deleting a UserPushSubscription: {e} - {m}. {o}', [ 'e' => \get_class($e), 'm' => $e->getMessage(), 'o' => json_encode($e), ]); return new JsonResponse(status: 500, headers: $headers); } } #[OA\Response( response: 200, description: 'A test notification should arrive shortly', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not allowed to create push notifications', content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Tag(name: 'notification')] #[Security(name: 'oauth2', scopes: ['user:notification:read'])] #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')] /** * Send a test push notification. */ public function testSubscription( RateLimiterFactoryInterface $apiNotificationLimiter, UserPushSubscriptionRepository $repository, UserPushSubscriptionManager $pushSubscriptionManager, TranslatorInterface $translator, ): JsonResponse { $headers = $this->rateLimit($apiNotificationLimiter); $user = $this->getUserOrThrow(); $token = $this->getOAuthToken(); $apiToken = $this->getAccessToken($token); $sub = $repository->findOneBy(['user' => $user, 'apiToken' => $apiToken]); if ($sub) { $testNotification = new PushNotification(null, '', $translator->trans('test_push_message', locale: $sub->locale)); try { $pushSubscriptionManager->sendTextToUser($user, $testNotification, specificToken: $apiToken); return new JsonResponse(headers: $headers); } catch (\ErrorException $e) { $this->logger->error('There was an exception while deleting a UserPushSubscription: {e} - {m}. {o}', [ 'e' => \get_class($e), 'm' => $e->getMessage(), 'o' => json_encode($e), ]); return new JsonResponse(status: 500, headers: $headers); } } else { throw new BadRequestException(message: 'PushSubscription not found', statusCode: 404); } } } ================================================ FILE: src/Controller/Api/Notification/NotificationReadApi.php ================================================ rateLimit($apiNotificationLimiter); $notification->status = Notification::STATUS_READ; $this->entityManager->flush(); return new JsonResponse( $this->serializeNotification($notification), headers: $headers ); } #[OA\Response( response: 204, description: 'Marked all notifications as read', content: null, headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not allowed to mark notifications as read', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Notification not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Tag(name: 'notification')] #[Security(name: 'oauth2', scopes: ['user:notification:read'])] #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')] public function readAll( NotificationManager $manager, RateLimiterFactoryInterface $apiNotificationLimiter, ): JsonResponse { $headers = $this->rateLimit($apiNotificationLimiter); $manager->markAllAsRead($this->getUserOrThrow()); return new JsonResponse( status: 204, headers: $headers ); } #[OA\Response( response: 200, description: 'Marked the notification as new', content: new Model(type: NotificationSchema::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not allowed to mark this notification as new', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Notification not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'notification_id', in: 'path', description: 'The notification to mark as new', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'notification')] #[Security(name: 'oauth2', scopes: ['user:notification:read'])] #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')] #[IsGranted('view', 'notification')] public function unread( #[MapEntity(id: 'notification_id')] Notification $notification, RateLimiterFactoryInterface $apiNotificationLimiter, ): JsonResponse { $headers = $this->rateLimit($apiNotificationLimiter); $notification->status = Notification::STATUS_NEW; $this->entityManager->flush(); return new JsonResponse( $this->serializeNotification($notification), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Notification/NotificationRetrieveApi.php ================================================ rateLimit($apiNotificationLimiter); return new JsonResponse( $this->serializeNotification($notification), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of notifications for the current user', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: NotificationSchema::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'Invalid status type requested', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You do not have permission to view notifications', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of notifications to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of notifications per page', in: 'query', schema: new OA\Schema(type: 'integer', default: NotificationRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'status', description: 'Notification status to retrieve', in: 'path', schema: new OA\Schema(type: 'string', default: NotificationRepository::STATUS_ALL, enum: NotificationRepository::STATUS_OPTIONS) )] #[OA\Tag(name: 'notification')] #[Security(name: 'oauth2', scopes: ['user:notification:read'])] #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')] public function collection( string $status, NotificationRepository $repository, RateLimiterFactoryInterface $apiNotificationLimiter, ): JsonResponse { $headers = $this->rateLimit($apiNotificationLimiter); // 0 is falsy so need to compare with false to be certain the item was not found if (false === array_search($status, NotificationRepository::STATUS_OPTIONS)) { throw new BadRequestHttpException(); } $request = $this->request->getCurrentRequest(); $notifications = $repository->findByUser( $this->getUserOrThrow(), $this->getPageNb($request), $status, $this->constrainPerPage($request->get('perPage', NotificationRepository::PER_PAGE)) ); $dtos = []; foreach ($notifications->getCurrentPageResults() as $value) { array_push($dtos, $this->serializeNotification($value)); } return new JsonResponse( $this->serializePaginated($dtos, $notifications), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns the number of unread notifications for the current user', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'count', type: 'integer', minimum: 0 ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You do not have permission to view notification counts', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Tag(name: 'notification')] #[Security(name: 'oauth2', scopes: ['user:notification:read'])] #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')] public function count( NotificationRepository $repository, RateLimiterFactoryInterface $apiNotificationLimiter, ): JsonResponse { $headers = $this->rateLimit($apiNotificationLimiter); $count = $repository->countUnreadNotifications($this->getUserOrThrow()); return new JsonResponse( [ 'count' => $count, ], headers: $headers ); } } ================================================ FILE: src/Controller/Api/Notification/NotificationSettingApi.php ================================================ rateLimit($apiUpdateLimiter); $user = $this->getUserOrThrow(); $notificationSetting = ENotificationStatus::getFromString($setting); if (null === $notificationSetting) { throw $this->createNotFoundException('setting does not exist'); } if ('entry' === $targetType) { $repo = $this->entityManager->getRepository(Entry::class); } elseif ('post' === $targetType) { $repo = $this->entityManager->getRepository(Post::class); } elseif ('magazine' === $targetType) { $repo = $this->entityManager->getRepository(Magazine::class); } elseif ('user' === $targetType) { $repo = $this->entityManager->getRepository(User::class); } else { throw new \LogicException(); } $target = $repo->find($targetId); if (null === $target) { throw $this->createNotFoundException(); } $this->notificationSettingsRepository->setStatusByTarget($user, $target, $notificationSetting); return new JsonResponse(); } } ================================================ FILE: src/Controller/Api/OAuth2/Admin/RetrieveClientApi.php ================================================ 'identifier'])] Client $client, RateLimiterFactoryInterface $apiModerateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); $dto = new ClientResponseDto($client); return new JsonResponse( $dto, headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of clients', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: ClientResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not permitted to read a list of oauth2 clients on this instance', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Page does not exist', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of clients to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of clients per page', in: 'query', schema: new OA\Schema(type: 'integer', default: self::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Tag(name: 'admin/oauth2')] #[IsGranted('ROLE_ADMIN')] #[Security(name: 'oauth2', scopes: ['admin:oauth_clients:read'])] #[IsGranted('ROLE_OAUTH2_ADMIN:OAUTH_CLIENTS:READ')] public function collection( EntityManagerInterface $manager, Request $request, RateLimiterFactoryInterface $apiModerateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); $page = $this->getPageNb($request); $perPage = self::constrainPerPage($request->get('perPage', self::PER_PAGE)); /** @var EntityRepository $repository */ $repository = $manager->getRepository(Client::class); $qb = $repository->createQueryBuilder('c'); $pagerfanta = new Pagerfanta( new QueryAdapter( $qb ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } $dtos = []; foreach ($pagerfanta->getCurrentPageResults() as $client) { \assert($client instanceof Client); array_push($dtos, new ClientResponseDto($client)); } return new JsonResponse( $this->serializePaginated($dtos, $pagerfanta), headers: $headers ); } } ================================================ FILE: src/Controller/Api/OAuth2/Admin/RetrieveClientStatsApi.php ================================================ rateLimit($apiModerateLimiter); $resolution = $request->get('resolution'); try { $startString = $request->get('start'); if (null === $startString) { $start = null; } else { $start = new \DateTime($startString); } $endString = $request->get('end'); if (null === $endString) { $end = null; } else { $end = new \DateTime($endString); } } catch (\Exception $e) { throw new BadRequestHttpException('Failed to parse start or end time'); } if (null === $resolution) { throw new BadRequestHttpException('Resolution must be provided!'); } try { $stats = $repository->getStats($resolution, $start, $end); } catch (\LogicException $e) { throw new BadRequestHttpException($e->getMessage()); } return new JsonResponse( [ 'data' => $stats, ], headers: $headers ); } } ================================================ FILE: src/Controller/Api/OAuth2/CreateClientApi.php ================================================ get('KBIN_ADMIN_ONLY_OAUTH_CLIENTS') && !$this->isGranted('ROLE_ADMIN')) { throw new AccessDeniedHttpException('This instance only allows admins to create oauth clients'); } $headers = $this->rateLimit($apiOauthClientLimiter, $apiOauthClientLimiter); $request = $this->request->getCurrentRequest(); /** @var OAuth2ClientDto $dto */ $dto = $serializer->deserialize($request->getContent(), OAuth2ClientDto::class, 'json', ['groups' => ['creating']]); $validatorGroups = ['Default', 'creating']; // If the client being requested wishes to use the client_credentials flow, // validate that it has a username. if (false !== array_search('client_credentials', $dto->grants)) { $validatorGroups[] = 'client_credentials'; } $errors = $validator->validate($dto, groups: $validatorGroups); if (0 < \count($errors)) { throw new BadRequestHttpException((string) $errors); } $identifier = hash('md5', random_bytes(16)); // If a public client is requested, use null for the secret $secret = $dto->public ? null : hash('sha512', random_bytes(32)); $client = new Client($dto->name, $identifier, $secret); if (false !== array_search('client_credentials', $dto->grants)) { if ($userRepository->findOneByUsername($dto->username)) { throw new BadRequestHttpException('That username/email is taken!'); } if ($userRepository->findOneBy(['email' => $dto->contactEmail])) { throw new BadRequestHttpException('That username/email is taken!'); } $userDto = new UserDto(); $userDto->username = $dto->username; $userDto->email = $dto->contactEmail; // Only way to authenticate as this user will be to use client_credentials, unless they guess the very random password $userDto->plainPassword = hash('sha512', random_bytes(32)); // This user is a bot user. $userDto->isBot = true; // Rate limiting is handled by the apiClientLimiter $user = $userManager->create($userDto, false, false); $client->setUser($user); } $client->setDescription($dto->description); $client->setContactEmail($dto->contactEmail); $client->setGrants(...array_map(fn (string $grant) => new Grant($grant), $dto->grants)); $client->setScopes(...array_map(fn (string $scope) => new Scope($scope), $dto->scopes)); $client->setRedirectUris(...array_map(fn (string $redirectUri) => new RedirectUri($redirectUri), $dto->redirectUris)); $manager->save($client); $dto = $clientFactory->createDto($client); return new JsonResponse( $dto, status: 201, headers: $headers ); } #[OA\Response( response: 201, description: 'Returns the created oauth2 client. Be sure to save the identifier and secret since these will be how you obtain tokens for the API.', content: new Model(type: OAuth2ClientDto::class, groups: ['Default', 'created', 'common']), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'Grant type(s), scope(s), redirectUri(s) were invalid, or username/email was taken', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 403, description: 'This instance only allows admins to create clients', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\RequestBody(content: new OA\MediaType( 'multipart/form-data', schema: new OA\Schema( ref: new Model( type: OAuth2ClientDto::class, groups: [ 'creating', ImageUploadDto::IMAGE_UPLOAD_NO_ALT, ] ) ) ))] #[OA\Tag(name: 'oauth')] /** * This endpoint can create an OAuth2 client with a logo for your application. * * The image uploaded to this endpoint will be shown to users on the consent page as your application's logo. * * You can create a public or confidential client with any of 3 flows available. It's * recommended that you pick **either** `client_credentials`, **or** `authorization_code` *and* `refresh_token`. * * When creating clients with the client_credentials grant type, you must provide a unique * username and contact email. The username and email will be used to create a new bot user, * which your client authenticates as during the client_credentials flow. This user will be * tagged as a bot on all of their posts, comments, and on their profile. In addition, the bot * will not be allowed to use the API to vote on content. * * If you are creating a client that will be used on a native app or webapp, the client * should be marked as public. This will skip generation of a client secret and will require * the client to use the PKCE (https://www.oauth.com/oauth2-servers/pkce/) extension during * authorization_code flow. A public client cannot use the client_credentials flow. Public clients * are recommended because apps running on user devices technically cannot store secrets safely - * if they're determined enough, the user could retrieve the secret from their device's memory. */ public function uploadImage( ClientManagerInterface $manager, ClientFactory $clientFactory, UserManager $userManager, UserRepository $userRepository, SettingsManager $settingsManager, ValidatorInterface $validator, RateLimiterFactoryInterface $apiOauthClientLimiter, ): JsonResponse { if ($settingsManager->get('KBIN_ADMIN_ONLY_OAUTH_CLIENTS') && !$this->isGranted('ROLE_ADMIN')) { throw new AccessDeniedHttpException('This instance only allows admins to create oauth clients'); } $headers = $this->rateLimit($apiOauthClientLimiter, $apiOauthClientLimiter); $image = $this->handleUploadedImage(); $dto = $this->deserializeClientFromForm(); $validatorGroups = ['Default']; // If the client being requested wishes to use the client_credentials flow, // validate that it has a username. if (false !== array_search('client_credentials', $dto->grants)) { $validatorGroups[] = 'client_credentials'; } $errors = $validator->validate($dto, groups: $validatorGroups); if (0 < \count($errors)) { throw new BadRequestHttpException((string) $errors); } $identifier = hash('md5', random_bytes(16)); // If a public client is requested, use null for the secret $secret = $dto->public ? null : hash('sha512', random_bytes(32)); $client = new Client($dto->name, $identifier, $secret); if (false !== array_search('client_credentials', $dto->grants)) { if ($userRepository->findOneByUsername($dto->username)) { throw new BadRequestHttpException('That username/email is taken!'); } if ($userRepository->findOneBy(['email' => $dto->contactEmail])) { throw new BadRequestHttpException('That username/email is taken!'); } $userDto = new UserDto(); $userDto->username = $dto->username; $userDto->email = $dto->contactEmail; // Only way to authenticate as this user will be to use client_credentials, unless they guess the very random password $userDto->plainPassword = hash('sha512', random_bytes(32)); // This user is a bot user. $userDto->isBot = true; // Rate limiting is handled by the apiClientLimiter $user = $userManager->create($userDto, false, false); $client->setUser($user); } $client->setDescription($dto->description); $client->setContactEmail($dto->contactEmail); $client->setGrants(...array_map(fn (string $grant) => new Grant($grant), $dto->grants)); $client->setScopes(...array_map(fn (string $scope) => new Scope($scope), $dto->scopes)); $client->setRedirectUris(...array_map(fn (string $redirectUri) => new RedirectUri($redirectUri), $dto->redirectUris)); $client->setImage($image); $manager->save($client); $dto = $clientFactory->createDto($client); return new JsonResponse( $dto, status: 201, headers: $headers ); } protected function deserializeClientFromForm(?OAuth2ClientDto $dto = null): OAuth2ClientDto { $request = $this->request->getCurrentRequest(); $dto = $dto ? $dto : new OAuth2ClientDto(); $dto->name = $request->get('name', $dto->name); $dto->contactEmail = $request->get('contactEmail', $dto->contactEmail); $dto->description = $request->get('description', $dto->description); $dto->public = filter_var($request->get('public', $dto->public), FILTER_VALIDATE_BOOL); $dto->username = $request->get('username', $dto->username); $redirectUris = $request->get('redirectUris', $dto->redirectUris); if (\is_string($redirectUris)) { $redirectUris = preg_split('/(,| )/', $redirectUris, flags: PREG_SPLIT_NO_EMPTY); } $dto->redirectUris = $redirectUris; $grants = $request->get('grants', $dto->grants); if (\is_string($grants)) { $grants = preg_split('/(,| )/', $grants, flags: PREG_SPLIT_NO_EMPTY); } $dto->grants = $grants; $scopes = $request->get('scopes', $dto->scopes); if (\is_string($scopes)) { $scopes = preg_split('/(,| )/', $scopes, flags: PREG_SPLIT_NO_EMPTY); } $dto->scopes = $scopes; return $dto; } } ================================================ FILE: src/Controller/Api/OAuth2/DeleteClientApi.php ================================================ rateLimit(anonLimiterFactory: $apiOauthClientDeleteLimiter); $dto = new OAuth2ClientDto(null); $dto->identifier = $request->get('client_id'); $dto->secret = $request->get('client_secret'); $validatorGroups = ['deleting']; $errors = $validator->validate($dto, groups: $validatorGroups); if (0 < \count($errors)) { throw new BadRequestHttpException((string) $errors); } $client = $manager->find($dto->identifier); if (null === $client || null === $client->getSecret()) { throw new BadRequestHttpException(); } if (!hash_equals($client->getSecret(), $dto->secret)) { throw new BadRequestHttpException(); } $client->setActive(false); $revoker->revokeCredentialsForClient($client); $entityManager->flush(); return new JsonResponse( status: 204, headers: $headers ); } } ================================================ FILE: src/Controller/Api/OAuth2/RevokeTokenApi.php ================================================ rateLimit($apiOauthTokenRevokeLimiter); $token = $this->container->get('security.token_storage')->getToken(); $user = $this->getUserOrThrow(); $client = $entityManager->getReference(Client::class, $token->getOAuthClientId()); $revoker->revokeCredentialsForUserWithClient($user, $client); return new JsonResponse( status: 204, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Admin/PostsPurgeApi.php ================================================ rateLimit($apiModerateLimiter); $manager->purge($this->getUserOrThrow(), $post); return new JsonResponse( status: 204, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Comments/Admin/PostCommentsPurgeApi.php ================================================ rateLimit($apiModerateLimiter); $manager->purge($this->getUserOrThrow(), $comment); return new JsonResponse( status: 204, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Comments/Moderate/PostCommentsSetAdultApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); // Returns true for "1", "true", "on" and "yes". Returns false otherwise. $comment->isAdult = filter_var($request->get('adult', 'true'), FILTER_VALIDATE_BOOL); $manager->flush(); return new JsonResponse( $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Comments/Moderate/PostCommentsSetLanguageApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); $newLang = $request->get('lang', ''); $valid = false !== array_search($newLang, Languages::getLanguageCodes()); if (!$valid) { throw new BadRequestHttpException('The given language is not valid!'); } $comment->lang = $newLang; $manager->flush(); return new JsonResponse( $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Comments/Moderate/PostCommentsTrashApi.php ================================================ rateLimit($apiModerateLimiter); $moderator = $this->getUserOrThrow(); $manager->trash($moderator, $comment); // Force response to have all fields visible $visibility = $comment->visibility; $comment->visibility = VisibilityInterface::VISIBILITY_VISIBLE; $response = $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment))->jsonSerialize(); $response['visibility'] = $visibility; return new JsonResponse( $response, headers: $headers ); } #[OA\Response( response: 200, description: 'Comment restored', content: new Model(type: PostCommentResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'The comment was not in the trashed state', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not authorized to restore this comment', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Comment not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'comment_id', in: 'path', description: 'The comment to restore', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'moderation/post_comment')] #[Security(name: 'oauth2', scopes: ['moderate:post_comment:trash'])] #[IsGranted('ROLE_OAUTH2_MODERATE:POST_COMMENT:TRASH')] #[IsGranted('moderate', subject: 'comment')] public function restore( #[MapEntity(id: 'comment_id')] PostComment $comment, PostCommentManager $manager, PostCommentFactory $factory, RateLimiterFactoryInterface $apiModerateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); $moderator = $this->getUserOrThrow(); try { $manager->restore($moderator, $comment); } catch (\Exception $e) { throw new BadRequestHttpException('The comment cannot be restored because it was not trashed!'); } return new JsonResponse( $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Comments/PostCommentsActivityApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $this->handlePrivateContent($comment); $dto = $dtoFactory->createActivitiesDto($comment); return new JsonResponse( $dto, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Comments/PostCommentsCreateApi.php ================================================ rateLimit($apiCommentLimiter); if (!$this->isGranted('create_content', $post->magazine)) { throw new AccessDeniedHttpException(); } if ($parent && $parent->post->getId() !== $post->getId()) { throw new BadRequestHttpException('The parent comment does not belong to that post!'); } $dto = $this->deserializePostComment(); $dto->post = $post; $dto->magazine = $post->magazine; $dto->parent = $parent; $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } // Rate limit handled above $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), status: 201, headers: $headers ); } #[OA\Response( response: 201, description: 'Post comment created', content: new Model(type: PostCommentResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'The request body was invalid or the comment you are replying to does not belong to the post you are trying to add the new comment to.', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not permitted to add comments to this post', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Post or parent comment not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'post_id', in: 'path', description: 'Post to which the new comment will belong', schema: new OA\Schema(type: 'integer') )] #[OA\RequestBody(content: new OA\MediaType( 'multipart/form-data', schema: new OA\Schema( ref: new Model( type: PostCommentRequestDto::class, groups: [ 'common', 'comment', ImageUploadDto::IMAGE_UPLOAD, ] ) ) ))] #[OA\Tag(name: 'post_comment')] #[Security(name: 'oauth2', scopes: ['post_comment:create'])] #[IsGranted('ROLE_OAUTH2_POST_COMMENT:CREATE')] #[IsGranted('comment', subject: 'post')] public function uploadImage( #[MapEntity(id: 'post_id')] Post $post, #[MapEntity(id: 'comment_id')] ?PostComment $parent, PostCommentManager $manager, PostCommentFactory $factory, ValidatorInterface $validator, RateLimiterFactoryInterface $apiImageLimiter, ): JsonResponse { $headers = $this->rateLimit($apiImageLimiter); if (!$this->isGranted('create_content', $post->magazine)) { throw new AccessDeniedHttpException(); } $image = $this->handleUploadedImage(); if ($parent && $parent->post->getId() !== $post->getId()) { throw new BadRequestHttpException('The parent comment does not belong to that post!'); } $dto = $this->deserializePostCommentFromForm(); $dto->post = $post; $dto->magazine = $post->magazine; $dto->parent = $parent; $dto->image = $this->imageFactory->createDto($image); $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } // Rate limit handled above $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), status: 201, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Comments/PostCommentsDeleteApi.php ================================================ rateLimit($apiDeleteLimiter); $manager->delete($this->getUserOrThrow(), $comment); return new JsonResponse(status: 204, headers: $headers); } } ================================================ FILE: src/Controller/Api/Post/Comments/PostCommentsFavouriteApi.php ================================================ rateLimit($apiVoteLimiter); $manager->toggle($this->getUserOrThrow(), $comment); return new JsonResponse( $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Comments/PostCommentsReportApi.php ================================================ rateLimit($apiReportLimiter); $this->reportContent($comment); return new JsonResponse( status: 204, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Comments/PostCommentsRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $this->handlePrivateContent($comment); $criteria = new PostCommentPageView(0, $security); $repository->hydrate($comment); return new JsonResponse( $this->serializePostCommentTree($comment, $criteria), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of post comments', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: PostCommentResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'post_id', description: 'Post to retrieve comments from', in: 'path', schema: new OA\Schema(type: 'integer') )] #[OA\Parameter( name: 'p', description: 'Page of comments to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'd', description: 'Max depth of comment tree to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: self::DEPTH, minimum: self::MIN_DEPTH, maximum: self::MAX_DEPTH) )] #[OA\Parameter( name: 'perPage', description: 'Number of posts per page to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: PostCommentRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'sort', description: 'Sort method to use when retrieving comments', in: 'path', schema: new OA\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS) )] #[OA\Parameter( name: 'time', description: 'Max age of retrieved posts', in: 'path', schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN) )] #[OA\Parameter( name: 'lang[]', description: 'Language(s) of comments to return', in: 'query', explode: true, allowReserved: true, schema: new OA\Schema( type: 'array', items: new OA\Items(type: 'string') ) )] #[OA\Parameter( name: 'usePreferredLangs', description: 'Filter by a user\'s preferred languages? (Requires authentication and takes precedence over lang[])', in: 'query', schema: new OA\Schema(type: 'boolean', default: false), )] #[OA\Tag(name: 'post')] public function collection( #[MapEntity(id: 'post_id')] Post $post, PostCommentRepository $repository, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, Security $security, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $request = $this->request->getCurrentRequest(); $criteria = new PostCommentPageView($this->getPageNb($request), $security); $criteria->post = $post; $criteria->sortOption = $criteria->resolveSort($request->get('sort', Criteria::SORT_HOT)); $criteria->time = $criteria->resolveTime($request->get('time', Criteria::TIME_ALL)); $criteria->perPage = self::constrainPerPage($request->get('perPage', PostCommentRepository::PER_PAGE)); $this->handleLanguageCriteria($criteria); $comments = $repository->findByCriteria($criteria); $dtos = []; foreach ($comments->getCurrentPageResults() as $value) { \assert($value instanceof PostComment); try { $this->handlePrivateContent($value); $dtos[] = $this->serializePostCommentTree($value, $criteria); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $comments), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Comments/PostCommentsUpdateApi.php ================================================ rateLimit($apiUpdateLimiter); if (!$this->isGranted('create_content', $comment->magazine)) { throw new AccessDeniedHttpException(); } $dto = $this->deserializePostComment($factory->createDto($comment)); $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } $comment = $manager->edit($comment, $dto, $this->getUserOrThrow()); $criteria = new PostCommentPageView(0, $security); return new JsonResponse( $this->serializePostCommentTree($comment, $criteria), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Comments/PostCommentsVoteApi.php ================================================ rateLimit($apiVoteLimiter); if (!\in_array($choice, VotableInterface::VOTE_CHOICES)) { throw new BadRequestHttpException('Vote must be either -1, 0, or 1'); } if (VotableInterface::VOTE_DOWN === $choice) { throw new BadRequestHttpException('Downvotes for post comments are disabled!'); } // Rate limit handled above $manager->vote($choice, $comment, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Comments/UserPostCommentsRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $request = $this->request->getCurrentRequest(); $criteria = new PostCommentPageView($this->getPageNb($request), $security); $criteria->user = $user; $criteria->sortOption = $criteria->resolveSort($request->get('sort', Criteria::SORT_HOT)); $criteria->time = $criteria->resolveTime($request->get('time', Criteria::TIME_ALL)); $criteria->perPage = self::constrainPerPage($request->get('perPage', PostCommentRepository::PER_PAGE)); $criteria->onlyParents = false; $this->handleLanguageCriteria($criteria); $comments = $repository->findByCriteria($criteria); $dtos = []; foreach ($comments->getCurrentPageResults() as $value) { \assert($value instanceof PostComment); try { $this->handlePrivateContent($value); $dtos[] = $this->serializePostCommentTree($value, $criteria); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $comments), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Moderate/PostsLockApi.php ================================================ rateLimit($apiModerateLimiter); $manager->toggleLock($post, $this->getUserOrThrow()); return new JsonResponse( $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Moderate/PostsPinApi.php ================================================ rateLimit($apiModerateLimiter); $manager->pin($post); return new JsonResponse( $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Moderate/PostsSetAdultApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); // Returns true for "1", "true", "on" and "yes". Returns false otherwise. $post->isAdult = filter_var($request->get('adult', 'true'), FILTER_VALIDATE_BOOL); $manager->flush(); return new JsonResponse( $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Moderate/PostsSetLanguageApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); $newLang = $request->get('lang', ''); $valid = false !== array_search($newLang, Languages::getLanguageCodes()); if (!$valid) { throw new BadRequestHttpException('The given language is not valid!'); } $post->lang = $newLang; $manager->flush(); return new JsonResponse( $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/Moderate/PostsTrashApi.php ================================================ rateLimit($apiModerateLimiter); $moderator = $this->getUserOrThrow(); $manager->trash($moderator, $post); // Force response to have all fields visible $visibility = $post->visibility; $post->visibility = VisibilityInterface::VISIBILITY_VISIBLE; $response = $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post))->jsonSerialize(); $response['visibility'] = $visibility; return new JsonResponse( $response, headers: $headers ); } #[OA\Response( response: 200, description: 'Post restored', content: new Model(type: PostResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'The post was not in the trashed state', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not authorized to restore this post', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Post not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'post_id', in: 'path', description: 'The post to restore', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'moderation/post')] #[Security(name: 'oauth2', scopes: ['moderate:post:trash'])] #[IsGranted('ROLE_OAUTH2_MODERATE:POST:TRASH')] #[IsGranted('moderate', subject: 'post')] public function restore( #[MapEntity(id: 'post_id')] Post $post, PostManager $manager, PostFactory $factory, RateLimiterFactoryInterface $apiModerateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); $moderator = $this->getUserOrThrow(); try { $manager->restore($moderator, $post); } catch (\Exception $e) { throw new BadRequestHttpException('The post cannot be restored because it was not trashed!'); } return new JsonResponse( $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/PostsActivityApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $this->handlePrivateContent($post); $dto = $dtoFactory->createActivitiesDto($post); return new JsonResponse( $dto, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/PostsBaseApi.php ================================================ serializer->deserialize($this->request->getCurrentRequest()->getContent(), PostRequestDto::class, 'json', [ 'groups' => [ 'common', 'post', 'no-upload', ], ]); \assert($deserialized instanceof PostRequestDto); $dto = $deserialized->mergeIntoDto($dto, $this->settingsManager); return $dto; } protected function deserializePostFromForm(?PostDto $dto = null): PostDto { $request = $this->request->getCurrentRequest(); $dto = $dto ? $dto : new PostDto(); $deserialized = new PostRequestDto(); $deserialized->body = $request->get('body'); $deserialized->lang = $request->get('lang'); $deserialized->isAdult = filter_var($request->get('isAdult'), FILTER_VALIDATE_BOOL); $dto = $deserialized->mergeIntoDto($dto, $this->settingsManager); return $dto; } /** * Deserialize a comment from JSON. * * @param ?PostCommentDto $dto The EntryCommentDto to modify with new values (default: null to create a new EntryCommentDto) * * @return PostCommentDto A comment with only certain fields allowed to be modified by the user * * Modifies: * * body * * isAdult * * lang * * imageAlt (currently not working to modify the image) * * imageUrl (currently not working to modify the image) */ protected function deserializePostComment(?PostCommentDto $dto = null): PostCommentDto { $request = $this->request->getCurrentRequest(); $dto = $dto ? $dto : new PostCommentDto(); $deserialized = $this->serializer->deserialize($request->getContent(), PostCommentRequestDto::class, 'json', [ 'groups' => [ 'common', 'comment', 'no-upload', ], ]); \assert($deserialized instanceof PostCommentRequestDto); return $deserialized->mergeIntoDto($dto, $this->settingsManager); } protected function deserializePostCommentFromForm(?PostCommentDto $dto = null): PostCommentDto { $request = $this->request->getCurrentRequest(); $dto = $dto ? $dto : new PostCommentDto(); $deserialized = new PostCommentRequestDto(); $deserialized->body = $request->get('body'); $deserialized->lang = $request->get('lang'); $dto = $deserialized->mergeIntoDto($dto, $this->settingsManager); return $dto; } /** * Serialize a comment tree to JSON. * * @param ?PostComment $comment The root comment to base the tree on * @param ?int $depth how many levels of children to include. If null (default), retrieves depth from query parameter 'd'. * * @return array An associative array representation of the comment's hierarchy, to be used as JSON */ protected function serializePostCommentTree(?PostComment $comment, PostCommentPageView $commentPageView, ?int $depth = null): array { if (null === $comment) { return []; } if (null === $depth) { $depth = self::constrainDepth($this->request->getCurrentRequest()->get('d', self::DEPTH)); } $canModerate = null; if ($user = $this->getUser()) { $canModerate = $comment->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin(); } $commentTree = $this->postCommentFactory->createResponseTree($comment, $commentPageView, $depth, $canModerate); $commentTree->canAuthUserModerate = $canModerate; return $commentTree->jsonSerialize(); } } ================================================ FILE: src/Controller/Api/Post/PostsCreateApi.php ================================================ rateLimit($apiPostLimiter); if (!$this->isGranted('create_content', $magazine)) { throw new AccessDeniedHttpException('Create content permission not granted'); } $dto = new PostDto(); $dto->magazine = $magazine; if (null === $dto->magazine) { throw new NotFoundHttpException('Magazine not found'); } $dto = $this->deserializePost($dto); $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } // Rate limit handled elsewhere $post = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( $this->serializePost($manager->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), status: 201, headers: $headers ); } #[OA\Response( response: 201, description: 'Post created', content: new Model(type: PostResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'Banned from magazine', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Magazine not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', description: 'The magazine to create the post in. Use the id of the "random" magazine to submit posts which should not be posted to a specific magazine.', in: 'path', schema: new OA\Schema(type: 'integer'), )] #[OA\RequestBody(content: new OA\MediaType( 'multipart/form-data', schema: new OA\Schema( ref: new Model( type: PostRequestDto::class, groups: [ 'common', 'post', ImageUploadDto::IMAGE_UPLOAD, ] ) ) ))] #[OA\Tag(name: 'magazine')] #[Security(name: 'oauth2', scopes: ['post:create'])] #[IsGranted('ROLE_OAUTH2_POST:CREATE')] public function uploadImage( #[MapEntity(id: 'magazine_id')] Magazine $magazine, PostManager $manager, ValidatorInterface $validator, RateLimiterFactoryInterface $apiImageLimiter, ): JsonResponse { $headers = $this->rateLimit($apiImageLimiter); if (!$this->isGranted('create_content', $magazine)) { throw new AccessDeniedHttpException('Create content permission not granted'); } $image = $this->handleUploadedImage(); $dto = new PostDto(); $dto->magazine = $magazine; $dto->image = $this->imageFactory->createDto($image); if (null === $dto->magazine) { throw new NotFoundHttpException('Magazine not found'); } $dto = $this->deserializePostFromForm($dto); $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } // Rate limit handled elsewhere $post = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( $this->serializePost($manager->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), status: 201, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/PostsDeleteApi.php ================================================ rateLimit($apiDeleteLimiter); if ($post->user->getId() !== $this->getUserOrThrow()->getId()) { throw new AccessDeniedHttpException(); } $manager->delete($this->getUserOrThrow(), $post); return new JsonResponse(status: 204, headers: $headers); } } ================================================ FILE: src/Controller/Api/Post/PostsFavouriteApi.php ================================================ rateLimit($apiVoteLimiter); $manager->toggle($this->getUserOrThrow(), $post); return new JsonResponse( $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/PostsReportApi.php ================================================ rateLimit($apiReportLimiter); $this->reportContent($post); return new JsonResponse( status: 204, headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/PostsRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $this->handlePrivateContent($post); $dispatcher->dispatch(new PostHasBeenSeenEvent($post)); $dto = $factory->createDto($post); return new JsonResponse( $this->serializePost($dto, $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } #[OA\Response( response: 200, description: 'A paginated list of posts', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: PostResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of posts to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of posts to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: PostRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'sort', description: 'Sort method to use when retrieving posts', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS) )] #[OA\Parameter( name: 'time', description: 'Max age of retrieved posts', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN) )] #[OA\Parameter( name: 'lang[]', description: 'Language(s) of posts to return', in: 'query', explode: true, allowReserved: true, schema: new OA\Schema( type: 'array', items: new OA\Items(type: 'string', default: null, minLength: 2, maxLength: 3) ) )] #[OA\Parameter( name: 'usePreferredLangs', description: 'Filter by a user\'s preferred languages? (Requires authentication and takes precedence over lang[])', in: 'query', schema: new OA\Schema(type: 'boolean', default: false), )] #[OA\Parameter( name: 'federation', description: 'What type of federated entries to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Tag(name: 'post')] public function collection( PostRepository $repository, PostFactory $factory, RequestStack $request, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, SymfonySecurity $security, #[MapQueryParameter] ?string $federation, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = new PostPageView((int) $request->getCurrentRequest()->get('p', 1), $security); $criteria->sortOption = $request->getCurrentRequest()->get('sort', Criteria::SORT_HOT); $criteria->time = $criteria->resolveTime( $request->getCurrentRequest()->get('time', Criteria::TIME_ALL) ); $criteria->perPage = self::constrainPerPage($request->getCurrentRequest()->get('perPage', PostRepository::PER_PAGE)); $criteria->setFederation($federation ?? Criteria::AP_ALL); $this->handleLanguageCriteria($criteria); $posts = $repository->findByCriteria($criteria); $dtos = []; foreach ($posts->getCurrentPageResults() as $value) { try { \assert($value instanceof Post); $this->handlePrivateContent($value); array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $posts), headers: $headers ); } #[OA\Response( response: 200, description: 'A paginated list of posts from user\'s subscribed magazines', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: PostResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of posts to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of posts to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: PostRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'sort', description: 'Sort method to use when retrieving posts', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS) )] #[OA\Parameter( name: 'time', description: 'Max age of retrieved posts', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN) )] #[OA\Parameter( name: 'lang[]', description: 'Language(s) of posts to return', in: 'query', explode: true, allowReserved: true, schema: new OA\Schema( type: 'array', items: new OA\Items(type: 'string', default: null, minLength: 2, maxLength: 3) ) )] #[OA\Parameter( name: 'usePreferredLangs', description: 'Filter by a user\'s preferred languages? (Requires authentication and takes precedence over lang[])', in: 'query', schema: new OA\Schema(type: 'boolean', default: false), )] #[OA\Parameter( name: 'federation', description: 'What type of federated entries to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Tag(name: 'post')] #[Security(name: 'oauth2', scopes: ['read'])] #[IsGranted('ROLE_OAUTH2_READ')] public function subscribed( ContentRepository $repository, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, SymfonySecurity $security, SqlHelpers $sqlHelpers, #[MapQueryParameter] ?int $p, #[MapQueryParameter] ?int $perPage, #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = new PostPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE); $criteria->setFederation($federation ?? Criteria::AP_ALL); $criteria->subscribed = true; $criteria->setContent(Criteria::CONTENT_MICROBLOG); $this->handleLanguageCriteria($criteria); $user = $security->getUser(); if ($user instanceof User) { $criteria->fetchCachedItems($sqlHelpers, $user); } $posts = $repository->findByCriteria($criteria); $dtos = []; foreach ($posts->getCurrentPageResults() as $value) { try { \assert($value instanceof Post); $this->handlePrivateContent($value); $dtos[] = $this->serializePost($this->postFactory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $posts), headers: $headers ); } #[OA\Response( response: 200, description: 'A paginated list of posts from user\'s subscribed magazines', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: ContentResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of posts to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of posts to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: PostRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'sort', description: 'Sort method to use when retrieving posts', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS) )] #[OA\Parameter( name: 'time', description: 'Max age of retrieved posts', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN) )] #[OA\Parameter( name: 'lang[]', description: 'Language(s) of posts to return', in: 'query', explode: true, allowReserved: true, schema: new OA\Schema( type: 'array', items: new OA\Items(type: 'string', default: null, minLength: 2, maxLength: 3) ) )] #[OA\Parameter( name: 'usePreferredLangs', description: 'Filter by a user\'s preferred languages? (Requires authentication and takes precedence over lang[])', in: 'query', schema: new OA\Schema(type: 'boolean', default: false), )] #[OA\Parameter( name: 'federation', description: 'What type of federated entries to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Parameter( name: 'includeBoosts', description: 'if true then boosted content from followed users are included', in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] #[OA\Tag(name: 'post')] #[Security(name: 'oauth2', scopes: ['read'])] #[IsGranted('ROLE_OAUTH2_READ')] public function subscribedWithBoosts( ContentRepository $repository, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, SymfonySecurity $security, SqlHelpers $sqlHelpers, #[MapQueryParameter] ?int $p, #[MapQueryParameter] ?int $perPage, #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = new PostPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE); $criteria->setFederation($federation ?? Criteria::AP_ALL); $criteria->subscribed = true; $criteria->includeBoosts = true; $criteria->setContent(Criteria::CONTENT_MICROBLOG); $this->handleLanguageCriteria($criteria); $user = $this->getUserOrThrow(); $criteria->fetchCachedItems($sqlHelpers, $user); $posts = $repository->findByCriteria($criteria); $dtos = []; foreach ($posts->getCurrentPageResults() as $value) { try { if ($value instanceof Post) { $this->handlePrivateContent($value); $dtos[] = new ContentResponseDto(post: $this->serializePost($this->postFactory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); } elseif ($value instanceof PostComment) { $this->handlePrivateContent($value); $dtos[] = new ContentResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); } else { throw new \AssertionError('got unexpected type '.\get_class($value)); } } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $posts), headers: $headers ); } #[OA\Response( response: 200, description: 'A paginated list of posts from user\'s moderated magazines', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: PostResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'The client does not have permission to perform moderation actions on posts', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of posts to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of posts to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: PostRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'sort', description: 'Sort method to use when retrieving posts', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::SORT_NEW, enum: Criteria::SORT_OPTIONS) )] #[OA\Parameter( name: 'time', description: 'Max age of retrieved posts', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN) )] #[OA\Parameter( name: 'federation', description: 'What type of federated entries to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Tag(name: 'post')] #[Security(name: 'oauth2', scopes: ['moderate:post'])] #[IsGranted('ROLE_OAUTH2_MODERATE:POST')] public function moderated( ContentRepository $repository, PostFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, SymfonySecurity $security, SqlHelpers $sqlHelpers, #[MapQueryParameter] ?int $p, #[MapQueryParameter] ?int $perPage, #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = new PostPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE); $criteria->setFederation($federation ?? Criteria::AP_ALL); $criteria->moderated = true; $criteria->setContent(Criteria::CONTENT_MICROBLOG); $user = $security->getUser(); if ($user instanceof User) { $criteria->fetchCachedItems($sqlHelpers, $user); } $posts = $repository->findByCriteria($criteria); $dtos = []; foreach ($posts->getCurrentPageResults() as $value) { try { \assert($value instanceof Post); $this->handlePrivateContent($value); array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $posts), headers: $headers ); } #[OA\Response( response: 200, description: 'A paginated list of user\'s favourited posts', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: PostResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of posts to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of posts to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: PostRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'sort', description: 'Sort method to use when retrieving posts', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS) )] #[OA\Parameter( name: 'time', description: 'Max age of retrieved posts', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN) )] #[OA\Parameter( name: 'federation', description: 'What type of federated entries to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Tag(name: 'post')] #[Security(name: 'oauth2', scopes: ['post:vote'])] #[IsGranted('ROLE_OAUTH2_POST:VOTE')] public function favourited( ContentRepository $repository, PostFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, SymfonySecurity $security, SqlHelpers $sqlHelpers, #[MapQueryParameter] ?int $p, #[MapQueryParameter] ?int $perPage, #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = new PostPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE); $criteria->setFederation($federation ?? Criteria::AP_ALL); $criteria->favourite = true; $criteria->setContent(Criteria::CONTENT_MICROBLOG); $this->logger->debug(var_export($criteria, true)); $user = $security->getUser(); if ($user instanceof User) { $criteria->fetchCachedItems($sqlHelpers, $user); } $posts = $repository->findByCriteria($criteria); $dtos = []; foreach ($posts->getCurrentPageResults() as $value) { try { \assert($value instanceof Post); $this->handlePrivateContent($value); array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $posts), headers: $headers ); } #[OA\Response( response: 200, description: 'A paginated list of posts from the magazine', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: PostResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Magazine not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'magazine_id', description: 'Magazine to retrieve posts from', in: 'path', schema: new OA\Schema(type: 'integer') )] #[OA\Parameter( name: 'p', description: 'Page of posts to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of posts to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: PostRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'sort', description: 'Sort method to use when retrieving posts', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS) )] #[OA\Parameter( name: 'time', description: 'Max age of retrieved posts', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN) )] #[OA\Parameter( name: 'lang[]', description: 'Language(s) of posts to return', in: 'query', explode: true, allowReserved: true, schema: new OA\Schema( type: 'array', items: new OA\Items(type: 'string', default: null, minLength: 2, maxLength: 3) ) )] #[OA\Parameter( name: 'usePreferredLangs', description: 'Filter by a user\'s preferred languages? (Requires authentication and takes precedence over lang[])', in: 'query', schema: new OA\Schema(type: 'boolean', default: false), )] #[OA\Parameter( name: 'federation', description: 'What type of federated entries to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] #[OA\Tag(name: 'magazine')] public function byMagazine( #[MapEntity(id: 'magazine_id')] Magazine $magazine, ContentRepository $repository, PostFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, SymfonySecurity $security, SqlHelpers $sqlHelpers, #[MapQueryParameter] ?int $p, #[MapQueryParameter] ?int $perPage, #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = new PostPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE); $criteria->setFederation($federation ?? Criteria::AP_ALL); $criteria->stickiesFirst = true; $this->handleLanguageCriteria($criteria); $criteria->magazine = $magazine; $criteria->setContent(Criteria::CONTENT_MICROBLOG); $user = $security->getUser(); if ($user instanceof User) { $criteria->fetchCachedItems($sqlHelpers, $user); } $posts = $repository->findByCriteria($criteria); $dtos = []; foreach ($posts->getCurrentPageResults() as $value) { try { \assert($value instanceof Post); $this->handlePrivateContent($value); array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $posts), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/PostsUpdateApi.php ================================================ rateLimit($apiUpdateLimiter); $user = $this->getUserOrThrow(); if ($post->user->getId() !== $user->getId()) { throw new AccessDeniedHttpException(); } $dto = $this->deserializePost($manager->createDto($post)); $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } $post = $manager->edit($post, $dto, $user); return new JsonResponse( $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/PostsVoteApi.php ================================================ rateLimit($apiVoteLimiter); if (!\in_array($choice, VotableInterface::VOTE_CHOICES)) { throw new BadRequestHttpException('Vote must be either -1, 0, or 1'); } if (VotableInterface::VOTE_DOWN === $choice) { throw new BadRequestHttpException('Downvotes for posts are disabled!'); } // Rate limit handled above $manager->vote($choice, $post, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/Post/UserPostsRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $criteria = new PostPageView((int) $request->getCurrentRequest()->get('p', 1), $security); $criteria->sortOption = $request->getCurrentRequest()->get('sort', Criteria::SORT_HOT); $criteria->time = $criteria->resolveTime( $request->getCurrentRequest()->get('time', Criteria::TIME_ALL) ); $criteria->perPage = self::constrainPerPage($request->getCurrentRequest()->get('perPage', PostRepository::PER_PAGE)); $this->handleLanguageCriteria($criteria); $criteria->user = $user; $posts = $repository->findByCriteria($criteria); $dtos = []; foreach ($posts->getCurrentPageResults() as $value) { try { \assert($value instanceof Post); $this->handlePrivateContent($value); array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value))); } catch (\Exception $e) { continue; } } return new JsonResponse( $this->serializePaginated($dtos, $posts), headers: $headers ); } } ================================================ FILE: src/Controller/Api/PostComments.php ================================================ request->getCurrentRequest()->get('p', 1), $this->security); $criteria->post = $post; $criteria->onlyParents = false; $comments = $this->repository->findByCriteria($criteria); } catch (\Exception $e) { return []; } $dtos = array_map(fn ($comment) => $this->factory->createDto($comment), (array) $comments->getCurrentPageResults()); return new DtoPaginator($dtos, 0, PostCommentRepository::PER_PAGE, $comments->getNbResults()); } } ================================================ FILE: src/Controller/Api/RandomMagazine.php ================================================ repository->findRandom(); } catch (\Exception $e) { return []; } $dtos = [$this->factory->createDto($magazine)]; return new DtoPaginator($dtos, 0, 1, 1); } } ================================================ FILE: src/Controller/Api/Search/SearchRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $request = $this->request->getCurrentRequest(); $q = $request->get('q'); if (null === $q) { throw new BadRequestHttpException(); } $page = $this->getPageNb($request); $perPage = self::constrainPerPage($request->get('perPage', SearchRepository::PER_PAGE)); $authorIdRaw = $request->get('authorId'); $authorId = null === $authorIdRaw ? null : \intval($authorIdRaw); $magazineIdRaw = $request->get('magazineId'); $magazineId = null === $magazineIdRaw ? null : \intval($magazineIdRaw); $type = $request->get('type'); if ('entry' !== $type && 'post' !== $type && null !== $type) { throw new BadRequestHttpException(); } $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage, authorId: $authorId, magazineId: $magazineId, specificType: $type); $dtos = []; foreach ($items->getCurrentPageResults() as $value) { \assert($value instanceof ContentInterface); array_push($dtos, $this->serializeContentInterface($value)); } $response = $this->serializePaginated($dtos, $items); $response['apActors'] = []; $response['apObjects'] = []; $actors = []; $objects = []; if (!$settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN') || $this->getUser()) { $actors = $manager->findActivityPubActorsByUsername($q); $objects = $manager->findActivityPubObjectsByURL($q); } foreach ($actors as $actor) { switch ($actor['type']) { case 'user': $response['apActors'][] = [ 'type' => 'user', 'object' => $this->serializeUser($userFactory->createDto($actor['object'])), ]; break; case 'magazine': $response['apActors'][] = [ 'type' => 'magazine', 'object' => $this->serializeMagazine($magazineFactory->createDto($actor['object'])), ]; break; } } foreach ($objects as $object) { \assert($object instanceof ContentInterface); $response['apObjects'][] = $this->serializeContentInterface($object); } return new JsonResponse( $response, headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of content, along with any ActivityPub actors that matched the query by username, or ActivityPub objects that matched the query by URL. AP-Objects are not paginated.', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: SearchResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), new OA\Property( property: 'apResults', type: 'array', items: new OA\Items(ref: new Model(type: SearchResponseDto::class)) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'The search query parameter `q` is required!', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of items to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of items per page', in: 'query', schema: new OA\Schema(type: 'integer', default: SearchRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'q', description: 'Search term', in: 'query', required: true, schema: new OA\Schema(type: 'string') )] #[OA\Parameter( name: 'authorId', description: 'User id of the author', in: 'query', required: false, schema: new OA\Schema(type: 'integer') )] #[OA\Parameter( name: 'magazineId', description: 'Id of the magazine', in: 'query', required: false, schema: new OA\Schema(type: 'integer') )] #[OA\Parameter( name: 'type', description: 'The type of content', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['', 'entry', 'post']) )] #[OA\Tag(name: 'search')] public function searchV2( SearchManager $manager, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, #[MapQueryParameter(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] string $q, #[MapQueryParameter(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] int $perPage = SearchRepository::PER_PAGE, #[MapQueryParameter('authorId', validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] ?int $authorId = null, #[MapQueryParameter('magazineId', validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] ?int $magazineId = null, #[MapQueryParameter] ?string $type = null, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $request = $this->request->getCurrentRequest(); $page = $this->getPageNb($request); if ('entry' !== $type && 'post' !== $type && null !== $type) { throw new BadRequestHttpException(); } /** @var ?SearchResponseDto[] $searchResults */ $searchResults = []; $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage, authorId: $authorId, magazineId: $magazineId, specificType: $type); foreach ($items->getCurrentPageResults() as $item) { $searchResults[] = $this->serializeItem($item); } /** @var ?SearchResponseDto $apResults */ $apResults = []; if ($this->federatedSearchAllowed()) { $objects = $manager->findActivityPubActorsOrObjects($q); foreach ($objects['errors'] as $error) { /** @var \Throwable $error */ $this->logger->warning( 'Exception while resolving AP handle / url {q}: {type}: {msg}', [ 'q' => $q, 'type' => \get_class($error), 'msg' => $error->getMessage(), ] ); } foreach ($objects['results'] as $object) { $apResults[] = $this->serializeItem($object['object']); } } $response = $this->serializePaginated($searchResults, $items); $response['apResults'] = $apResults; return new JsonResponse( $response, headers: $headers ); } private function federatedSearchAllowed(): bool { return !$this->settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN') || $this->getUser(); } private function serializeItem(object $item): ?SearchResponseDto { if ($item instanceof Entry) { $this->handlePrivateContent($item); return new SearchResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof Post) { $this->handlePrivateContent($item); return new SearchResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof EntryComment) { $this->handlePrivateContent($item); return new SearchResponseDto(entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof PostComment) { $this->handlePrivateContent($item); return new SearchResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof Magazine) { return new SearchResponseDto(magazine: $this->serializeMagazine($this->magazineFactory->createDto($item))); } elseif ($item instanceof User) { return new SearchResponseDto(user: $this->serializeUser($this->userFactory->createDto($item))); } else { $this->logger->error('Unexpected result type: '.\get_class($item)); return null; } } } ================================================ FILE: src/Controller/Api/User/Admin/UserApplicationApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $users = $this->userRepository->findAllSignupRequestsPaginated($p); $dtos = []; foreach ($users->getCurrentPageResults() as $value) { \assert($value instanceof User); $dtos[] = $userFactory->createSignupResponseDto($value); } return new JsonResponse( $this->serializePaginated($dtos, $users), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns nothing on success', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: null )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not authorized to verify this user', content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'User not found', content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Parameter( name: 'user_id', description: 'The user to approve', in: 'path', schema: new OA\Schema(type: 'integer', minimum: 1) )] #[OA\Tag(name: 'admin/user')] #[IsGranted(new Expression('is_granted("ROLE_ADMIN") or is_granted("ROLE_MODERATOR")'))] #[Security(name: 'oauth2', scopes: ['admin:user:application'])] #[IsGranted('ROLE_OAUTH2_ADMIN:USER:APPLICATION')] public function approve( RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, #[MapEntity(id: 'user_id')] User $user, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $this->userManager->approveUserApplication($user); return new JsonResponse(null, headers: $headers); } #[OA\Response( response: 200, description: 'Returns nothing on success', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: null )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not authorized to verify this user', content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'User not found', content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Parameter( name: 'user_id', description: 'The user to reject', in: 'path', schema: new OA\Schema(type: 'integer', minimum: 1) )] #[OA\Tag(name: 'admin/user')] #[IsGranted(new Expression('is_granted("ROLE_ADMIN") or is_granted("ROLE_MODERATOR")'))] #[Security(name: 'oauth2', scopes: ['admin:user:application'])] #[IsGranted('ROLE_OAUTH2_ADMIN:USER:APPLICATION')] public function reject( RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, #[MapEntity(id: 'user_id')] User $user, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $this->userManager->rejectUserApplication($user); return new JsonResponse(null, headers: $headers); } } ================================================ FILE: src/Controller/Api/User/Admin/UserBanApi.php ================================================ rateLimit($apiModerateLimiter); $manager->ban($user, $this->getUserOrThrow(), null); // Response needs to be an array to insert isBanned $response = $this->serializeUser($factory->createDto($user))->jsonSerialize(); $response['isBanned'] = $user->isBanned; return new JsonResponse( $response, headers: $headers ); } #[OA\Response( response: 200, description: 'User unbanned', content: new Model(type: UserBanResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not authorized to unban this user', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'User not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'user_id', in: 'path', description: 'The user to unban', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'admin/user')] #[IsGranted('ROLE_ADMIN')] #[Security(name: 'oauth2', scopes: ['admin:user:ban'])] #[IsGranted('ROLE_OAUTH2_ADMIN:USER:BAN')] /** Unbans a user from the instance */ public function unban( #[MapEntity(id: 'user_id')] User $user, UserManager $manager, UserFactory $factory, RateLimiterFactoryInterface $apiModerateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); $manager->unban($user, $this->getUserOrThrow(), null); // Response needs to be an array to insert isBanned $response = $this->serializeUser($factory->createDto($user))->jsonSerialize(); $response['isBanned'] = $user->isBanned; return new JsonResponse( $response, headers: $headers ); } } ================================================ FILE: src/Controller/Api/User/Admin/UserDeleteApi.php ================================================ rateLimit($apiModerateLimiter); $manager->deleteRequest($user, false); return new JsonResponse( $this->serializeUser($factory->createDto($user)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/User/Admin/UserPurgeApi.php ================================================ rateLimit($apiModerateLimiter); $manager->delete($user); return new JsonResponse( status: 204, headers: $headers ); } } ================================================ FILE: src/Controller/Api/User/Admin/UserRetrieveBannedApi.php ================================================ rateLimit($apiModerateLimiter); $request = $this->request->getCurrentRequest(); $group = $request->get('group', UserRepository::USERS_ALL); $users = $userRepository->findBannedPaginated( $this->getPageNb($request), $group, $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)) ); $dtos = []; foreach ($users->getCurrentPageResults() as $value) { \assert($value instanceof User); array_push($dtos, new UserBanResponseDto($factory->createDto($value), $value->isBanned)); } return new JsonResponse( $this->serializePaginated($dtos, $users), headers: $headers ); } } ================================================ FILE: src/Controller/Api/User/Admin/UserVerifyApi.php ================================================ rateLimit($apiModerateLimiter); $user->isVerified = true; $manager->persist($user); $manager->flush(); // Response needs to be an array to insert isVerified $response = $this->serializeUser($factory->createDto($user))->jsonSerialize(); $response['isVerified'] = $user->isVerified; return new JsonResponse( $response, headers: $headers ); } } ================================================ FILE: src/Controller/Api/User/UserBaseApi.php ================================================ request->getCurrentRequest(); $deserialized = $this->serializer->deserialize($request->getContent(), UserSettingsDto::class, 'json'); \assert($deserialized instanceof UserSettingsDto); $dto = $deserialized->mergeIntoDto($dto); return $dto; } } ================================================ FILE: src/Controller/Api/User/UserBlockApi.php ================================================ rateLimit($apiUpdateLimiter); if ($user->getId() === $this->getUserOrThrow()->getId()) { throw new BadRequestHttpException('You cannot block yourself'); } $manager->block($this->getUserOrThrow(), $user); return new JsonResponse( $this->serializeUser($factory->createDto($user)), headers: $headers ); } #[OA\Response( response: 200, description: 'User unblocked', content: new Model(type: UserResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'You cannot block yourself', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'User not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'user_id', in: 'path', description: 'The user to unblock', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:block'])] #[IsGranted('ROLE_OAUTH2_USER:BLOCK')] public function unblock( #[MapEntity(id: 'user_id')] User $user, UserManager $manager, UserFactory $factory, RateLimiterFactoryInterface $apiUpdateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiUpdateLimiter); if ($user->getId() === $this->getUserOrThrow()->getId()) { throw new BadRequestHttpException('You cannot block yourself'); } $manager->unblock($this->getUserOrThrow(), $user); return new JsonResponse( $this->serializeUser($factory->createDto($user)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/User/UserContentApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $this->checkUserAccess($user); $search = $repository->findBoosts($p ?? 1, $user); $result = $this->serializeResults($search->getCurrentPageResults()); return new JsonResponse( $this->serializePaginated($result, $search), headers: $headers ); } #[OA\Response( response: 200, description: 'A paginated list of combined entries, posts, comments and replies boosted by the given user', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent( properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: ExtendedContentResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ], type: 'object' ) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'user not found or you are not allowed to access them', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Parameter( name: 'hideAdult', description: 'If true exclude all adult content', in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] #[OA\Parameter( name: 'p', description: 'Page of content to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Tag(name: 'user')] public function getUserContent( #[MapEntity(id: 'user_id')] User $user, #[MapQueryParameter(filter: \FILTER_VALIDATE_BOOLEAN)] ?bool $hideAdult, #[MapQueryParameter] ?int $p, SearchRepository $repository, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, ): Response { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $this->checkUserAccess($user); $search = $repository->findUserPublicActivity($p ?? 1, $user, $hideAdult ?? false); $result = $this->serializeResults($search->getCurrentPageResults()); return new JsonResponse( $this->serializePaginated($result, $search), headers: $headers ); } private function checkUserAccess(User $user) { $requestingUser = $this->getUser(); if ($user->isDeleted && (!$requestingUser || (!$requestingUser->isAdmin() && !$requestingUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } } private function serializeResults(array $results): array { $result = []; foreach ($results as $item) { try { if ($item instanceof Entry) { $this->handlePrivateContent($item); $result[] = new ExtendedContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof Post) { $this->handlePrivateContent($item); $result[] = new ExtendedContentResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof EntryComment) { $this->handlePrivateContent($item); $result[] = new ExtendedContentResponseDto(entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } elseif ($item instanceof PostComment) { $this->handlePrivateContent($item); $result[] = new ExtendedContentResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); } } catch (\Exception) { } } return $result; } } ================================================ FILE: src/Controller/Api/User/UserDeleteImagesApi.php ================================================ rateLimit($apiImageLimiter); $user = $this->getUserOrThrow(); $manager->detachAvatar($user); /* * Call edit so the @see UserEditedEvent is triggered and the changes are federated */ $manager->edit($user, $manager->createDto($user)); return new JsonResponse( $this->serializeUser($factory->createDto($this->getUserOrThrow())), headers: $headers ); } #[OA\Response( response: 200, description: 'User cover deleted', content: new Model(type: UserResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:profile:edit'])] #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')] public function cover( UserManager $manager, UserFactory $factory, RateLimiterFactoryInterface $apiImageLimiter, ): JsonResponse { $headers = $this->rateLimit($apiImageLimiter); $user = $this->getUserOrThrow(); $manager->detachCover($user); /* * Call edit so the @see UserEditedEvent is triggered and the changes are federated */ $manager->edit($user, $manager->createDto($user)); return new JsonResponse( $this->serializeUser($factory->createDto($this->getUserOrThrow())), headers: $headers ); } } ================================================ FILE: src/Controller/Api/User/UserFilterListApi.php ================================================ getUserOrThrow(); $items = []; foreach ($user->filterLists as $list) { $items[] = $this->serializeFilterList($list); } return new JsonResponse([ 'items' => $items, ]); } #[OA\Response( response: 200, description: 'Filter list created', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: UserFilterListResponseDto::class) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\RequestBody(content: new Model(type: UserFilterListDto::class))] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:profile:edit'])] #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')] public function create( #[MapRequestPayload] UserFilterListDto $dto, RateLimiterFactoryInterface $apiUpdateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiUpdateLimiter); $user = $this->getUserOrThrow(); $list = new UserFilterList(); $list->name = $dto->name; $list->expirationDate = $dto->expirationDate; $list->feeds = $dto->feeds; $list->comments = $dto->comments; $list->profile = $dto->profile; $list->user = $user; $list->words = $dto->wordsToArray(); $this->entityManager->persist($list); $this->entityManager->flush(); $freshList = $this->entityManager->getRepository(UserFilterList::class)->find($list->getId()); return new JsonResponse( $this->serializeFilterList($freshList), headers: $headers ); } #[OA\Response( response: 200, description: 'Filter list updated', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new Model(type: UserFilterListResponseDto::class) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\RequestBody(content: new Model(type: UserFilterListDto::class))] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:profile:edit'])] #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')] #[IsGranted(FilterListVoter::EDIT, 'list')] public function edit( RateLimiterFactoryInterface $apiUpdateLimiter, #[MapEntity] UserFilterList $list, #[MapRequestPayload] UserFilterListDto $dto, ): JsonResponse { $headers = $this->rateLimit($apiUpdateLimiter); $list->name = $dto->name; $list->expirationDate = $dto->expirationDate; $list->feeds = $dto->feeds; $list->comments = $dto->comments; $list->profile = $dto->profile; $list->words = $dto->wordsToArray(); $this->entityManager->persist($list); $this->entityManager->flush(); $freshList = $this->entityManager->getRepository(UserFilterList::class)->find($list->getId()); return new JsonResponse( $this->serializeFilterList($freshList), headers: $headers ); } #[OA\Response( response: 204, description: 'Filter list deleted', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) )] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:profile:edit'])] #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')] #[IsGranted(FilterListVoter::DELETE, 'list')] public function delete( RateLimiterFactoryInterface $apiDeleteLimiter, #[MapEntity(class: UserFilterList::class)] UserFilterList $list, ): JsonResponse { $headers = $this->rateLimit($apiDeleteLimiter); $this->entityManager->remove($list); $this->entityManager->flush(); return new JsonResponse( headers: $headers ); } } ================================================ FILE: src/Controller/Api/User/UserFollowApi.php ================================================ rateLimit($apiUpdateLimiter); if ($user->getId() === $this->getUserOrThrow()->getId()) { throw new BadRequestHttpException('You cannot follow yourself'); } $manager->follow($this->getUserOrThrow(), $user); return new JsonResponse( $this->serializeUser($factory->createDto($user)), headers: $headers ); } #[OA\Response( response: 200, description: 'User follow status updated', content: new Model(type: UserResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'You cannot follow yourself', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'User not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'user_id', in: 'path', description: 'The user to unfollow', schema: new OA\Schema(type: 'integer'), )] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:follow'])] #[IsGranted('ROLE_OAUTH2_USER:FOLLOW')] #[IsGranted('follow', subject: 'user')] public function unfollow( #[MapEntity(id: 'user_id')] User $user, UserManager $manager, UserFactory $factory, RateLimiterFactoryInterface $apiUpdateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiUpdateLimiter); if ($user->getId() === $this->getUserOrThrow()->getId()) { throw new BadRequestHttpException('You cannot follow yourself'); } $manager->unfollow($this->getUserOrThrow(), $user); return new JsonResponse( $this->serializeUser($factory->createDto($user)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/User/UserModeratesApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $requestingUser = $this->getUser(); if ($user->isDeleted && (!$requestingUser || (!$requestingUser->isAdmin() && !$requestingUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } $magazines = $repository->findModeratedMagazines($user, $p ?? 1); $result = []; foreach ($magazines as $magazine) { $result[] = $this->magazineFactory->createSmallDto($magazine); } return new JsonResponse( $this->serializePaginated($result, $magazines), headers: $headers ); } } ================================================ FILE: src/Controller/Api/User/UserRetrieveApi.php ================================================ rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $dto = $factory->createDto($user); return new JsonResponse( $this->serializeUser($dto), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns the user by username', content: new Model(type: UserResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 404, description: 'User not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'username', in: 'path', description: 'The user to retrieve', schema: new OA\Schema(type: 'string'), )] #[OA\Tag(name: 'user')] public function username( #[MapEntity(mapping: ['username' => 'username'])] User $user, UserFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $dto = $factory->createDto($user, $this->reputationRepository->getUserReputationTotal($user)); return new JsonResponse( $this->serializeUser($dto), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns the current user', content: new Model(type: UserResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:profile:read'])] #[IsGranted('ROLE_OAUTH2_USER:PROFILE:READ')] public function me( UserFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $user = $this->getUserOrThrow(); $dto = $factory->createDto($user, $this->reputationRepository->getUserReputationTotal($user)); return new JsonResponse( $this->serializeUser($dto), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns the current user\'s settings', content: new Model(type: UserSettingsDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:profile:read'])] #[IsGranted('ROLE_OAUTH2_USER:PROFILE:READ')] public function settings( UserSettingsManager $manager, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $dto = $manager->createDto($this->getUserOrThrow()); return new JsonResponse( $dto, headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of users', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: UserResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of users to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of users per page', in: 'query', schema: new OA\Schema(type: 'integer', default: UserRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Parameter( name: 'group', description: 'What group of users to retrieve', in: 'query', schema: new OA\Schema(type: 'string', default: UserRepository::USERS_ALL, enum: UserRepository::USERS_OPTIONS) )] #[OA\Parameter( name: 'q', description: 'The term to search for', in: 'query', schema: new OA\Schema(type: 'string') )] #[OA\Parameter( name: 'withAbout', description: 'Only include users with a filled in profile', in: 'query', schema: new OA\Schema(type: 'boolean') )] #[OA\Tag(name: 'user')] public function collection( UserRepository $userRepository, UserFactory $userFactory, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $request = $this->request->getCurrentRequest(); $group = $request->get('group', UserRepository::USERS_ALL); $withAboutRaw = $request->get('withAbout'); $withAbout = null === $withAboutRaw ? false : \boolval($withAboutRaw); $users = $userRepository->findPaginated( $this->getPageNb($request), $withAbout, $group, $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)), $request->get('q'), ); $dtos = []; foreach ($users->getCurrentPageResults() as $value) { \assert($value instanceof User); array_push($dtos, $this->serializeUser($userFactory->createDto($value))); } return new JsonResponse( $this->serializePaginated($dtos, $users), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of users being followed by given user', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: UserResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'This user does not allow others to view the users they follow', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'user_id', description: 'User from which to retrieve followed users', in: 'path', schema: new OA\Schema(type: 'integer') )] #[OA\Parameter( name: 'p', description: 'Page of users to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of users per page', in: 'query', schema: new OA\Schema( type: 'integer', default: UserRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE ) )] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:follow'])] #[IsGranted('ROLE_OAUTH2_USER:FOLLOW')] public function followed( #[MapEntity(id: 'user_id')] User $user, UserRepository $repository, UserFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); if ($user->getId() !== $this->getUserOrThrow()->getId() && !$user->getShowProfileFollowings()) { throw new AccessDeniedHttpException('You are not permitted to view the users followed by this user'); } $request = $this->request->getCurrentRequest(); $users = $repository->findFollowing( $this->getPageNb($request), $user, self::constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)) ); $dtos = []; foreach ($users->getCurrentPageResults() as $value) { \assert($value instanceof UserFollow); array_push($dtos, $this->serializeUser($factory->createDto($value->following))); } return new JsonResponse( $this->serializePaginated($dtos, $users), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of users following the given user', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: UserResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'user_id', description: 'User from which to retrieve following users', in: 'path', schema: new OA\Schema(type: 'integer') )] #[OA\Parameter( name: 'p', description: 'Page of users to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of users per page', in: 'query', schema: new OA\Schema( type: 'integer', default: UserRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE ) )] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:follow'])] #[IsGranted('ROLE_OAUTH2_USER:FOLLOW')] public function followers( #[MapEntity(id: 'user_id')] User $user, UserRepository $repository, UserFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $request = $this->request->getCurrentRequest(); $users = $repository->findFollowers( $this->getPageNb($request), $user, self::constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)) ); $dtos = []; foreach ($users->getCurrentPageResults() as $value) { \assert($value instanceof UserFollow); array_push($dtos, $this->serializeUser($factory->createDto($value->follower))); } return new JsonResponse( $this->serializePaginated($dtos, $users), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of users being followed by the current user', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: UserResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'This user does not allow others to view the users they follow', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of users to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of users per page', in: 'query', schema: new OA\Schema( type: 'integer', default: UserRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE ) )] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:follow'])] #[IsGranted('ROLE_OAUTH2_USER:FOLLOW')] public function followedByCurrent( UserRepository $repository, UserFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $request = $this->request->getCurrentRequest(); $users = $repository->findFollowing( $this->getPageNb($request), $this->getUserOrThrow(), self::constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)) ); $dtos = []; foreach ($users->getCurrentPageResults() as $value) { \assert($value instanceof UserFollow); array_push($dtos, $this->serializeUser($factory->createDto($value->following))); } return new JsonResponse( $this->serializePaginated($dtos, $users), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of users following the current user', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: UserResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of users to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of users per page', in: 'query', schema: new OA\Schema( type: 'integer', default: UserRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE ) )] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:follow'])] #[IsGranted('ROLE_OAUTH2_USER:FOLLOW')] public function followersOfCurrent( UserRepository $repository, UserFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $request = $this->request->getCurrentRequest(); $users = $repository->findFollowers( $this->getPageNb($request), $this->getUserOrThrow(), self::constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)) ); $dtos = []; foreach ($users->getCurrentPageResults() as $value) { \assert($value instanceof UserFollow); array_push($dtos, $this->serializeUser($factory->createDto($value->follower))); } return new JsonResponse( $this->serializePaginated($dtos, $users), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of users blocked by the current user', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: UserResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of users to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of users per page', in: 'query', schema: new OA\Schema(type: 'integer', default: UserRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:block'])] #[IsGranted('ROLE_OAUTH2_USER:BLOCK')] public function blocked( UserRepository $repository, UserFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $request = $this->request->getCurrentRequest(); $users = $repository->findBlockedUsers( $this->getPageNb($request), $this->getUserOrThrow(), self::constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)) ); $dtos = []; foreach ($users->getCurrentPageResults() as $value) { \assert($value instanceof UserBlock); array_push($dtos, $this->serializeUser($factory->createDto($value->blocked))); } return new JsonResponse( $this->serializePaginated($dtos, $users), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns all instance admins', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent( properties: [ new OA\Property(property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: UserResponseDto::class))), new OA\Property(property: 'pagination', ref: new Model(type: PaginationSchema::class)), ], type: 'object' ) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)) )] #[OA\Tag(name: 'instance')] public function admins( UserRepository $repository, UserFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $users = $repository->findAllAdmins(); $dtos = []; foreach ($users as $value) { \assert($value instanceof User); $dtos[] = $this->serializeUser($factory->createDto($value)); } return new JsonResponse(['items' => $dtos], headers: $headers); } #[OA\Response( response: 200, description: 'Returns all instance moderators', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent( properties: [ new OA\Property(property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: UserResponseDto::class))), new OA\Property(property: 'pagination', ref: new Model(type: PaginationSchema::class)), ], type: 'object' ) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', headers: [ new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), ], content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)) )] #[OA\Tag(name: 'instance')] public function moderators( UserRepository $repository, UserFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $users = $repository->findAllModerators(); $dtos = []; foreach ($users as $value) { \assert($value instanceof User); $dtos[] = $this->serializeUser($factory->createDto($value)); } return new JsonResponse(['items' => $dtos], headers: $headers); } } ================================================ FILE: src/Controller/Api/User/UserRetrieveOAuthConsentsApi.php ================================================ rateLimit($apiReadLimiter); return new JsonResponse( $factory->createDto($consent), headers: $headers ); } #[OA\Response( response: 200, description: 'Returns a paginated list of OAuth2 consents given to clients by the user', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', items: new OA\Items(ref: new Model(type: ClientConsentsResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), ] ), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not authorized to view this page', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 404, description: 'Page not found', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Parameter( name: 'p', description: 'Page of clients to retrieve', in: 'query', schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) )] #[OA\Parameter( name: 'perPage', description: 'Number of clients to retrieve per page', in: 'query', schema: new OA\Schema(type: 'integer', default: self::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) )] #[OA\Tag(name: 'oauth')] #[Security(name: 'oauth2', scopes: ['user:oauth_clients:read'])] #[IsGranted('ROLE_OAUTH2_USER:OAUTH_CLIENTS:READ')] public function collection( ClientConsentsFactory $factory, RateLimiterFactoryInterface $apiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter); $pagerfanta = new Pagerfanta( new CollectionAdapter( $this->getUserOrThrow()->getOAuth2UserConsents() ) ); $request = $this->request->getCurrentRequest(); $page = $this->getPageNb($request); $perPage = self::constrainPerPage($request->get('perPage', self::PER_PAGE)); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } $dtos = []; foreach ($pagerfanta->getCurrentPageResults() as $consent) { \assert($consent instanceof OAuth2UserConsent); array_push($dtos, $factory->createDto($consent)); } return new JsonResponse( $this->serializePaginated($dtos, $pagerfanta), headers: $headers ); } } ================================================ FILE: src/Controller/Api/User/UserUpdateApi.php ================================================ rateLimit($apiUpdateLimiter); $request = $this->request->getCurrentRequest(); /** @var UserProfileRequestDto $dto */ $deserialized = $this->serializer->deserialize($request->getContent(), UserProfileRequestDto::class, 'json'); $errors = $validator->validate($deserialized); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } $dto = $manager->createDto($this->getUserOrThrow()); $dto->about = $deserialized->about; $dto->title = $deserialized->title; $user = $manager->edit($this->getUserOrThrow(), $dto); return new JsonResponse( $this->serializeUser($factory->createDto($user)), headers: $headers ); } #[OA\Response( response: 200, description: 'User settings updated', content: new Model(type: UserSettingsDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\RequestBody(content: new Model(type: UserSettingsDto::class))] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:profile:edit'])] #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')] public function settings( UserSettingsManager $manager, ValidatorInterface $validator, RateLimiterFactoryInterface $apiUpdateLimiter, ): JsonResponse { $headers = $this->rateLimit($apiUpdateLimiter); $settings = $manager->createDto($this->getUserOrThrow()); $dto = $this->deserializeUserSettings($settings); $errors = $validator->validate($dto); if (\count($errors) > 0) { throw new BadRequestHttpException((string) $errors); } $manager->update($this->getUserOrThrow(), $dto); return new JsonResponse( $manager->createDto($this->getUserOrThrow()), headers: $headers ); } } ================================================ FILE: src/Controller/Api/User/UserUpdateImagesApi.php ================================================ rateLimit($apiImageLimiter); $image = $this->handleUploadedImage(); $dto = $manager->createDto($this->getUserOrThrow()); $dto->avatar = $image ? $this->imageFactory->createDto($image) : $dto->avatar; $user = $manager->edit($this->getUserOrThrow(), $dto); return new JsonResponse( $this->serializeUser($factory->createDto($user)), headers: $headers ); } #[OA\Response( response: 200, description: 'User cover updated', content: new Model(type: UserResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\Response( response: 400, description: 'The uploaded image was missing or invalid', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\BadRequestErrorSchema::class)) )] #[OA\Response( response: 401, description: 'Permission denied due to missing or expired token', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) )] #[OA\Response( response: 403, description: 'You are not authorized to update the user\'s profile', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\ForbiddenErrorSchema::class)) )] #[OA\Response( response: 429, description: 'You are being rate limited', content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), ] )] #[OA\RequestBody(content: new OA\MediaType( 'multipart/form-data', schema: new OA\Schema( ref: new Model( type: ImageUploadDto::class, groups: [ ImageUploadDto::IMAGE_UPLOAD_NO_ALT, ] ) ) ))] #[OA\Tag(name: 'user')] #[Security(name: 'oauth2', scopes: ['user:profile:edit'])] #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')] public function cover( UserManager $manager, UserFactory $factory, RateLimiterFactoryInterface $apiImageLimiter, ): JsonResponse { $headers = $this->rateLimit($apiImageLimiter); $image = $this->handleUploadedImage(); $dto = $manager->createDto($this->getUserOrThrow()); $dto->cover = $image ? $this->imageFactory->createDto($image) : $dto->cover; $user = $manager->edit($this->getUserOrThrow(), $dto); return new JsonResponse( $this->serializeUser($factory->createDto($user)), headers: $headers ); } } ================================================ FILE: src/Controller/Api/User/UserUpdateOAuthConsentsApi.php ================================================ rateLimit($apiReadLimiter); $request = $this->request->getCurrentRequest(); /** @var ClientConsentsRequestDto $dto */ $dto = $this->serializer->deserialize($request->getContent(), ClientConsentsRequestDto::class, 'json'); $errors = $this->validator->validate($dto); if (0 < \count($errors)) { throw new BadRequestHttpException((string) $errors); } if (array_intersect($dto->scopes, $consent->getScopes()) !== $dto->scopes) { // $dto->scopesGranted is not a subset of the current scopes // The client is attempting to request more scopes than it currently has throw new AccessDeniedHttpException('An API client cannot add scopes with this API, only remove them.'); } $consent->setScopes($dto->scopes); $this->entityManager->flush(); return new JsonResponse( $factory->createDto($consent), headers: $headers ); } } ================================================ FILE: src/Controller/BookmarkController.php ================================================ entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); $this->bookmarkManager->addBookmarkToDefaultList($this->getUserOrThrow(), $subjectEntity); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('components/_ajax.html.twig', [ 'component' => 'bookmark_standard', 'attributes' => [ 'subject' => $subjectEntity, 'subjectClass' => $subjectClass, ], ] ), ]); } return $this->redirect($request->headers->get('Referer')); } #[IsGranted('ROLE_USER')] public function subjectBookmarkRefresh(int $subject_id, string $subject_type, Request $request): Response { $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('components/_ajax.html.twig', [ 'component' => 'bookmark_standard', 'attributes' => [ 'subject' => $subjectEntity, 'subjectClass' => $subjectClass, ], ] ), ]); } return $this->redirect($request->headers->get('Referer')); } #[IsGranted('ROLE_USER')] public function subjectBookmarkToList(int $subject_id, string $subject_type, #[MapEntity] BookmarkList $list, Request $request): Response { $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); $user = $this->getUserOrThrow(); if ($user->getId() !== $list->user->getId()) { throw new AccessDeniedHttpException(); } $this->bookmarkManager->addBookmark($user, $list, $subjectEntity); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('components/_ajax.html.twig', [ 'component' => 'bookmark_list', 'attributes' => [ 'subject' => $subjectEntity, 'subjectClass' => $subjectClass, 'list' => $list, ], ] ), ]); } return $this->redirect($request->headers->get('Referer')); } #[IsGranted('ROLE_USER')] public function subjectRemoveBookmarks(int $subject_id, string $subject_type, Request $request): Response { $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); $this->bookmarkRepository->removeAllBookmarksForContent($this->getUserOrThrow(), $subjectEntity); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('components/_ajax.html.twig', [ 'component' => 'bookmark_standard', 'attributes' => [ 'subject' => $subjectEntity, 'subjectClass' => $subjectClass, ], ] ), ]); } return $this->redirect($request->headers->get('Referer')); } #[IsGranted('ROLE_USER')] public function subjectRemoveBookmarkFromList(int $subject_id, string $subject_type, #[MapEntity] BookmarkList $list, Request $request): Response { $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); $user = $this->getUserOrThrow(); if ($user->getId() !== $list->user->getId()) { throw new AccessDeniedHttpException(); } $this->bookmarkRepository->removeBookmarkFromList($user, $list, $subjectEntity); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('components/_ajax.html.twig', [ 'component' => 'bookmark_list', 'attributes' => [ 'subject' => $subjectEntity, 'subjectClass' => $subjectClass, 'list' => $list, ], ] ), ]); } return $this->redirect($request->headers->get('Referer')); } } ================================================ FILE: src/Controller/BookmarkListController.php ================================================ getPageNb($request); $user = $this->getUserOrThrow(); $criteria = new EntryPageView($page, $this->security); $criteria->setTime($criteria->resolveTime($time)); $criteria->setType($criteria->resolveType($type)); $criteria->showSortOption($criteria->resolveSort($sortBy ?? Criteria::SORT_NEW)); $criteria->setFederation($federation); if (null !== $list) { $bookmarkList = $this->bookmarkListRepository->findOneByUserAndName($user, $list); } else { $bookmarkList = $this->bookmarkListRepository->findOneByUserDefault($user); } $res = $this->bookmarkRepository->findPopulatedByList($bookmarkList, $criteria); $objects = $res->getCurrentPageResults(); $lists = $this->bookmarkListRepository->findByUser($user); $this->logger->info('got results in list {l}: {r}', ['l' => $list, 'r' => $objects]); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('layout/_subject_list.html.twig', [ 'results' => $objects, 'pagination' => $res, ]), ]); } return $this->render( 'bookmark/front.html.twig', [ 'criteria' => $criteria, 'list' => $bookmarkList, 'lists' => $lists, 'results' => $objects, 'pagination' => $res, ] ); } #[IsGranted('ROLE_USER')] public function list(Request $request): Response { $user = $this->getUserOrThrow(); $dto = new BookmarkListDto(); $form = $this->createForm(BookmarkListType::class, $dto); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { /** @var BookmarkListDto $dto */ $dto = $form->getData(); $list = $this->bookmarkManager->createList($user, $dto->name); if ($dto->isDefault) { $this->bookmarkListRepository->makeListDefault($user, $list); } return $this->redirectToRoute('bookmark_lists'); } return $this->render('bookmark/overview.html.twig', [ 'lists' => $this->bookmarkListRepository->findByUser($user), 'form' => $form->createView(), ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); } #[IsGranted('ROLE_USER')] public function subjectBookmarkMenuListRefresh(int $subject_id, string $subject_type, Request $request): Response { $user = $this->getUserOrThrow(); $bookmarkLists = $this->bookmarkListRepository->findByUser($user); $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('components/_ajax.html.twig', [ 'component' => 'bookmark_menu_list', 'attributes' => [ 'subject' => $subjectEntity, 'subjectClass' => $subjectClass, 'bookmarkLists' => $bookmarkLists, ], ] ), ]); } return $this->redirect($request->headers->get('Referer')); } #[IsGranted('ROLE_USER')] public function makeDefault(#[MapQueryParameter] ?int $makeDefault): Response { $user = $this->getUserOrThrow(); $this->logger->info('making list id {id} default for user {u}', ['user' => $user->username, 'id' => $makeDefault]); if (null !== $makeDefault) { $list = $this->bookmarkListRepository->findOneBy(['id' => $makeDefault]); $this->bookmarkListRepository->makeListDefault($user, $list); } return $this->redirectToRoute('bookmark_lists'); } #[IsGranted('ROLE_USER')] public function editList(#[MapEntity] BookmarkList $list, Request $request): Response { $user = $this->getUserOrThrow(); $dto = BookmarkListDto::fromList($list); $form = $this->createForm(BookmarkListType::class, $dto); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $dto = $form->getData(); $this->bookmarkListRepository->editList($user, $list, $dto); return $this->redirectToRoute('bookmark_lists'); } return $this->render('bookmark/edit.html.twig', [ 'list' => $list, 'form' => $form->createView(), ]); } #[IsGranted('ROLE_USER')] public function deleteList(#[MapEntity] BookmarkList $list): Response { $user = $this->getUserOrThrow(); if ($user->getId() !== $list->user->getId()) { $this->logger->error('user {u} tried to delete a list that is not his own: {l}', ['u' => $user->username, 'l' => "$list->name ({$list->getId()})"]); throw new AccessDeniedHttpException(); } $this->bookmarkListRepository->deleteList($list); return $this->redirectToRoute('bookmark_lists'); } } ================================================ FILE: src/Controller/BoostController.php ================================================ manager->vote(VotableInterface::VOTE_UP, $subject, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'boost', 'attributes' => [ 'subject' => $subject, 'path' => $request->attributes->get('_route'), ], ] ), ] ); } return $this->redirectToRefererOrHome($request, $this->classService->fromEntity($subject)); } } ================================================ FILE: src/Controller/ContactController.php ================================================ findAll(); $form = $this->createForm(ContactType::class, options: [ 'antispam_profile' => 'default', ]); try { $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { /** * @var ContactDto $dto */ $dto = $form->getData(); $dto->ip = $ipResolver->resolve(); if (!$dto->surname) { $manager->send($dto); } $this->addFlash('success', 'flash_email_was_sent'); return $this->redirectToRefererOrHome($request); } } catch (\Exception $e) { // Show an error to the user $this->addFlash('error', 'flash_email_failed_to_sent'); $this->logger->error('there was an exception sending an email: {e} - {m}', ['e' => \get_class($e), 'm' => $e->getMessage(), 'exception' => $e]); } return $this->render( 'page/contact.html.twig', [ 'body' => $site[0]->contact ?? '', 'form' => $form->createView(), ] ); } } ================================================ FILE: src/Controller/CrosspostController.php ================================================ isAdult ? '1' : '0'; $query['isOc'] = $entry->isOc ? '1' : '0'; if ('' !== $entry->title) { $query['title'] = $entry->title; } if (null !== $entry->url && '' !== $entry->url) { $query['url'] = $entry->url; } if (null !== $entry->image) { $query['imageHash'] = strtok($entry->image->fileName, '.'); if (null !== $entry->image->altText && '' !== $entry->image->altText) { $query['imageAlt'] = $entry->image->altText; } } $tagNum = 0; foreach ($entry->hashtags as $hashtag) { /* @var $hashtag \App\Entity\HashtagLink */ $query["tags[$tagNum]"] = $hashtag->hashtag->tag; ++$tagNum; } if (null !== $entry->apId) { $entryUrl = $entry->apId; } else { $entryUrl = $this->urlGenerator->generate( 'ap_entry', ['magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId()], UrlGeneratorInterface::ABSOLUTE_URL ); } $body = 'Crossposted from ['.$entryUrl.']('.$entryUrl.')'; if (null !== $entry->body && '' !== $entry->body) { $bodyLines = explode("\n", $entry->body); $body = $body."\n"; foreach ($bodyLines as $line) { $body = $body."\n> ".$line; } } $query['body'] = $body; return $this->redirectToRoute('entry_create', $query); } } ================================================ FILE: src/Controller/CustomStyleController.php ================================================ query->get('magazine'); $magazine = $repository->findOneByName($magazineName); $css = $this->renderView('styles/custom.css.twig', [ 'magazine' => $magazine, ]); return $this->createResponse($request, $css); } private function createResponse(Request $request, ?string $customCss): Response { $response = new Response(); $response->headers->set('Content-Type', 'text/css'); $response->setPrivate(); if (!empty($customCss)) { $response->setContent($customCss); $response->setEtag(md5($response->getContent())); $response->isNotModified($request); } else { $response->setStatusCode(Response::HTTP_NOT_FOUND); } return $response; } } ================================================ FILE: src/Controller/Domain/DomainBlockController.php ================================================ 'name'])] Domain $domain, Request $request): Response { $this->manager->block($domain, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($domain); } return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] public function unblock(#[MapEntity(mapping: ['name' => 'name'])] Domain $domain, Request $request): Response { $this->manager->unblock($domain, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($domain); } return $this->redirectToRefererOrHome($request); } private function getJsonResponse(Domain $domain): JsonResponse { return new JsonResponse( [ 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'domain_sub', 'attributes' => [ 'domain' => $domain, ], ] ), ] ); } } ================================================ FILE: src/Controller/Domain/DomainCommentFrontController.php ================================================ domainRepository->findOneBy(['name' => $name])) { throw $this->createNotFoundException(); } $params = []; $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)) ->setDomain($name); $params['comments'] = $this->commentRepository->findByCriteria($criteria); $params['domain'] = $domain; $params['criteria'] = $criteria; return $this->render( 'domain/comment/front.html.twig', $params ); } } ================================================ FILE: src/Controller/Domain/DomainFrontController.php ================================================ domainRepository->findOneBy(['name' => $name])) { throw $this->createNotFoundException(); } $criteria = new EntryPageView($this->getPageNb($request), $security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)) ->setType($criteria->resolveType($type)) ->setDomain($name); $resolvedSort = $criteria->resolveSort($sortBy); $criteria->sortOption = $resolvedSort; $user = $security->getUser(); if ($user instanceof User) { $criteria->fetchCachedItems($this->sqlHelpers, $user); } $listing = $this->contentRepository->findByCriteria($criteria); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'html' => $this->renderView( 'entry/_list.html.twig', [ 'entries' => $listing, ] ), ] ); } return $this->render( 'domain/front.html.twig', [ 'domain' => $domain, 'entries' => $listing, 'criteria' => $criteria, ] ); } } ================================================ FILE: src/Controller/Domain/DomainSubController.php ================================================ 'name'])] Domain $domain, Request $request): Response { $this->manager->subscribe($domain, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($domain); } return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] public function unsubscribe(#[MapEntity(mapping: ['name' => 'name'])] Domain $domain, Request $request): Response { $this->manager->unsubscribe($domain, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($domain); } return $this->redirectToRefererOrHome($request); } private function getJsonResponse(Domain $domain): JsonResponse { return new JsonResponse( [ 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'domain_sub', 'attributes' => [ 'domain' => $domain, ], ] ), ] ); } } ================================================ FILE: src/Controller/Entry/Comment/EntryCommentChangeAdultController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'comment_id')] EntryComment $comment, Request $request, ): Response { $this->validateCsrf('change_adult', $request->getPayload()->get('token')); $comment->isAdult = 'on' === $request->get('adult'); $this->entityManager->flush(); $this->addFlash( 'success', $comment->isAdult ? 'flash_mark_as_adult_success' : 'flash_unmark_as_adult_success' ); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Entry/Comment/EntryCommentChangeLangController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'comment_id')] EntryComment $comment, Request $request, ): Response { $comment->lang = $request->get('lang')['lang']; $this->entityManager->flush(); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Entry/Comment/EntryCommentCreateController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'parent_comment_id')] ?EntryComment $parent, Request $request, Security $security, ): Response { $form = $this->getForm($entry, $parent); try { // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image) $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $dto = $form->getData(); $dto->magazine = $magazine; $dto->entry = $entry; $dto->parent = $parent; $dto->ip = $this->ipResolver->resolve(); if (!$this->isGranted('create_content', $dto->magazine)) { throw new AccessDeniedHttpException(); } return $this->handleValidRequest($dto, $request); } } catch (InstanceBannedException) { $this->addFlash('error', 'flash_instance_banned_error'); } catch (\Exception $e) { // Show an error to the user $this->addFlash('error', 'flash_comment_new_error'); } if ($request->isXmlHttpRequest()) { return $this->getJsonFormResponse( $form, 'entry/comment/_form_comment.html.twig', ['entry' => $entry, 'parent' => $parent] ); } $user = $this->getUserOrThrow(); $criteria = new EntryCommentPageView($this->getPageNb($request), $security); $criteria->entry = $entry; return $this->getEntryCommentPageResponse( 'entry/comment/create.html.twig', $user, $criteria, $form, $request, $parent ); } private function getForm(Entry $entry, ?EntryComment $parent = null): FormInterface { $dto = new EntryCommentDto(); if ($parent && $this->getUser()->addMentionsEntries) { $handle = $this->mentionManager->addHandle([$parent->user->username])[0]; if ($parent->user !== $this->getUser()) { $dto->body = $handle; } else { $dto->body .= PHP_EOL; } if ($parent->mentions) { $mentions = $this->mentionManager->addHandle($parent->mentions); $mentions = array_filter( $mentions, fn (string $mention) => $mention !== $handle && $mention !== $this->mentionManager->addHandle( [$this->getUser()->username] )[0] ); $dto->body .= PHP_EOL.PHP_EOL; $dto->body .= implode(' ', array_unique($mentions)); } } return $this->createForm( EntryCommentType::class, $dto, [ 'action' => $this->generateUrl( 'entry_comment_create', [ 'magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'parent_comment_id' => $parent?->getId(), ] ), 'parentLanguage' => $parent?->lang ?? $entry->lang, ] ); } private function handleValidRequest(EntryCommentDto $dto, Request $request): Response { $comment = $this->manager->create($dto, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return $this->getJsonCommentSuccessResponse($comment); } $this->addFlash('success', 'flash_comment_new_success'); return $this->redirectToEntry($comment->entry); } } ================================================ FILE: src/Controller/Entry/Comment/EntryCommentDeleteController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'comment_id')] EntryComment $comment, Request $request, ): Response { $this->validateCsrf('entry_comment_delete', $request->getPayload()->get('token')); $this->manager->delete($this->getUserOrThrow(), $comment); return $this->redirectToEntry($entry); } #[IsGranted('ROLE_USER')] #[IsGranted('delete', subject: 'comment')] public function restore( #[MapEntity(mapping: ['magazine_name' => 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'comment_id')] EntryComment $comment, Request $request, ): Response { $this->validateCsrf('entry_comment_restore', $request->getPayload()->get('token')); $this->manager->restore($this->getUserOrThrow(), $comment); return $this->redirectToEntry($entry); } #[IsGranted('ROLE_USER')] #[IsGranted('purge', subject: 'comment')] public function purge( #[MapEntity(mapping: ['magazine_name' => 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'comment_id')] EntryComment $comment, Request $request, ): Response { $this->validateCsrf('entry_comment_purge', $request->getPayload()->get('token')); $this->manager->purge($this->getUserOrThrow(), $comment); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Entry/Comment/EntryCommentDeleteImageController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'comment_id')] EntryComment $comment, Request $request, ): Response { $this->manager->detachImage($comment); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'success' => true, ] ); } return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Entry/Comment/EntryCommentEditController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'comment_id')] EntryComment $comment, Request $request, Security $security, ): Response { $dto = $this->manager->createDto($comment); $form = $this->getForm($dto, $comment); try { // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image) $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { if (!$this->isGranted('create_content', $dto->magazine)) { throw new AccessDeniedHttpException(); } return $this->handleValidRequest($dto, $comment, $request); } } catch (\Exception $e) { // Show an error to the user $this->addFlash('error', 'flash_comment_edit_error'); } if ($request->isXmlHttpRequest()) { return $this->getJsonFormResponse( $form, 'entry/comment/_form_comment.html.twig', ['comment' => $comment, 'entry' => $entry, 'edit' => true] ); } $user = $this->getUserOrThrow(); $criteria = new EntryCommentPageView($this->getPageNb($request), $security); $criteria->entry = $entry; return $this->getEntryCommentPageResponse('entry/comment/edit.html.twig', $user, $criteria, $form, $request, $comment); } private function getForm(EntryCommentDto $dto, EntryComment $comment): FormInterface { return $this->createForm( EntryCommentType::class, $dto, [ 'action' => $this->generateUrl( 'entry_comment_edit', [ 'magazine_name' => $comment->magazine->name, 'entry_id' => $comment->entry->getId(), 'comment_id' => $comment->getId(), ] ), ] ); } private function handleValidRequest(EntryCommentDto $dto, EntryComment $comment, Request $request): Response { $comment = $this->manager->edit($comment, $dto, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return $this->getJsonCommentSuccessResponse($comment); } $this->addFlash('success', 'flash_comment_edit_success'); return $this->redirectToEntry($comment->entry); } } ================================================ FILE: src/Controller/Entry/Comment/EntryCommentFavouriteController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'comment_id')] EntryComment $comment, Request $request, ): Response { return $this->render('entry/comment/favourites.html.twig', [ 'magazine' => $magazine, 'entry' => $entry, 'comment' => $comment, 'favourites' => $comment->favourites, ]); } } ================================================ FILE: src/Controller/Entry/Comment/EntryCommentFrontController.php ================================================ getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy ?? Criteria::SORT_DEFAULT)) ->setTime($criteria->resolveTime($time)); $criteria->setFederation($federation ?? Criteria::AP_ALL); if ($magazine) { $criteria->magazine = $params['magazine'] = $magazine; } $params['comments'] = $this->repository->findByCriteria($criteria); $params['criteria'] = $criteria; return $this->render( 'entry/comment/front.html.twig', $params ); } #[IsGranted('ROLE_USER')] public function subscribed(?string $sortBy, ?string $time, Request $request): Response { $params = []; $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)); $criteria->subscribed = true; $params['comments'] = $this->repository->findByCriteria($criteria); $params['criteria'] = $criteria; return $this->render( 'entry/comment/front.html.twig', $params ); } #[IsGranted('ROLE_USER')] public function moderated(?string $sortBy, ?string $time, Request $request): Response { $params = []; $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)); $criteria->moderated = true; $params['comments'] = $this->repository->findByCriteria($criteria); $params['criteria'] = $criteria; return $this->render( 'entry/comment/front.html.twig', $params ); } #[IsGranted('ROLE_USER')] public function favourite(?string $sortBy, ?string $time, Request $request): Response { $params = []; $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)); $criteria->favourite = true; $params['comments'] = $this->repository->findByCriteria($criteria); $params['criteria'] = $criteria; return $this->render( 'entry/comment/front.html.twig', $params ); } } ================================================ FILE: src/Controller/Entry/Comment/EntryCommentModerateController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'comment_id')] EntryComment $comment, Request $request, ): Response { if ($entry->magazine !== $magazine) { return $this->redirectToRoute( 'entry_single', ['magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'slug' => $entry->slug], 301 ); } $form = $this->createForm(LangType::class); $form->get('lang') ->setData($comment->lang); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('entry/comment/_moderate_panel.html.twig', [ 'magazine' => $magazine, 'entry' => $entry, 'comment' => $comment, 'form' => $form->createView(), ]), ]); } return $this->render('entry/comment/moderate.html.twig', [ 'magazine' => $magazine, 'entry' => $entry, 'comment' => $comment, 'form' => $form->createView(), ]); } } ================================================ FILE: src/Controller/Entry/Comment/EntryCommentResponseTrait.php ================================================ isXmlHttpRequest()) { $this->getJsonFormResponse($form, 'entry/comment/_form.html.twig'); } return $this->render( $template, [ 'user' => $user, 'magazine' => $criteria->entry->magazine, 'entry' => $criteria->entry, 'parent' => $parent, 'comment' => $parent, 'form' => $form->createView(), ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 322 : 200) ); } private function getJsonCommentSuccessResponse(EntryComment $comment): Response { return new JsonResponse( [ 'id' => $comment->getId(), 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'entry_comment', 'attributes' => [ 'comment' => $comment, 'showEntryTitle' => false, 'showMagazineName' => false, ], ] ), ] ); } } ================================================ FILE: src/Controller/Entry/Comment/EntryCommentViewController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'comment_id')] ?EntryComment $comment, Request $request, Security $security, ): Response { $this->handlePrivateContent($entry); // @TODO there is no entry comment has been seen event, maybe // it should be added so one comment view does not mark all as read in the same entry $this->dispatcher->dispatch(new EntryHasBeenSeenEvent($entry)); $this->entryRepository->hydrate($entry); // Both comment and root comment can be null if (null !== $comment?->root) { $this->entryCommentRepository->hydrateChildren($comment->root); } $criteria = new EntryCommentPageView(1, $security); return $this->render( 'entry/comment/view.html.twig', [ 'magazine' => $magazine, 'entry' => $entry, 'comment' => $comment, 'criteria' => $criteria, ] ); } } ================================================ FILE: src/Controller/Entry/Comment/EntryCommentVotersController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, #[MapEntity(id: 'comment_id')] EntryComment $comment, Request $request, string $type, ): Response { if ('down' === $type && DownvotesMode::Enabled !== $this->settingsManager->getDownvotesMode()) { $votes = []; } else { $votes = $comment->votes->filter( fn ($e) => $e->choice === ('up' === $type ? VotableInterface::VOTE_UP : VotableInterface::VOTE_DOWN) ); } if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('components/voters_inline.html.twig', [ 'votes' => $votes, 'more' => null, ]), ]); } return $this->render('entry/comment/voters.html.twig', [ 'magazine' => $magazine, 'entry' => $entry, 'comment' => $comment, 'votes' => $votes, ]); } } ================================================ FILE: src/Controller/Entry/EntryChangeAdultController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { $this->validateCsrf('change_adult', $request->getPayload()->get('token')); $entry->isAdult = 'on' === $request->get('adult'); $this->entityManager->flush(); $this->addFlash( 'success', $entry->isAdult ? 'flash_mark_as_adult_success' : 'flash_unmark_as_adult_success' ); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Entry/EntryChangeLangController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { $entry->lang = $request->get('lang')['lang']; $this->entityManager->flush(); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Entry/EntryChangeMagazineController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { $this->validateCsrf('change_magazine', $request->getPayload()->get('token')); $newMagazine = $this->repository->findOneByName($request->get('change_magazine')['new_magazine']); $this->manager->changeMagazine($entry, $newMagazine); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Entry/EntryCreateController.php ================================================ getUserOrThrow(); $maxBytes = $this->settingsManager->getMaxImageByteString(); $dto = new EntryDto(); $dto->magazine = $magazine; $dto->title = $title; $dto->url = $url; $dto->body = $body; $dto->imageAlt = $imageAlt; $dto->isAdult = '1' === $isNsfw; $dto->isOc = '1' === $isOc; $dto->tags = $tags; if (null !== $imageHash) { $img = $this->imageRepository->findOneBySha256(hex2bin($imageHash)); if (null !== $img) { $dto->image = $this->imageFactory->createDto($img); } else { $form = $this->createForm(EntryType::class, $dto); return $this->showFailure('flash_thread_ref_image_not_found', 400, $magazine, $user, $form, $maxBytes); } } $form = $this->createForm(EntryType::class, $dto); try { // Could throw an error on event handlers (e.g. onPostSubmit if a user upload an incorrect image) $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { /** @var EntryDto $dto */ $dto = $form->getData(); $dto->ip = $this->ipResolver->resolve(); if (!$this->isGranted('create_content', $dto->magazine)) { throw new AccessDeniedHttpException(); } $entry = $this->manager->create($dto, $this->getUserOrThrow()); foreach ($dto->tags ?? [] as $tag) { $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); if (!$hashtag) { $hashtag = $this->tagRepository->create($tag); } elseif ($this->tagLinkRepository->entryHasTag($entry, $hashtag)) { continue; } $this->tagLinkRepository->addTagToEntry($entry, $hashtag); } $this->addFlash('success', 'flash_thread_new_success'); return $this->redirectToMagazine( $entry->magazine, Criteria::SORT_NEW ); } return $this->render( $this->getTemplateName(), [ 'magazine' => $magazine, 'user' => $user, 'form' => $form->createView(), 'maxSize' => $maxBytes, ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); } catch (TagBannedException $e) { $this->logger->error($e); return $this->showFailure('flash_thread_tag_banned_error', 422, $magazine, $user, $form, $maxBytes); } catch (InstanceBannedException $e) { $this->logger->error($e); return $this->showFailure('flash_thread_instance_banned', 422, $magazine, $user, $form, $maxBytes); } catch (PostingRestrictedException $e) { $this->logger->error($e); return $this->showFailure('flash_posting_restricted_error', 422, $magazine, $user, $form, $maxBytes); } catch (ImageDownloadTooLargeException $e) { $this->logger->error($e); return $this->showFailure( $this->translator->trans('flash_image_download_too_large_error', ['%bytes%' => $maxBytes]), 422, $magazine, $user, $form, $maxBytes ); } catch (\Exception $e) { $this->logger->error($e); return $this->showFailure('flash_thread_new_error', 422, $magazine, $user, $form, $maxBytes); } } private function showFailure(string $flashMessage, int $httpCode, ?Magazine $magazine, User $user, FormInterface $form, string $maxBytes): Response { $this->addFlash('error', $flashMessage); return $this->render( $this->getTemplateName(), [ 'magazine' => $magazine, 'user' => $user, 'form' => $form->createView(), 'maxSize' => $maxBytes, ], new Response(null, $httpCode), ); } } ================================================ FILE: src/Controller/Entry/EntryDeleteController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { $this->validateCsrf('entry_delete', $request->getPayload()->get('token')); $this->manager->delete($this->getUserOrThrow(), $entry); $this->addFlash( 'danger', 'flash_thread_delete_success' ); return $this->redirectToMagazine($magazine); } #[IsGranted('ROLE_USER')] #[IsGranted('delete', subject: 'entry')] public function restore( #[MapEntity(mapping: ['magazine_name' => 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { $this->validateCsrf('entry_restore', $request->getPayload()->get('token')); $this->manager->restore($this->getUserOrThrow(), $entry); return $this->redirectToMagazine($magazine); } #[IsGranted('ROLE_USER')] #[IsGranted('purge', subject: 'entry')] public function purge( #[MapEntity(mapping: ['magazine_name' => 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { $this->validateCsrf('entry_purge', $request->getPayload()->get('token')); $this->manager->purge($this->getUserOrThrow(), $entry); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Entry/EntryDeleteImageController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { $this->manager->detachImage($entry); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'success' => true, ] ); } return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Entry/EntryEditController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { $dto = $this->manager->createDto($entry); $maxBytes = $this->settingsManager->getMaxImageByteString(); $form = $this->createForm(EntryEditType::class, $dto); try { $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { if (!$this->isGranted('create_content', $dto->magazine)) { throw new AccessDeniedHttpException(); } /** @var EntryDto $dto */ $dto = $form->getData(); $entry = $this->manager->edit($entry, $dto, $this->getUserOrThrow()); $this->addFlash('success', 'flash_thread_edit_success'); return $this->redirectToEntry($entry); } } catch (\Exception $e) { // Show an error to the user $this->addFlash('error', 'flash_thread_edit_error'); } return $this->render( $this->getTemplateName(edit: true), [ 'magazine' => $magazine, 'entry' => $entry, 'form' => $form->createView(), 'maxSize' => $maxBytes, ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); } } ================================================ FILE: src/Controller/Entry/EntryFavouriteController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { return $this->render('entry/favourites.html.twig', [ 'magazine' => $magazine, 'entry' => $entry, 'favourites' => $entry->favourites, ]); } } ================================================ FILE: src/Controller/Entry/EntryFrontController.php ================================================ getUser(); $criteria = $this->createCriteria($content, $request, $user); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setFederation($federation) ->setTime($criteria->resolveTime($time)) ->setType($criteria->resolveType($type)); if ('home' === $subscription) { $subscription = $this->subscriptionFor($user); } $this->handleSubscription($subscription, $criteria); $this->setUserPreferences($user, $criteria); if (null !== $user) { $criteria->fetchCachedItems($this->sqlHelpers, $user); } $entities = $this->contentRepository->findByCriteriaCursored($criteria, $this->getCursorByCriteria($criteria->sortOption, $cursor)); $templatePath = 'content/'; $dataKey = 'results'; return $this->renderResponse( $request, $criteria, [$dataKey => $entities], $templatePath, $user ); } public function frontRedirect( string $content, ?string $sortBy, ?string $time, string $federation, #[MapQueryParameter] ?string $type, Request $request, ): Response { $user = $this->getUser(); $subscription = $this->subscriptionFor($user); return $this->redirectToRoute('front', [ 'subscription' => $subscription, 'sortBy' => $sortBy, 'time' => $time, 'type' => $type, 'federation' => $federation, 'content' => $content, ]); } public function magazine( #[MapEntity(expr: 'repository.findOneByName(name)')] Magazine $magazine, string $content, ?string $sortBy, ?string $time, string $federation, #[MapQueryParameter] ?string $type, Request $request, #[MapQueryParameter] ?string $cursor = null, #[MapQueryParameter] ?string $cursor2 = null, ): Response { $user = $this->getUser(); $response = new Response(); if ($magazine->apId) { $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); } $criteria = $this->createCriteria($content, $request, $user); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setFederation($federation) ->setTime($criteria->resolveTime($time)) ->setType($criteria->resolveType($type)); $criteria->magazine = $magazine; $criteria->stickiesFirst = true; $subscription = $request->query->get('subscription') ?: 'all'; $this->handleSubscription($subscription, $criteria); $this->setUserPreferences($user, $criteria); if (null !== $user) { $criteria->fetchCachedItems($this->sqlHelpers, $user); } $cursorValue = $this->getCursorByCriteria($criteria->sortOption, $cursor); $cursor2Value = $cursor2 ? $this->getCursorByCriteria(Criteria::SORT_NEW, $cursor2) : null; $results = $this->contentRepository->findByCriteriaCursored($criteria, $cursorValue, $cursor2Value); return $this->renderResponse( $request, $criteria, ['results' => $results, 'magazine' => $magazine], 'content/', $user ); } /** * @param string $name magazine name */ public function magazineRedirect( string $name, string $content, ?string $sortBy, ?string $time, string $federation, #[MapQueryParameter] ?string $type, ): Response { $user = $this->getUser(); // Fetch the user $subscription = $this->subscriptionFor($user); // Determine the subscription filter based on the user return $this->redirectToRoute('front_magazine', [ 'name' => $name, 'subscription' => $subscription, 'sortBy' => $sortBy, 'time' => $time, 'type' => $type, 'federation' => $federation, 'content' => $content, ]); } private function createCriteria(string $content, Request $request, ?User $user): Criteria { if ('default' === $content) { $content = $user?->frontDefaultContent ?? 'threads'; } if ('threads' === $content || 'combined' === $content) { $criteria = new EntryPageView($this->getPageNb($request), $this->security); } elseif ('microblog' === $content) { $criteria = new PostPageView($this->getPageNb($request), $this->security); } else { throw new \LogicException('Invalid content '.$content); } return $criteria->setContent($content); } private function handleSubscription(string $subscription, &$criteria) { if (\in_array($subscription, ['sub', 'mod', 'fav'])) { $this->denyAccessUnlessGranted('ROLE_USER'); $this->getUserOrThrow(); } if ('sub' === $subscription) { $criteria->subscribed = true; } elseif ('mod' === $subscription) { $criteria->moderated = true; } elseif ('fav' === $subscription) { $criteria->favourite = true; } elseif ($subscription && 'all' !== $subscription) { throw new \LogicException('Invalid subscription filter '.$subscription); } } private function setUserPreferences(?User $user, Criteria &$criteria): void { if (null === $user) { return; } $criteria->includeBoosts = $user->showBoostsOfFollowing; if (0 < \count($user->preferredLanguages)) { $criteria->languages = $user->preferredLanguages; } } private function renderResponse(Request $request, Criteria $criteria, array $data, string $templatePath, ?User $user): Response { $baseData = array_merge(['criteria' => $criteria], $data); if ('microblog' === $criteria->content) { $dto = new PostDto(); if (isset($data['magazine'])) { $dto->magazine = $data['magazine']; } else { // check if the "random" magazine exists and if so, use it $randomMagazine = $this->magazineRepository->findOneByName('random'); if (null !== $randomMagazine) { $dto->magazine = $randomMagazine; } } $baseData['form'] = $this->createForm(PostType::class)->setData($dto)->createView(); $baseData['user'] = $user; } if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView($templatePath.'_list.html.twig', $baseData), ]); } return $this->render($templatePath.'front.html.twig', $baseData); } private function subscriptionFor(?User $user): string { if ($user) { return match ($user->homepage) { User::HOMEPAGE_SUB => 'sub', User::HOMEPAGE_MOD => 'mod', User::HOMEPAGE_FAV => 'fav', default => 'all', }; } else { return 'all'; // Global default } } private function handleCrossposts($pagination): PagerfantaInterface { $posts = $pagination->getCurrentPageResults(); $firstIndexes = []; $tmp = []; $duplicates = []; foreach ($posts as $post) { $groupingField = !empty($post->url) ? $post->url : $post->title; if (!\in_array($groupingField, $firstIndexes) || (empty($post->url) && \strlen($post->title) <= 10)) { $tmp[] = $post; $firstIndexes[] = $groupingField; } else { if (!\in_array($groupingField, array_column($duplicates, 'groupingField'), true)) { $duplicates[] = (object) [ 'groupingField' => $groupingField, 'items' => [], ]; } $duplicateIndex = array_search($groupingField, array_column($duplicates, 'groupingField')); $duplicates[$duplicateIndex]->items[] = $post; $post->cross = true; } } $results = []; foreach ($tmp as $item) { $results[] = $item; $groupingField = !empty($item->url) ? $item->url : $item->title; $duplicateIndex = array_search($groupingField, array_column($duplicates, 'groupingField')); if (false !== $duplicateIndex) { foreach ($duplicates[$duplicateIndex]->items as $duplicateItem) { $results[] = $duplicateItem; } } } $pagerfanta = new MbinPagerfanta($pagination->getAdapter()); $pagerfanta->setCurrentPage($pagination->getCurrentPage()); $pagerfanta->setMaxNbPages($pagination->getNbPages()); $pagerfanta->setCurrentPageResults($results); return $pagerfanta; } /** * @throws \DateMalformedStringException */ private function getCursorByCriteria(string $sortOption, ?string $cursor): int|\DateTimeImmutable { $guessedCursor = $this->contentRepository->guessInitialCursor($sortOption); if ($guessedCursor instanceof \DateTimeImmutable) { $currentCursor = null !== $cursor ? new \DateTimeImmutable($cursor) : $guessedCursor; } elseif (\is_int($guessedCursor)) { $currentCursor = null !== $cursor ? \intval($cursor) : $guessedCursor; } else { throw new \LogicException(\get_class($guessedCursor).' is not accounted for'); } return $currentCursor; } } ================================================ FILE: src/Controller/Entry/EntryLockController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { $this->validateCsrf('entry_lock', $request->getPayload()->get('token')); $entry = $this->manager->toggleLock($entry, $this->getUserOrThrow()); $this->addFlash( 'success', $entry->isLocked ? 'flash_thread_lock_success' : 'flash_thread_unlock_success' ); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Entry/EntryModerateController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { if ($entry->magazine !== $magazine) { return $this->redirectToRoute( 'entry_single', ['magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'slug' => $entry->slug], 301 ); } $form = $this->createForm(LangType::class); // $form->get('lang')->setData(['lang' => $entry->lang]); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('entry/_moderate_panel.html.twig', [ 'magazine' => $magazine, 'entry' => $entry, 'form' => $form->createView(), ]), ]); } return $this->render('entry/moderate.html.twig', [ 'magazine' => $magazine, 'entry' => $entry, 'form' => $form->createView(), ]); } } ================================================ FILE: src/Controller/Entry/EntryPinController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { $this->validateCsrf('entry_pin', $request->getPayload()->get('token')); $entry = $this->manager->pin($entry, $this->getUserOrThrow()); $this->addFlash( 'success', $entry->sticky ? 'flash_thread_pin_success' : 'flash_thread_unpin_success' ); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Entry/EntrySingleController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, ?string $sortBy, Request $request, ): Response { if ($entry->magazine !== $magazine) { return $this->redirectToRoute( 'entry_single', ['magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'slug' => $entry->slug], 301 ); } $response = new Response(); if ($entry->apId && $entry->user->apId) { $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); } $this->handlePrivateContent($entry); $images = []; if ($entry->image) { $images[] = $entry->image; } $images = array_merge($images, $this->commentRepository->findImagesByEntry($entry)); $this->imageRepository->redownloadImagesIfNecessary($images); $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)); $criteria->entry = $entry; if (ThemeSettingsController::CHAT === $request->cookies->get( ThemeSettingsController::ENTRY_COMMENTS_VIEW )) { $criteria->showSortOption(Criteria::SORT_OLD); $criteria->perPage = 100; $criteria->onlyParents = false; } $comments = $this->commentRepository->findByCriteria($criteria); $commentObjects = [...$comments->getCurrentPageResults()]; $this->commentRepository->hydrate(...$commentObjects); $this->commentRepository->hydrateChildren(...$commentObjects); $this->dispatcher->dispatch(new EntryHasBeenSeenEvent($entry)); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($magazine, $entry, $comments); } $user = $this->getUser(); $dto = new EntryCommentDto(); if ($user && $user->addMentionsEntries && $entry->user !== $user) { $dto->body = $this->mentionManager->addHandle([$entry->user->username])[0]; } return $this->render( 'entry/single.html.twig', [ 'user' => $user, 'magazine' => $magazine, 'comments' => $comments, 'entry' => $entry, 'criteria' => $criteria, 'form' => $this->createForm(EntryCommentType::class, $dto, [ 'action' => $this->generateUrl( 'entry_comment_create', [ 'magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), ] ), 'parentLanguage' => $entry->lang, ])->createView(), ], $response ); } private function getJsonResponse(Magazine $magazine, Entry $entry, PagerfantaInterface $comments): JsonResponse { return new JsonResponse( [ 'html' => $this->renderView( 'entry/_single_popup.html.twig', [ 'magazine' => $magazine, 'comments' => $comments, 'entry' => $entry, ] ), ] ); } } ================================================ FILE: src/Controller/Entry/EntryTemplateTrait.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'entry_id')] Entry $entry, Request $request, ): Response { if ('down' === $type && DownvotesMode::Enabled !== $this->settingsManager->getDownvotesMode()) { $votes = []; } else { $votes = $entry->votes->filter( fn ($e) => $e->choice === ('up' === $type ? VotableInterface::VOTE_UP : VotableInterface::VOTE_DOWN) ); } return $this->render('entry/voters.html.twig', [ 'magazine' => $magazine, 'entry' => $entry, 'votes' => $votes, ]); } } ================================================ FILE: src/Controller/FaqController.php ================================================ findAll(); return $this->render( 'page/faq.html.twig', [ 'body' => $site[0]->faq ?? '', ] ); } } ================================================ FILE: src/Controller/FavouriteController.php ================================================ toggle($this->getUserOrThrow(), $subject); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'html' => $this->renderView('components/_ajax.html.twig', [ 'component' => 'vote', 'attributes' => [ 'subject' => $subject, 'showDownvote' => str_contains(\get_class($subject), 'Entry'), ], ] ), ] ); } return $this->redirectToRefererOrHome($request, $this->classService->fromEntity($subject)); } } ================================================ FILE: src/Controller/FederationController.php ================================================ get('KBIN_FEDERATION_PAGE_ENABLED')) { return $this->redirectToRoute('front'); } $allowedInstances = $instanceRepository->getAllowedInstances($settings->getUseAllowList()); $defederatedInstances = $instanceRepository->getBannedInstances(); $deadInstances = $instanceRepository->getDeadInstances(); return $this->render( 'page/federation.html.twig', [ 'allowedInstances' => $allowedInstances, 'defederatedInstances' => $defederatedInstances, 'deadInstances' => $deadInstances, ] ); } } ================================================ FILE: src/Controller/Magazine/MagazineAbandonedController.php ================================================ render( 'magazine/list_abandoned.html.twig', [ 'magazines' => $this->repository->findAbandoned($request->query->getInt('p', 1)), ] ); } } ================================================ FILE: src/Controller/Magazine/MagazineBlockController.php ================================================ 'name'])] Magazine $magazine, Request $request): Response { $this->manager->block($magazine, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($magazine); } return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] #[IsGranted('block', subject: 'magazine')] public function unblock(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response { $this->manager->unblock($magazine, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($magazine); } return $this->redirectToRefererOrHome($request); } private function getJsonResponse(Magazine $magazine): JsonResponse { return new JsonResponse( [ 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'magazine_sub', 'attributes' => [ 'magazine' => $magazine, ], ] ), ] ); } } ================================================ FILE: src/Controller/Magazine/MagazineCreateController.php ================================================ getUserOrThrow(); if (true === $this->settingsManager->get('MBIN_RESTRICT_MAGAZINE_CREATION') && !$user->isAdmin() && !$user->isModerator()) { throw new AccessDeniedException(); } $form = $this->createForm(MagazineType::class); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $dto = $form->getData(); $dto->ip = $this->ipResolver->resolve(); $magazine = $this->manager->create($dto, $this->getUserOrThrow()); $this->addFlash('success', 'flash_magazine_new_success'); return $this->redirectToMagazine($magazine); } return $this->render( 'magazine/create.html.twig', [ 'user' => $user, 'form' => $form->createView(), ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); } } ================================================ FILE: src/Controller/Magazine/MagazineDeleteController.php ================================================ 'name'])] Magazine $magazine, Request $request): Response { $this->validateCsrf('magazine_delete', $request->getPayload()->get('token')); $this->manager->delete($magazine); return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] #[IsGranted('delete', subject: 'magazine')] public function restore(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response { $this->validateCsrf('magazine_restore', $request->getPayload()->get('token')); $this->manager->restore($magazine); return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] #[IsGranted('purge', subject: 'magazine')] public function purge(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response { $this->validateCsrf('magazine_purge', $request->getPayload()->get('token')); $this->manager->purge($magazine); return $this->redirectToRoute('front'); } #[IsGranted('ROLE_USER')] #[IsGranted('purge', subject: 'magazine')] public function purgeContent(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response { $this->validateCsrf('magazine_purge_content', $request->getPayload()->get('token')); $this->manager->purge($magazine, true); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Magazine/MagazineListController.php ================================================ tokenStorage->getToken()?->getUser(); $criteria = new MagazinePageView( $this->getPageNb($request), $sortBy, Criteria::AP_ALL, $user?->hideAdult ? MagazinePageView::ADULT_HIDE : MagazinePageView::ADULT_SHOW, ); $form = $this->createForm(MagazinePageViewType::class, $criteria); $form->handleRequest($request); $magazines = $this->repository->findPaginated($criteria); return $this->render( 'magazine/list_all.html.twig', [ 'form' => $form, 'magazines' => $magazines, 'view' => $view, 'criteria' => $criteria, ] ); } } ================================================ FILE: src/Controller/Magazine/MagazineModController.php ================================================ 'name'])] Magazine $magazine, MagazineRepository $repository, Request $request, ): Response { $moderators = $repository->findModerators($magazine, $this->getPageNb($request)); return $this->render( 'magazine/moderators.html.twig', [ 'magazine' => $magazine, 'moderators' => $moderators, ] ); } } ================================================ FILE: src/Controller/Magazine/MagazineModeratorRequestController.php ================================================ 'name'])] Magazine $magazine, Request $request): Response { // applying to be a moderator is only supported for local magazines if ($magazine->apId) { throw new AccessDeniedException(); } $this->validateCsrf('moderator_request', $request->getPayload()->get('token')); $this->manager->toggleModeratorRequest($magazine, $this->getUserOrThrow()); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Magazine/MagazineOwnershipRequestController.php ================================================ 'name'])] Magazine $magazine, Request $request): Response { // applying to be owner is only supported for local magazines if ($magazine->apId) { throw new AccessDeniedException(); } $this->validateCsrf('magazine_ownership_request', $request->getPayload()->get('token')); $this->manager->toggleOwnershipRequest($magazine, $this->getUserOrThrow()); return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_ADMIN')] public function accept(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response { $this->validateCsrf('magazine_ownership_request', $request->getPayload()->get('token')); $user = $this->getUserOrThrow(); $this->manager->acceptOwnershipRequest($magazine, $user, $user); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Magazine/MagazinePeopleFrontController.php ================================================ 'name'])] Magazine $magazine, ?string $category, Request $request, ): Response { return $this->render( 'people/front.html.twig', [ 'magazine' => $magazine, 'magazines' => array_filter( $this->magazineRepository->findByActivity(), fn ($val) => 'random' !== $val->name && $val !== $magazine ), 'local' => $this->userRepository->findUsersForMagazine($magazine, limit: 28, limitTime: $magazine->getContentCount() > 1000), 'federated' => $this->userRepository->findUsersForMagazine($magazine, true, limit: 28, limitTime: $magazine->getContentCount() > 1000), ] ); } } ================================================ FILE: src/Controller/Magazine/MagazineRemoveSubscriptionsController.php ================================================ 'name'])] Magazine $magazine, Request $request): Response { $this->validateCsrf('magazine_remove_subscriptions', $request->getPayload()->get('token')); $this->manager->removeSubscriptions($magazine); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Magazine/MagazineSubController.php ================================================ 'name'])] Magazine $magazine, Request $request): Response { $this->manager->subscribe($magazine, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($magazine); } return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] #[IsGranted('subscribe', subject: 'magazine')] public function unsubscribe(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response { $this->manager->unsubscribe($magazine, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($magazine); } return $this->redirectToRefererOrHome($request); } private function getJsonResponse(Magazine $magazine): JsonResponse { return new JsonResponse( [ 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'magazine_sub', 'attributes' => [ 'magazine' => $magazine, ], ] ), ] ); } } ================================================ FILE: src/Controller/Magazine/Panel/MagazineBadgeController.php ================================================ 'name'])] Magazine $magazine, BadgeManager $manager, Request $request, ): Response { $badges = $this->repository->findBadges($magazine); $dto = new BadgeDto(); $form = $this->createForm(BadgeType::class, $dto); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $dto->magazine = $magazine; $manager->create($dto); return $this->redirectToRefererOrHome($request); } return $this->render( 'magazine/panel/badges.html.twig', [ 'badges' => $badges, 'magazine' => $magazine, 'form' => $form->createView(), ] ); } #[IsGranted('ROLE_USER')] #[IsGranted('moderate', subject: 'magazine')] public function remove( #[MapEntity(mapping: ['magazine_name' => 'name'])] Magazine $magazine, #[MapEntity(id: 'badge_id')] Badge $badge, BadgeManager $manager, Request $request, ): Response { $this->validateCsrf('badge_remove', $request->getPayload()->get('token')); $manager->delete($badge); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Magazine/Panel/MagazineBanController.php ================================================ 'name'])] Magazine $magazine, UserRepository $repository, Request $request, ): Response { return $this->render( 'magazine/panel/bans.html.twig', [ 'bans' => $this->repository->findBans($magazine, $this->getPageNb($request)), 'magazine' => $magazine, ] ); } #[IsGranted('ROLE_USER')] #[IsGranted('moderate', subject: 'magazine')] public function ban( #[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request, #[MapEntity(mapping: ['username' => 'username'])] ?User $user = null, ): Response { if (!$user) { $user = $this->userRepository->findOneByUsername($request->query->get('username')); } $form = $this->createForm(MagazineBanType::class, $magazineBanDto = new MagazineBanDto()); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->manager->ban($magazine, $user, $this->getUserOrThrow(), $magazineBanDto); return $this->redirectToRoute('magazine_panel_bans', ['name' => $magazine->name]); } return $this->render( 'magazine/panel/ban.html.twig', [ 'magazine' => $magazine, 'user' => $user, 'form' => $form->createView(), ] ); } #[IsGranted('ROLE_USER')] #[IsGranted('moderate', subject: 'magazine')] public function unban( #[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, #[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request, ): Response { $this->validateCsrf('magazine_unban', $request->getPayload()->get('token')); $this->manager->unban($magazine, $user); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Magazine/Panel/MagazineEditController.php ================================================ 'name'])] Magazine $magazine, Request $request, ): Response { $magazineDto = $this->manager->createDto($magazine); $form = $this->createForm(MagazineType::class, $magazineDto); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->manager->edit($magazine, $magazineDto, $this->getUserOrThrow()); $this->addFlash('success', 'flash_magazine_edit_success'); return $this->redirectToRefererOrHome($request); } return $this->render( 'magazine/panel/general.html.twig', [ 'magazine' => $magazine, 'form' => $form->createView(), ] ); } } ================================================ FILE: src/Controller/Magazine/Panel/MagazineModeratorController.php ================================================ 'name'])] Magazine $magazine, Request $request, ): Response { $dto = new ModeratorDto($magazine); $form = $this->createForm(ModeratorType::class, $dto); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $dto->addedBy = $this->getUserOrThrow(); $this->manager->addModerator($dto); } $moderators = $this->repository->findModerators($magazine, $this->getPageNb($request)); return $this->render( 'magazine/panel/moderators.html.twig', [ 'moderators' => $moderators, 'magazine' => $magazine, 'form' => $form->createView(), ] ); } #[IsGranted('ROLE_USER')] #[IsGranted('edit', subject: 'magazine')] public function remove( #[MapEntity(mapping: ['magazine_name' => 'name'])] Magazine $magazine, #[MapEntity(id: 'moderator_id')] Moderator $moderator, Request $request, ): Response { $this->validateCsrf('remove_moderator', $request->getPayload()->get('token')); $this->manager->removeModerator($moderator, $this->getUser()); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Magazine/Panel/MagazineModeratorRequestsController.php ================================================ 'name'])] Magazine $magazine, Request $request, ): Response { return $this->render('magazine/panel/moderator_requests.html.twig', [ 'magazine' => $magazine, 'requests' => $this->repository->findAllPaginated($magazine, $request->get('page', 1)), ]); } #[IsGranted('ROLE_USER')] #[IsGranted('edit', subject: 'magazine')] public function accept( #[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, #[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request, ): Response { $this->validateCsrf('magazine_panel_moderator_request_accept', $request->getPayload()->get('token')); $this->manager->acceptModeratorRequest($magazine, $user, $this->getUserOrThrow()); return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] #[IsGranted('edit', subject: 'magazine')] public function reject( #[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, #[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request, ): Response { $this->validateCsrf('magazine_panel_moderator_request_reject', $request->getPayload()->get('token')); $this->manager->toggleModeratorRequest($magazine, $user); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Magazine/Panel/MagazineReportController.php ================================================ 'name'])] Magazine $magazine, Request $request, string $status, ): Response { $reports = $this->repository->findReports($magazine, $this->getPageNb($request), status: $status); $this->notificationRepository->markReportNotificationsInMagazineAsRead($this->getUserOrThrow(), $magazine); return $this->render( 'magazine/panel/reports.html.twig', [ 'reports' => $reports, 'magazine' => $magazine, ] ); } #[IsGranted('ROLE_USER')] #[IsGranted('moderate', subject: 'magazine')] public function reportApprove( #[MapEntity(mapping: ['magazine_name' => 'name'])] Magazine $magazine, #[MapEntity(id: 'report_id')] Report $report, Request $request, ): Response { $this->validateCsrf('report_approve', $request->getPayload()->get('token')); $this->reportManager->accept($report, $this->getUserOrThrow()); return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] #[IsGranted('moderate', subject: 'magazine')] public function reportReject( #[MapEntity(mapping: ['magazine_name' => 'name'])] Magazine $magazine, #[MapEntity(id: 'report_id')] Report $report, Request $request, ): Response { $this->validateCsrf('report_decline', $request->getPayload()->get('token')); $this->reportManager->reject($report, $this->getUserOrThrow()); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Magazine/Panel/MagazineStatsController.php ================================================ 'name'])] Magazine $magazine, ?string $statsType, ?int $statsPeriod, ?bool $withFederated, Request $request, ): Response { $this->denyAccessUnlessGranted('edit_profile', $this->getUserOrThrow()); $statsType = $this->manager->resolveType($statsType); if (!$statsPeriod) { $statsPeriod = 31; } if (-1 === $statsPeriod) { $statsPeriod = null; } if ($statsPeriod) { $statsPeriod = min($statsPeriod, 365); $start = (new \DateTime())->modify("-$statsPeriod days"); } if (null === $withFederated) { $withFederated = false; } $results = match ($statsType) { StatsRepository::TYPE_VOTES => $statsPeriod ? $this->manager->drawDailyVotesStatsByTime($start, null, $magazine, !$withFederated) : $this->manager->drawMonthlyVotesChart(null, $magazine, !$withFederated), default => $statsPeriod ? $this->manager->drawDailyContentStatsByTime($start, null, $magazine, !$withFederated) : $this->manager->drawMonthlyContentChart(null, $magazine, !$withFederated), }; return $this->render( 'magazine/panel/stats.html.twig', [ 'magazine' => $magazine, 'period' => $statsPeriod, 'chart' => $results, 'withFederated' => $withFederated, ] ); } } ================================================ FILE: src/Controller/Magazine/Panel/MagazineTagController.php ================================================ 'name'])] Magazine $magazine, BadgeManager $manager, Request $request, ): Response { $form = $this->createForm(MagazineTagsType::class, $magazine); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $form->getData(); $this->entityManager->flush(); return $this->redirectToRefererOrHome($request); } return $this->render('magazine/panel/tags.html.twig', [ 'magazine' => $magazine, 'form' => $form, ]); } } ================================================ FILE: src/Controller/Magazine/Panel/MagazineThemeController.php ================================================ 'name'])] Magazine $magazine, Request $request, ): Response { $dto = new MagazineThemeDto($magazine); $form = $this->createForm(MagazineThemeType::class, $dto); try { // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image) $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $magazine = $this->manager->changeTheme($dto); $this->addFlash('success', 'flash_magazine_theme_changed_success'); $this->redirectToRefererOrHome($request); } } catch (\Exception $e) { // Show an error to the user $this->addFlash('error', 'flash_magazine_theme_changed_error'); } return $this->render( 'magazine/panel/theme.html.twig', [ 'magazine' => $magazine, 'form' => $form->createView(), ] ); } #[IsGranted('ROLE_USER')] #[IsGranted('edit', subject: 'magazine')] public function detachIcon(#[MapEntity] Magazine $magazine): Response { $this->manager->detachIcon($magazine); $this->addFlash('success', 'flash_magazine_theme_icon_detached_success'); return $this->redirectToRoute('magazine_panel_theme', ['name' => $magazine->name]); } #[IsGranted('ROLE_USER')] #[IsGranted('edit', subject: 'magazine')] public function detachBanner(#[MapEntity] Magazine $magazine): Response { $this->manager->detachBanner($magazine); $this->addFlash('success', 'flash_magazine_theme_banner_detached_success'); return $this->redirectToRoute('magazine_panel_theme', ['name' => $magazine->name]); } } ================================================ FILE: src/Controller/Magazine/Panel/MagazineTrashController.php ================================================ 'name'])] Magazine $magazine, BadgeManager $manager, Request $request, ): Response { return $this->render( 'magazine/panel/trash.html.twig', [ 'magazine' => $magazine, 'results' => $this->repository->findTrashed($magazine, $this->getPageNb($request)), ] ); } } ================================================ FILE: src/Controller/Message/MessageCreateThreadController.php ================================================ 'username'])] User $receiver, Request $request): Response { $threads = $this->threadRepository->findByParticipants([$this->getUserOrThrow(), $receiver]); if ($threads && \sizeof($threads) > 0) { return $this->redirectToRoute('messages_single', ['id' => $threads[0]->getId()]); } $form = $this->createForm(MessageType::class); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->manager->toThread($form->getData(), $this->getUserOrThrow(), $receiver); return $this->redirectToRoute( 'messages_front' ); } return $this->render( 'user/message.html.twig', [ 'user' => $receiver, 'form' => $form->createView(), ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); } } ================================================ FILE: src/Controller/Message/MessageThreadController.php ================================================ createForm(MessageType::class); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->manager->toMessage($form->getData(), $thread, $this->getUserOrThrow()); return $this->redirectToRoute('messages_single', ['id' => $thread->getId()]); } $this->manager->readMessages($thread, $this->getUserOrThrow()); return $this->render( 'messages/single.html.twig', [ 'user' => $this->getUserOrThrow(), 'thread' => $thread, 'form' => $form->createView(), ] ); } } ================================================ FILE: src/Controller/Message/MessageThreadListController.php ================================================ render( 'messages/front.html.twig', [ 'threads' => $repository->findUserMessages($this->getUser(), $this->getPageNb($request)), ] ); } } ================================================ FILE: src/Controller/ModlogController.php ================================================ magazine = null; $form = $this->createForm(ModlogFilterType::class, $dto, ['method' => 'GET']); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { /** @var ModlogFilterDto $dto */ $dto = $form->getData(); if (null !== $dto->magazine) { return $this->redirectToRoute('magazine_modlog', ['name' => $dto->magazine->name]); } $logs = $this->magazineLogRepository->findByCustom($this->getPageNb($request), types: $dto->types); } else { $logs = $this->magazineLogRepository->findByCustom($this->getPageNb($request)); } return $this->render( 'modlog/front.html.twig', [ 'logs' => $logs, 'form' => $form, ] ); } public function magazine(#[MapEntity] ?Magazine $magazine, Request $request): Response { $dto = new ModlogFilterDto(); $dto->magazine = $magazine; $form = $this->createForm(ModlogFilterType::class, $dto, ['method' => 'GET']); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { /** @var ModlogFilterDto $dto */ $dto = $form->getData(); if (null === $dto->magazine) { return $this->redirectToRoute('modlog'); } elseif ($dto->magazine?->name !== $magazine->name) { return $this->redirectToRoute('magazine_modlog', ['name' => $dto->magazine->name]); } $logs = $this->magazineLogRepository->findByCustom($this->getPageNb($request), types: $dto->types, magazine: $magazine); } else { $logs = $this->magazineLogRepository->findByCustom($this->getPageNb($request), magazine: $magazine); } return $this->render( 'modlog/front.html.twig', [ 'magazine' => $magazine, 'logs' => $logs, 'form' => $form, ] ); } } ================================================ FILE: src/Controller/NotificationSettingsController.php ================================================ entityManager->getRepository(self::GetClassFromSubjectType($subject_type))->findOneBy(['id' => $subject_id]); $user = $this->getUserOrThrow(); $this->repository->setStatusByTarget($user, $subject, $status); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('components/_ajax.html.twig', [ 'component' => 'notification_switch', 'attributes' => [ 'target' => $subject, ], ] ), ]); } return $this->redirect($request->headers->get('Referer')); } protected static function GetClassFromSubjectType(string $subjectType): string { return match ($subjectType) { 'entry' => Entry::class, 'post' => Post::class, 'user' => User::class, 'magazine' => Magazine::class, default => throw new \LogicException("cannot match type $subjectType"), }; } } ================================================ FILE: src/Controller/People/PeopleFrontController.php ================================================ render( 'people/front.html.twig', [ 'magazines' => array_filter( $this->magazineRepository->findByActivity(), fn ($val) => 'random' !== $val->name ), 'local' => $this->manager->general(), 'federated' => $this->manager->general(true), ] ); } } ================================================ FILE: src/Controller/Post/Comment/PostCommentChangeAdultController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, #[MapEntity(id: 'comment_id')] PostComment $comment, Request $request, ): Response { $this->validateCsrf('change_adult', $request->getPayload()->get('token')); $comment->isAdult = 'on' === $request->get('adult'); $this->entityManager->flush(); $this->addFlash( 'success', $comment->isAdult ? 'flash_mark_as_adult_success' : 'flash_unmark_as_adult_success' ); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Post/Comment/PostCommentChangeLangController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, #[MapEntity(id: 'comment_id')] PostComment $comment, Request $request, ): Response { $comment->lang = $request->get('lang')['lang']; $this->entityManager->flush(); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Post/Comment/PostCommentCreateController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, #[MapEntity(id: 'parent_comment_id')] ?PostComment $parent, Request $request, ): Response { $form = $this->getForm($post, $parent); try { // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image) $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $dto = $form->getData(); $dto->post = $post; $dto->magazine = $magazine; $dto->parent = $parent; $dto->ip = $this->ipResolver->resolve(); if (!$this->isGranted('create_content', $dto->magazine)) { throw new AccessDeniedHttpException(); } return $this->handleValidRequest($dto, $request); } } catch (InstanceBannedException) { $this->addFlash('error', 'flash_instance_banned_error'); } catch (\Exception $e) { // Show an error to the user $this->addFlash('error', 'flash_comment_new_error'); } if ($request->isXmlHttpRequest()) { return $this->getJsonFormResponse( $form, 'post/comment/_form_comment.html.twig', ['post' => $post, 'parent' => $parent] ); } $user = $this->getUserOrThrow(); $criteria = new PostCommentPageView($this->getPageNb($request), $this->security); $criteria->post = $post; $comments = $this->repository->findByCriteria($criteria); return $this->render( 'post/comment/create.html.twig', [ 'user' => $user, 'magazine' => $magazine, 'post' => $post, 'comments' => $comments, 'parent' => $parent, 'form' => $form->createView(), ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); } private function getForm(Post $post, ?PostComment $parent): FormInterface { $dto = new PostCommentDto(); if ($parent && $this->getUser()->addMentionsPosts) { $handle = $this->mentionManager->addHandle([$parent->user->username])[0]; if ($parent->user !== $this->getUser()) { $dto->body = $handle; } else { $dto->body .= PHP_EOL; } if ($parent->mentions) { $mentions = $this->mentionManager->addHandle($parent->mentions); $mentions = array_filter( $mentions, fn (string $mention) => $mention !== $handle && $mention !== $this->mentionManager->addHandle([$this->getUser()->username])[0] ); $dto->body .= PHP_EOL.PHP_EOL; $dto->body .= implode(' ', array_unique($mentions)); } } elseif ($this->getUser()->addMentionsPosts) { if ($post->user !== $this->getUser()) { $dto->body = $this->mentionManager->addHandle([$post->user->username])[0]; } } return $this->createForm( PostCommentType::class, $dto, [ 'action' => $this->generateUrl( 'post_comment_create', [ 'magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'parent_comment_id' => $parent?->getId(), ] ), 'parentLanguage' => $parent?->lang ?? $post->lang, ] ); } /** * @throws InstanceBannedException * @throws TagBannedException * @throws UserBannedException */ private function handleValidRequest(PostCommentDto $dto, Request $request): Response { $comment = $this->manager->create($dto, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return $this->getPostCommentJsonSuccessResponse($comment); } $this->addFlash('success', 'flash_comment_new_success'); return $this->redirectToPost($comment->post); } } ================================================ FILE: src/Controller/Post/Comment/PostCommentDeleteController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'comment_id')] PostComment $comment, Request $request, ): Response { $this->validateCsrf('post_comment_delete', $request->getPayload()->get('token')); $this->manager->delete($this->getUserOrThrow(), $comment); return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] #[IsGranted('delete', subject: 'comment')] public function restore( #[MapEntity(mapping: ['magazine_name' => 'name'])] Magazine $magazine, #[MapEntity(id: 'comment_id')] PostComment $comment, Request $request, ): Response { $this->validateCsrf('post_comment_restore', $request->getPayload()->get('token')); $this->manager->restore($this->getUserOrThrow(), $comment); return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] #[IsGranted('purge', subject: 'comment')] public function purge( #[MapEntity(mapping: ['magazine_name' => 'name'])] Magazine $magazine, #[MapEntity(id: 'comment_id')] PostComment $comment, Request $request, ): Response { $this->validateCsrf('post_comment_purge', $request->getPayload()->get('token')); $this->manager->purge($this->getUserOrThrow(), $comment); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Post/Comment/PostCommentDeleteImageController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, #[MapEntity(id: 'comment_id')] PostComment $comment, Request $request, ): Response { $this->manager->detachImage($comment); if ($request->isXmlHttpRequest()) { return $this->getJsonSuccessResponse(); } return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Post/Comment/PostCommentEditController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, #[MapEntity(id: 'comment_id')] PostComment $comment, Request $request, ): Response { $dto = $this->manager->createDto($comment); $form = $this->getCreateForm($dto, $comment); try { // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image) $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { if (!$this->isGranted('create_content', $dto->magazine)) { throw new AccessDeniedHttpException(); } return $this->handleValidRequest($dto, $comment, $request); } } catch (\Exception $e) { // Show an error to the user $this->addFlash('error', 'flash_comment_edit_error'); } $criteria = new PostCommentPageView($this->getPageNb($request), $this->security); $criteria->post = $post; if ($request->isXmlHttpRequest()) { return $this->getJsonFormResponse( $form, 'post/comment/_form_comment.html.twig', ['comment' => $comment, 'post' => $post, 'edit' => true] ); } $comments = $this->repository->findByCriteria($criteria); return $this->render( 'post/comment/edit.html.twig', [ 'magazine' => $post->magazine, 'post' => $post, 'comments' => $comments, 'comment' => $comment, 'form' => $form->createView(), ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); } private function getCreateForm(PostCommentDto $dto, PostComment $comment): FormInterface { return $this->createForm( PostCommentType::class, $dto, [ 'action' => $this->generateUrl( 'post_comment_edit', [ 'magazine_name' => $comment->magazine->name, 'post_id' => $comment->post->getId(), 'comment_id' => $comment->getId(), ] ), ] ); } private function handleValidRequest(PostCommentDto $dto, PostComment $comment, Request $request): Response { $comment = $this->manager->edit($comment, $dto, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return $this->getPostCommentJsonSuccessResponse($comment); } $this->addFlash('success', 'flash_comment_edit_success'); return $this->redirectToPost($comment->post); } } ================================================ FILE: src/Controller/Post/Comment/PostCommentFavouriteController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, #[MapEntity(id: 'comment_id')] PostComment $comment, Request $request, ): Response { return $this->render('post/comment/favourites.html.twig', [ 'magazine' => $magazine, 'post' => $post, 'comment' => $comment, 'favourites' => $comment->favourites, ]); } } ================================================ FILE: src/Controller/Post/Comment/PostCommentModerateController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, #[MapEntity(id: 'comment_id')] PostComment $comment, Request $request, ): Response { if ($post->magazine !== $magazine) { return $this->redirectToRoute( 'post_single', ['magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => $post->slug], 301 ); } $form = $this->createForm(LangType::class); $form->get('lang') ->setData($comment->lang); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('post/comment/_moderate_panel.html.twig', [ 'magazine' => $magazine, 'post' => $post, 'comment' => $comment, 'form' => $form->createView(), ]), ]); } return $this->render('post/comment/moderate.html.twig', [ 'magazine' => $magazine, 'post' => $post, 'comment' => $comment, 'form' => $form->createView(), ]); } } ================================================ FILE: src/Controller/Post/Comment/PostCommentResponseTrait.php ================================================ $comment->getId(), 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'post_comment', 'attributes' => [ 'comment' => $comment, 'showEntryTitle' => false, ], ] ), ] ); } } ================================================ FILE: src/Controller/Post/Comment/PostCommentVotersController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, #[MapEntity(id: 'comment_id')] PostComment $comment, Request $request, ): Response { if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('_layout/_voters_inline.html.twig', [ 'votes' => $comment->getUpVotes(), 'more' => null, ]), ]); } return $this->render('post/comment/voters.html.twig', [ 'magazine' => $magazine, 'post' => $post, 'comment' => $comment, 'votes' => $comment->getUpVotes(), ]); } } ================================================ FILE: src/Controller/Post/PostChangeAdultController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, ): Response { $this->validateCsrf('change_adult', $request->getPayload()->get('token')); $post->isAdult = 'on' === $request->get('adult'); $this->entityManager->flush(); $this->addFlash( 'success', $post->isAdult ? 'flash_mark_as_adult_success' : 'flash_unmark_as_adult_success' ); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Post/PostChangeLangController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, ): Response { $post->lang = $request->get('lang')['lang']; $this->entityManager->flush(); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Post/PostChangeMagazineController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, ): Response { $this->validateCsrf('change_magazine', $request->getPayload()->get('token')); $newMagazine = $this->repository->findOneByName($request->get('change_magazine')['new_magazine']); $this->manager->changeMagazine($post, $newMagazine); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Post/PostCreateController.php ================================================ magazineRepository->findOneByName('random'); if (null !== $randomMagazine) { $dto->magazine = $randomMagazine; } $form = $this->createForm(PostType::class, $dto); $user = $this->getUserOrThrow(); try { // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image) $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $dto = $form->getData(); $dto->ip = $this->ipResolver->resolve(); if (!$this->isGranted('create_content', $dto->magazine)) { throw new AccessDeniedHttpException(); } $this->manager->create($dto, $user); $this->addFlash('success', 'flash_post_new_success'); return $this->redirectToRoute( 'magazine_posts', [ 'name' => $dto->magazine->name, 'sortBy' => Criteria::SORT_NEW, ] ); } } catch (InstanceBannedException) { $this->addFlash('error', 'flash_instance_banned_error'); } catch (\Exception $e) { $this->logger->error('{user} tried to create a post, but an exception occurred: {ex} - {message}', ['user' => $user->username, 'ex' => \get_class($e), 'message' => $e->getMessage(), 'stacktrace' => $e->getTrace()]); // Show an error to the user $this->addFlash('error', 'flash_post_new_error'); } return $this->render('post/create.html.twig', [ 'form' => $form->createView(), ]); } } ================================================ FILE: src/Controller/Post/PostDeleteController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, ): Response { $this->manager->delete($this->getUserOrThrow(), $post); return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] #[IsGranted('delete', subject: 'post')] public function restore( #[MapEntity(mapping: ['magazine_name' => 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, ): Response { $this->manager->restore($this->getUserOrThrow(), $post); return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] #[IsGranted('purge', subject: 'post')] public function purge( #[MapEntity(mapping: ['magazine_name' => 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, ): Response { $this->manager->purge($this->getUserOrThrow(), $post); return $this->redirectToMagazine($magazine); } } ================================================ FILE: src/Controller/Post/PostDeleteImageController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, ): Response { $this->manager->detachImage($post); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'success' => true, ] ); } return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Post/PostEditController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, PostCommentRepository $repository, ): Response { $dto = $this->manager->createDto($post); $form = $this->createForm(PostType::class, $dto); try { // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image) $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { if (!$this->isGranted('create_content', $magazine)) { throw new AccessDeniedHttpException(); } $post = $this->manager->edit($post, $dto, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'id' => $post->getId(), 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'post', 'attributes' => [ 'post' => $post, 'showMagazineName' => false, ], ] ), ] ); } $this->addFlash('success', 'flash_post_edit_success'); return $this->redirectToPost($post); } } catch (\Exception $e) { // Show an error to the user $this->addFlash('error', 'flash_post_edit_error'); } $criteria = new PostCommentPageView($this->getPageNb($request), $this->security); $criteria->post = $post; if ($request->isXmlHttpRequest()) { return $this->getJsonFormResponse( $form, 'post/_form_post.html.twig', ['post' => $post, 'edit' => true] ); } return $this->render( 'post/edit.html.twig', [ 'magazine' => $magazine, 'post' => $post, 'comments' => $repository->findByCriteria($criteria), 'form' => $form->createView(), 'criteria' => $criteria, ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); } } ================================================ FILE: src/Controller/Post/PostFavouriteController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, ): Response { return $this->render('post/favourites.html.twig', [ 'magazine' => $magazine, 'post' => $post, 'favourites' => $post->favourites, ]); } } ================================================ FILE: src/Controller/Post/PostLockController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, ): Response { $this->validateCsrf('post_lock', $request->getPayload()->get('token')); $entry = $this->manager->toggleLock($post, $this->getUserOrThrow()); $this->addFlash( 'success', $entry->isLocked ? 'flash_post_lock_success' : 'flash_post_unlock_success' ); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Post/PostModerateController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, PostCommentRepository $repository, ): Response { if ($post->magazine !== $magazine) { return $this->redirectToRoute( 'post_single', ['magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => $post->slug], 301 ); } $form = $this->createForm(LangType::class); $form->get('lang') ->setData($post->lang); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('post/_moderate_panel.html.twig', [ 'magazine' => $magazine, 'post' => $post, 'form' => $form->createView(), ]), ]); } return $this->render('post/moderate.html.twig', [ 'magazine' => $magazine, 'post' => $post, 'form' => $form->createView(), ]); } } ================================================ FILE: src/Controller/Post/PostPinController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, ): Response { $this->validateCsrf('post_pin', $request->getPayload()->get('token')); $entry = $this->manager->pin($post); $this->addFlash( 'success', $entry->sticky ? 'flash_post_pin_success' : 'flash_post_unpin_success' ); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/Post/PostSingleController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, ?string $sortBy, Request $request, ): Response { if ($post->magazine !== $magazine) { return $this->redirectToRoute( 'post_single', ['magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => $post->slug], 301 ); } $response = new Response(); if ($post->apId && $post->user->apId) { $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); } $this->handlePrivateContent($post); $images = []; if ($post->image) { $images[] = $post->image; } $images = array_merge($images, $this->commentRepository->findImagesByPost($post)); $this->imageRepository->redownloadImagesIfNecessary($images); $criteria = new PostCommentPageView($this->getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)); $criteria->content = Criteria::CONTENT_MICROBLOG; $criteria->post = $post; $criteria->onlyParents = true; $criteria->perPage = 25; if (ThemeSettingsController::CHAT === $request->cookies->get( ThemeSettingsController::POST_COMMENTS_VIEW )) { $criteria->showSortOption(Criteria::SORT_OLD); $criteria->perPage = 100; $criteria->onlyParents = false; } $comments = $this->commentRepository->findByCriteria($criteria); $commentObjects = [...$comments->getCurrentPageResults()]; $this->commentRepository->hydrate(...$commentObjects); $this->commentRepository->hydrateChildren(...$commentObjects); $this->dispatcher->dispatch(new PostHasBeenSeenEvent($post)); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($magazine, $post, $comments); } $dto = new PostCommentDto(); if ($this->getUser() && $this->getUser()->addMentionsPosts && $post->user !== $this->getUser()) { $dto->body = $this->mentionManager->addHandle([$post->user->username])[0]; } return $this->render( 'post/single.html.twig', [ 'magazine' => $magazine, 'post' => $post, 'comments' => $comments, 'criteria' => $criteria, 'form' => $this->createForm( PostCommentType::class, $dto, [ 'parentLanguage' => $post->lang, ] )->createView(), ], $response ); } private function getJsonResponse(Magazine $magazine, Post $post, PagerfantaInterface $comments): JsonResponse { return new JsonResponse( [ 'html' => $this->renderView( 'post/_single_popup.html.twig', [ 'magazine' => $magazine, 'post' => $post, 'comments' => $comments, ] ), ] ); } } ================================================ FILE: src/Controller/Post/PostVotersController.php ================================================ 'name'])] Magazine $magazine, #[MapEntity(id: 'post_id')] Post $post, Request $request, ): Response { if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('components/voters_inline.html.twig', [ 'voters' => $post->getUpVotes()->map(fn ($vote) => $vote->user->username), 'attributes' => new ComponentAttributes([], new EscaperRuntime()), 'count' => 0, ]), ]); } return $this->render('post/voters.html.twig', [ 'magazine' => $magazine, 'post' => $post, 'votes' => $post->getUpVotes(), ]); } } ================================================ FILE: src/Controller/PrivacyPolicyController.php ================================================ findAll(); return $this->render( 'page/privacy_policy.html.twig', [ 'body' => \count($site) ? $site[0]->privacyPolicy : '', ] ); } } ================================================ FILE: src/Controller/ReportController.php ================================================ getForm($dto, $subject); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { return $this->handleReportRequest($dto, $request); } if ($request->isXmlHttpRequest()) { return $this->getJsonFormResponse($form, 'report/_form_report.html.twig'); } return $this->render( 'report/create.html.twig', [ 'form' => $form->createView(), 'magazine' => $subject->magazine, 'subject' => $subject, ] ); } private function getForm(ReportDto $dto, ReportInterface $subject): FormInterface { return $this->createForm( ReportType::class, $dto, [ 'action' => $this->generateUrl($dto->getRouteName(), ['id' => $subject->getId()]), ] ); } private function handleReportRequest(ReportDto $dto, Request $request): Response { $reportError = false; try { $this->manager->report($dto, $this->getUserOrThrow()); $responseMessage = $this->translator->trans('subject_reported'); } catch (SubjectHasBeenReportedException $exception) { $reportError = true; $responseMessage = $this->translator->trans('subject_reported_exists'); } finally { if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'success' => true, 'html' => \sprintf("
    %s
    ", ($reportError) ? 'alert__danger' : 'alert__info', $responseMessage), ] ); } $this->addFlash($reportError ? 'error' : 'info', $responseMessage); return $this->redirectToRefererOrHome($request); } } } ================================================ FILE: src/Controller/SearchController.php ================================================ since = new \DateTimeImmutable('@0'); $form = $this->createForm(SearchType::class, $dto, ['csrf_protection' => false]); try { $form = $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { /** @var SearchDto $dto */ $dto = $form->getData(); $query = trim($dto->q); $this->logger->debug('searching for {query}', ['query' => $query]); $objects = []; if ($this->federatedSearchAllowed() && (str_contains($query, '@') || false !== filter_var($query, FILTER_VALIDATE_URL))) { $this->logger->debug('searching for a matched handle or ap url {query}', ['query' => $query]); $objects = $this->findObjectsByAp($query); } $user = $this->getUser(); $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request), authorId: $dto->user?->getId(), magazineId: $dto->magazine?->getId(), specificType: $dto->type, sinceDate: $dto->since); $this->logger->debug('results: {num}', ['num' => $res->count()]); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('search/_list.html.twig', [ 'results' => $res, ]), ]); } return $this->render( 'search/front.html.twig', [ 'objects' => $objects, 'results' => $res, 'pagination' => $res, 'form' => $form->createView(), 'q' => $query, ] ); } } catch (\Exception $e) { $this->logger->error($e); } return $this->render( 'search/front.html.twig', [ 'objects' => [], 'results' => [], 'form' => $form->createView(), ] ); } private function federatedSearchAllowed(): bool { return !$this->settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN') || $this->getUser(); } private function findObjectsByAp(string $urlOrHandle): array { $result = $this->manager->findActivityPubActorsOrObjects($urlOrHandle); foreach ($result['errors'] as $error) { /** @var \Throwable $error */ $this->addFlash('error', $error->getMessage()); } return $result['results']; } } ================================================ FILE: src/Controller/Security/AuthentikController.php ================================================ getClient('authentik') ->redirect([ 'openid', 'email', 'profile', ]); } public function verify(Request $request, ClientRegistry $client) { } } ================================================ FILE: src/Controller/Security/AzureController.php ================================================ getClient('azure') ->redirect([ 'User.Read.All', ]); } public function verify(Request $request, ClientRegistry $client) { } } ================================================ FILE: src/Controller/Security/DiscordController.php ================================================ getClient('discord') ->redirect(); } public function verify(Request $request, ClientRegistry $client) { } } ================================================ FILE: src/Controller/Security/FacebookController.php ================================================ getClient('facebook') ->redirect([ 'public_profile', 'email', ]); } public function verify(Request $request, ClientRegistry $client) { } } ================================================ FILE: src/Controller/Security/GithubController.php ================================================ getClient('github') ->redirect( ['read:user', 'user:email'], [ 'scope' => 'read:user,user:email', ] ); } public function verify(Request $request, ClientRegistry $client) { } } ================================================ FILE: src/Controller/Security/GoogleController.php ================================================ getClient('google') ->redirect(); } public function verify(Request $request, ClientRegistry $client) { } } ================================================ FILE: src/Controller/Security/KeycloakController.php ================================================ getClient('keycloak') ->redirect([ 'openid', 'email', 'profile', 'address', ]); } public function verify(Request $request, ClientRegistry $client) { } } ================================================ FILE: src/Controller/Security/LoginController.php ================================================ getUser()) { return $this->redirectToRoute('front'); } $error = $utils->getLastAuthenticationError(); $lastUsername = $utils->getLastUsername(); return $this->render('user/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); } public function consent(Request $request, EntityManagerInterface $entityManager): Response { $clientId = $request->query->get('client_id'); if (!$clientId || !ctype_alnum($clientId) || !$this->getUser()) { return $this->redirectToRoute('front'); } /** @var Client $appClient */ $appClient = $entityManager->getRepository(Client::class)->findOneBy(['identifier' => $clientId]); if (!$appClient) { $this->addFlash('danger', 'oauth.client_identifier.invalid'); return $this->redirectToRoute('front'); } $appName = $appClient->getName(); // Get the client scopes $requestedScopes = explode(' ', $request->query->get('scope')); // Get the client scopes in the database $clientScopes = $appClient->getScopes(); // Check all requested scopes are in the client scopes, if not return an error if (0 < \count(array_diff($requestedScopes, $clientScopes))) { $request->getSession()->set('consent_granted', false); return $this->redirectToRoute('oauth2_authorize', $request->query->all()); } // Check if the user has already consented to the scopes /** @var User $user */ $user = $this->getUser(); /** @var ?OAuth2UserConsent $userConsents */ $userConsents = $user->getOAuth2UserConsents()->filter( fn (OAuth2UserConsent $consent) => $consent->getClient() === $appClient )->first() ?: null; if ($userConsents) { $userScopes = $userConsents->getScopes(); } else { $userScopes = []; } $hasExistingScopes = \count($userScopes) > 0; // If user has already consented to the scopes, give consent if (0 === \count(array_diff($requestedScopes, $userScopes))) { $request->getSession()->set('consent_granted', true); return $this->redirectToRoute('oauth2_authorize', $request->query->all()); } // Remove the scopes to which the user has already consented $requestedScopes = array_diff($requestedScopes, $userScopes); // Get all the scope translation keys in the requested scopes. $requestedScopeNames = array_map(fn ($scope) => OAuth2UserConsent::SCOPE_DESCRIPTIONS[$scope], $requestedScopes); $existingScopes = array_map(fn ($scope) => OAuth2UserConsent::SCOPE_DESCRIPTIONS[$scope], $userScopes); if ($request->isMethod('POST')) { if ('yes' === $request->request->get('consent')) { $request->getSession()->set('consent_granted', true); // Add the requested scopes to the user's scopes $consents = $userConsents ?? new OAuth2UserConsent(); $consents->setScopes(array_merge($requestedScopes, $userScopes)); $consents->setClient($appClient); $consents->setCreatedAt(new \DateTimeImmutable()); $consents->setExpiresAt(new \DateTimeImmutable('+30 days')); $consents->setIpAddress($request->getClientIp()); $user->addOAuth2UserConsent($consents); $entityManager->persist($consents); $entityManager->flush(); } if ('no' === $request->request->get('consent')) { $request->getSession()->set('consent_granted', false); } return $this->redirectToRoute('oauth2_authorize', $request->query->all()); } return $this->render('user/consent.html.twig', [ 'app_name' => $appName, 'scopes' => $requestedScopeNames, 'has_existing_scopes' => $hasExistingScopes, 'existing_scopes' => $existingScopes, 'image' => $appClient->getImage(), ]); } } ================================================ FILE: src/Controller/Security/LogoutController.php ================================================ getClient('privacyportal') ->redirect([ 'openid', 'name', 'email', ]); } public function verify(Request $request, ClientRegistry $client) { } } ================================================ FILE: src/Controller/Security/RegisterController.php ================================================ settingsManager->get('MBIN_SSO_ONLY_MODE')) { return $this->redirectToRoute('app_login'); } if ($this->getUser()) { return $this->redirectToRoute('front'); } $form = $this->createForm(UserRegisterType::class, options: [ 'antispam_profile' => 'default', ]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { /** @var UserDto $dto */ $dto = $form->getData(); $dto->ip = $this->ipResolver->resolve(); $this->manager->create($dto); $this->addFlash( 'success', 'flash_register_success' ); if ($this->settingsManager->getNewUsersNeedApproval()) { $this->addFlash( 'success', 'flash_application_info' ); } return $this->redirectToRoute('app_login'); } elseif ($form->isSubmitted() && !$form->isValid()) { $this->logger->error('Registration form submission was invalid.', [ 'errors' => $form->getErrors(true, false), ]); } return $this->render( 'user/register.html.twig', [ 'form' => $form->createView(), ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); } } ================================================ FILE: src/Controller/Security/ResendActivationEmailController.php ================================================ createForm(ResendEmailActivationFormType::class); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $email = $form->get('email')->getData(); $user = $this->entityManager->getRepository(User::class)->findOneBy([ 'email' => $email, ]); if (\is_null($user) || $user->isVerified || $user->isDeleted) { $this->addFlash('error', 'resend_account_activation_email_error'); return $this->redirectToRoute('app_resend_email_activation'); } try { // send confirmation email to user $confirmationHandler->sendConfirmationEmail($user); $this->addFlash('success', 'resend_account_activation_email_success'); return $this->redirectToRoute('app_resend_email_activation'); } catch (\Exception $e) { $this->logger->error('There was an exception trying to re-send the activation email to: {u} - {mail}: {e} - {msg}', ['u' => $user->username, 'mail' => $user->email, 'e' => \get_class($e), 'msg' => $e->getMessage()]); $this->addFlash('error', 'resend_account_activation_email_error'); return $this->redirectToRoute('app_resend_email_activation'); } } return $this->render('resend_verification_email/resend.html.twig', [ 'form' => $form, ]); } } ================================================ FILE: src/Controller/Security/ResetPasswordController.php ================================================ createForm(ResetPasswordRequestFormType::class); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { return $this->processSendingPasswordResetEmail( $form->get('email')->getData(), $mailer, $translator ); } return $this->render('reset_password/request.html.twig', [ 'form' => $form->createView(), ]); } private function processSendingPasswordResetEmail( string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator, ): RedirectResponse { $user = $this->entityManager->getRepository(User::class)->findOneBy([ 'email' => $emailFormData, ]); // Do not reveal whether a user account was found or not. if (!$user) { return $this->redirectToRoute('app_check_email'); } try { $resetToken = $this->resetPasswordHelper->generateResetToken($user); } catch (ResetPasswordExceptionInterface $e) { // If you want to tell the user why a reset email was not sent, uncomment // the lines below and change the redirect to 'app_forgot_password_request'. // Caution: This may reveal if a user is registered or not. // // $this->addFlash('reset_password_error', sprintf( // '%s - %s', // $translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE, [], 'ResetPasswordBundle'), // $translator->trans($e->getReason(), [], 'ResetPasswordBundle') // )); return $this->redirectToRoute('app_check_email'); } $email = (new TemplatedEmail()) ->from(new Address($this->settingsManager->get('KBIN_SENDER_EMAIL'), $this->settingsManager->get('KBIN_DOMAIN'))) ->to($user->getEmail()) ->subject($translator->trans('reset_password')) ->htmlTemplate('_email/reset_pass_confirm.html.twig') ->context([ 'resetToken' => $resetToken, ]); $mailer->send($email); // Store the token object in session for retrieval in check-email route. $this->setTokenObjectInSession($resetToken); return $this->redirectToRoute('app_check_email'); } public function checkEmail(): Response { // Generate a fake token if the user does not exist or someone hit this page directly. // This prevents exposing whether or not a user was found with the given email address or not if (null === ($resetToken = $this->getTokenObjectFromSession())) { $resetToken = $this->resetPasswordHelper->generateFakeResetToken(); } return $this->render('reset_password/check_email.html.twig', [ 'resetToken' => $resetToken, ]); } public function reset( Request $request, UserPasswordHasherInterface $userPasswordHasher, TranslatorInterface $translator, ?string $token = null, ): Response { if ($token) { // We store the token in session and remove it from the URL, to avoid the URL being // loaded in a browser and potentially leaking the token to 3rd party JavaScript. $this->storeTokenInSession($token); return $this->redirectToRoute('app_reset_password'); } $token = $this->getTokenFromSession(); if (null === $token) { throw $this->createNotFoundException('No reset password token found in the URL or in the session.'); } try { $user = $this->resetPasswordHelper->validateTokenAndFetchUser($token); } catch (ResetPasswordExceptionInterface $e) { $this->addFlash( 'reset_password_error', \sprintf( '%s - %s', $translator->trans( ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, [], 'ResetPasswordBundle' ), $translator->trans($e->getReason(), [], 'ResetPasswordBundle') ) ); return $this->redirectToRoute('app_forgot_password_request'); } // The token is valid; allow the user to change their password. $form = $this->createForm(ChangePasswordFormType::class); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { // A password reset token should be used only once, remove it. $this->resetPasswordHelper->removeResetRequest($token); // Encode(hash) the plain password, and set it. $encodedPassword = $userPasswordHasher->hashPassword( $user, $form->get('plainPassword')->getData() ); $user->setPassword($encodedPassword); $this->entityManager->flush(); // The session is cleaned up after the password has been changed. $this->cleanSessionAfterReset(); return $this->redirectToRoute('app_login'); } return $this->render('reset_password/reset.html.twig', [ 'form' => $form->createView(), ]); } } ================================================ FILE: src/Controller/Security/SimpleLoginController.php ================================================ getClient('simplelogin') ->redirect([ 'openid', 'email', 'profile', ]); } public function verify(Request $request, ClientRegistry $client) { } } ================================================ FILE: src/Controller/Security/VerifyEmailController.php ================================================ get('id'); if (null === $id) { return $this->redirectToRoute('app_register'); } try { $user = $repository->find($id); } catch (\Exception) { return $this->redirectToRoute('app_register'); } if (null === $user) { return $this->redirectToRoute('app_register'); } try { $manager->verify($request, $user); } catch (VerifyEmailExceptionInterface $exception) { return $this->redirectToRoute('app_register'); } return $this->redirectToRoute('app_login'); } } ================================================ FILE: src/Controller/Security/ZitadelController.php ================================================ getClient('zitadel') ->redirect([ 'openid', 'email', 'profile', ]); } public function verify(Request $request, ClientRegistry $client) { } } ================================================ FILE: src/Controller/StatsController.php ================================================ manager->resolveType($statsType); if (!$statsPeriod) { $statsPeriod = 31; } if (-1 === $statsPeriod) { $statsPeriod = null; } if ($statsPeriod) { $statsPeriod = min($statsPeriod, 365); $start = (new \DateTime())->modify("-$statsPeriod days"); } if (null === $withFederated) { $withFederated = false; } $results = match ($statsType) { StatsRepository::TYPE_CONTENT => $statsPeriod ? $this->manager->drawDailyContentStatsByTime($start, onlyLocal: !$withFederated) : $this->manager->drawMonthlyContentChart(onlyLocal: !$withFederated), StatsRepository::TYPE_VOTES => $statsPeriod ? $this->manager->drawDailyVotesStatsByTime($start, onlyLocal: !$withFederated) : $this->manager->drawMonthlyVotesChart(onlyLocal: !$withFederated), default => null, }; return $this->render( 'stats/front.html.twig', [ 'type' => $statsType ?? StatsRepository::TYPE_GENERAL, 'period' => $statsPeriod, 'chart' => $results, 'withFederated' => $withFederated, ] + ((!$statsType || StatsRepository::TYPE_GENERAL === $statsType) ? $this->counter->count($statsPeriod ? "-$statsPeriod days" : null, $withFederated) : []), ); } } ================================================ FILE: src/Controller/Tag/TagBanController.php ================================================ validateCsrf('ban', $request->getPayload()->get('token')); $hashtag = $this->tagRepository->findOneBy(['tag' => $name]); if (null === $hashtag) { $hashtag = $this->tagRepository->create($name); } $this->tagManager->ban($hashtag); return $this->redirectToRoute('tag_overview', ['name' => $hashtag->tag]); } #[IsGranted('ROLE_ADMIN')] public function unban(string $name, Request $request): Response { $this->validateCsrf('ban', $request->getPayload()->get('token')); $hashtag = $this->tagRepository->findOneBy(['tag' => $name]); if ($hashtag) { $this->tagManager->unban($hashtag); return $this->redirectToRoute('tag_overview', ['name' => $hashtag->tag]); } else { throw $this->createNotFoundException(); } } } ================================================ FILE: src/Controller/Tag/TagCommentFrontController.php ================================================ getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)) ->setTag($this->tagManager->transliterate(strtolower($name))); $params = [ 'comments' => $this->repository->findByCriteria($criteria), 'tag' => $name, 'counts' => $this->tagRepository->getCounts($name), ]; return $this->render( 'tag/comments.html.twig', $params ); } } ================================================ FILE: src/Controller/Tag/TagEntryFrontController.php ================================================ getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)) ->setType($criteria->resolveType($type)) ->setTag($this->tagManager->transliterate(strtolower($name))); $method = $criteria->resolveSort($sortBy); $listing = $this->$method($criteria); return $this->render( 'tag/front.html.twig', [ 'tag' => $name, 'entries' => $listing, 'counts' => $this->tagRepository->getCounts($name), ] ); } private function hot(EntryPageView $criteria): PagerfantaInterface { return $this->entryRepository->findByCriteria($criteria->showSortOption(Criteria::SORT_HOT)); } private function top(EntryPageView $criteria): PagerfantaInterface { return $this->entryRepository->findByCriteria($criteria->showSortOption(Criteria::SORT_TOP)); } private function active(EntryPageView $criteria): PagerfantaInterface { return $this->entryRepository->findByCriteria($criteria->showSortOption(Criteria::SORT_ACTIVE)); } private function newest(EntryPageView $criteria): PagerfantaInterface { return $this->entryRepository->findByCriteria($criteria->showSortOption(Criteria::SORT_NEW)); } private function commented(EntryPageView $criteria): PagerfantaInterface { return $this->entryRepository->findByCriteria($criteria->showSortOption(Criteria::SORT_COMMENTED)); } } ================================================ FILE: src/Controller/Tag/TagOverviewController.php ================================================ tagRepository->findOverall( $this->getPageNb($request), $this->tagManager->transliterate(strtolower($name)) ); $params = [ 'tag' => $name, 'results' => $this->overviewManager->buildList($activity), 'pagination' => $activity, 'counts' => $this->tagRepository->getCounts($name), ]; if ($request->isXmlHttpRequest()) { return new JsonResponse(['html' => $this->renderView('tag/_list.html.twig', $params)]); } return $this->render('tag/overview.html.twig', $params); } } ================================================ FILE: src/Controller/Tag/TagPeopleFrontController.php ================================================ render( 'tag/people.html.twig', [ 'tag' => $name, 'magazines' => array_filter( $this->magazineRepository->findByActivity(), fn ($val) => 'random' !== $val->name ), 'local' => $this->manager->general(), 'federated' => $this->manager->general(true), 'counts' => $this->tagRepository->getCounts($name), ] ); } } ================================================ FILE: src/Controller/Tag/TagPostFrontController.php ================================================ getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)) ->setTag($this->tagManager->transliterate(strtolower($name))); $posts = $repository->findByCriteria($criteria); return $this->render( 'tag/posts.html.twig', [ 'tag' => $name, 'posts' => $posts, 'counts' => $this->tagRepository->getCounts($name), ] ); } } ================================================ FILE: src/Controller/TermsController.php ================================================ findAll(); return $this->render( 'page/terms.html.twig', [ 'body' => $site[0]->terms ?? '', ] ); } } ================================================ FILE: src/Controller/Traits/PrivateContentTrait.php ================================================ isPrivate()) { if (null === $this->getUser()) { throw $this->createAccessDeniedException(); } if (false === $this->getUser()->isFollowing($entry->user)) { throw $this->createAccessDeniedException(); } } } } ================================================ FILE: src/Controller/User/AccountDeletionController.php ================================================ denyAccessUnlessGranted('edit_profile', $this->getUserOrThrow()); $form = $this->createForm(UserAccountDeletionType::class); $user = $this->getUserOrThrow(); if ($user->isAdmin()) { return $this->redirectToRoute('user_settings_general'); } try { // Could throw an error $form->handleRequest($request); if ($form->isSubmitted() && $form->has('currentPassword')) { if (!$this->userPasswordHasher->isPasswordValid($user, $form->get('currentPassword')->getData())) { $form->get('currentPassword')->addError(new FormError($this->translator->trans('Password is invalid'))); } } if ($form->isSubmitted() && $form->isValid()) { $limiter = $this->userDeleteLimiter->create($this->ipResolver->resolve()); if (false === $limiter->consume()->isAccepted()) { throw new TooManyRequestsHttpException(); } $this->userManager->deleteRequest($user, true === $form->get('instantDelete')->getData()); $this->security->logout(false); return $this->redirect('/'); } } catch (\Exception $e) { // Show an error to the user $this->logger->error('An error occurred during account deletion of user {username}: {error}', ['username' => $user->username, 'error' => \get_class($e).': '.$e->getMessage()]); $this->addFlash('error', 'flash_user_settings_general_error'); } return $this->render( 'user/settings/account_deletion.html.twig', ['user' => $user, 'form' => $form->createView()], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); } } ================================================ FILE: src/Controller/User/FilterListsController.php ================================================ render('user/settings/filter_lists.html.twig'); } #[IsGranted('ROLE_USER')] public function create(Request $request): Response { $dto = new UserFilterListDto(); $dto->addEmptyWords(); $form = $this->createForm(UserFilterListType::class, $dto); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { /** @var UserFilterListDto $data */ $data = $form->getData(); $list = $this->createFromDto($data); $this->entityManager->persist($list); $this->entityManager->flush(); return $this->redirectToRoute('user_settings_filter_lists'); } return $this->render( 'user/settings/filter_lists_create.html.twig', [ 'form' => $form->createView(), ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); } #[IsGranted('ROLE_USER')] #[IsGranted(FilterListVoter::EDIT, 'list')] public function edit(Request $request, #[MapEntity(id: 'id')] UserFilterList $list): Response { $dto = UserFilterListDto::fromList($list); $dto->addEmptyWords(); $form = $this->createForm(UserFilterListType::class, $dto); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { /** @var UserFilterListDto $data */ $data = $form->getData(); $list->name = $data->name; $list->expirationDate = $data->expirationDate; $list->feeds = $data->feeds; $list->comments = $data->comments; $list->profile = $data->profile; $list->words = $data->wordsToArray(); $this->entityManager->persist($list); $this->entityManager->flush(); return $this->redirectToRoute('user_settings_filter_lists'); } return $this->render( 'user/settings/filter_lists_edit.html.twig', [ 'form' => $form->createView(), ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); } #[IsGranted('ROLE_USER')] #[IsGranted(FilterListVoter::DELETE, 'list')] public function delete(#[MapEntity(id: 'id')] UserFilterList $list): Response { $this->entityManager->remove($list); $this->entityManager->flush(); return $this->redirectToRoute('user_settings_filter_lists'); } private function createFromDto(UserFilterListDto $data): UserFilterList { $list = new UserFilterList(); $list->user = $this->getUserOrThrow(); $list->name = $data->name; $list->expirationDate = $data->expirationDate; $list->feeds = $data->feeds; $list->comments = $data->comments; $list->profile = $data->profile; $list->words = $data->wordsToArray(); return $list; } } ================================================ FILE: src/Controller/User/Profile/User2FAController.php ================================================ getUserOrThrow(); $this->denyAccessUnlessGranted('edit_profile', $user); if ($user->isSsoControlled()) { throw new CoreAccessDeniedException(); } if ($user->isTotpAuthenticationEnabled()) { throw new SuspiciousOperationException('User accessed 2fa enable path with existing 2fa in place'); } $totpSecret = $request->getSession()->get(self::TOTP_SESSION_KEY, null); if (null === $totpSecret || 'GET' === $request->getMethod()) { $totpSecret = $this->totpAuthenticator->generateSecret(); $request->getSession()->set(self::TOTP_SESSION_KEY, $totpSecret); } $backupCodes = $request->getSession()->get(self::BACKUP_SESSION_KEY, null); if (null === $backupCodes || 'GET' === $request->getMethod()) { $backupCodes = $this->twoFactorManager->createBackupCodes($user); $request->getSession()->set(self::BACKUP_SESSION_KEY, $backupCodes); } $dto = $this->manager->createDto($user); $dto->totpSecret = $totpSecret; $temp2fa = new Temp2FADto($user->username, $totpSecret); $qrCodeContent = $this->totpAuthenticator->getQRContent($temp2fa); $form = $this->handleForm($this->createForm(UserTwoFactorType::class, $dto), $dto, $request); if (!$form instanceof FormInterface) { return $form; } return $this->render( 'user/settings/2fa.html.twig', [ 'form' => $form->createView(), 'two_fa_url' => $qrCodeContent, 'codes' => $backupCodes, 'secret' => $totpSecret, ], new Response( null, $form->isSubmitted() && !$form->isValid() ? 422 : 200 ) ); } #[IsGranted('ROLE_USER')] public function disable(Request $request): Response { $user = $this->getUserOrThrow(); if (!$user->isTotpAuthenticationEnabled()) { throw new SuspiciousOperationException('User accessed 2fa disable path without existing 2fa in place'); } $dto = $this->manager->createDto($user); $dto->totpSecret = $user->getTotpSecret(); $form = $this->createForm(UserDisable2FAType::class, $dto); $form->handleRequest($request); $this->handleCurrentPassword($form); $this->handleTotpCode($form, $dto); if ($form->isValid()) { $this->twoFactorManager->remove2FA($user); } else { $errors = $form->getErrors(true); foreach ($errors as $error) { /** @var FormError $error */ $this->addFlash('error', $error->getMessage()); } } return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] public function qrCode(Request $request): Response { $user = $this->getUserOrThrow(); $this->denyAccessUnlessGranted('edit_profile', $user); $totpSecret = $request->getSession()->get(self::TOTP_SESSION_KEY, null); if (null === $totpSecret) { throw new AccessDeniedException('/settings/2fa/qrcode'); } $temp2fa = new Temp2FADto($user->username, $totpSecret); $builder = new Builder( writer: new PngWriter(), writerOptions: [], data: $this->totpAuthenticator->getQRContent($temp2fa), encoding: new Encoding('UTF-8'), errorCorrectionLevel: ErrorCorrectionLevel::High, size: 250, margin: 0, roundBlockSizeMode: RoundBlockSizeMode::Margin, logoPath: $this->getParameter('kernel.project_dir').'/public/logo.png', logoResizeToWidth: 60, ); $result = $builder->build(); return new Response($result->getString(), 200, ['Content-Type' => 'image/png']); } #[IsGranted('ROLE_ADMIN')] public function remove(#[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request): Response { $this->validateCsrf('user_2fa_remove', $request->getPayload()->get('token')); $this->twoFactorManager->remove2FA($user); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'has2FA' => false, ] ); } return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] public function backup(Request $request): Response { $user = $this->getUserOrThrow(); $this->denyAccessUnlessGranted('edit_profile', $user); if (!$user->isTotpAuthenticationEnabled()) { throw new SuspiciousOperationException('User accessed 2fa backup path without existing 2fa'); } $dto = $this->manager->createDto($user); $dto->totpSecret = $user->getTotpSecret(); $form = $this->createForm(UserRegenerate2FABackupType::class, $dto); $form->handleRequest($request); $this->handleCurrentPassword($form); $this->handleTotpCode($form, $dto); if (!$form->isValid()) { $errors = $form->getErrors(true); foreach ($errors as $error) { /** @var FormError $error */ $this->addFlash('error', $error->getMessage()); } return $this->redirectToRefererOrHome($request); } return $this->render( 'user/settings/2fa_backup.html.twig', [ 'codes' => $this->twoFactorManager->createBackupCodes($user), ] ); } private function handleForm( FormInterface $form, UserDto $dto, Request $request, ): FormInterface|Response { $form->handleRequest($request); if (!$form->isSubmitted()) { return $form; } $this->handleTotpCode($form, $dto); if (!$form->isValid()) { $this->logger->warning('2fa error occurred user "{username}" submitting the form "{errors}"', [ 'username' => $dto->username, 'errors' => $form->getErrors(), ]); $form->get('totpCode')->addError(new FormError($this->translator->trans('2fa.setup_error'))); return $form; } $this->handleCurrentPassword($form); if (!$form->isValid()) { return $form; } $this->manager->edit($this->getUser(), $dto); if (!$dto->totpSecret) { return $this->redirectToRoute('user_settings_profile'); } $this->security->logout(false); $this->addFlash('success', 'flash_account_settings_changed'); return $this->redirectToRoute('app_login'); } private function handleTotpCode(FormInterface $form, UserDto $dto): void { if ($form->has('totpCode') && !$this->setupHasValidCode($dto->totpSecret, $form->get('totpCode')->getData())) { $form->get('totpCode')->addError(new FormError($this->translator->trans('2fa.code_invalid'))); } } private function handleCurrentPassword(FormInterface $form): void { if ($form->has('currentPassword')) { if (!$this->userPasswordHasher->isPasswordValid( $this->getUser(), $form->get('currentPassword')->getData() )) { $form->get('currentPassword')->addError(new FormError($this->translator->trans('Password is invalid'))); } } } private function setupHasValidCode(string $totpSecret, string $submittedCode): bool { $user = $this->getUserOrThrow(); $temp = new Temp2FADto($user->username, $totpSecret); $isValid = false; if ($this->totpAuthenticator->checkCode($temp, $submittedCode)) { $isValid = true; } return $isValid; } } ================================================ FILE: src/Controller/User/Profile/UserBlockController.php ================================================ getUserOrThrow(); return $this->render( 'user/settings/block_magazines.html.twig', [ 'user' => $user, 'magazines' => $repository->findBlockedMagazines($this->getPageNb($request), $user), ] ); } #[IsGranted('ROLE_USER')] public function users(UserRepository $repository, Request $request): Response { $user = $this->getUserOrThrow(); return $this->render( 'user/settings/block_users.html.twig', [ 'user' => $user, 'users' => $repository->findBlockedUsers($this->getPageNb($request), $user), ] ); } #[IsGranted('ROLE_USER')] public function domains(DomainRepository $repository, Request $request): Response { $user = $this->getUserOrThrow(); return $this->render( 'user/settings/block_domains.html.twig', [ 'user' => $user, 'domains' => $repository->findBlockedDomains($this->getPageNb($request), $user), ] ); } } ================================================ FILE: src/Controller/User/Profile/UserEditController.php ================================================ getUserOrThrow(); $this->denyAccessUnlessGranted('edit_profile', $user); $dto = $this->manager->createDto($user); $form = $this->createForm(UserBasicType::class, $dto); $formHandler = $this->handleForm($form, $dto, $request, $user); if (null === $formHandler) { $this->addFlash('error', 'flash_user_edit_profile_error'); } else { if (!$formHandler instanceof FormInterface) { return $formHandler; } } return $this->render( 'user/settings/profile.html.twig', [ 'user' => $user, 'form' => $form->createView(), ], new Response( null, $form->isSubmitted() && !$form->isValid() ? 422 : 200 ) ); } #[IsGranted('ROLE_USER')] public function email(Request $request): Response { $user = $this->getUserOrThrow(); $this->denyAccessUnlessGranted('edit_profile', $user); $dto = $this->manager->createDto($user); $form = $this->createForm(UserEmailType::class, $dto); $formHandler = $this->handleForm($form, $dto, $request, $user); if (null === $formHandler) { $this->addFlash('error', 'flash_user_edit_email_error'); } else { if (!$formHandler instanceof FormInterface) { return $formHandler; } } return $this->render( 'user/settings/email.html.twig', [ 'user' => $user, 'form' => $form->createView(), ], new Response( null, $form->isSubmitted() && !$form->isValid() ? 422 : 200 ) ); } #[IsGranted('ROLE_USER')] public function password(Request $request): Response { $user = $this->getUserOrThrow(); $this->denyAccessUnlessGranted('edit_profile', $user); if ($user->isSsoControlled()) { throw new AccessDeniedException(); } $dto = $this->manager->createDto($user); $form = $this->createForm(UserPasswordType::class, $dto); $formHandler = $this->handleForm($form, $dto, $request, $user); if (null === $formHandler) { $this->addFlash('error', 'flash_user_edit_password_error'); } else { if (!$formHandler instanceof FormInterface) { return $formHandler; } } $dto2 = $this->manager->createDto($user); $disable2faForm = $this->createForm(UserDisable2FAType::class, $dto2); $dto3 = $this->manager->createDto($user); $regenerateBackupCodesForm = $this->createForm(UserRegenerate2FABackupType::class, $dto3); return $this->render( 'user/settings/password.html.twig', [ 'user' => $user, 'form' => $form->createView(), 'disable2faForm' => $disable2faForm->createView(), 'regenerateBackupCodes' => $regenerateBackupCodesForm->createView(), 'has2fa' => $user->isTotpAuthenticationEnabled(), ], new Response( null, $form->isSubmitted() && !$form->isValid() ? 422 : 200 ) ); } /** * Handle form submit request. */ private function handleForm( FormInterface $form, UserDto $dto, Request $request, User $user, ): FormInterface|Response|null { try { // Could throw an error on event handlers (eg. onPostSubmit if a user upload an incorrect image) $form->handleRequest($request); if ($form->isSubmitted() && $form->has('currentPassword')) { if (!$this->userPasswordHasher->isPasswordValid( $this->getUser(), $form->get('currentPassword')->getData() )) { $form->get('currentPassword')->addError(new FormError($this->translator->trans('Password is invalid'))); } } if ($form->isSubmitted() && $form->has('totpCode') && $user->isTotpAuthenticationEnabled()) { if (!$this->totpAuthenticator->checkCode( $this->getUser(), $form->get('totpCode')->getData() )) { $form->get('totpCode')->addError(new FormError($this->translator->trans('2fa.code_invalid'))); } } if ($form->has('newEmail')) { $dto->email = $form->get('newEmail')->getData(); } if ($form->isSubmitted() && $form->isValid()) { $email = $this->getUser()->email; $this->manager->edit($this->getUser(), $dto); // Check successful to use if profile was changed (which contains the about field) if ($form->has('about')) { $this->addFlash('success', 'flash_user_edit_profile_success'); } // Show successful message to user and tell them to re-login // In case of an email change or password change if ($dto->email !== $email || $dto->plainPassword) { $this->security->logout(false); $this->addFlash('success', 'flash_account_settings_changed'); return $this->redirectToRoute('app_login'); } return $this->redirectToRoute('user_settings_profile'); } return $form; } catch (ImageDownloadTooLargeException $e) { $this->addFlash('error', $this->translator->trans('flash_image_download_too_large_error', ['%bytes%' => $this->settingsManager->getMaxImageByteString()])); return null; } catch (\Exception $e) { return null; } } } ================================================ FILE: src/Controller/User/Profile/UserNotificationController.php ================================================ render( 'notifications/front.html.twig', [ 'applicationServerKey' => $siteRepository->findAll()[0]->pushPublicKey, 'notifications' => $repository->findByUser($this->getUserOrThrow(), $this->getPageNb($request)), ] ); } #[IsGranted('ROLE_USER')] public function read(NotificationManager $manager, Request $request): Response { $manager->markAllAsRead($this->getUserOrThrow()); return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] public function clear(NotificationManager $manager, Request $request): Response { $manager->clear($this->getUserOrThrow()); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/User/Profile/UserReportsController.php ================================================ getUserOrThrow(); $reports = $this->repository->findByUserPaginated($user, $this->getPageNb($request), status: $status); $this->notificationRepository->markOwnReportNotificationsAsRead($this->getUserOrThrow()); return $this->render( 'user/settings/reports.html.twig', [ 'user' => $user, 'reports' => $reports, ] ); } } ================================================ FILE: src/Controller/User/Profile/UserReportsModController.php ================================================ getUserOrThrow(); $dto = $manager->createDto($user); $form = $this->createForm(UserSettingsType::class, $dto); try { // Could thrown an error $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $manager->update($user, $dto); $this->addFlash('success', 'flash_user_settings_general_success'); $this->redirectToRefererOrHome($request); } } catch (\Exception $e) { $this->logger->error('There was an error saving the user {u}\'s settings: {e} - {m}', ['u' => $user->username, 'e' => \get_class($e), 'm' => $e->getMessage()]); // Show an error to the user $this->addFlash('error', 'flash_user_settings_general_error'); } return $this->render( 'user/settings/general.html.twig', [ 'user' => $user, 'form' => $form->createView(), ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); } } ================================================ FILE: src/Controller/User/Profile/UserStatsController.php ================================================ getUserOrThrow(); $this->denyAccessUnlessGranted('edit_profile', $user); $statsType = $this->manager->resolveType($statsType); if (!$statsPeriod) { $statsPeriod = 31; } if (-1 === $statsPeriod) { $statsPeriod = null; } if ($statsPeriod) { $statsPeriod = min($statsPeriod, 256); $start = (new \DateTime())->modify("-$statsPeriod days"); } if (null === $withFederated) { $withFederated = false; } $results = match ($statsType) { StatsRepository::TYPE_VOTES => $statsPeriod ? $this->manager->drawDailyVotesStatsByTime($start, $user, null, !$withFederated) : $this->manager->drawMonthlyVotesChart($user, null, !$withFederated), default => $statsPeriod ? $this->manager->drawDailyContentStatsByTime($start, $user, null, !$withFederated) : $this->manager->drawMonthlyContentChart($user, null, !$withFederated), }; return $this->render( 'user/settings/stats.html.twig', [ 'user' => $user, 'period' => $statsPeriod, 'chart' => $results, 'withFederated' => $withFederated, ] ); } } ================================================ FILE: src/Controller/User/Profile/UserSubController.php ================================================ getUserOrThrow(); return $this->render( 'user/settings/sub_magazines.html.twig', [ 'user' => $user, 'magazines' => $repository->findSubscribedMagazines( $this->getPageNb($request), $user ), ] ); } #[IsGranted('ROLE_USER')] public function users(UserRepository $repository, Request $request): Response { $user = $this->getUserOrThrow(); return $this->render( 'user/settings/sub_users.html.twig', [ 'user' => $user, 'users' => $repository->findFollowing($this->getPageNb($request), $user), ] ); } #[IsGranted('ROLE_USER')] public function domains(DomainRepository $repository, Request $request): Response { $user = $this->getUserOrThrow(); return $this->render( 'user/settings/sub_domains.html.twig', [ 'user' => $user, 'domains' => $repository->findSubscribedDomains($this->getPageNb($request), $user), ] ); } } ================================================ FILE: src/Controller/User/Profile/UserVerifyController.php ================================================ 'username'])] User $user, Request $request): Response { $this->validateCsrf('user_verify', $request->getPayload()->get('token')); $this->manager->adminUserVerify($user); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'isVerified' => true, ] ); } return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/User/ThemeSettingsController.php ================================================ headers->setCookie(new Cookie($key, $value, strtotime('+1 year'))); } if (self::MBIN_LANG === $key) { $response->headers->setCookie(new Cookie(self::MBIN_LANG, $value, strtotime('+1 year'))); } if ($request->isXmlHttpRequest()) { return new JsonResponse(['success' => true]); } return new \Symfony\Component\HttpFoundation\RedirectResponse( ($request->headers->get('referer') ?? '/').'#settings', 302, $response->headers->all() ); } public static function getShowUserFullName(?Request $request): bool { if (null === $request) { return false; } return self::TRUE === $request->cookies->get(self::MBIN_SHOW_USER_DOMAIN, 'false'); } public static function getShowMagazineFullName(?Request $request): bool { if (null === $request) { return false; } return self::TRUE === $request->cookies->get(self::MBIN_SHOW_MAGAZINE_DOMAIN, 'false'); } public static function getShowRichMentionEntry(?Request $request): bool { if (null === $request) { return true; } return self::TRUE === $request->cookies->get(self::MBIN_ENTRIES_SHOW_RICH_MENTION, self::TRUE); } public static function getShowRichMentionPosts(?Request $request): bool { if (null === $request) { return false; } return self::TRUE === $request->cookies->get(self::MBIN_POSTS_SHOW_RICH_MENTION, self::FALSE); } public static function getShowRichMagazineMentionEntry(?Request $request): bool { if (null === $request) { return true; } return self::TRUE === $request->cookies->get(self::MBIN_ENTRIES_SHOW_RICH_MENTION_MAGAZINE, self::TRUE); } public static function getShowRichMagazineMentionPosts(?Request $request): bool { if (null === $request) { return true; } return self::TRUE === $request->cookies->get(self::MBIN_POSTS_SHOW_RICH_MENTION_MAGAZINE, self::TRUE); } public static function getShowRichAPLinkEntries(?Request $request): bool { if (null === $request) { return true; } return self::TRUE === $request->cookies->get(self::MBIN_ENTRIES_SHOW_RICH_AP_LINK, self::TRUE); } public static function getShowRichAPLinkPosts(?Request $request): bool { if (null === $request) { return true; } return self::TRUE === $request->cookies->get(self::MBIN_POSTS_SHOW_RICH_AP_LINK, self::TRUE); } } ================================================ FILE: src/Controller/User/UserAvatarDeleteController.php ================================================ denyAccessUnlessGranted('edit_profile', $this->getUserOrThrow()); $user = $this->getUserOrThrow(); $this->userManager->detachAvatar($user); /* * Call edit so the @see UserEditedEvent is triggered and the changes are federated */ $this->userManager->edit($user, $this->userManager->createDto($user)); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'success' => true, ] ); } return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/User/UserBanController.php ================================================ 'username'])] User $user, UserManager $manager, Request $request, ): Response { $this->validateCsrf('user_ban', $request->getPayload()->get('token')); $manager->ban($user, $this->getUserOrThrow(), reason: null); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'isBanned' => true, ] ); } $this->addFlash('success', 'account_banned'); return $this->redirectToRefererOrHome($request); } #[IsGranted(new Expression('is_granted("ROLE_ADMIN") or is_granted("ROLE_MODERATOR")'))] public function unban( #[MapEntity(mapping: ['username' => 'username'])] User $user, UserManager $manager, Request $request, ): Response { $this->validateCsrf('user_ban', $request->getPayload()->get('token')); $manager->unban($user, $this->getUserOrThrow(), reason: null); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'isBanned' => false, ] ); } $this->addFlash('success', 'account_unbanned'); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/User/UserBlockController.php ================================================ 'username'])] User $blocked, UserManager $manager, Request $request, ): Response { $manager->block($this->getUserOrThrow(), $blocked); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($blocked); } return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] public function unblock( #[MapEntity(mapping: ['username' => 'username'])] User $blocked, UserManager $manager, Request $request, ): Response { $manager->unblock($this->getUserOrThrow(), $blocked); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($blocked); } return $this->redirectToRefererOrHome($request); } private function getJsonResponse(User $user): JsonResponse { return new JsonResponse( [ 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'user_actions', 'attributes' => [ 'user' => $user, ], ] ), ] ); } } ================================================ FILE: src/Controller/User/UserCoverDeleteController.php ================================================ denyAccessUnlessGranted('edit_profile', $this->getUserOrThrow()); $user = $this->getUserOrThrow(); $this->userManager->detachCover($user); /* * Call edit so the @see UserEditedEvent is triggered and the changes are federated */ $this->userManager->edit($user, $this->userManager->createDto($user)); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'success' => true, ] ); } return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/User/UserDeleteController.php ================================================ 'username'])] User $user, UserManager $manager, Request $request, ): Response { $this->validateCsrf('user_delete_account', $request->getPayload()->get('token')); $manager->delete($user); return $this->redirectToRoute('front'); } #[IsGranted('ROLE_ADMIN')] public function scheduleDeleteAccount( #[MapEntity(mapping: ['username' => 'username'])] User $user, UserManager $manager, Request $request, ): Response { $this->validateCsrf('schedule_user_delete_account', $request->getPayload()->get('token')); $manager->deleteRequest($user, false); return $this->redirectToRoute('user_overview', ['username' => $user->username]); } #[IsGranted('ROLE_ADMIN')] public function removeScheduleDeleteAccount( #[MapEntity(mapping: ['username' => 'username'])] User $user, UserManager $manager, Request $request, ): Response { $this->validateCsrf('remove_schedule_user_delete_account', $request->getPayload()->get('token')); $manager->removeDeleteRequest($user); return $this->redirectToRoute('user_overview', ['username' => $user->username]); } } ================================================ FILE: src/Controller/User/UserFollowController.php ================================================ 'username'])] User $following, UserManager $manager, Request $request, ): Response { $manager->follow($this->getUserOrThrow(), $following); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($following); } return $this->redirectToRefererOrHome($request); } #[IsGranted('ROLE_USER')] #[IsGranted('follow', subject: 'following')] public function unfollow( #[MapEntity(mapping: ['username' => 'username'])] User $following, UserManager $manager, Request $request, ): Response { $manager->unfollow($this->getUserOrThrow(), $following); if ($request->isXmlHttpRequest()) { return $this->getJsonResponse($following); } return $this->redirectToRefererOrHome($request); } private function getJsonResponse(User $user): JsonResponse { return new JsonResponse( [ 'html' => $this->renderView( 'components/_ajax.html.twig', [ 'component' => 'user_actions', 'attributes' => [ 'user' => $user, ], ] ), ] ); } } ================================================ FILE: src/Controller/User/UserFrontController.php ================================================ 'username'])] User $user, Request $request, SearchRepository $repository, ): Response { $response = new Response(); if ($user->apId) { $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); } $requestedByUser = $this->getUser(); $hideAdult = (!$requestedByUser || $requestedByUser->hideAdult); if (EApplicationStatus::Approved !== $user->getApplicationStatus()) { throw $this->createNotFoundException(); } if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } if ($loggedInUser = $this->getUser()) { $this->notificationRepository->markUserSignupNotificationsAsRead($loggedInUser, $user); } $activity = $repository->findUserPublicActivity($this->getPageNb($request), $user, $hideAdult); $results = $this->overviewManager->buildList($activity); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView( 'layout/_generic_subject_list.html.twig', [ 'results' => $results, 'pagination' => $activity, ] ), ]); } return $this->render( 'user/overview.html.twig', [ 'user' => $user, 'results' => $results, 'pagination' => $activity, ], $response ); } public function entries( #[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request, EntryRepository $repository, ): Response { $response = new Response(); if ($user->apId) { $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); } $requestedByUser = $this->getUser(); if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } $criteria = new EntryPageView($this->getPageNb($request), $this->security); $criteria->sortOption = Criteria::SORT_NEW; $criteria->user = $user; $entries = $repository->findByCriteria($criteria); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView( 'entry/_list.html.twig', [ 'entries' => $entries, ] ), ]); } return $this->render( 'user/entries.html.twig', [ 'user' => $user, 'entries' => $entries, ], $response ); } public function comments( #[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request, EntryCommentRepository $repository, ): Response { $response = new Response(); if ($user->apId) { $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); } $requestedByUser = $this->getUser(); if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security); $criteria->sortOption = Criteria::SORT_NEW; $criteria->user = $user; $criteria->onlyParents = false; $comments = $repository->findByCriteria($criteria); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView( 'entry/comment/_list.html.twig', [ 'comments' => $comments, 'criteria' => $criteria, 'showNested' => false, ] ), ]); } return $this->render( 'user/comments.html.twig', [ 'user' => $user, 'comments' => $comments, 'criteria' => $criteria, ], $response ); } public function posts( #[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request, PostRepository $repository, ): Response { $response = new Response(); if ($user->apId) { $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); } $requestedByUser = $this->getUser(); if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } $criteria = new PostPageView($this->getPageNb($request), $this->security); $criteria->sortOption = Criteria::SORT_NEW; $criteria->user = $user; $posts = $repository->findByCriteria($criteria); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView( 'post/_list.html.twig', [ 'posts' => $posts, ] ), ]); } return $this->render( 'user/posts.html.twig', [ 'user' => $user, 'posts' => $posts, ], $response ); } public function replies( #[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request, PostCommentRepository $repository, ): Response { $response = new Response(); if ($user->apId) { $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); } $requestedByUser = $this->getUser(); if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } $criteria = new PostCommentPageView($this->getPageNb($request), $this->security); $criteria->sortOption = Criteria::SORT_NEW; $criteria->onlyParents = false; $criteria->user = $user; $comments = $repository->findByCriteria($criteria); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView( 'layout/_subject_list.html.twig', [ 'results' => $comments, 'criteria' => $criteria, 'postCommentAttributes' => [ 'showNested' => false, 'withPost' => true, ], ] ), ]); } return $this->render( 'user/replies.html.twig', [ 'user' => $user, 'results' => $comments, 'criteria' => $criteria, ], $response ); } public function moderated( #[MapEntity(mapping: ['username' => 'username'])] User $user, MagazineRepository $repository, Request $request, ): Response { $requestedByUser = $this->getUser(); if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } $criteria = new MagazinePageView( $this->getPageNb($request), Criteria::SORT_ACTIVE, Criteria::AP_ALL, MagazinePageView::ADULT_SHOW, ); $response = new Response(); if ($user->apId) { $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); } return $this->render( 'user/moderated.html.twig', [ 'view' => 'list', 'user' => $user, 'magazines' => $repository->findModeratedMagazines($user, (int) $request->get('p', 1)), 'criteria' => $criteria, ], $response ); } public function subscriptions( #[MapEntity(mapping: ['username' => 'username'])] User $user, MagazineRepository $repository, Request $request, ): Response { $requestedByUser = $this->getUser(); if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } $response = new Response(); if ($user->apId) { $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); } if (!$user->showProfileSubscriptions) { if ($user !== $this->getUser()) { throw new AccessDeniedException(); } } return $this->render( 'user/subscriptions.html.twig', [ 'user' => $user, 'magazines' => $repository->findSubscribedMagazines($this->getPageNb($request), $user), ], $response ); } public function followers( #[MapEntity(mapping: ['username' => 'username'])] User $user, UserRepository $repository, Request $request, ): Response { $requestedByUser = $this->getUser(); if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } $response = new Response(); if ($user->apId) { $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); } return $this->render( 'user/followers.html.twig', [ 'user' => $user, 'users' => $repository->findFollowers($this->getPageNb($request), $user), ], $response ); } public function following( #[MapEntity(mapping: ['username' => 'username'])] User $user, UserRepository $manager, Request $request, ): Response { $requestedByUser = $this->getUser(); if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } $response = new Response(); if ($user->apId) { $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); } if (!$user->showProfileFollowings && !$user->apId) { if ($user !== $this->getUser()) { throw new AccessDeniedException(); } } return $this->render( 'user/following.html.twig', [ 'user' => $user, 'users' => $manager->findFollowing($this->getPageNb($request), $user), ], $response ); } public function boosts( #[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request, SearchRepository $repository, ): Response { $requestedByUser = $this->getUser(); if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } $response = new Response(); if ($user->apId) { $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); } $activity = $repository->findBoosts($this->getPageNb($request), $user); if ($request->isXmlHttpRequest()) { return new JsonResponse([ 'html' => $this->renderView('user/_boost_list.html.twig', [ 'results' => $activity->getCurrentPageResults(), 'pagination' => $activity, ]), ]); } return $this->render( 'user/overview.html.twig', [ 'user' => $user, 'results' => $activity->getCurrentPageResults(), 'pagination' => $activity, ], $response ); } } ================================================ FILE: src/Controller/User/UserNoteController.php ================================================ 'username'])] User $user, Request $request): Response { $dto = $this->manager->createDto($this->getUserOrThrow(), $user); $form = $this->createForm(UserNoteType::class, $dto); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $dto = $form->getData(); if ($dto->body) { $this->manager->save($this->getUserOrThrow(), $user, $dto->body); } else { $this->manager->clear($this->getUserOrThrow(), $user); } } if ($request->isXmlHttpRequest()) { return $this->getJsonSuccessResponse(); } return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/User/UserRemoveFollowing.php ================================================ 'username'])] User $user, UserManager $manager, Request $request, ): Response { $this->validateCsrf('user_remove_following', $request->getPayload()->get('token')); $manager->removeFollowing($user); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/User/UserReputationController.php ================================================ 'username'])] User $user, ?string $reputationType, Request $request, ): Response { $requestedByUser = $this->getUser(); if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } $page = (int) $request->get('p', 1); $results = match ($this->manager->resolveType($reputationType)) { ReputationRepository::TYPE_ENTRY => $this->repository->getUserReputation($user, Entry::class, $page), ReputationRepository::TYPE_ENTRY_COMMENT => $this->repository->getUserReputation( $user, EntryComment::class, $page ), ReputationRepository::TYPE_POST => $this->repository->getUserReputation($user, Post::class, $page), ReputationRepository::TYPE_POST_COMMENT => $this->repository->getUserReputation( $user, PostComment::class, $page ), default => null, }; return $this->render( 'user/reputation.html.twig', [ 'type' => $reputationType, 'user' => $user, 'results' => $results, ] ); } } ================================================ FILE: src/Controller/User/UserSuspendController.php ================================================ 'username'])] User $user, Request $request): Response { $this->validateCsrf('user_suspend', $request->getPayload()->get('token')); $this->userManager->suspend($user); $this->addFlash('success', 'account_suspended'); return $this->redirectToRefererOrHome($request); } #[IsGranted(new Expression('is_granted("ROLE_ADMIN") or is_granted("ROLE_MODERATOR")'))] public function unsuspend(#[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request): Response { $this->validateCsrf('user_suspend', $request->getPayload()->get('token')); $this->userManager->unsuspend($user); $this->addFlash('success', 'account_unsuspended'); return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/User/UserThemeController.php ================================================ toggleTheme($this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'success' => true, ] ); } return $this->redirectToRefererOrHome($request); } } ================================================ FILE: src/Controller/VoteController.php ================================================ settingsManager->getDownvotesMode()) { throw new BadRequestException('Downvotes are disabled!'); } $vote = $this->manager->vote($choice, $votable, $this->getUserOrThrow()); if ($request->isXmlHttpRequest()) { return new JsonResponse( [ 'html' => $this->renderView('components/_ajax.html.twig', [ 'component' => 'vote', 'attributes' => [ 'subject' => $vote->getSubject(), 'showDownvote' => str_contains(\get_class($vote->getSubject()), 'Entry'), ], ] ), ] ); } if (!$request->headers->has('Referer')) { return $this->redirectToRoute('front', ['_fragment' => $this->getFragment($votable)]); } return $this->redirect($request->headers->get('Referer').'#'.$this->getFragment($votable)); } public function getFragment($votable): string { return match (true) { $votable instanceof Entry => 'entry-'.$votable->getId(), $votable instanceof EntryComment => 'entry-comment-'.$votable->getId(), $votable instanceof Post => 'post-'.$votable->getId(), $votable instanceof PostComment => 'post-comment-'.$votable->getId(), default => throw new \InvalidArgumentException('Invalid votable type'), }; } } ================================================ FILE: src/DTO/ActivitiesResponseDto.php ================================================ boosts = $boosts; $dto->upvotes = $upvotes; $dto->downvotes = $downvotes; return $dto; } public function jsonSerialize(): mixed { return [ 'boosts' => $this->boosts, 'upvotes' => $this->upvotes, 'downvotes' => $this->downvotes, ]; } } ================================================ FILE: src/DTO/ActivityPub/ImageDto.php ================================================ url = $url; $this->format = $format; $this->name = $name ?? $format; return $this; } } ================================================ FILE: src/DTO/ActivityPub/VideoDto.php ================================================ url = $url; $this->format = $format; $this->name = $name ?? $format; return $this; } } ================================================ FILE: src/DTO/BadgeDto.php ================================================ id = $id; $dto->magazine = $magazine; $dto->name = $name; return $dto; } public function getId(): ?int { return $this->id; } } ================================================ FILE: src/DTO/BadgeResponseDto.php ================================================ magazineId = $badge->magazine->getId(); $this->name = $badge->name; $this->badgeId = $badge->getId(); } public function jsonSerialize(): mixed { return [ 'magazineId' => $this->magazineId, 'name' => $this->name, 'badgeId' => $this->badgeId, ]; } } ================================================ FILE: src/DTO/BookmarkListDto.php ================================================ name = $list->name; $dto->isDefault = $list->isDefault; $dto->count = $list->entities->count(); return $dto; } public function jsonSerialize(): array { return [ 'name' => $this->name, 'isDefault' => $this->isDefault, 'count' => $this->count, ]; } } ================================================ FILE: src/DTO/BookmarksDto.php ================================================ consentId = $consentId; $toReturn->client = $clientName; $toReturn->description = $clientDescription; $toReturn->clientLogo = $logo; $toReturn->scopesGranted = $scopesGranted; $toReturn->scopesAvailable = $scopesAvailable; return $toReturn; } public function jsonSerialize(): mixed { return [ 'consentId' => $this->consentId, 'client' => $this->client, 'description' => $this->description, 'clientLogo' => $this->clientLogo?->jsonSerialize(), 'scopesGranted' => $this->scopesGranted, 'scopesAvailable' => $this->scopesAvailable, ]; } } ================================================ FILE: src/DTO/ClientResponseDto.php ================================================ getUser(); $this->identifier = $client->getIdentifier(); $this->name = $client->getName(); $this->contactEmail = $client->getContactEmail(); $this->description = $client->getDescription(); $this->user = $user ? new UserSmallResponseDto($user) : null; $this->active = $client->isActive(); $this->createdAt = $client->getCreatedAt(); $this->redirectUris = array_map(fn (RedirectUri $uri) => (string) $uri, $client->getRedirectUris()); $this->grants = array_map(fn (Grant $grant) => (string) $grant, $client->getGrants()); $this->scopes = array_map(fn (Scope $scope) => (string) $scope, $client->getScopes()); } } public function jsonSerialize(): mixed { return [ 'identifier' => $this->identifier, 'name' => $this->name, 'contactEmail' => $this->contactEmail, 'description' => $this->description, 'user' => $this->user?->jsonSerialize(), 'active' => $this->active, 'createdAt' => $this->createdAt->format(\DateTimeImmutable::ATOM), 'redirectUris' => $this->redirectUris, 'grants' => $this->grants, 'scopes' => $this->scopes, ]; } } ================================================ FILE: src/DTO/ConfirmDefederationDto.php ================================================ visibility, [ VisibilityInterface::VISIBILITY_VISIBLE, VisibilityInterface::VISIBILITY_PRIVATE, ]) ) { return $value; } array_walk($value, fn (&$val, $key) => $val = false !== array_search($key, self::$keysToDelete) ? null : $val); return $value; } } ================================================ FILE: src/DTO/DomainDto.php ================================================ id = $id; $toReturn->name = $name; $toReturn->entryCount = $entryCount; $toReturn->subscriptionsCount = $subscriptionsCount; return $toReturn; } public function getId(): ?int { return $this->id; } public function jsonSerialize(): mixed { return [ 'domainId' => $this->getId(), 'name' => $this->name, 'entryCount' => $this->entryCount, 'subscriptionsCount' => $this->subscriptionsCount, 'isUserSubscribed' => $this->isUserSubscribed, 'isBlockedByUser' => $this->isBlockedByUser, ]; } } ================================================ FILE: src/DTO/EntryCommentDto.php ================================================ image)) { $image = Request::createFromGlobals()->files->filter('entry_comment'); if (\is_array($image) && isset($image['image'])) { $image = $image['image']; } else { $image = $context->getValue()->image; } } else { $image = $this->image; } if (empty($this->body) && empty($image)) { $this->buildViolation($context, 'body'); } } private function buildViolation(ExecutionContextInterface $context, $path) { $context->buildViolation('This value should not be blank.') ->atPath($path) ->addViolation(); } public function createWithParent( Entry $entry, ?EntryComment $parent, ?Image $image = null, ?string $body = null, ): self { $this->entry = $entry; $this->parent = $parent; $this->body = $body; $this->image = $image; if ($parent) { $this->root = $parent->root ?? $parent; } return $this; } public function getId(): ?int { return $this->id; } public function setId(int $id): void { $this->id = $id; } public function isFavored(): ?bool { return $this->isFavourited; } public function userChoice(): ?int { return $this->userVote; } public function getApId(): ?string { return $this->apId; } public function getMagazine(): ?Magazine { return $this->magazine; } public function getUser(): ?User { return $this->user; } public function getVisibility(): string { return $this->visibility; } public function isPrivate(): bool { return VisibilityInterface::VISIBILITY_PRIVATE === $this->visibility; } public function isSoftDeleted(): bool { return VisibilityInterface::VISIBILITY_SOFT_DELETED === $this->visibility; } public function isTrashed(): bool { return VisibilityInterface::VISIBILITY_TRASHED === $this->visibility; } public function isVisible(): bool { return VisibilityInterface::VISIBILITY_VISIBLE === $this->visibility; } } ================================================ FILE: src/DTO/EntryCommentRequestDto.php ================================================ body = $this->body ?? $dto->body; $dto->lang = $this->lang ?? $dto->lang ?? $settingsManager->getValue('KBIN_DEFAULT_LANG'); $dto->isAdult = $this->isAdult ?? $dto->isAdult; return $dto; } } ================================================ FILE: src/DTO/EntryCommentResponseDto.php ================================================ 0, 'user' => [ 'userId' => 0, 'username' => 'test', ], 'magazine' => [ 'magazineId' => 0, 'name' => 'test', ], 'entryId' => 0, 'parentId' => 0, 'rootId' => 0, 'image' => [ 'filePath' => 'x/y/z.png', 'width' => 3000, 'height' => 4000, ], 'body' => 'string', 'lang' => 'en', 'isAdult' => false, 'uv' => 0, 'dv' => 0, 'favourites' => 0, 'visibility' => 'visible', 'apId' => 'string', 'mentions' => [ '@user@instance', ], 'tags' => [ 'string', ], 'createdAt' => '2023-06-18 11:59:41-07:00', 'editedAt' => '2023-06-18 11:59:41-07:00', 'lastActive' => '2023-06-18 12:00:45-07:00', 'childCount' => 0, 'children' => [], ], ] )] public array $children = []; #[OA\Property(description: 'The total number of children the comment has.')] public int $childCount = 0; public ?bool $canAuthUserModerate = null; /** @var string[]|null */ #[OA\Property(type: 'array', items: new OA\Items(type: 'string'))] public ?array $bookmarks = null; public ?bool $isAuthorModeratorInMagazine = null; public static function create( ?int $id = null, ?UserSmallResponseDto $user = null, ?MagazineSmallResponseDto $magazine = null, ?int $entryId = null, ?int $parentId = null, ?int $rootId = null, ?ImageDto $image = null, ?string $body = null, ?string $lang = null, ?bool $isAdult = null, ?int $uv = null, ?int $dv = null, ?int $favourites = null, ?string $visibility = null, ?string $apId = null, ?array $mentions = null, ?array $tags = null, ?\DateTimeImmutable $createdAt = null, ?\DateTimeImmutable $editedAt = null, ?\DateTime $lastActive = null, int $childCount = 0, ?bool $canAuthUserModerate = null, ?array $bookmarks = null, ?bool $isAuthorModeratorInMagazine = null, ): self { $dto = new EntryCommentResponseDto(); $dto->commentId = $id; $dto->user = $user; $dto->magazine = $magazine; $dto->entryId = $entryId; $dto->parentId = $parentId; $dto->rootId = $rootId; $dto->image = $image; $dto->body = $body; $dto->lang = $lang; $dto->isAdult = $isAdult; $dto->uv = $uv; $dto->dv = $dv; $dto->favourites = $favourites; $dto->visibility = $visibility; $dto->apId = $apId; $dto->mentions = $mentions; $dto->tags = $tags; $dto->createdAt = $createdAt; $dto->editedAt = $editedAt; $dto->lastActive = $lastActive; $dto->childCount = $childCount; $dto->canAuthUserModerate = $canAuthUserModerate; $dto->bookmarks = $bookmarks; $dto->isAuthorModeratorInMagazine = $isAuthorModeratorInMagazine; return $dto; } public static function recursiveChildCount(int $initial, EntryComment $child): int { return 1 + array_reduce($child->children->toArray(), self::class.'::recursiveChildCount', $initial); } public function jsonSerialize(): mixed { if (null === self::$keysToDelete) { self::$keysToDelete = [ 'image', 'body', 'tags', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'mentions', ]; } return $this->handleDeletion([ 'commentId' => $this->commentId, 'user' => $this->user->jsonSerialize(), 'magazine' => $this->magazine->jsonSerialize(), 'entryId' => $this->entryId, 'parentId' => $this->parentId, 'rootId' => $this->rootId, 'image' => $this->image?->jsonSerialize(), 'body' => $this->body, 'lang' => $this->lang, 'isAdult' => $this->isAdult, 'uv' => $this->uv, 'dv' => $this->dv, 'favourites' => $this->favourites, 'isFavourited' => $this->isFavourited, 'userVote' => $this->userVote, 'visibility' => $this->visibility, 'apId' => $this->apId, 'mentions' => $this->mentions, 'tags' => $this->tags, 'createdAt' => $this->createdAt->format(\DateTimeInterface::ATOM), 'editedAt' => $this->editedAt?->format(\DateTimeInterface::ATOM), 'lastActive' => $this->lastActive?->format(\DateTimeInterface::ATOM), 'childCount' => $this->childCount, 'children' => $this->children, 'canAuthUserModerate' => $this->canAuthUserModerate, 'bookmarks' => $this->bookmarks, 'isAuthorModeratorInMagazine' => $this->isAuthorModeratorInMagazine, ]); } } ================================================ FILE: src/DTO/EntryDto.php ================================================ image)) { $keys = ['entry', 'entry_edit']; for ($i = 0; $i < \sizeof($keys) && empty($image); ++$i) { $image = Request::createFromGlobals()->files->filter($keys[$i]); if (\is_array($image)) { $image = $image['image']; } else { $image = $context->getValue()->image; } } } else { $image = $this->image; } if (empty($this->body) && empty($this->url) && empty($image)) { $this->buildViolation($context, 'url'); $this->buildViolation($context, 'body'); $this->buildViolation($context, 'image'); } } private function buildViolation(ExecutionContextInterface $context, $path) { $context->buildViolation('One of these values should not be blank.') ->atPath($path) ->addViolation(); } public function getId(): ?int { return $this->id; } public function getApId(): ?string { return $this->apId; } public function setId(int $id): void { $this->id = $id; } public function getMagazine(): ?Magazine { return $this->magazine; } public function getUser(): ?User { return $this->user; } public function getVisibility(): string { return $this->visibility; } public function isVisible(): bool { return VisibilityInterface::VISIBILITY_VISIBLE === $this->visibility; } public function isPrivate(): bool { return VisibilityInterface::VISIBILITY_PRIVATE === $this->visibility; } public function isSoftDeleted(): bool { return VisibilityInterface::VISIBILITY_SOFT_DELETED === $this->visibility; } public function isTrashed(): bool { return VisibilityInterface::VISIBILITY_TRASHED === $this->visibility; } public function getType(): string { if ($this->url) { return Entry::ENTRY_TYPE_LINK; } $type = Entry::ENTRY_TYPE_IMAGE; if ($this->body) { $type = Entry::ENTRY_TYPE_ARTICLE; } return $type; } } ================================================ FILE: src/DTO/EntryRequestDto.php ================================================ title = $this->title ?? $dto->title; $dto->body = $this->body ?? $dto->body; // TODO: Support for badges when they're implemented // $dto->badges = $this->badges ?? $dto->badges; $dto->isAdult = $this->isAdult ?? $dto->isAdult; $dto->isOc = $this->isOc ?? $dto->isOc; $dto->lang = $this->lang ?? $dto->lang ?? $settingsManager->getValue('KBIN_DEFAULT_LANG'); $dto->url = $this->url ?? $dto->url; $dto->tags = $this->tags ?? $dto->tags; return $dto; } } ================================================ FILE: src/DTO/EntryResponseDto.php ================================================ entryId = $id; $dto->magazine = $magazine; $dto->user = $user; $dto->domain = $domain; $dto->title = $title; $dto->url = $url; $dto->image = $image; $dto->body = $body; $dto->lang = $lang; $dto->tags = $tags; $dto->badges = $badges; $dto->numComments = $comments; $dto->uv = $uv; $dto->dv = $dv; $dto->isPinned = $isPinned; $dto->isLocked = $isLocked; $dto->visibility = $visibility; $dto->favourites = $favouriteCount; $dto->isOc = $isOc; $dto->isAdult = $isAdult; $dto->createdAt = $createdAt; $dto->editedAt = $editedAt; $dto->lastActive = $lastActive; $dto->type = $type; $dto->slug = $slug; $dto->apId = $apId; $dto->canAuthUserModerate = $canAuthUserModerate; $dto->bookmarks = $bookmarks; $dto->crosspostedEntries = $crosspostedEntries; $dto->isAuthorModeratorInMagazine = $isAuthorModeratorInMagazine; return $dto; } public function jsonSerialize(): mixed { if (null === self::$keysToDelete) { self::$keysToDelete = [ 'domain', 'title', 'url', 'image', 'body', 'tags', 'badges', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'slug', ]; } return $this->handleDeletion([ 'entryId' => $this->entryId, 'magazine' => $this->magazine->jsonSerialize(), 'user' => $this->user->jsonSerialize(), 'domain' => $this->domain?->jsonSerialize(), 'title' => $this->title, 'url' => $this->url, 'image' => $this->image?->jsonSerialize(), 'body' => $this->body, 'lang' => $this->lang, 'tags' => $this->tags, 'badges' => $this->badges, 'numComments' => $this->numComments, 'uv' => $this->uv, 'dv' => $this->dv, 'favourites' => $this->favourites, 'isFavourited' => $this->isFavourited, 'userVote' => $this->userVote, 'isOc' => $this->isOc, 'isAdult' => $this->isAdult, 'isPinned' => $this->isPinned, 'isLocked' => $this->isLocked, 'createdAt' => $this->createdAt->format(\DateTimeInterface::ATOM), 'editedAt' => $this->editedAt?->format(\DateTimeInterface::ATOM), 'lastActive' => $this->lastActive?->format(\DateTimeInterface::ATOM), 'visibility' => $this->visibility, 'type' => $this->type, 'slug' => $this->slug, 'apId' => $this->apId, 'canAuthUserModerate' => $this->canAuthUserModerate, 'notificationStatus' => $this->notificationStatus, 'bookmarks' => $this->bookmarks, 'crosspostedEntries' => $this->crosspostedEntries, 'isAuthorModeratorInMagazine' => $this->isAuthorModeratorInMagazine, ]); } } ================================================ FILE: src/DTO/ExtendedContentResponseDto.php ================================================ $this->federationEnabled, 'federationUsesAllowList' => $this->federationUsesAllowList, 'federationPageEnabled' => $this->federationPageEnabled, ]; } } ================================================ FILE: src/DTO/GroupedMonitoringQueryDto.php ================================================ filePath = $filePath; $dto->altText = $altText; $dto->width = $width; $dto->height = $height; $dto->sourceUrl = $sourceUrl; $dto->storageUrl = $storageUrl; $dto->blurHash = $blurHash; $dto->id = $id; return $dto; } public function jsonSerialize(): array { return [ 'filePath' => $this->filePath, 'sourceUrl' => $this->sourceUrl, 'storageUrl' => $this->storageUrl, 'altText' => $this->altText, 'width' => $this->width, 'height' => $this->height, 'blurHash' => $this->blurHash, ]; } } ================================================ FILE: src/DTO/ImageUploadDto.php ================================================ $this->domain, 'software' => $this->software, 'version' => $this->version, ]; } } ================================================ FILE: src/DTO/InstancesDto.php ================================================ $this->instances, ]; } } ================================================ FILE: src/DTO/InstancesDtoV2.php ================================================ $this->instances, ]; } } ================================================ FILE: src/DTO/MagazineBanDto.php ================================================ reason = $reason; $dto->expiredAt = $expiredAt; return $dto; } public function getId(): ?int { return $this->id; } } ================================================ FILE: src/DTO/MagazineBanResponseDto.php ================================================ reason = $reason; $dto->expiredAt = $expiredAt; $dto->magazine = $magazine; $dto->bannedUser = $user; $dto->bannedBy = $bannedBy; $dto->banId = $id; return $dto; } public function getExpired(): bool { return $this->expiredAt && (new \DateTime('+10 seconds')) >= $this->expiredAt; } public function jsonSerialize(): mixed { return [ 'banId' => $this->banId, 'reason' => $this->reason, 'expired' => $this->getExpired(), 'expiredAt' => $this->expiredAt?->format(\DateTimeInterface::ATOM), 'bannedUser' => $this->bannedUser->jsonSerialize(), 'bannedBy' => $this->bannedBy->jsonSerialize(), 'magazine' => $this->magazine->jsonSerialize(), ]; } } ================================================ FILE: src/DTO/MagazineDto.php ================================================ id; } public function setId(int $id): void { $this->id = $id; } public function getOwner(): User|UserDto|null { return $this->owner; } public function setOwner(User|UserDto|null $owner): void { $this->owner = $owner; } } ================================================ FILE: src/DTO/MagazineLogResponseDto.php ================================================ magazine = $magazine; $dto->moderator = $moderator; $dto->createdAt = $createdAt; $dto->type = $type; return $dto; } public static function createBanUnban( MagazineSmallResponseDto $magazine, UserSmallResponseDto $moderator, \DateTimeImmutable $createdAt, string $type, MagazineBanResponseDto $banSubject, ): self { $dto = self::create($magazine, $moderator, $createdAt, $type); $dto->subject2 = $banSubject; return $dto; } public static function createModeratorAddRemove( MagazineSmallResponseDto $magazine, UserSmallResponseDto $moderator, \DateTimeImmutable $createdAt, string $type, UserSmallResponseDto $moderatorSubject, ): self { $dto = self::create($magazine, $moderator, $createdAt, $type); $dto->subject2 = $moderatorSubject; return $dto; } #[Ignore] public function setSubject( ?ContentInterface $subject, EntryFactory $entryFactory, EntryCommentFactory $entryCommentFactory, PostFactory $postFactory, PostCommentFactory $postCommentFactory, TagLinkRepository $tagLinkRepository, ): void { switch ($this->type) { case 'log_entry_deleted': case 'log_entry_restored': case 'log_entry_pinned': case 'log_entry_unpinned': \assert($subject instanceof Entry); $this->subject2 = $entryFactory->createResponseDto($subject, tags: $tagLinkRepository->getTagsOfContent($subject)); break; case 'log_entry_comment_deleted': case 'log_entry_comment_restored': \assert($subject instanceof EntryComment); $this->subject2 = $entryCommentFactory->createResponseDto($subject, tags: $tagLinkRepository->getTagsOfContent($subject)); break; case 'log_post_deleted': case 'log_post_restored': \assert($subject instanceof Post); $this->subject2 = $postFactory->createResponseDto($subject, tags: $tagLinkRepository->getTagsOfContent($subject)); break; case 'log_post_comment_deleted': case 'log_post_comment_restored': \assert($subject instanceof PostComment); $this->subject2 = $postCommentFactory->createResponseDto($subject, tags: $tagLinkRepository->getTagsOfContent($subject)); break; default: break; } } public function jsonSerialize(): mixed { return [ 'type' => $this->type, 'createdAt' => $this->createdAt->format(\DateTimeInterface::ATOM), 'magazine' => $this->magazine, 'moderator' => $this->moderator, 'subject' => $this->subject2?->jsonSerialize(), ]; } } ================================================ FILE: src/DTO/MagazineRequestDto.php ================================================ name = $this->name ?? $dto->name; $dto->title = $this->title ?? $dto->title; $dto->description = $this->description ?? $dto->description; $dto->rules = $this->rules ?? $dto->rules; $dto->isAdult = null !== $this->isAdult ? $this->isAdult : $dto->isAdult; $dto->isPostingRestrictedToMods = $this->isPostingRestrictedToMods ?? false; $dto->discoverable = $this->discoverable ?? $dto->discoverable ?? true; $dto->indexable = $this->indexable ?? $dto->indexable ?? true; return $dto; } } ================================================ FILE: src/DTO/MagazineResponseDto.php ================================================ owner = $owner; $dto->icon = $icon; $dto->banner = $banner; $dto->name = $name; $dto->title = $title; $dto->description = $description; $dto->rules = $rules; $dto->subscriptionsCount = $subscriptionsCount; $dto->entryCount = $entryCount; $dto->entryCommentCount = $entryCommentCount; $dto->postCount = $postCount; $dto->postCommentCount = $postCommentCount; $dto->isAdult = $isAdult; $dto->isUserSubscribed = $isUserSubscribed; $dto->isBlockedByUser = $isBlockedByUser; $dto->tags = $tags; $dto->badges = $badges; $dto->moderators = $moderators; $dto->apId = $apId; $dto->apProfileId = $apProfileId; $dto->magazineId = $magazineId; $dto->serverSoftware = $serverSoftware; $dto->serverSoftwareVersion = $serverSoftwareVersion; $dto->isPostingRestrictedToMods = $isPostingRestrictedToMods; $dto->localSubscribers = $localSubscribers; $dto->discoverable = $discoverable; $dto->indexable = $indexable; return $dto; } public function jsonSerialize(): mixed { return [ 'magazineId' => $this->magazineId, 'owner' => $this->owner?->jsonSerialize(), 'icon' => $this->icon ? $this->icon->jsonSerialize() : null, 'banner' => $this->banner?->jsonSerialize(), 'name' => $this->name, 'title' => $this->title, 'description' => $this->description, 'rules' => $this->rules, 'subscriptionsCount' => $this->subscriptionsCount, 'entryCount' => $this->entryCount, 'entryCommentCount' => $this->entryCommentCount, 'postCount' => $this->postCount, 'postCommentCount' => $this->postCommentCount, 'isAdult' => $this->isAdult, 'isUserSubscribed' => $this->isUserSubscribed, 'isBlockedByUser' => $this->isBlockedByUser, 'tags' => $this->tags, 'badges' => array_map(fn (BadgeResponseDto $badge) => $badge->jsonSerialize(), $this->badges), 'moderators' => array_map(fn (ModeratorResponseDto $moderator) => $moderator->jsonSerialize(), $this->moderators), 'apId' => $this->apId, 'apProfileId' => $this->apProfileId, 'serverSoftware' => $this->serverSoftware, 'serverSoftwareVersion' => $this->serverSoftwareVersion, 'isPostingRestrictedToMods' => $this->isPostingRestrictedToMods, 'localSubscribers' => $this->localSubscribers, 'notificationStatus' => $this->notificationStatus, 'discoverable' => $this->discoverable, 'indexable' => $this->indexable, ]; } } ================================================ FILE: src/DTO/MagazineSmallResponseDto.php ================================================ name = $dto->name; $this->magazineId = $dto->getId(); $this->icon = $dto->icon; $this->banner = $dto->banner; $this->isUserSubscribed = $dto->isUserSubscribed; $this->isBlockedByUser = $dto->isBlockedByUser; $this->apId = $dto->apId; $this->apProfileId = $dto->apProfileId; $this->discoverable = $dto->discoverable; $this->indexable = $dto->indexable; } public function jsonSerialize(): mixed { return [ 'magazineId' => $this->magazineId, 'name' => $this->name, 'icon' => $this->icon, 'banner' => $this->banner, 'isUserSubscribed' => $this->isUserSubscribed, 'isBlockedByUser' => $this->isBlockedByUser, 'apId' => $this->apId, 'apProfileId' => $this->apProfileId, 'discoverable' => $this->discoverable, 'indexable' => $this->indexable, ]; } } ================================================ FILE: src/DTO/MagazineThemeDto.php ================================================ magazine = $magazine; $this->customCss = $magazine->customCss; } public function create(?ImageDto $icon) { $this->icon = $icon; } public function createBanner(ImageDto $banner): void { $this->banner = $banner; } } ================================================ FILE: src/DTO/MagazineThemeRequestDto.php ================================================ customCss = $this->customCss ?? $dto->customCss; $dto->backgroundImage = $this->backgroundImage ?? $dto->backgroundImage; return $dto; } } ================================================ FILE: src/DTO/MagazineThemeResponseDto.php ================================================ magazine = new MagazineSmallResponseDto($magazine); $dto->customCss = $customCss; $dto->icon = $icon; $dto->banner = $banner; return $dto; } public function jsonSerialize(): mixed { return [ 'magazine' => $this->magazine->jsonSerialize(), 'customCss' => $this->customCss, 'icon' => $this->icon?->jsonSerialize(), 'banner' => $this->banner?->jsonSerialize(), ]; } } ================================================ FILE: src/DTO/MagazineUpdateRequestDto.php ================================================ icon = null !== $this->iconId ? $imageRepository->find($this->iconId) : $dto->icon; $dto->title = $this->title ?? $dto->title; $dto->description = $this->description ?? $dto->description; $dto->rules = $this->rules ?? $dto->rules; $dto->isAdult = null === $this->isAdult ? $this->isAdult : $dto->isAdult; $dto->isPostingRestrictedToMods = $this->isPostingRestrictedToMods ?? false; $dto->discoverable = $this->discoverable ?? $dto->discoverable ?? true; $dto->indexable = $this->indexable ?? $dto->indexable ?? true; return $dto; } } ================================================ FILE: src/DTO/MessageDto.php ================================================ sender = new UserSmallResponseDto($message->sender); // $this->body = $message->body; // $this->status = $message->status; // $this->threadId = $message->thread->getId(); // $this->createdAt = $message->createdAt; // $this->messageId = $message->getId(); // } public static function create(UserSmallResponseDto $sender, string $body, string $status, int $threadId, \DateTimeImmutable $createdAt, int $messageId): self { $dto = new MessageResponseDto(); $dto->sender = $sender; $dto->body = $body; $dto->status = $status; $dto->threadId = $threadId; $dto->createdAt = $createdAt; $dto->messageId = $messageId; return $dto; } public function jsonSerialize(): mixed { return [ 'messageId' => $this->messageId, 'threadId' => $this->threadId, 'sender' => $this->sender?->jsonSerialize(), 'body' => $this->body, 'status' => $this->status, 'createdAt' => $this->createdAt->format(\DateTimeInterface::ATOM), ]; } } ================================================ FILE: src/DTO/MessageThreadResponseDto.php ================================================ participants = $participants; $dto->messageCount = $messageCount; $dto->messages = $messages; $dto->threadId = $threadId; return $dto; } public function jsonSerialize(): mixed { return [ 'threadId' => $this->threadId, 'participants' => $this->participants, 'messageCount' => $this->messageCount, 'messages' => $this->messages, ]; } } ================================================ FILE: src/DTO/ModeratorDto.php ================================================ magazine = $magazine; $this->user = $user; $this->addedBy = $addedBy; } } ================================================ FILE: src/DTO/ModeratorResponseDto.php ================================================ magazineId = $magazineId; $dto->userId = $userId; $dto->avatar = $avatar; $dto->username = $username; $dto->apId = $apId; return $dto; } public function jsonSerialize(): mixed { return [ 'magazineId' => $this->magazineId, 'userId' => $this->userId, 'avatar' => $this->avatar?->jsonSerialize(), 'username' => $this->username, 'apId' => $this->apId, ]; } } ================================================ FILE: src/DTO/ModlogFilterDto.php ================================================ executionType) { $criteria->andWhere(Criteria::expr()->eq('executionType', $this->executionType)); } if (null !== $this->userType) { $criteria->andWhere(Criteria::expr()->eq('userType', $this->userType)); } if (null !== $this->path) { $criteria->andWhere(Criteria::expr()->eq('path', $this->path)); } if (null !== $this->handler) { $criteria->andWhere(Criteria::expr()->eq('handler', $this->handler)); } if (null !== $this->hasException) { if ($this->hasException) { $criteria->andWhere(Criteria::expr()->isNotNull('exception')); } else { $criteria->andWhere(Criteria::expr()->isNull('exception')); } } if (null !== $this->durationMinimum) { $criteria->andWhere(Criteria::expr()->gt('durationMilliseconds', $this->durationMinimum)); } if (null !== $this->createdFrom) { $criteria->andWhere(Criteria::expr()->gt('createdAt', $this->createdFrom)); } if (null !== $this->createdTo) { $criteria->andWhere(Criteria::expr()->lt('createdAt', $this->createdTo)); } return $criteria; } /** * @return array{whereConditions: string[], parameters: array} */ public function toSqlWheres(): array { $criteria = []; $parameters = []; if (null !== $this->executionType) { $criteria[] = 'execution_type = :executionType'; $parameters[':executionType'] = $this->executionType; } if (null !== $this->userType) { $criteria[] = 'user_type = :userType'; $parameters[':userType'] = $this->userType; } if (null !== $this->path) { $criteria[] = 'path = :path'; $parameters['path'] = $this->path; } if (null !== $this->handler) { $criteria[] = 'handler = :handler'; $parameters[':handler'] = $this->handler; } if (null !== $this->hasException) { if ($this->hasException) { $criteria[] = 'exception IS NOT NULL'; } else { $criteria[] = 'exception IS NULL'; } } if (null !== $this->durationMinimum) { $criteria[] = 'duration_milliseconds > :durationMin'; $parameters[':durationMin'] = $this->durationMinimum; } if (null !== $this->createdFrom) { $criteria[] = 'created_at > :createdFrom'; $parameters['createdFrom'] = $this->createdFrom->format(DATE_ATOM); } if (null !== $this->createdTo) { $criteria[] = 'created_at < :createdTo'; $parameters['createdTo'] = $this->createdTo->format(DATE_ATOM); } return [ 'whereConditions' => $criteria, 'parameters' => $parameters, ]; } } ================================================ FILE: src/DTO/NotificationPushSubscriptionRequestDto.php ================================================ redirectUris, fn (string $uri) => filter_var($uri, FILTER_VALIDATE_URL) && !parse_url($uri, PHP_URL_QUERY)); $invalidUris = array_diff($this->redirectUris, $validUris); foreach ($invalidUris as $invalid) { $context->buildViolation('Invalid redirect uri "'.$invalid.'"'.(parse_url($invalid, PHP_URL_QUERY) ? ' - the query must be empty' : '')) ->atPath('redirectUris') ->addViolation(); } $validScopes = array_filter($this->scopes, fn (string $scope) => \array_key_exists($scope, OAuth2UserConsent::SCOPE_DESCRIPTIONS)); $invalidScopes = array_diff($this->scopes, $validScopes); foreach ($invalidScopes as $invalid) { $context->buildViolation('Invalid scope "'.$invalid.'"') ->atPath('scopes') ->addViolation(); } $validGrants = array_filter($this->grants, fn (string $grant) => false !== array_search($grant, ['client_credentials', 'authorization_code', 'refresh_token'])); $invalidGrants = array_diff($this->grants, $validGrants); foreach ($invalidGrants as $invalid) { $context->buildViolation('Invalid grant "'.$invalid.'"') ->atPath('grants') ->addViolation(); } if (false !== array_search('client_credentials', $validGrants) && null === $this->username) { $context->buildViolation('client_credentials grant type requires a username for the bot user representing your application.') ->atPath('username') ->addViolation(); } if (false !== array_search('client_credentials', $validGrants) && $this->public) { $context->buildViolation('client_credentials grant type requires a confidential client.') ->atPath('username') ->addViolation(); } } public static function create(string $identifier, ?string $secret, string $name, ?UserSmallResponseDto $user = null, ?string $contactEmail = null, ?string $description = null, array $redirectUris = [], array $grants = [], array $scopes = ['read'], ?ImageDto $image = null): OAuth2ClientDto { $dto = new OAuth2ClientDto(); $dto->identifier = $identifier; $dto->secret = $secret; $dto->name = $name; $dto->user = $user; $dto->contactEmail = $contactEmail; $dto->description = $description; $dto->redirectUris = $redirectUris; $dto->grants = $grants; $dto->scopes = $scopes; $dto->image = $image; return $dto; } public function jsonSerialize(): mixed { return [ 'identifier' => $this->identifier, 'secret' => $this->secret, 'name' => $this->name, 'contactEmail' => $this->contactEmail, 'description' => $this->description, 'user' => $this->user?->jsonSerialize(), 'redirectUris' => $this->redirectUris, 'grants' => $this->grants, 'scopes' => $this->scopes, 'image' => $this->image?->jsonSerialize(), ]; } } ================================================ FILE: src/DTO/PageDto.php ================================================ body = $body; return $this; } } ================================================ FILE: src/DTO/PostCommentDto.php ================================================ image)) { $image = Request::createFromGlobals()->files->filter('post_comment'); if (\is_array($image) && isset($image['image'])) { $image = $image['image']; } else { $image = $context->getValue()->image; } } else { $image = $this->image; } if (empty($this->body) && empty($image)) { $this->buildViolation($context, 'body'); } } private function buildViolation(ExecutionContextInterface $context, $path) { $context->buildViolation('This value should not be blank.') ->atPath($path) ->addViolation(); } public function createWithParent(Post $post, ?PostComment $parent, ?ImageDto $image = null, ?string $body = null): self { $this->post = $post; $this->parent = $parent; $this->body = $body; $this->image = $image; if ($parent) { $this->root = $parent->root ?? $parent; } return $this; } public function getId(): ?int { return $this->id; } public function getApId(): ?string { return $this->apId; } public function setId(int $id): void { $this->id = $id; } public function getVisibility(): string { return $this->visibility; } public function isPrivate(): bool { return VisibilityInterface::VISIBILITY_PRIVATE === $this->visibility; } public function isSoftDeleted(): bool { return VisibilityInterface::VISIBILITY_SOFT_DELETED === $this->visibility; } public function isTrashed(): bool { return VisibilityInterface::VISIBILITY_TRASHED === $this->visibility; } public function isVisible(): bool { return VisibilityInterface::VISIBILITY_VISIBLE === $this->visibility; } public function getMagazine(): ?Magazine { return $this->magazine; } public function getUser(): ?User { return $this->user; } } ================================================ FILE: src/DTO/PostCommentRequestDto.php ================================================ image = $this->image ?? $dto->image; $dto->body = $this->body ?? $dto->body; $dto->lang = $this->lang ?? $dto->lang ?? $settingsManager->getValue('KBIN_DEFAULT_LANG'); $dto->isAdult = $this->isAdult ?? $dto->isAdult; return $dto; } } ================================================ FILE: src/DTO/PostCommentResponseDto.php ================================================ 0, 'userId' => 0, 'magazineId' => 0, 'postId' => 0, 'parentId' => 0, 'rootId' => 0, 'image' => [ 'filePath' => 'x/y/z.png', 'width' => 3000, 'height' => 4000, ], 'body' => 'comment body', 'lang' => 'en', 'isAdult' => false, 'uv' => 0, 'dv' => 0, 'favourites' => 0, 'visibility' => 'visible', 'apId' => 'string', 'mentions' => [ '@user@instance', ], 'tags' => [ 'sometag', ], 'createdAt' => '2023-06-18 11:59:41+00:00', 'lastActive' => '2023-06-18 12:00:45+00:00', 'childCount' => 0, 'children' => [], ], ] )] public array $children = []; public ?bool $canAuthUserModerate = null; public ?bool $isAuthorModeratorInMagazine = null; /** @var string[]|null */ #[OA\Property(type: 'array', items: new OA\Items(type: 'string'))] public ?array $bookmarks = null; /** * @param string[] $bookmarks */ public static function create( int $id, ?UserSmallResponseDto $user = null, ?MagazineSmallResponseDto $magazine = null, ?Post $post = null, ?PostComment $parent = null, int $childCount = 0, ?ImageDto $image = null, ?string $body = null, ?string $lang = null, ?bool $isAdult = null, ?int $uv = null, ?int $dv = null, ?int $favourites = null, ?string $visibility = null, ?string $apId = null, ?array $mentions = null, ?array $tags = null, ?\DateTimeImmutable $createdAt = null, ?\DateTimeImmutable $editedAt = null, ?\DateTime $lastActive = null, ?bool $canAuthUserModerate = null, ?array $bookmarks = null, ?bool $isAuthorModeratorInMagazine = null, ): self { $dto = new PostCommentResponseDto(); $dto->commentId = $id; $dto->user = $user; $dto->magazine = $magazine; $dto->postId = $post->getId(); $dto->parentId = $parent ? $parent->getId() : null; $dto->rootId = $parent ? ($parent->root ? $parent->root->getId() : $parent->getId()) : null; $dto->image = $image; $dto->body = $body; $dto->lang = $lang; $dto->isAdult = $isAdult; $dto->uv = $uv; $dto->dv = $dv; $dto->favourites = $favourites; $dto->visibility = $visibility; $dto->apId = $apId; $dto->mentions = $mentions; $dto->tags = $tags; $dto->createdAt = $createdAt; $dto->editedAt = $editedAt; $dto->lastActive = $lastActive; $dto->childCount = $childCount; $dto->canAuthUserModerate = $canAuthUserModerate; $dto->bookmarks = $bookmarks; $dto->isAuthorModeratorInMagazine = $isAuthorModeratorInMagazine; return $dto; } public function jsonSerialize(): mixed { if (null === self::$keysToDelete) { self::$keysToDelete = [ 'image', 'body', 'tags', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'slug', 'mentions', ]; } return $this->handleDeletion([ 'commentId' => $this->commentId, 'user' => $this->user, 'magazine' => $this->magazine, 'postId' => $this->postId, 'parentId' => $this->parentId, 'rootId' => $this->rootId, 'image' => $this->image, 'body' => $this->body, 'lang' => $this->lang, 'isAdult' => $this->isAdult, 'uv' => $this->uv, 'dv' => $this->dv, 'favourites' => $this->favourites, 'isFavourited' => $this->isFavourited, 'userVote' => $this->userVote, 'visibility' => $this->visibility, 'apId' => $this->apId, 'mentions' => $this->mentions, 'tags' => $this->tags, 'createdAt' => $this->createdAt->format(\DateTimeInterface::ATOM), 'editedAt' => $this->editedAt?->format(\DateTimeInterface::ATOM), 'lastActive' => $this->lastActive?->format(\DateTimeInterface::ATOM), 'childCount' => $this->childCount, 'children' => $this->children, 'canAuthUserModerate' => $this->canAuthUserModerate, 'bookmarks' => $this->bookmarks, 'isAuthorModeratorInMagazine' => $this->isAuthorModeratorInMagazine, ]); } public static function recursiveChildCount(int $initial, PostComment $child): int { return 1 + array_reduce($child->children->toArray(), self::class.'::recursiveChildCount', $initial); } } ================================================ FILE: src/DTO/PostDto.php ================================================ image)) { $image = Request::createFromGlobals()->files->filter('post'); if (\is_array($image) && isset($image['image'])) { $image = $image['image']; } else { $image = $context->getValue()->image; } } else { $image = $this->image; } if (empty($this->body) && empty($image)) { $this->buildViolation($context, 'body'); } } private function buildViolation(ExecutionContextInterface $context, $path) { $context->buildViolation('This value should not be blank.') ->atPath($path) ->addViolation(); } public function getId(): ?int { return $this->id; } public function getApId(): ?string { return $this->apId; } public function setId(int $id): void { $this->id = $id; } public function getVisibility(): string { return $this->visibility; } public function isPrivate(): bool { return VisibilityInterface::VISIBILITY_PRIVATE === $this->visibility; } public function isSoftDeleted(): bool { return VisibilityInterface::VISIBILITY_SOFT_DELETED === $this->visibility; } public function isTrashed(): bool { return VisibilityInterface::VISIBILITY_TRASHED === $this->visibility; } public function isVisible(): bool { return VisibilityInterface::VISIBILITY_VISIBLE === $this->visibility; } public function getMagazine(): ?Magazine { return $this->magazine; } public function getUser(): ?User { return $this->user; } } ================================================ FILE: src/DTO/PostRequestDto.php ================================================ body = $this->body ?? $dto->body; $dto->lang = $this->lang ?? $dto->lang ?? $settingsManager->getValue('KBIN_DEFAULT_LANG'); $dto->isAdult = $this->isAdult ?? $dto->isAdult; return $dto; } } ================================================ FILE: src/DTO/PostResponseDto.php ================================================ postId = $id; $dto->user = $user; $dto->magazine = $magazine; $dto->image = $image; $dto->body = $body; $dto->lang = $lang; $dto->isAdult = $isAdult; $dto->isPinned = $isPinned; $dto->isLocked = $isLocked; $dto->comments = $comments; $dto->uv = $uv; $dto->dv = $dv; $dto->favourites = $favouriteCount; $dto->visibility = $visibility; $dto->tags = $tags; $dto->mentions = $mentions; $dto->apId = $apId; $dto->createdAt = $createdAt; $dto->editedAt = $editedAt; $dto->lastActive = $lastActive; $dto->slug = $slug; $dto->canAuthUserModerate = $canAuthUserModerate; $dto->bookmarks = $bookmarks; $dto->isAuthorModeratorInMagazine = $isAuthorModeratorInMagazine; return $dto; } public function jsonSerialize(): mixed { if (null === self::$keysToDelete) { self::$keysToDelete = [ 'image', 'body', 'tags', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'slug', 'mentions', ]; } return $this->handleDeletion([ 'postId' => $this->postId, 'user' => $this->user, 'magazine' => $this->magazine, 'image' => $this->image, 'body' => $this->body, 'lang' => $this->lang, 'isAdult' => $this->isAdult, 'isPinned' => $this->isPinned, 'isLocked' => $this->isLocked, 'comments' => $this->comments, 'uv' => $this->uv, 'dv' => $this->dv, 'favourites' => $this->favourites, 'isFavourited' => $this->isFavourited, 'userVote' => $this->userVote, 'visibility' => $this->visibility, 'apId' => $this->apId, 'tags' => $this->tags, 'mentions' => $this->mentions, 'createdAt' => $this->createdAt->format(\DateTimeInterface::ATOM), 'editedAt' => $this->editedAt?->format(\DateTimeInterface::ATOM), 'lastActive' => $this->lastActive?->format(\DateTimeInterface::ATOM), 'slug' => $this->slug, 'canAuthUserModerate' => $this->canAuthUserModerate, 'notificationStatus' => $this->notificationStatus, 'bookmarks' => $this->bookmarks, 'isAuthorModeratorInMagazine' => $this->isAuthorModeratorInMagazine, ]); } } ================================================ FILE: src/DTO/RemoteInstanceDto.php ================================================ domain, $instance->getId(), $instanceCounts['magazines'], $instanceCounts['users'], $instance->software, $instance->version, $instance->getLastSuccessfulDeliver(), $instance->getLastFailedDeliver(), $instance->getLastSuccessfulReceive(), $instance->getFailedDelivers(), $instance->isBanned, $instance->isExplicitlyAllowed, $instanceCounts['ourUserFollows'], $instanceCounts['theirUserFollows'], $instanceCounts['ourSubscriptions'], $instanceCounts['theirSubscriptions'], ); } public function jsonSerialize(): mixed { return [ 'software' => $this->software, 'version' => $this->version, 'domain' => $this->domain, 'lastSuccessfulDeliver' => $this->lastSuccessfulDeliver, 'lastFailedDeliver' => $this->lastFailedDeliver, 'lastSuccessfulReceive' => $this->lastSuccessfulReceive, 'failedDelivers' => $this->failedDelivers, 'isBanned' => $this->isBanned, 'isExplicitlyAllowed' => $this->isExplicitlyAllowed, 'id' => $this->id, 'magazines' => $this->magazines, 'users' => $this->users, 'ourUserFollows' => $this->ourUserFollows, 'theirUserFollows' => $this->theirUserFollows, 'ourSubscriptions' => $this->ourSubscriptions, 'theirSubscriptions' => $this->theirSubscriptions, ]; } } ================================================ FILE: src/DTO/ReportDto.php ================================================ id = $id; $dto->subject = $subject; $dto->reason = $reason; $dto->magazine = $subject->magazine; $dto->reported = $subject->user; return $dto; } public function getId(): ?int { return $this->id; } public function getRouteName(): string { switch (\get_class($this->getSubject())) { case Entry::class: return 'entry_report'; case EntryComment::class: return 'entry_comment_report'; case Post::class: return 'post_report'; case PostComment::class: return 'post_comment_report'; } throw new \LogicException(); } public function getSubject(): ReportInterface { return $this->subject; } } ================================================ FILE: src/DTO/ReportRequestDto.php ================================================ reportId = $id; $dto->magazine = $magazine; $dto->reported = $reported; $dto->reporting = $reporting; $dto->reason = $reason; $dto->status = $status; $dto->weight = $weight; $dto->createdAt = $createdAt; $dto->consideredAt = $consideredAt; $dto->consideredBy = $consideredBy; return $dto; } #[OA\Property( 'type', enum: [ 'entry_report', 'entry_comment_report', 'post_report', 'post_comment_report', 'null_report', ] )] public function getType(): string { if (null === $this->subject) { // item was purged return 'null_report'; } switch (\get_class($this->subject)) { case EntryResponseDto::class: return 'entry_report'; case EntryCommentResponseDto::class: return 'entry_comment_report'; case PostResponseDto::class: return 'post_report'; case PostCommentResponseDto::class: return 'post_comment_report'; } throw new \LogicException(); } public function jsonSerialize(): mixed { $serializedSubject = null; if ($this->subject) { $visibility = $this->subject->visibility; $this->subject->visibility = VisibilityInterface::VISIBILITY_VISIBLE; $serializedSubject = $this->subject->jsonSerialize(); $serializedSubject['visibility'] = $visibility; } return [ 'reportId' => $this->reportId, 'type' => $this->getType(), 'magazine' => $this->magazine->jsonSerialize(), 'reason' => $this->reason, 'reported' => $this->reported->jsonSerialize(), 'reporting' => $this->reporting->jsonSerialize(), 'subject' => $serializedSubject, 'status' => $this->status, 'weight' => $this->weight, 'createdAt' => $this->createdAt->format(\DateTimeInterface::ATOM), 'consideredAt' => $this->consideredAt?->format(\DateTimeInterface::ATOM), 'consideredBy' => $this->consideredBy?->jsonSerialize(), ]; } } ================================================ FILE: src/DTO/SearchDto.php ================================================ KBIN_DOMAIN = $this->KBIN_DOMAIN ?? $dto->KBIN_DOMAIN; $dto->KBIN_TITLE = $this->KBIN_TITLE ?? $dto->KBIN_TITLE; $dto->KBIN_META_TITLE = $this->KBIN_META_TITLE ?? $dto->KBIN_META_TITLE; $dto->KBIN_META_KEYWORDS = $this->KBIN_META_KEYWORDS ?? $dto->KBIN_META_KEYWORDS; $dto->KBIN_META_DESCRIPTION = $this->KBIN_META_DESCRIPTION ?? $dto->KBIN_META_DESCRIPTION; $dto->KBIN_DEFAULT_LANG = $this->KBIN_DEFAULT_LANG ?? $dto->KBIN_DEFAULT_LANG; $dto->KBIN_CONTACT_EMAIL = $this->KBIN_CONTACT_EMAIL ?? $dto->KBIN_CONTACT_EMAIL; $dto->KBIN_SENDER_EMAIL = $this->KBIN_SENDER_EMAIL ?? $dto->KBIN_SENDER_EMAIL; $dto->MBIN_DEFAULT_THEME = $this->MBIN_DEFAULT_THEME ?? $dto->MBIN_DEFAULT_THEME; $dto->KBIN_JS_ENABLED = $this->KBIN_JS_ENABLED ?? $dto->KBIN_JS_ENABLED; $dto->KBIN_FEDERATION_ENABLED = $this->KBIN_FEDERATION_ENABLED ?? $dto->KBIN_FEDERATION_ENABLED; $dto->KBIN_REGISTRATIONS_ENABLED = $this->KBIN_REGISTRATIONS_ENABLED ?? $dto->KBIN_REGISTRATIONS_ENABLED; $dto->KBIN_HEADER_LOGO = $this->KBIN_HEADER_LOGO ?? $dto->KBIN_HEADER_LOGO; $dto->KBIN_CAPTCHA_ENABLED = $this->KBIN_CAPTCHA_ENABLED ?? $dto->KBIN_CAPTCHA_ENABLED; $dto->KBIN_MERCURE_ENABLED = $this->KBIN_MERCURE_ENABLED ?? $dto->KBIN_MERCURE_ENABLED; $dto->KBIN_FEDERATION_PAGE_ENABLED = $this->KBIN_FEDERATION_PAGE_ENABLED ?? $dto->KBIN_FEDERATION_PAGE_ENABLED; $dto->KBIN_ADMIN_ONLY_OAUTH_CLIENTS = $this->KBIN_ADMIN_ONLY_OAUTH_CLIENTS ?? $dto->KBIN_ADMIN_ONLY_OAUTH_CLIENTS; $dto->MBIN_SSO_ONLY_MODE = $this->MBIN_SSO_ONLY_MODE ?? $dto->MBIN_SSO_ONLY_MODE; $dto->MBIN_PRIVATE_INSTANCE = $this->MBIN_PRIVATE_INSTANCE ?? $dto->MBIN_PRIVATE_INSTANCE; $dto->KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN = $this->KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN ?? $dto->KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN; $dto->MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY = $this->MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY ?? $dto->MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY; $dto->MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY = $this->MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY ?? $dto->MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY; $dto->MBIN_SSO_REGISTRATIONS_ENABLED = $this->MBIN_SSO_REGISTRATIONS_ENABLED ?? $dto->MBIN_SSO_REGISTRATIONS_ENABLED; $dto->MBIN_RESTRICT_MAGAZINE_CREATION = $this->MBIN_RESTRICT_MAGAZINE_CREATION ?? $dto->MBIN_RESTRICT_MAGAZINE_CREATION; $dto->MBIN_SSO_SHOW_FIRST = $this->MBIN_SSO_SHOW_FIRST ?? $dto->MBIN_SSO_SHOW_FIRST; $dto->MBIN_DOWNVOTES_MODE = $this->MBIN_DOWNVOTES_MODE ?? $dto->MBIN_DOWNVOTES_MODE; $dto->MBIN_NEW_USERS_NEED_APPROVAL = $this->MBIN_NEW_USERS_NEED_APPROVAL ?? $dto->MBIN_NEW_USERS_NEED_APPROVAL; $dto->MBIN_USE_FEDERATION_ALLOW_LIST = $this->MBIN_USE_FEDERATION_ALLOW_LIST ?? $dto->MBIN_USE_FEDERATION_ALLOW_LIST; return $dto; } public function jsonSerialize(): mixed { return [ 'KBIN_DOMAIN' => $this->KBIN_DOMAIN, 'KBIN_TITLE' => $this->KBIN_TITLE, 'KBIN_META_TITLE' => $this->KBIN_META_TITLE, 'KBIN_META_KEYWORDS' => $this->KBIN_META_KEYWORDS, 'KBIN_META_DESCRIPTION' => $this->KBIN_META_DESCRIPTION, 'KBIN_DEFAULT_LANG' => $this->KBIN_DEFAULT_LANG, 'KBIN_CONTACT_EMAIL' => $this->KBIN_CONTACT_EMAIL, 'KBIN_SENDER_EMAIL' => $this->KBIN_SENDER_EMAIL, 'MBIN_DEFAULT_THEME' => $this->MBIN_DEFAULT_THEME, 'KBIN_JS_ENABLED' => $this->KBIN_JS_ENABLED, 'KBIN_FEDERATION_ENABLED' => $this->KBIN_FEDERATION_ENABLED, 'KBIN_REGISTRATIONS_ENABLED' => $this->KBIN_REGISTRATIONS_ENABLED, 'KBIN_HEADER_LOGO' => $this->KBIN_HEADER_LOGO, 'KBIN_CAPTCHA_ENABLED' => $this->KBIN_CAPTCHA_ENABLED, 'KBIN_MERCURE_ENABLED' => $this->KBIN_MERCURE_ENABLED, 'KBIN_FEDERATION_PAGE_ENABLED' => $this->KBIN_FEDERATION_PAGE_ENABLED, 'KBIN_ADMIN_ONLY_OAUTH_CLIENTS' => $this->KBIN_ADMIN_ONLY_OAUTH_CLIENTS, 'MBIN_SSO_ONLY_MODE' => $this->MBIN_SSO_ONLY_MODE, 'MBIN_PRIVATE_INSTANCE' => $this->MBIN_PRIVATE_INSTANCE, 'KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN' => $this->KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN, 'MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY' => $this->MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY, 'MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY' => $this->MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY, 'MBIN_SSO_REGISTRATIONS_ENABLED' => $this->MBIN_SSO_REGISTRATIONS_ENABLED, 'MBIN_RESTRICT_MAGAZINE_CREATION' => $this->MBIN_RESTRICT_MAGAZINE_CREATION, 'MBIN_SSO_SHOW_FIRST' => $this->MBIN_SSO_SHOW_FIRST, 'MBIN_DOWNVOTES_MODE' => $this->MBIN_DOWNVOTES_MODE, 'MBIN_NEW_USERS_NEED_APPROVAL' => $this->MBIN_NEW_USERS_NEED_APPROVAL, 'MBIN_USE_FEDERATION_ALLOW_LIST' => $this->MBIN_USE_FEDERATION_ALLOW_LIST, ]; } } ================================================ FILE: src/DTO/SiteResponseDto.php ================================================ terms = $site?->terms; $this->privacyPolicy = $site?->privacyPolicy; $this->faq = $site?->faq; $this->about = $site?->about; $this->contact = $site?->contact; $this->downvotesMode = $downvotesMode; } public function jsonSerialize(): mixed { return [ 'about' => $this->about, 'contact' => $this->contact, 'faq' => $this->faq, 'privacyPolicy' => $this->privacyPolicy, 'terms' => $this->terms, ]; } } ================================================ FILE: src/DTO/Temp2FADto.php ================================================ secret; } public function getTotpAuthenticationUsername(): string { return $this->forUsername; } /** * Has to match User::getTotpAuthenticationConfiguration. * * @see User::getTotpAuthenticationConfiguration() */ public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface { return new TotpConfiguration($this->secret, TotpConfiguration::ALGORITHM_SHA1, 30, 6); } } ================================================ FILE: src/DTO/ToggleCreatedDto.php ================================================ created = $created; } public function jsonSerialize(): mixed { return ['created' => $this->created]; } } ================================================ FILE: src/DTO/UserBanResponseDto.php ================================================ isBanned = $isBanned; } public function jsonSerialize(): mixed { $response = parent::jsonSerialize(); $response['isBanned'] = $this->isBanned; return $response; } } ================================================ FILE: src/DTO/UserDto.php ================================================ request->has('user_register')) { return; } if (false === $this->agreeTerms) { $this->buildViolation($context, 'agreeTerms'); } } private function buildViolation(ExecutionContextInterface $context, $path) { $context->buildViolation('This value should not be blank.') ->atPath($path) ->addViolation(); } public function getId(): ?int { return $this->id; } public static function create( string $username, ?string $email = null, ?ImageDto $avatar = null, ?ImageDto $cover = null, ?string $about = null, ?\DateTimeImmutable $createdAt = null, ?array $fields = null, ?string $apId = null, ?string $apProfileId = null, ?int $id = null, ?int $followersCount = 0, ?bool $isBot = null, ?bool $isAdmin = null, ?bool $isGlobalModerator = null, ?string $applicationText = null, ?int $reputationPoints = null, ?bool $discoverable = null, ?bool $indexable = null, ?string $title = null, ): self { $dto = new UserDto(); $dto->id = $id; $dto->username = $username; $dto->email = $email; $dto->avatar = $avatar; $dto->cover = $cover; $dto->about = $about; $dto->createdAt = $createdAt; $dto->fields = $fields; $dto->apId = $apId; $dto->apProfileId = $apProfileId; $dto->followersCount = $followersCount; $dto->isBot = $isBot; $dto->isAdmin = $isAdmin; $dto->isGlobalModerator = $isGlobalModerator; $dto->applicationText = $applicationText; $dto->reputationPoints = $reputationPoints; $dto->discoverable = $discoverable; $dto->indexable = $indexable; $dto->title = $title; return $dto; } } ================================================ FILE: src/DTO/UserFilterListDto.php ================================================ words, fn (?UserFilterWordDto $word) => null !== $word?->word && '' !== trim($word->word)); return array_map(fn (UserFilterWordDto $word) => [ 'word' => $word->word, 'exactMatch' => $word->exactMatch, ], $nonEmptyWords); } public function addEmptyWords(): void { $wordsToAdd = 5 - \sizeof($this->words); if ($wordsToAdd <= 0) { $wordsToAdd = 1; } for ($i = 0; $i < $wordsToAdd; ++$i) { $this->words[] = new UserFilterWordDto(); } } public static function fromList(UserFilterList $list): self { $dto = new self(); $dto->id = $list->getId(); $dto->name = $list->name; $dto->expirationDate = $list->expirationDate; $dto->feeds = $list->feeds; $dto->comments = $list->comments; $dto->profile = $list->profile; foreach ($list->words as $word) { $dto2 = new UserFilterWordDto(); $dto2->word = $word['word']; $dto2->exactMatch = $word['exactMatch']; $dto->words[] = $dto2; } return $dto; } } ================================================ FILE: src/DTO/UserFilterListResponseDto.php ================================================ */ public array $words = []; public static function fromList(UserFilterList $list): self { $dto = new self(); $dto->id = $list->getId(); $dto->name = $list->name; $dto->expirationDate = $list->expirationDate?->format(DATE_ATOM); $dto->feeds = $list->feeds; $dto->comments = $list->comments; $dto->profile = $list->profile; $dto->words = $list->words; return $dto; } public function jsonSerialize(): mixed { return [ 'id' => $this->id, 'name' => $this->name, 'expirationDate' => $this->expirationDate, 'feeds' => $this->feeds, 'comments' => $this->comments, 'profile' => $this->profile, 'words' => $this->words, ]; } } ================================================ FILE: src/DTO/UserFilterWordDto.php ================================================ userId = $dto->getId(); $this->username = $dto->username; $this->title = $dto->title; $this->about = $dto->about; $this->avatar = $dto->avatar; $this->cover = $dto->cover; $this->createdAt = $dto->createdAt; $this->apId = $dto->apId; $this->apProfileId = $dto->apProfileId; $this->followersCount = $dto->followersCount; $this->isBot = true === $dto->isBot; $this->isFollowedByUser = $dto->isFollowedByUser; $this->isFollowerOfUser = $dto->isFollowerOfUser; $this->isBlockedByUser = $dto->isBlockedByUser; $this->serverSoftware = $dto->serverSoftware; $this->serverSoftwareVersion = $dto->serverSoftwareVersion; $this->isAdmin = $dto->isAdmin; $this->isGlobalModerator = $dto->isGlobalModerator; $this->reputationPoints = $dto->reputationPoints; $this->discoverable = $dto->discoverable; $this->indexable = $dto->indexable; } public function jsonSerialize(): mixed { return [ 'userId' => $this->userId, 'username' => $this->username, 'title' => $this->title, 'about' => $this->about, 'avatar' => $this->avatar?->jsonSerialize(), 'cover' => $this->cover?->jsonSerialize(), 'createdAt' => $this->createdAt?->format(\DateTimeInterface::ATOM), 'followersCount' => $this->followersCount, 'apId' => $this->apId, 'apProfileId' => $this->apProfileId, 'isBot' => $this->isBot, 'isAdmin' => $this->isAdmin, 'isGlobalModerator' => $this->isGlobalModerator, 'isFollowedByUser' => $this->isFollowedByUser, 'isFollowerOfUser' => $this->isFollowerOfUser, 'isBlockedByUser' => $this->isBlockedByUser, 'serverSoftware' => $this->serverSoftware, 'serverSoftwareVersion' => $this->serverSoftwareVersion, 'notificationStatus' => $this->notificationStatus, 'reputationPoints' => $this->reputationPoints, 'discoverable' => $this->discoverable, 'indexable' => $this->indexable, ]; } } ================================================ FILE: src/DTO/UserSettingsDto.php ================================================ $this->notifyOnNewEntry, 'notifyOnNewEntryReply' => $this->notifyOnNewEntryReply, 'notifyOnNewEntryCommentReply' => $this->notifyOnNewEntryCommentReply, 'notifyOnNewPost' => $this->notifyOnNewPost, 'notifyOnNewPostReply' => $this->notifyOnNewPostReply, 'notifyOnNewPostCommentReply' => $this->notifyOnNewPostCommentReply, 'hideAdult' => $this->hideAdult, 'showProfileSubscriptions' => $this->showProfileSubscriptions, 'showProfileFollowings' => $this->showProfileFollowings, 'addMentionsEntries' => $this->addMentionsEntries, 'addMentionsPosts' => $this->addMentionsPosts, 'homepage' => $this->homepage, 'frontDefaultSort' => $this->frontDefaultSort, 'frontDefaultContent' => $this->frontDefaultContent, 'commentDefaultSort' => $this->commentDefaultSort, 'featuredMagazines' => $this->featuredMagazines, 'preferredLanguages' => $this->preferredLanguages, 'customCss' => $this->customCss, 'ignoreMagazinesCustomCss' => $this->ignoreMagazinesCustomCss, 'notifyOnUserSignup' => $this->notifyOnUserSignup, 'directMessageSetting' => $this->directMessageSetting, 'discoverable' => $this->discoverable, 'indexable' => $this->indexable, ]; } public function mergeIntoDto(UserSettingsDto $dto): UserSettingsDto { $dto->notifyOnNewEntry = $this->notifyOnNewEntry ?? $dto->notifyOnNewEntry; $dto->notifyOnNewEntryReply = $this->notifyOnNewEntryReply ?? $dto->notifyOnNewEntryReply; $dto->notifyOnNewEntryCommentReply = $this->notifyOnNewEntryCommentReply ?? $dto->notifyOnNewEntryCommentReply; $dto->notifyOnNewPost = $this->notifyOnNewPost ?? $dto->notifyOnNewPost; $dto->notifyOnNewPostReply = $this->notifyOnNewPostReply ?? $dto->notifyOnNewPostReply; $dto->notifyOnNewPostCommentReply = $this->notifyOnNewPostCommentReply ?? $dto->notifyOnNewPostCommentReply; $dto->hideAdult = $this->hideAdult ?? $dto->hideAdult; $dto->showProfileSubscriptions = $this->showProfileSubscriptions ?? $dto->showProfileSubscriptions; $dto->showProfileFollowings = $this->showProfileFollowings ?? $dto->showProfileFollowings; $dto->addMentionsEntries = $this->addMentionsEntries ?? $dto->addMentionsEntries; $dto->addMentionsPosts = $this->addMentionsPosts ?? $dto->addMentionsPosts; $dto->homepage = $this->homepage ?? $dto->homepage; $dto->frontDefaultSort = $this->frontDefaultSort ?? $dto->frontDefaultSort; $dto->commentDefaultSort = $this->commentDefaultSort ?? $dto->commentDefaultSort; $dto->featuredMagazines = $this->featuredMagazines ?? $dto->featuredMagazines; $dto->preferredLanguages = $this->preferredLanguages ?? $dto->preferredLanguages; $dto->customCss = $this->customCss ?? $dto->customCss; $dto->ignoreMagazinesCustomCss = $this->ignoreMagazinesCustomCss ?? $dto->ignoreMagazinesCustomCss; $dto->directMessageSetting = $this->directMessageSetting ?? $dto->directMessageSetting; $dto->frontDefaultContent = $this->frontDefaultContent ?? $dto->frontDefaultContent; $dto->discoverable = $this->discoverable ?? $dto->discoverable; $dto->indexable = $this->indexable ?? $dto->indexable; return $dto; } } ================================================ FILE: src/DTO/UserSignupResponseDto.php ================================================ userId = $dto->getId(); $this->username = $dto->username; $this->isBot = $dto->isBot; $this->createdAt = $dto->createdAt; $this->email = $dto->email; $this->applicationText = $dto->applicationText; } public function jsonSerialize(): mixed { return [ 'userId' => $this->userId, 'username' => $this->username, 'isBot' => $this->isBot, 'createdAt' => $this->createdAt?->format(\DateTimeInterface::ATOM), 'email' => $this->email, 'applicationText' => $this->applicationText, ]; } } ================================================ FILE: src/DTO/UserSmallResponseDto.php ================================================ userId = $dto->getId(); $this->username = $dto->username; $this->title = $dto->title; $this->isBot = $dto->isBot; $this->isFollowedByUser = $dto->isFollowedByUser; $this->isFollowerOfUser = $dto->isFollowerOfUser; $this->isBlockedByUser = $dto->isBlockedByUser; $this->avatar = $dto->avatar; $this->apId = $dto->apId; $this->apProfileId = $dto->apProfileId; $this->createdAt = $dto->createdAt; $this->isAdmin = $dto->isAdmin; $this->isGlobalModerator = $dto->isGlobalModerator; $this->discoverable = $dto->discoverable; $this->indexable = $dto->indexable; } public function jsonSerialize(): mixed { return [ 'userId' => $this->userId, 'username' => $this->username, 'title' => $this->title, 'isBot' => $this->isBot, 'isFollowedByUser' => $this->isFollowedByUser, 'isFollowerOfUser' => $this->isFollowerOfUser, 'isBlockedByUser' => $this->isBlockedByUser, 'isAdmin' => $this->isAdmin, 'isGlobalModerator' => $this->isGlobalModerator, 'avatar' => $this->avatar, 'apId' => $this->apId, 'apProfileId' => $this->apProfileId, 'createdAt' => $this->createdAt?->format(\DateTimeImmutable::ATOM), 'discoverable' => $this->discoverable, 'indexable' => $this->indexable, ]; } } ================================================ FILE: src/DTO/VoteStatsResponseDto.php ================================================ manager = $manager; $this->faker = Factory::create(); $this->loadData($manager); } abstract protected function loadData(ObjectManager $manager): void; protected function camelCase(string $value): string { return Slugger::camelCase($value); } protected function getRandomTime(?\DateTimeImmutable $from = null): \DateTimeImmutable { return new \DateTimeImmutable( $this->faker->dateTimeBetween( $from ? $from->format('Y-m-d H:i:s') : '-1 month', 'now' ) ->format('Y-m-d H:i:s') ); } } ================================================ FILE: src/DataFixtures/EntryCommentFixtures.php ================================================ provideRandomComments(self::COMMENTS_COUNT) as $index => $comment) { $dto = new EntryCommentDto(); $dto->entry = $comment['entry']; $dto->body = $comment['body']; $dto->lang = 'en'; $entity = $this->commentManager->create($dto, $comment['user']); $manager->persist($entity); $this->addReference('entry_comment_'.$index, $entity); $manager->flush(); $roll = rand(0, 4); $children = [$entity]; if ($roll) { for ($i = 1; $i <= rand(0, 20); ++$i) { $children[] = $this->createChildren($children[array_rand($children, 1)], $manager); } } $entity->createdAt = $this->getRandomTime($entity->entry->createdAt); $entity->updateLastActive(); } $manager->flush(); } /** * @return array[] */ private function provideRandomComments(int $count = 1): iterable { for ($i = 0; $i <= $count; ++$i) { yield [ 'body' => $this->faker->paragraphs($this->faker->numberBetween(1, 3), true), 'entry' => $this->getReference('entry_'.rand(1, EntryFixtures::ENTRIES_COUNT), Entry::class), 'user' => $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class), ]; } } private function createChildren(EntryComment $parent, ObjectManager $manager): EntryComment { $dto = (new EntryCommentDto())->createWithParent( $parent->entry, $parent, null, $this->faker->paragraphs($this->faker->numberBetween(1, 3), true) ); $dto->lang = 'en'; $entity = $this->commentManager->create($dto, $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class)); $roll = rand(1, 400); if ($roll % 10) { try { $tempFile = $this->imageManager->download("https://picsum.photos/300/$roll?hash=$roll"); } catch (\Exception $e) { $tempFile = null; } if ($tempFile) { $image = $this->imageRepository->findOrCreateFromPath($tempFile); $entity->image = $image; $this->entityManager->flush(); } } $entity->createdAt = $this->getRandomTime($parent->createdAt); $entity->updateLastActive(); $manager->flush(); return $entity; } } ================================================ FILE: src/DataFixtures/EntryFixtures.php ================================================ provideRandomEntries(self::ENTRIES_COUNT) as $index => $entry) { $dto = new EntryDto(); $dto->magazine = $entry['magazine']; $dto->user = $entry['user']; $dto->title = $entry['title']; $dto->url = $entry['url']; $dto->body = $entry['body']; $dto->ip = $entry['ip']; $dto->lang = 'en'; $entity = $this->entryManager->create($dto, $entry['user']); $roll = rand(1, 400); if ($roll % 5) { try { $tempFile = $this->imageManager->download("https://picsum.photos/300/$roll?hash=$roll"); } catch (\Exception $e) { $tempFile = null; } if ($tempFile) { $image = $this->imageRepository->findOrCreateFromPath($tempFile); $entity->image = $image; $this->entityManager->flush(); } } $entity->createdAt = $this->getRandomTime(); $entity->updateCounts(); $entity->updateLastActive(); $entity->updateRanking(); $this->addReference('entry_'.$index, $entity); } $manager->flush(); } /** * @return array[] */ private function provideRandomEntries(int $count = 1): iterable { for ($i = 0; $i <= $count; ++$i) { $isUrl = $this->faker->numberBetween(0, 1); $body = $isUrl ? null : $this->faker->paragraphs($this->faker->numberBetween(1, 10), true); yield [ 'title' => $this->faker->realText($this->faker->numberBetween(10, 255)), 'url' => $isUrl ? $this->faker->url() : null, 'body' => $body, 'magazine' => $this->getReference('magazine_'.rand(1, (int) MagazineFixtures::MAGAZINES_COUNT), Magazine::class), 'user' => $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class), 'ip' => $this->faker->ipv4(), ]; } } } ================================================ FILE: src/DataFixtures/MagazineFixtures.php ================================================ provideRandomMagazines(self::MAGAZINES_COUNT) as $index => $magazine) { $image = null; $width = rand(100, 400); try { $tempFile = $this->imageManager->download("https://picsum.photos/{$width}/?hash=$width"); } catch (\Exception $e) { $tempFile = null; } if ($tempFile) { $image = $this->imageRepository->findOrCreateFromPath($tempFile); $this->entityManager->flush(); } $dto = new MagazineDto(); $dto->name = $magazine['name']; $dto->title = $magazine['title']; $dto->description = $magazine['description']; $dto->rules = $magazine['rules']; $dto->badges = $magazine['badges']; $dto->icon = $image; $entity = $this->magazineManager->create($dto, $magazine['user']); $this->addReference('magazine_'.$index, $entity); } $manager->flush(); } /** * @return array[] */ private function provideRandomMagazines(int $count = 1): iterable { $titles = []; for ($i = 0; $i <= $count; ++$i) { $title = substr($this->faker->words($this->faker->numberBetween(1, 5), true), 0, 50); if (\in_array($title, $titles)) { $title = $title.bin2hex(random_bytes(5)); } $titles[] = $title; yield [ 'name' => substr($this->camelCase($title), 0, 24), 'title' => $title, 'user' => $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class), 'description' => rand(0, 3) ? null : $this->faker->realText($this->faker->numberBetween(10, 550)), 'rules' => rand(0, 3) ? null : $this->faker->realText($this->faker->numberBetween(10, 550)), 'badges' => new ArrayCollection(), ]; } } public function getDependencies(): array { return [ UserFixtures::class, ]; } } ================================================ FILE: src/DataFixtures/PostCommentFixtures.php ================================================ postCommentManager = $postCommentManager; } public function getDependencies(): array { return [ PostFixtures::class, ]; } public function loadData(ObjectManager $manager): void { foreach ($this->provideRandomComments(self::COMMENTS_COUNT) as $index => $comment) { $dto = new PostCommentDto(); $dto->post = $comment['post']; $dto->body = $comment['body']; $dto->lang = 'en'; $entity = $this->postCommentManager->create($dto, $comment['user']); $manager->persist($entity); $this->addReference('post_comment_'.$index, $entity); $manager->flush(); $roll = rand(0, 4); $children = [$entity]; if ($roll) { for ($i = 1; $i <= rand(0, 20); ++$i) { $children[] = $this->createChildren($children[array_rand($children, 1)], $manager); } } $entity->createdAt = $this->getRandomTime($entity->post->createdAt); $entity->updateLastActive(); } $manager->flush(); } /** * @return array[] */ private function provideRandomComments(int $count = 1): iterable { for ($i = 0; $i <= $count; ++$i) { yield [ 'body' => $this->faker->realText($this->faker->numberBetween(10, 1024)), 'post' => $this->getReference('post_'.rand(1, EntryFixtures::ENTRIES_COUNT), Post::class), 'user' => $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class), ]; } } private function createChildren(PostComment $parent, ObjectManager $manager): PostComment { $dto = (new PostCommentDto())->createWithParent( $parent->post, $parent, null, $this->faker->realText($this->faker->numberBetween(10, 1024)) ); $dto->lang = 'en'; $entity = $this->postCommentManager->create( $dto, $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class) ); $roll = rand(1, 400); if ($roll % 10) { try { $tempFile = $this->imageManager->download("https://picsum.photos/300/$roll?hash=$roll"); } catch (\Exception $e) { $tempFile = null; } if ($tempFile) { $image = $this->imageRepository->findOrCreateFromPath($tempFile); $entity->image = $image; $this->entityManager->flush(); } } $entity->createdAt = $this->getRandomTime($parent->createdAt); $entity->updateLastActive(); $manager->flush(); return $entity; } } ================================================ FILE: src/DataFixtures/PostFixtures.php ================================================ provideRandomPosts(self::ENTRIES_COUNT) as $index => $post) { $dto = new PostDto(); $dto->magazine = $post['magazine']; $dto->user = $post['user']; $dto->body = $post['body']; $dto->ip = $post['ip']; $dto->lang = 'en'; $entity = $this->postManager->create($dto, $post['user']); $roll = rand(1, 400); if ($roll % 7) { try { $tempFile = $this->imageManager->download("https://picsum.photos/300/$roll?hash=$roll"); } catch (\Exception $e) { $tempFile = null; } if ($tempFile) { $image = $this->imageRepository->findOrCreateFromPath($tempFile); $entity->image = $image; $this->entityManager->flush(); } } $entity->createdAt = $this->getRandomTime(); $entity->updateCounts(); $entity->updateLastActive(); $entity->updateRanking(); $this->addReference('post_'.$index, $entity); } $manager->flush(); } /** * @return array[] */ private function provideRandomPosts(int $count = 1): iterable { for ($i = 0; $i <= $count; ++$i) { yield [ 'body' => $this->faker->realText($this->faker->numberBetween(10, 1024)), 'magazine' => $this->getReference('magazine_'.rand(1, \intval(MagazineFixtures::MAGAZINES_COUNT)), Magazine::class), 'user' => $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class), 'ip' => $this->faker->ipv4(), ]; } } } ================================================ FILE: src/DataFixtures/ReportFixtures.php ================================================ entries(); $this->entryComments(); $this->posts(); $this->postComments(); $this->manager->flush(); } private function entries(): void { $randomNb = $this->getUniqueNb( EntryFixtures::ENTRIES_COUNT, \intval(EntryFixtures::ENTRIES_COUNT / rand(2, 5)) ); foreach ($randomNb as $e) { $roll = rand(0, 2); if (0 === $roll) { continue; } $r = new EntryReport( $this->getReference('user_'.$this->getRandomNumber(UserFixtures::USERS_COUNT), User::class), $this->getReference('entry_'.$e, Entry::class) ); $this->manager->persist($r); $this->dispatcher->dispatch(new SubjectReportedEvent($r)); } } /** * @return int[] */ private function getUniqueNb(int $max, int $quantity): array { $numbers = range(1, $max); shuffle($numbers); return \array_slice($numbers, 0, $quantity); } public function getRandomNumber(int $max): int { $numbers = range(1, $max); shuffle($numbers); return $numbers[0]; } private function entryComments(): void { $randomNb = $this->getUniqueNb( EntryCommentFixtures::COMMENTS_COUNT, \intval(EntryCommentFixtures::COMMENTS_COUNT / rand(2, 5)) ); foreach ($randomNb as $c) { $roll = rand(0, 2); if (0 === $roll) { continue; } $r = new EntryCommentReport( $this->getReference('user_'.$this->getRandomNumber(UserFixtures::USERS_COUNT), User::class), $this->getReference('entry_comment_'.$c, EntryComment::class) ); $this->manager->persist($r); $this->dispatcher->dispatch(new SubjectReportedEvent($r)); } } private function posts(): void { $randomNb = $this->getUniqueNb( PostFixtures::ENTRIES_COUNT, \intval(PostFixtures::ENTRIES_COUNT / rand(2, 5)) ); foreach ($randomNb as $e) { $roll = rand(0, 2); if (0 === $roll) { continue; } $r = new PostReport( $this->getReference('user_'.$this->getRandomNumber(UserFixtures::USERS_COUNT), User::class), $this->getReference('post_'.$e, Post::class) ); $this->manager->persist($r); $this->dispatcher->dispatch(new SubjectReportedEvent($r)); } } private function postComments(): void { $randomNb = $this->getUniqueNb( PostCommentFixtures::COMMENTS_COUNT, \intval(PostCommentFixtures::COMMENTS_COUNT / rand(2, 5)) ); foreach ($randomNb as $c) { $roll = rand(0, 2); if (0 === $roll) { continue; } $r = new PostCommentReport( $this->getReference('user_'.$this->getRandomNumber(UserFixtures::USERS_COUNT), User::class), $this->getReference('post_comment_'.$c, PostComment::class) ); $this->manager->persist($r); $this->dispatcher->dispatch(new SubjectReportedEvent($r)); } } public function getDependencies(): array { return [ EntryCommentFixtures::class, PostCommentFixtures::class, ]; } } ================================================ FILE: src/DataFixtures/SubFixtures.php ================================================ magazines($u); $this->users($u); } } private function magazines(int $u): void { $randomNb = $this->getUniqueNb( MagazineFixtures::MAGAZINES_COUNT, \intval(MagazineFixtures::MAGAZINES_COUNT / rand(2, 5)) ); foreach ($randomNb as $m) { $roll = rand(0, 2); if (0 === $roll) { $this->magazineManager->block( $this->getReference('magazine_'.$m, Magazine::class), $this->getReference('user_'.$u, User::class) ); continue; } $this->magazineManager->subscribe( $this->getReference('magazine_'.$m, Magazine::class), $this->getReference('user_'.$u, User::class) ); } } /** * @return int[] */ private function getUniqueNb(int $max, int $quantity): array { $numbers = range(1, $max); shuffle($numbers); return \array_slice($numbers, 0, $quantity); } private function users(int $u): void { $randomNb = $this->getUniqueNb( UserFixtures::USERS_COUNT, \intval(UserFixtures::USERS_COUNT / rand(2, 5)) ); foreach ($randomNb as $f) { $roll = rand(0, 2); if (0 === $roll) { $this->userManager->block( $this->getReference('user_'.$f, User::class), $this->getReference('user_'.$u, User::class) ); continue; } $this->userManager->follow( $this->getReference('user_'.$f, User::class), $this->getReference('user_'.$u, User::class) ); } } public function getDependencies(): array { return [ UserFixtures::class, MagazineFixtures::class, ]; } } ================================================ FILE: src/DataFixtures/UserFixtures.php ================================================ provideRandomUsers(self::USERS_COUNT) as $index => $user) { $newUser = new User( $user['email'], $user['username'], $user['password'], $user['type'] ); $newUser->setPassword( $this->hasher->hashPassword($newUser, $user['password']) ); $newUser->notifyOnNewEntry = true; $newUser->notifyOnNewEntryReply = true; $newUser->notifyOnNewEntryCommentReply = true; $newUser->notifyOnNewPostReply = true; $newUser->notifyOnNewPostCommentReply = true; $newUser->isVerified = true; $manager->persist($newUser); $this->addReference('user_'.$index, $newUser); $manager->flush(); if ('demo' !== $user['username']) { $rand = rand(1, 500); try { $tempFile = $this->imageManager->download("https://picsum.photos/500/500?hash={$rand}"); } catch (\Exception $e) { $tempFile = null; } if ($tempFile) { $image = $this->imageRepository->findOrCreateFromPath($tempFile); $newUser->avatar = $image; $manager->flush(); } } } } /** * @return array[] */ private function provideRandomUsers(int $count = 1): iterable { if (!$this->userRepository->findOneByUsername('demo')) { yield [ 'email' => 'demo@karab.in', 'username' => 'demo', 'password' => 'demo', 'type' => 'Person', ]; } for ($i = 0; $i <= $count; ++$i) { yield [ 'email' => $this->faker->email(), 'username' => str_replace('.', '_', $this->faker->userName()), 'password' => 'secret', 'type' => 'Person', ]; } } } ================================================ FILE: src/DataFixtures/VoteFixtures.php ================================================ entries($u); $this->entryComments($u); $this->posts($u); $this->postComments($u); } } private function entries(int $u): void { $randomNb = $this->getUniqueNb( EntryFixtures::ENTRIES_COUNT, rand(0, 155), ); foreach ($randomNb as $e) { $roll = rand(0, 2); if (0 === $roll) { continue; } $this->voteManager->vote( rand(0, 4) > 0 ? 1 : -1, $this->getReference('entry_'.$e, Entry::class), $this->getReference('user_'.$u, User::class) ); } } /** * @return int[] */ private function getUniqueNb(int $max, int $quantity): array { $numbers = range(1, $max); shuffle($numbers); return \array_slice($numbers, 0, $quantity); } private function entryComments(int $u): void { $randomNb = $this->getUniqueNb( EntryCommentFixtures::COMMENTS_COUNT, rand(0, 155), ); foreach ($randomNb as $c) { $roll = rand(0, 2); if (0 === $roll) { continue; } $this->voteManager->vote( rand(0, 4) > 0 ? 1 : -1, $this->getReference('entry_comment_'.$c, EntryComment::class), $this->getReference('user_'.$u, User::class) ); } } private function posts(int $u): void { $randomNb = $this->getUniqueNb( PostFixtures::ENTRIES_COUNT, rand(0, 155), ); foreach ($randomNb as $e) { $roll = rand(0, 2); if (0 === $roll) { continue; } $this->voteManager->vote( rand(0, 4) > 0 ? 1 : -1, $this->getReference('post_'.$e, Post::class), $this->getReference('user_'.$u, User::class) ); } } private function postComments(int $u): void { $randomNb = $this->getUniqueNb( PostCommentFixtures::COMMENTS_COUNT, rand(0, 155), ); foreach ($randomNb as $c) { $roll = rand(0, 2); if (0 === $roll) { continue; } $this->voteManager->vote( rand(0, 4) > 0 ? 1 : -1, $this->getReference('post_comment_'.$c, PostComment::class), $this->getReference('user_'.$u, User::class) ); } } public function getDependencies(): array { return [ EntryFixtures::class, EntryCommentFixtures::class, PostFixtures::class, PostCommentFixtures::class, ]; } } ================================================ FILE: src/DoctrineExtensions/DBAL/Types/Citext.php ================================================ getDoctrineTypeMapping(self::CITEXT); } } ================================================ FILE: src/DoctrineExtensions/DBAL/Types/EnumApplicationStatus.php ================================================ getValues()); return 'ENUM('.implode(', ', $values).')'; } public function convertToPHPValue($value, AbstractPlatform $platform): mixed { return $value; } public function convertToDatabaseValue($value, AbstractPlatform $platform): mixed { if (!\in_array($value, $this->getValues())) { throw new \InvalidArgumentException("Invalid '".$this->getName()."' value."); } return $value; } public function requiresSQLCommentHint(AbstractPlatform $platform): bool { return true; } } ================================================ FILE: src/Document/.gitignore ================================================ ================================================ FILE: src/Entity/.gitignore ================================================ ================================================ FILE: src/Entity/Activity.php ================================================ false])] public bool $isRemote = false; #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?User $userActor; #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?Magazine $magazineActor; #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?Magazine $audience = null; #[ManyToOne, JoinColumn(referencedColumnName: 'uuid', nullable: true, onDelete: 'CASCADE', options: ['default' => null])] public ?Activity $innerActivity = null; #[Column(type: 'text', nullable: true)] public ?string $innerActivityUrl = null; #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?Entry $objectEntry = null; #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?EntryComment $objectEntryComment = null; #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?Post $objectPost = null; #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?PostComment $objectPostComment = null; #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?Message $objectMessage = null; #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?User $objectUser = null; #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?Magazine $objectMagazine = null; #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?MagazineBan $objectMagazineBan = null; #[Column(type: 'text', nullable: true)] public ?string $objectGeneric = null; #[Column(type: 'text', nullable: true)] public ?string $targetString = null; #[Column(type: 'text', nullable: true)] public ?string $contentString = null; /** * This should only be set when the json should not get compiled. */ #[Column(type: 'text', nullable: true)] public ?string $activityJson = null; public function __construct(string $type) { $this->type = $type; $this->createdAtTraitConstruct(); } public function setObject(ActivityPubActivityInterface|Entry|EntryComment|Post|PostComment|ActivityPubActorInterface|User|Magazine|MagazineBan|Activity|array|string $object): void { if ($object instanceof Entry) { $this->objectEntry = $object; } elseif ($object instanceof EntryComment) { $this->objectEntryComment = $object; } elseif ($object instanceof Post) { $this->objectPost = $object; } elseif ($object instanceof PostComment) { $this->objectPostComment = $object; } elseif ($object instanceof Message) { $this->objectMessage = $object; } elseif ($object instanceof User) { $this->objectUser = $object; } elseif ($object instanceof Magazine) { $this->objectMagazine = $object; } elseif ($object instanceof MagazineBan) { $this->objectMagazineBan = $object; } elseif ($object instanceof Activity) { $this->innerActivity = $object; } elseif (\is_array($object)) { if (isset($object['@context'])) { unset($object['@context']); } $this->objectGeneric = json_encode($object); } elseif (\is_string($object)) { $this->objectGeneric = $object; } else { throw new \LogicException(\get_class($object)); } } public function getObject(): Post|EntryComment|PostComment|Entry|Message|User|Magazine|MagazineBan|array|string|null { $o = $this->objectEntry ?? $this->objectEntryComment ?? $this->objectPost ?? $this->objectPostComment ?? $this->objectMessage ?? $this->objectUser ?? $this->objectMagazine ?? $this->objectMagazineBan; if (null !== $o) { return $o; } $o = json_decode($this->objectGeneric ?? '', associative: true); if (JSON_ERROR_NONE === json_last_error()) { return $o; } return $this->objectGeneric; } public function setActor(Magazine|User $actor): void { if ($actor instanceof User) { $this->userActor = $actor; } else { $this->magazineActor = $actor; } } public function getActor(): Magazine|User|null { return $this->userActor ?? $this->magazineActor; } } ================================================ FILE: src/Entity/ApActivity.php ================================================ magazine = $magazine; $this->name = $name; } public function getId(): ?int { return $this->id; } public function countBadges(): int { return $this->badges->count(); } } ================================================ FILE: src/Entity/Bookmark.php ================================================ user = $user; $this->list = $list; $this->createdAtTraitConstruct(); } public function setContent(Post|EntryComment|PostComment|Entry $content): void { if ($content instanceof Entry) { $this->entry = $content; } elseif ($content instanceof EntryComment) { $this->entryComment = $content; } elseif ($content instanceof Post) { $this->post = $content; } elseif ($content instanceof PostComment) { $this->postComment = $content; } } public function getContent(): Entry|EntryComment|Post|PostComment { return $this->entry ?? $this->entryComment ?? $this->post ?? $this->postComment; } } ================================================ FILE: src/Entity/BookmarkList.php ================================================ user = $user; $this->name = $name; $this->isDefault = $isDefault; $this->entities = new ArrayCollection(); } public function getId(): int { return $this->id; } } ================================================ FILE: src/Entity/Client.php ================================================ oAuth2UserConsents = new ArrayCollection(); $this->oAuth2ClientAccesses = new ArrayCollection(); $this->createdAtTraitConstruct(); } public function getDescription(): ?string { return $this->description; } public function setDescription(?string $description): self { $this->description = $description; return $this; } public function getUser(): ?User { return $this->user; } public function setUser(User $user): self { $this->user = $user; return $this; } public function getContactEmail(): ?string { return $this->contactEmail; } public function setContactEmail(string $contactEmail): self { $this->contactEmail = $contactEmail; return $this; } public function getImage(): ?Image { return $this->image; } public function setImage(Image $image): self { $this->image = $image; return $this; } /** * @return Collection */ public function getOAuth2UserConsents(): Collection { return $this->oAuth2UserConsents; } public function addOAuth2UserConsent(OAuth2UserConsent $oAuth2UserConsent): self { if (!$this->oAuth2UserConsents->contains($oAuth2UserConsent)) { $this->oAuth2UserConsents->add($oAuth2UserConsent); $oAuth2UserConsent->setClient($this); } return $this; } public function removeOAuth2UserConsent(OAuth2UserConsent $oAuth2UserConsent): self { if ($this->oAuth2UserConsents->removeElement($oAuth2UserConsent)) { // set the owning side to null (unless already changed) if ($oAuth2UserConsent->getClient() === $this) { $oAuth2UserConsent->setClient(null); } } return $this; } public function getRedirectUri(): string|array { return $this->getRedirectUris(); } /** * @return Collection */ public function getOAuth2ClientAccesses(): Collection { return $this->oAuth2ClientAccesses; } public function addOAuth2ClientAccess(OAuth2ClientAccess $oAuth2ClientAccess): self { if (!$this->oAuth2ClientAccesses->contains($oAuth2ClientAccess)) { $this->oAuth2ClientAccesses->add($oAuth2ClientAccess); $oAuth2ClientAccess->setClient($this); } return $this; } public function removeOAuth2ClientAccess(OAuth2ClientAccess $oAuth2ClientAccess): self { if ($this->oAuth2ClientAccesses->removeElement($oAuth2ClientAccess)) { // set the owning side to null (unless already changed) if ($oAuth2ClientAccess->getClient() === $this) { $oAuth2ClientAccess->setClient(null); } } return $this; } } ================================================ FILE: src/Entity/Contracts/ActivityPubActivityInterface.php ================================================ 'http://ostatus.org#', 'schema' => 'http://schema.org#', 'toot' => 'http://joinmastodon.org/ns#', 'pt' => 'https://joinpeertube.org/ns#', 'lemmy' => 'https://join-lemmy.org/ns#', // objects 'Hashtag' => 'as:Hashtag', 'PropertyValue' => 'schema:PropertyValue', // properties 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', 'sensitive' => 'as:sensitive', 'value' => 'schema:value', 'blurhash' => 'toot:blurhash', 'focalPoint' => 'toot:focalPoint', 'votersCount' => 'toot:votersCount', 'featured' => 'toot:featured', 'commentsEnabled' => 'pt:commentsEnabled', 'postingRestrictedToMods' => 'lemmy:postingRestrictedToMods', 'stickied' => 'lemmy:stickied', ]; public function getUser(): ?User; } ================================================ FILE: src/Entity/Contracts/ActivityPubActorInterface.php ================================================ 0])] public int $subscriptionsCount = 0; #[OneToMany(mappedBy: 'domain', targetEntity: DomainSubscription::class, cascade: [ 'persist', 'remove', ], orphanRemoval: true)] public Collection $subscriptions; #[Id] #[GeneratedValue] #[Column(type: 'integer')] private int $id; public function __construct(DomainInterface $entry, string $name) { $this->name = $name; $this->entries = new ArrayCollection(); $this->subscriptions = new ArrayCollection(); $this->addEntry($entry); } public function addEntry(DomainInterface $subject): self { if (!$this->entries->contains($subject)) { $this->entries->add($subject); $subject->setDomain($this); } $this->updateCounts(); return $this; } public function updateCounts() { $this->entryCount = $this->entries->count(); } public function getId(): ?int { return $this->id; } public function removeEntry(DomainInterface $subject): self { if ($this->entries->removeElement($subject)) { if ($subject->getDomain() === $this) { $subject->setDomain(null); } } $this->updateCounts(); return $this; } public function subscribe(User $user): self { if (!$this->isSubscribed($user)) { $this->subscriptions->add($sub = new DomainSubscription($user, $this)); $sub->domain = $this; } $this->updateSubscriptionsCount(); return $this; } public function isSubscribed(User $user): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('user', $user)); return $this->subscriptions->matching($criteria)->count() > 0; } private function updateSubscriptionsCount(): void { $this->subscriptionsCount = $this->subscriptions->count(); } public function unsubscribe(User $user): void { $criteria = Criteria::create() ->where(Criteria::expr()->eq('user', $user)); $subscription = $this->subscriptions->matching($criteria)->first(); if ($this->subscriptions->removeElement($subscription)) { if ($subscription->domain === $this) { $subscription->domain = null; } } $this->updateSubscriptionsCount(); } public function shouldRatio(): bool { return DomainManager::shouldRatio($this->name); } public function __sleep() { return []; } } ================================================ FILE: src/Entity/DomainBlock.php ================================================ createdAtTraitConstruct(); $this->user = $user; $this->domain = $domain; } public function getId(): ?int { return $this->id; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/DomainSubscription.php ================================================ createdAtTraitConstruct(); $this->user = $user; $this->domain = $domain; } public function getId(): ?int { return $this->id; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/Embed.php ================================================ url = $url; $this->hasEmbed = $embed; $this->createdAtTraitConstruct(); } public function getId(): ?int { return $this->id; } } ================================================ FILE: src/Entity/Entry.php ================================================ false])] public bool $isOc = false; #[Column(type: 'boolean', nullable: false)] public bool $hasEmbed = false; #[Column(type: 'integer', nullable: false)] public int $commentCount = 0; #[Column(type: 'integer', options: ['default' => 0])] public int $favouriteCount = 0; #[Column(type: 'integer', nullable: false)] public int $score = 0; #[Column(type: 'boolean', nullable: false)] public bool $isAdult = false; #[Column(type: 'boolean', nullable: false)] public bool $sticky = false; #[Column(type: 'boolean', nullable: false, options: ['default' => false])] public bool $isLocked = false; #[Column(type: 'datetimetz')] public ?\DateTime $lastActive = null; #[Column(type: 'string', nullable: true)] public ?string $ip = null; #[Column(type: Types::JSONB, nullable: true)] public ?array $mentions = null; #[OneToMany(mappedBy: 'entry', targetEntity: EntryComment::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $comments; #[OneToMany(mappedBy: 'entry', targetEntity: EntryVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $votes; #[OneToMany(mappedBy: 'entry', targetEntity: EntryReport::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $reports; #[OneToMany(mappedBy: 'entry', targetEntity: EntryFavourite::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $favourites; #[OneToMany(mappedBy: 'entry', targetEntity: EntryCreatedNotification::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $notifications; #[OneToMany(mappedBy: 'entry', targetEntity: HashtagLink::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $hashtags; #[OneToMany(mappedBy: 'entry', targetEntity: EntryBadge::class, cascade: ['remove', 'persist'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $badges; public array $children = []; #[Id] #[GeneratedValue] #[Column(type: 'integer')] private int $id; #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => 'english'])] private string $titleTs; #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => 'english'])] private ?string $bodyTs = null; public bool $cross = false; public function __construct( string $title, ?string $url, ?string $body, Magazine $magazine, User $user, bool $isAdult, ?bool $isOc, ?string $lang, ?string $ip = null, ) { $this->title = $title; $this->url = $url; $this->body = $body; $this->magazine = $magazine; $this->user = $user; $this->isAdult = $isAdult; $this->isOc = $isOc; $this->lang = $lang; $this->ip = $ip; $this->comments = new ArrayCollection(); $this->votes = new ArrayCollection(); $this->reports = new ArrayCollection(); $this->favourites = new ArrayCollection(); $this->notifications = new ArrayCollection(); $this->badges = new ArrayCollection(); $this->hashtags = new ArrayCollection(); $user->addEntry($this); $this->createdAtTraitConstruct(); $this->updateLastActive(); } public function updateLastActive(): void { $this->comments->get(-1); $criteria = Criteria::create() ->andWhere(Criteria::expr()->eq('visibility', VisibilityInterface::VISIBILITY_VISIBLE)) ->orderBy(['createdAt' => 'DESC']) ->setMaxResults(1); $lastComment = $this->comments->matching($criteria)->first(); if ($lastComment) { $this->lastActive = \DateTime::createFromImmutable($lastComment->createdAt); } else { $this->lastActive = \DateTime::createFromImmutable($this->createdAt); } } public function getId(): int { return $this->id; } public function getApId(): ?string { return $this->apId; } public function setBadges(Badge ...$badges) { $this->badges->clear(); foreach ($badges as $badge) { $this->badges->add(new EntryBadge($this, $badge)); } } public function softDelete(): void { $this->visibility = VisibilityInterface::VISIBILITY_SOFT_DELETED; } public function trash(): void { $this->visibility = VisibilityInterface::VISIBILITY_TRASHED; } public function restore(): void { $this->visibility = VisibilityInterface::VISIBILITY_VISIBLE; } public function addComment(EntryComment $comment): self { if (!$this->comments->contains($comment)) { $this->comments->add($comment); $comment->entry = $this; } $this->updateCounts(); $this->updateRanking(); $this->updateLastActive(); return $this; } public function updateCounts(): self { $criteria = Criteria::create() ->andWhere(Criteria::expr()->eq('visibility', VisibilityInterface::VISIBILITY_VISIBLE)); $this->commentCount = $this->comments->matching($criteria)->count(); $this->favouriteCount = $this->favourites->count(); return $this; } public function removeComment(EntryComment $comment): self { if ($this->comments->removeElement($comment)) { if ($comment->entry === $this) { $comment->entry = null; } } $this->updateCounts(); $this->updateRanking(); $this->updateLastActive(); return $this; } public function addVote(Vote $vote): self { Assert::isInstanceOf($vote, EntryVote::class); if (!$this->votes->contains($vote)) { $this->votes->add($vote); $vote->entry = $this; } $this->updateScore(); $this->updateRanking(); return $this; } public function updateScore(): self { $this->score = ($this->apShareCount ?? $this->getUpVotes()->count()) + ($this->apLikeCount ?? $this->favouriteCount) - ($this->apDislikeCount ?? $this->getDownVotes()->count()); return $this; } public function removeVote(Vote $vote): self { Assert::isInstanceOf($vote, EntryVote::class); if ($this->votes->removeElement($vote)) { if ($vote->entry === $this) { $vote->entry = null; } } $this->updateScore(); $this->updateRanking(); return $this; } public function isAuthor(User $user): bool { return $user === $this->user; } public function getShortTitle(?int $length = 60): string { $body = wordwrap($this->title, $length); $body = explode("\n", $body); return trim($body[0]).(isset($body[1]) ? '...' : ''); } public function getShortDesc(?int $length = 330): string { $body = wordwrap($this->body ?? '', $length); $body = explode("\n", $body); return trim($body[0]).(isset($body[1]) ? '...' : ''); } public function getUrl(): ?string { return $this->url; } public function setDomain(Domain $domain): DomainInterface { $this->domain = $domain; return $this; } public function getCommentCount(): int { return $this->commentCount; } public function getUniqueCommentCount(): int { $users = []; $count = 0; foreach ($this->comments as $comment) { if (!\in_array($comment->user, $users)) { $users[] = $comment->user; ++$count; } } return $count; } public function getScore(): int { return $this->score; } public function getMagazine(): ?Magazine { return $this->magazine; } public function getUser(): ?User { return $this->user; } public function isAdult(): bool { return $this->isAdult || $this->magazine->isAdult; } public function isFavored(User $user): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('user', $user)); return $this->favourites->matching($criteria)->count() > 0; } public function getAuthorComment(): ?string { return null; } public function getDescription(): string { return ''; // @todo get first author comment } public function __sleep() { return []; } } ================================================ FILE: src/Entity/EntryBadge.php ================================================ entry = $entry; $this->badge = $badge; } public function getId(): ?int { return $this->id; } public function __toString(): string { return $this->badge->name; } } ================================================ FILE: src/Entity/EntryComment.php ================================================ 0])] public int $favouriteCount = 0; #[Column(type: 'datetimetz')] public ?\DateTime $lastActive = null; #[Column(type: 'string', nullable: true)] public ?string $ip = null; #[Column(type: 'json', nullable: true)] public ?array $mentions = null; #[OneToMany(mappedBy: 'parent', targetEntity: EntryComment::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'ASC'])] public Collection $children; #[OneToMany(mappedBy: 'root', targetEntity: EntryComment::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'ASC'])] public Collection $nested; #[OneToMany(mappedBy: 'comment', targetEntity: EntryCommentVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $votes; #[OneToMany(mappedBy: 'entryComment', targetEntity: EntryCommentReport::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $reports; #[OneToMany(mappedBy: 'entryComment', targetEntity: EntryCommentFavourite::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $favourites; #[OneToMany(mappedBy: 'entryComment', targetEntity: EntryCommentCreatedNotification::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $notifications; #[OneToMany(mappedBy: 'entryComment', targetEntity: HashtagLink::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $hashtags; #[Id] #[GeneratedValue] #[Column(type: 'integer')] private int $id; #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => 'english'])] private $bodyTs; public function __construct( string $body, ?Entry $entry, User $user, ?EntryComment $parent = null, ?string $ip = null, ) { $this->body = $body; $this->entry = $entry; $this->user = $user; $this->parent = $parent; $this->ip = $ip; $this->votes = new ArrayCollection(); $this->children = new ArrayCollection(); $this->reports = new ArrayCollection(); $this->favourites = new ArrayCollection(); $this->notifications = new ArrayCollection(); if ($parent) { $this->root = $parent->root ?? $parent; } $this->createdAtTraitConstruct(); $this->updateLastActive(); } public function updateLastActive(): void { $this->lastActive = \DateTime::createFromImmutable($this->createdAt); if (!$this->root) { return; } $this->root->lastActive = \DateTime::createFromImmutable($this->createdAt); } public function getId(): int { return $this->id; } public function getApId(): ?string { return $this->apId; } public function addVote(Vote $vote): self { Assert::isInstanceOf($vote, EntryCommentVote::class); if (!$this->votes->contains($vote)) { $this->votes->add($vote); $vote->setComment($this); } return $this; } public function removeVote(Vote $vote): self { Assert::isInstanceOf($vote, EntryCommentVote::class); if ($this->votes->removeElement($vote)) { // set the owning side to null (unless already changed) if ($vote->comment === $this) { $vote->setComment(null); } } return $this; } public function getChildrenRecursive(int &$startIndex = 0): \Traversable { foreach ($this->children as $child) { yield $startIndex++ => $child; yield from $child->getChildrenRecursive($startIndex); } } public function softDelete(): void { $this->visibility = VisibilityInterface::VISIBILITY_SOFT_DELETED; } public function trash(): void { $this->visibility = VisibilityInterface::VISIBILITY_TRASHED; } public function restore(): void { $this->visibility = VisibilityInterface::VISIBILITY_VISIBLE; } public function isAuthor(User $user): bool { return $user === $this->user; } public function getShortTitle(?int $length = 60): string { $body = wordwrap($this->body ?? '', $length); $body = explode("\n", $body); return trim($body[0]).(isset($body[1]) ? '...' : ''); } public function getMagazine(): ?Magazine { return $this->magazine; } public function getUser(): ?User { return $this->user; } public function updateCounts(): self { $this->favouriteCount = $this->favourites->count(); return $this; } public function isFavored(User $user): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('user', $user)); return $this->favourites->matching($criteria)->count() > 0; } public function __sleep() { return []; } public function updateRanking(): void { } public function updateScore(): self { return $this; } public function getParentSubject(): ?ContentInterface { return $this->entry; } public function containsBannedHashtags(): bool { foreach ($this->hashtags as /** @var HashtagLink $hashtag */ $hashtag) { if ($hashtag->hashtag->banned) { return true; } } return false; } /** * @param 'profile'|'comments' $filterRealm */ public function containsFilteredWords(User $loggedInUser, string $filterRealm): bool { foreach ($loggedInUser->getCurrentFilterLists() as $list) { if (!$list->$filterRealm) { continue; } foreach ($list->words as $word) { if ($word['exactMatch']) { if (false !== mb_strpos($this->body, $word['word'])) { return true; } } else { if (false !== mb_stripos($this->body, $word['word'])) { return true; } } } } return false; } /** * @param 'profile'|'comments' $filterRealm */ public function getChildrenByCriteria(MbinCriteria $entryCommentCriteria, DownvotesMode $downvoteMode, ?User $loggedInUser, string $filterRealm): array { $criteria = Criteria::create(); if ($entryCommentCriteria->languages) { $criteria->andwhere(Criteria::expr()->in('lang', $entryCommentCriteria->languages)); } if (MbinCriteria::AP_LOCAL === $entryCommentCriteria->federation) { $criteria->andWhere(Criteria::expr()->isNull('apId')); } elseif (MbinCriteria::AP_FEDERATED === $entryCommentCriteria->federation) { $criteria->andWhere(Criteria::expr()->isNotNull('apId')); } if (MbinCriteria::TIME_ALL !== $entryCommentCriteria->time) { $criteria->andWhere(Criteria::expr()->gte('createdAt', $entryCommentCriteria->getSince())); } $children = $this->children ->matching($criteria) ->filter(fn (EntryComment $comment) => !$comment->containsBannedHashtags() && (!$loggedInUser || !$comment->containsFilteredWords($loggedInUser, $filterRealm))) ->toArray(); switch ($entryCommentCriteria->sortOption) { case MbinCriteria::SORT_TOP: if (DownvotesMode::Disabled === $downvoteMode) { uasort($children, fn (EntryComment $a, EntryComment $b) => ArrayUtils::numCompareDescending($a->favouriteCount + $a->upVotes, $b->favouriteCount + $b->upVotes)); } else { uasort($children, fn (EntryComment $a, EntryComment $b) => ArrayUtils::numCompareDescending($a->favouriteCount + $a->upVotes - $a->downVotes, $b->favouriteCount + $b->upVotes - $b->downVotes)); } break; case MbinCriteria::SORT_HOT: uasort($children, fn (EntryComment $a, EntryComment $b) => ArrayUtils::numCompareDescending($a->favouriteCount, $b->favouriteCount)); break; case MbinCriteria::SORT_ACTIVE: uasort($children, fn (EntryComment $a, EntryComment $b) => ArrayUtils::numCompareDescending($a->lastActive->getTimestamp(), $b->lastActive->getTimestamp())); break; case MbinCriteria::SORT_OLD: uasort($children, fn (EntryComment $a, EntryComment $b) => ArrayUtils::numCompareAscending($a->createdAt->getTimestamp(), $b->createdAt->getTimestamp())); break; case MbinCriteria::SORT_NEW: uasort($children, fn (EntryComment $a, EntryComment $b) => ArrayUtils::numCompareDescending($a->createdAt->getTimestamp(), $b->createdAt->getTimestamp())); break; default: } return $children; } } ================================================ FILE: src/Entity/EntryCommentCreatedNotification.php ================================================ entryComment = $comment; } public function getSubject(): EntryComment { return $this->entryComment; } public function getComment(): EntryComment { return $this->entryComment; } public function getType(): string { return 'entry_comment_created_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s: %s', $this->entryComment->user->username, $trans->trans('added_new_comment'), $this->entryComment->getShortTitle()); $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null; $url = $urlGenerator->generate('entry_comment_view', [ 'entry_id' => $this->entryComment->entry->getId(), 'magazine_name' => $this->entryComment->magazine->name, 'slug' => $this->entryComment->entry->slug ?? '-', 'comment_id' => $this->entryComment->getId(), ]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/EntryCommentDeletedNotification.php ================================================ entryComment = $comment; } public function getSubject(): EntryComment { return $this->entryComment; } public function getComment(): EntryComment { return $this->entryComment; } public function getType(): string { return 'entry_comment_deleted_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $trans->trans('comment'), $this->entryComment->getShortTitle(), $this->entryComment->isTrashed() ? $trans->trans('removed') : $trans->trans('deleted')); $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null; $url = $urlGenerator->generate('entry_comment_view', [ 'entry_id' => $this->entryComment->entry->getId(), 'magazine_name' => $this->entryComment->magazine->name, 'slug' => $this->entryComment->entry->slug ?? '-', 'comment_id' => $this->entryComment->getId(), ]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_removed_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/EntryCommentEditedNotification.php ================================================ entryComment = $comment; } public function getSubject(): EntryComment { return $this->entryComment; } public function getComment(): EntryComment { return $this->entryComment; } public function getType(): string { return 'entry_comment_edited_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $this->entryComment->user->username, $trans->trans('edited_comment'), $this->entryComment->getShortTitle()); $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null; $url = $urlGenerator->generate('entry_comment_view', [ 'entry_id' => $this->entryComment->entry->getId(), 'magazine_name' => $this->entryComment->magazine->name, 'slug' => $this->entryComment->entry->slug ?? '-', 'comment_id' => $this->entryComment->getId(), ]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_edited_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/EntryCommentFavourite.php ================================================ magazine = $comment->magazine; $this->entryComment = $comment; } public function getSubject(): EntryComment { return $this->entryComment; } public function clearSubject(): Favourite { $this->entryComment = null; return $this; } public function getType(): string { return 'entry_comment'; } } ================================================ FILE: src/Entity/EntryCommentMentionedNotification.php ================================================ entryComment = $comment; } public function getSubject(): EntryComment { return $this->entryComment; } public function getComment(): EntryComment { return $this->entryComment; } public function getType(): string { return 'entry_comment_mentioned_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $this->entryComment->user->username, $trans->trans('mentioned_you'), $this->entryComment->getShortTitle()); $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null; $url = $urlGenerator->generate('entry_comment_view', [ 'entry_id' => $this->entryComment->entry->getId(), 'magazine_name' => $this->entryComment->magazine->name, 'slug' => $this->entryComment->entry->slug ?? '-', 'comment_id' => $this->entryComment->getId(), ]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_mention', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/EntryCommentReplyNotification.php ================================================ entryComment = $comment; } public function getSubject(): EntryComment { return $this->entryComment; } public function getComment(): EntryComment { return $this->entryComment; } public function getType(): string { return 'entry_comment_reply_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $this->entryComment->user->username, $trans->trans('replied_to_your_comment'), $this->entryComment->getShortTitle()); $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null; $url = $urlGenerator->generate('entry_comment_view', [ 'entry_id' => $this->entryComment->entry->getId(), 'magazine_name' => $this->entryComment->magazine->name, 'slug' => $this->entryComment->entry->slug ?? '-', 'comment_id' => $this->entryComment->getId(), ]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_reply', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/EntryCommentReport.php ================================================ user, $comment->magazine, $reason); $this->entryComment = $comment; } public function getSubject(): EntryComment { return $this->entryComment; } public function clearSubject(): Report { $this->entryComment = null; return $this; } public function getType(): string { return 'entry_comment'; } } ================================================ FILE: src/Entity/EntryCommentVote.php ================================================ user); $this->comment = $comment; } public function getComment(): EntryComment { return $this->comment; } public function setComment(?EntryComment $comment): self { $this->comment = $comment; return $this; } public function getSubject(): VotableInterface { return $this->comment; } } ================================================ FILE: src/Entity/EntryCreatedNotification.php ================================================ entry = $entry; } public function getSubject(): Entry { return $this->entry; } public function getType(): string { return 'entry_created_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $this->entry->user->username, $trans->trans('added_new_thread'), $this->entry->getShortTitle()); $slash = $this->entry->user->avatar && !str_starts_with('/', $this->entry->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->entry->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entry->user->avatar->filePath : null; $url = $urlGenerator->generate('entry_single', [ 'entry_id' => $this->entry->getId(), 'magazine_name' => $this->entry->magazine->name, 'slug' => $this->entry->slug ?? '-', ]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_thread', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/EntryDeletedNotification.php ================================================ entry = $entry; } public function getSubject(): Entry { return $this->entry; } public function getType(): string { return 'entry_deleted_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s', $this->entry->getShortTitle(), $this->entry->isTrashed() ? $trans->trans('removed') : $trans->trans('deleted')); $slash = $this->entry->user->avatar && !str_starts_with('/', $this->entry->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->entry->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entry->user->avatar->filePath : null; $url = $urlGenerator->generate('entry_single', [ 'entry_id' => $this->entry->getId(), 'magazine_name' => $this->entry->magazine->name, 'slug' => $this->entry->slug ?? '-', ]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_removed_thread', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/EntryEditedNotification.php ================================================ entry = $entry; } public function getSubject(): Entry { return $this->entry; } public function getType(): string { return 'entry_edited_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $this->entry->user->username, $trans->trans('edited_thread'), $this->entry->getShortTitle()); $slash = $this->entry->user->avatar && !str_starts_with('/', $this->entry->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->entry->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entry->user->avatar->filePath : null; $url = $urlGenerator->generate('entry_single', [ 'entry_id' => $this->entry->getId(), 'magazine_name' => $this->entry->magazine->name, 'slug' => $this->entry->slug ?? '-', ]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_edited_thread', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/EntryFavourite.php ================================================ magazine = $entry->magazine; $this->entry = $entry; } public function getSubject(): Entry { return $this->entry; } public function clearSubject(): Favourite { $this->entry = null; return $this; } public function getType(): string { return 'entry'; } } ================================================ FILE: src/Entity/EntryMentionedNotification.php ================================================ entry = $entry; } public function getSubject(): Entry { return $this->entry; } public function getType(): string { return 'entry_mentioned_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $this->entry->user->username, $trans->trans('mentioned_you'), $this->entry->getShortTitle()); $slash = $this->entry->user->avatar && !str_starts_with('/', $this->entry->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->entry->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entry->user->avatar->filePath : null; $url = $urlGenerator->generate('entry_single', [ 'entry_id' => $this->entry->getId(), 'magazine_name' => $this->entry->magazine->name, 'slug' => $this->entry->slug ?? '-', ]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_mention', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/EntryReport.php ================================================ user, $entry->magazine, $reason); $this->entry = $entry; } public function getSubject(): Entry { return $this->entry; } public function clearSubject(): Report { $this->entry = null; return $this; } public function getType(): string { return 'entry'; } } ================================================ FILE: src/Entity/EntryVote.php ================================================ user); $this->entry = $entry; } public function getSubject(): VotableInterface { return $this->entry; } } ================================================ FILE: src/Entity/Favourite.php ================================================ 'EntryFavourite', 'entry_comment' => 'EntryCommentFavourite', 'post' => 'PostFavourite', 'post_comment' => 'PostCommentFavourite', ])] #[UniqueConstraint(name: 'favourite_user_entry_unique_idx', columns: ['entry_id', 'user_id'])] #[UniqueConstraint(name: 'favourite_user_entry_comment_unique_idx', columns: ['entry_comment_id', 'user_id'])] #[UniqueConstraint(name: 'favourite_user_post_unique_idx', columns: ['post_id', 'user_id'])] #[UniqueConstraint(name: 'favourite_user_post_comment_unique_idx', columns: ['post_comment_id', 'user_id'])] abstract class Favourite { use CreatedAtTrait { CreatedAtTrait::__construct as createdAtTraitConstruct; } #[ManyToOne(targetEntity: Magazine::class)] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] public Magazine $magazine; #[ManyToOne(targetEntity: User::class, inversedBy: 'favourites')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] public User $user; #[Id] #[GeneratedValue] #[Column(type: 'integer')] private int $id; public function __construct(User $user) { $this->user = $user; $this->createdAtTraitConstruct(); } public function getId(): int { return $this->id; } abstract public function getType(): string; abstract public function getSubject(): FavouriteInterface; abstract public function clearSubject(): Favourite; } ================================================ FILE: src/Entity/Hashtag.php ================================================ false])] public bool $banned = false; #[OneToMany(mappedBy: 'hashtag', targetEntity: HashtagLink::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $linkedPosts; } ================================================ FILE: src/Entity/HashtagLink.php ================================================ false])] public bool $isCompressed = false; #[Column(nullable: false, options: ['default' => false])] public bool $sourceTooBig = false; #[Column(type: 'datetimetz_immutable', nullable: true, options: ['default' => null])] public ?\DateTimeImmutable $downloadedAt; #[Column(type: 'bigint', options: ['default' => 0])] public int $localSize = 0; #[Column(type: 'bigint', options: ['default' => 0])] public int $originalSize = 0; #[Id] #[GeneratedValue] #[Column(type: 'integer')] private int $id; public function __construct( string $fileName, string $filePath, string $sha256, ?int $width, ?int $height, ?string $blurhash, ) { $this->createdAtTraitConstruct(); $this->filePath = $filePath; $this->fileName = $fileName; $this->blurhash = $blurhash; error_clear_last(); if (64 === \strlen($sha256)) { $sha256 = @hex2bin($sha256); if (false === $sha256) { throw new \InvalidArgumentException(error_get_last()['message']); } } elseif (32 !== \strlen($sha256)) { throw new \InvalidArgumentException('$sha256 must be a SHA256 hash in raw or binary form'); } $this->sha256 = $sha256; $this->setDimensions($width, $height); } public function setDimensions(?int $width, ?int $height): void { if (null !== $width && $width <= 0) { throw new \InvalidArgumentException('$width must be NULL or >0'); } if (null !== $height && $height <= 0) { throw new \InvalidArgumentException('$height must be NULL or >0'); } if (($width && $height) || (!$width && !$height)) { $this->width = $width; $this->height = $height; } else { throw new \InvalidArgumentException('$width and $height must both be set or NULL'); } } public function getId(): int { return $this->id; } public function __toString(): string { return $this->fileName; } // public function getSha256(): string // { // return bin2hex($this->sha256); // } public function __sleep() { return []; } } ================================================ FILE: src/Entity/Instance.php ================================================ false])] public bool $isBanned = false; #[Column(options: ['default' => false])] public bool $isExplicitlyAllowed = false; #[Column, Id, GeneratedValue] private int $id; public function __construct(string $domain) { $this->domain = $domain; $this->createdAtTraitConstruct(); } public function getId(): int { return $this->id; } public function setLastSuccessfulDeliver(): void { $this->lastSuccessfulDeliver = new \DateTimeImmutable(); $this->failedDelivers = 0; } public function getLastSuccessfulDeliver(): ?\DateTimeImmutable { return $this->lastSuccessfulDeliver; } public function setLastFailedDeliver(): void { $this->lastFailedDeliver = new \DateTimeImmutable(); ++$this->failedDelivers; } public function getLastFailedDeliver(): ?\DateTimeImmutable { return $this->lastFailedDeliver; } public function setLastSuccessfulReceive(): void { $this->lastSuccessfulReceive = new \DateTimeImmutable(); } public function getLastSuccessfulReceive(): ?\DateTimeImmutable { return $this->lastSuccessfulReceive; } public function getFailedDelivers(): int { return $this->failedDelivers; } public function isDead(): bool { return ($this->getLastSuccessfulDeliver() < self::getDateBeforeDead() || null === $this->getLastSuccessfulDeliver()) && ($this->getLastSuccessfulReceive() < self::getDateBeforeDead() || null === $this->getLastSuccessfulReceive()) && $this->getFailedDelivers() >= self::NUMBER_OF_FAILED_DELIVERS_UNTIL_DEAD; } } ================================================ FILE: src/Entity/Magazine.php ================================================ false])] public bool $postingRestrictedToMods = false; #[Column(type: 'integer', nullable: false)] public int $subscriptionsCount = 0; #[Column(type: 'integer', nullable: false)] public int $entryCount = 0; #[Column(type: 'integer', nullable: false)] public int $entryCommentCount = 0; #[Column(type: 'integer', nullable: false)] public int $postCount = 0; #[Column(type: 'integer', nullable: false)] public int $postCommentCount = 0; #[Column(type: 'boolean', nullable: false)] public bool $isAdult = false; #[Column(type: 'text', nullable: true)] public ?string $customCss = null; #[Column(type: 'datetimetz', nullable: true)] public ?\DateTime $lastActive = null; /** * @var \DateTime|null this is set if this is a remote magazine. * This is the last time we had an update from the origin of the magazine */ #[Column(type: 'datetimetz', nullable: true)] public ?\DateTime $lastOriginUpdate = null; #[Column(type: 'datetimetz', nullable: true)] public ?\DateTime $markedForDeletionAt = null; #[Column(type: Types::JSONB, nullable: true)] public ?array $tags = null; #[OneToMany(mappedBy: 'magazine', targetEntity: Moderator::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $moderators; #[OneToMany(mappedBy: 'magazine', targetEntity: MagazineOwnershipRequest::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $ownershipRequests; #[OneToMany(mappedBy: 'magazine', targetEntity: ModeratorRequest::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $moderatorRequests; #[OneToMany(mappedBy: 'magazine', targetEntity: Entry::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $entries; #[OneToMany(mappedBy: 'magazine', targetEntity: Post::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $posts; #[OneToMany(mappedBy: 'magazine', targetEntity: MagazineSubscription::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $subscriptions; #[OneToMany(mappedBy: 'magazine', targetEntity: MagazineBan::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $bans; #[OneToMany(mappedBy: 'magazine', targetEntity: Report::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $reports; #[OneToMany(mappedBy: 'magazine', targetEntity: Badge::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] #[OrderBy(['id' => 'DESC'])] public Collection $badges; #[OneToMany(mappedBy: 'magazine', targetEntity: MagazineLog::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $logs; #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => null])] private ?string $nameTs; #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => null])] private ?string $titleTs; #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => null])] private ?string $descriptionTs; #[Id] #[GeneratedValue] #[Column(type: 'integer')] private int $id; public function __construct( string $name, string $title, ?User $user, ?string $description, ?string $rules, bool $isAdult, bool $postingRestrictedToMods, ?Image $icon, ?Image $banner = null, ) { $this->name = $name; $this->title = $title; $this->description = $description; $this->rules = $rules; $this->isAdult = $isAdult; $this->postingRestrictedToMods = $postingRestrictedToMods; $this->icon = $icon; $this->banner = $banner; $this->moderators = new ArrayCollection(); $this->entries = new ArrayCollection(); $this->posts = new ArrayCollection(); $this->subscriptions = new ArrayCollection(); $this->bans = new ArrayCollection(); $this->reports = new ArrayCollection(); $this->badges = new ArrayCollection(); $this->logs = new ArrayCollection(); $this->moderatorRequests = new ArrayCollection(); $this->ownershipRequests = new ArrayCollection(); if (null !== $user) { $this->addModerator(new Moderator($this, $user, null, true, true)); } $this->createdAtTraitConstruct(); } /** * Only use this to add a moderator if you don't want that action to be federated. * If you want this action to be federated, use @see MagazineManager::addModerator(). * * @return $this */ public function addModerator(Moderator $moderator): self { if (!$this->moderators->contains($moderator)) { $this->moderators->add($moderator); $moderator->magazine = $this; } return $this; } public function getId(): int { return $this->id; } public function getApId(): ?string { return $this->apId; } public function userIsModerator(User $user): bool { $user->moderatorTokens->get(-1); $criteria = Criteria::create() ->where(Criteria::expr()->eq('magazine', $this)) ->andWhere(Criteria::expr()->eq('isConfirmed', true)); return !$user->moderatorTokens->matching($criteria)->isEmpty(); } public function getUserAsModeratorOrNull(User $user): ?Moderator { $user->moderatorTokens->get(-1); $criteria = Criteria::create() ->where(Criteria::expr()->eq('magazine', $this)) ->andWhere(Criteria::expr()->eq('isConfirmed', true)); $col = $user->moderatorTokens->matching($criteria); if (!$col->isEmpty()) { return $col->first(); } return null; } public function userIsOwner(User $user): bool { $user->moderatorTokens->get(-1); $criteria = Criteria::create() ->where(Criteria::expr()->eq('magazine', $this)) ->andWhere(Criteria::expr()->eq('isOwner', true)); return !$user->moderatorTokens->matching($criteria)->isEmpty(); } public function isAbandoned(): bool { return !$this->apId and (null === $this->getOwner() || $this->getOwner()->lastActive < new \DateTime('-1 month')); } public function getOwnerModerator(): ?Moderator { $criteria = Criteria::create() ->where(Criteria::expr()->eq('isOwner', true)); $res = $this->moderators->matching($criteria)->first(); if (false !== $res) { return $res; } return null; } public function getOwner(): ?User { $criteria = Criteria::create() ->where(Criteria::expr()->eq('isOwner', true)); $res = $this->moderators->matching($criteria)->first(); if (false !== $res) { return $res->user; } return null; } public function getModeratorCount(): int { return $this->moderators->count(); } public function addEntry(Entry $entry): self { if (!$this->entries->contains($entry)) { $this->entries->add($entry); $entry->magazine = $this; } $this->updateEntryCounts(); return $this; } public function updateEntryCounts(): self { $criteria = Criteria::create() ->andWhere(Criteria::expr()->eq('visibility', Entry::VISIBILITY_VISIBLE)); $this->entryCount = $this->entries->matching($criteria)->count(); return $this; } public function removeEntry(Entry $entry): self { if ($this->entries->removeElement($entry)) { if ($entry->magazine === $this) { $entry->magazine = null; } } $this->updateEntryCounts(); return $this; } public function getPosts(): Collection { return $this->posts; } public function addPost(Post $post): self { if (!$this->posts->contains($post)) { $this->posts->add($post); $post->magazine = $this; } $this->updatePostCounts(); return $this; } public function updatePostCounts(): self { $criteria = Criteria::create() ->andWhere(Criteria::expr()->eq('visibility', Entry::VISIBILITY_VISIBLE)); $this->postCount = $this->posts->matching($criteria)->count(); return $this; } public function removePost(Post $post): self { if ($this->posts->removeElement($post)) { if ($post->magazine === $this) { $post->magazine = null; } } $this->updatePostCounts(); return $this; } public function subscribe(User $user): self { if (!$this->isSubscribed($user)) { $this->subscriptions->add($sub = new MagazineSubscription($user, $this)); $sub->magazine = $this; } $this->updateSubscriptionsCount(); return $this; } public function isSubscribed(User $user): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('user', $user)); return $this->subscriptions->matching($criteria)->count() > 0; } public function updateSubscriptionsCount(): void { if (null !== $this->apFollowersCount) { $criteria = Criteria::create() ->where(Criteria::expr()->gt('createdAt', \DateTimeImmutable::createFromMutable($this->apFetchedAt))); $newSubscribers = $this->subscriptions->matching($criteria)->count(); $this->subscriptionsCount = $this->apFollowersCount + $newSubscribers; } else { $this->subscriptionsCount = $this->subscriptions->count(); } } public function unsubscribe(User $user): void { $criteria = Criteria::create() ->where(Criteria::expr()->eq('user', $user)); $subscription = $this->subscriptions->matching($criteria)->first(); if ($this->subscriptions->removeElement($subscription)) { if ($subscription->magazine === $this) { $subscription->magazine = null; } } $this->updateSubscriptionsCount(); } public function softDelete(): void { $this->markedForDeletionAt = new \DateTime('now + 30days'); $this->visibility = VisibilityInterface::VISIBILITY_SOFT_DELETED; } public function trash(): void { $this->visibility = VisibilityInterface::VISIBILITY_TRASHED; } public function restore(): void { $this->markedForDeletionAt = null; $this->visibility = VisibilityInterface::VISIBILITY_VISIBLE; } public function addBan(User $user, User $bannedBy, ?string $reason, ?\DateTimeImmutable $expiredAt): ?MagazineBan { $ban = $this->isBanned($user); if (!$ban) { $this->bans->add($ban = new MagazineBan($this, $user, $bannedBy, $reason, $expiredAt)); $ban->magazine = $this; } else { return null; } return $ban; } public function isBanned(User $user): bool { $criteria = Criteria::create() ->andWhere(Criteria::expr()->gt('expiredAt', new \DateTimeImmutable())) ->orWhere(Criteria::expr()->isNull('expiredAt')) ->andWhere(Criteria::expr()->eq('user', $user)); return $this->bans->matching($criteria)->count() > 0; } public function removeBan(MagazineBan $ban): self { if ($this->bans->removeElement($ban)) { if ($ban->magazine === $this) { $ban->magazine = null; } } return $this; } public function unban(User $user): MagazineBan { $criteria = Criteria::create() ->andWhere(Criteria::expr()->gt('expiredAt', new \DateTimeImmutable())) ->orWhere(Criteria::expr()->isNull('expiredAt')) ->andWhere(Criteria::expr()->eq('user', $user)); /** * @var MagazineBan $ban */ $ban = $this->bans->matching($criteria)->first(); $ban->expiredAt = new \DateTimeImmutable('-10 seconds'); return $ban; } public function addBadge(Badge ...$badges): self { foreach ($badges as $badge) { if (!$this->badges->contains($badge)) { $this->badges->add($badge); } } return $this; } public function removeBadge(Badge $badge): self { $this->badges->removeElement($badge); return $this; } public function addLog(MagazineLog $log): void { if (!$this->logs->contains($log)) { $this->logs->add($log); } } public function __sleep() { return []; } public function getApName(): string { return $this->name; } public function hasSameHostAsUser(User $actor): bool { if (!$actor->apId and !$this->apId) { return true; } if ($actor->apId and $this->apId) { return parse_url($actor->apId, PHP_URL_HOST) === parse_url($this->apId, PHP_URL_HOST); } return false; } public function canUpdateMagazine(User $actor): bool { if (null === $this->apId) { return $actor->isAdmin() || $actor->isModerator() || $this->userIsModerator($actor); } else { return $this->apDomain === $actor->apDomain || $this->userIsModerator($actor); } } /** * @param Magazine|User $actor the actor trying to create an Entry * * @return bool false if the user is not restricted, true if the user is restricted */ public function isActorPostingRestricted(Magazine|User $actor): bool { if (!$this->postingRestrictedToMods) { return false; } if ($actor instanceof User) { if (null !== $this->apId && $this->apDomain === $actor->apDomain) { return false; } if ((null === $this->apId && ($actor->isAdmin() || $actor->isModerator())) || $this->userIsModerator($actor)) { return false; } } return true; } public function getContentCount(): int { return $this->entryCount + $this->entryCommentCount + $this->postCount + $this->postCommentCount; } } ================================================ FILE: src/Entity/MagazineBan.php ================================================ magazine = $magazine; $this->user = $user; $this->bannedBy = $bannedBy; $this->reason = $reason; $this->expiredAt = $expiredAt; $this->createdAtTraitConstruct(); } public function getId(): ?int { return $this->id; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/MagazineBanNotification.php ================================================ ban = $ban; } public function getSubject(): MagazineBan { return $this->ban; } public function getType(): string { return 'magazine_ban_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $intl = new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::SHORT, calendar: \IntlDateFormatter::GREGORIAN); if ($this->ban->expiredAt) { $message = \sprintf('%s %s: %s. %s: %s', $trans->trans('you_have_been_banned_from_magazine', ['%m' => $this->ban->magazine->name], locale: $locale), new \DateTimeImmutable() > $this->ban->expiredAt ? $trans->trans('ban_expired', locale: $locale) : $trans->trans('ban_expires', locale: $locale), $intl->format($this->ban->expiredAt), $trans->trans('reason', locale: $locale), $this->ban->reason ); } else { $message = \sprintf('%s %s: %s', $trans->trans('you_have_been_banned_from_magazine_permanently', ['%m' => $this->ban->magazine->name], locale: $locale), $trans->trans('reason', locale: $locale), $this->ban->reason ); } $slash = $this->ban->magazine->icon && !str_starts_with('/', $this->ban->magazine->icon->filePath) ? '/' : ''; $avatarUrl = $this->ban->magazine->icon ? '/media/cache/resolve/avatar_thumb'.$slash.$this->ban->magazine->icon->filePath : null; return new PushNotification($this->getId(), $message, $trans->trans('notification_title_ban', locale: $locale), avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/MagazineBlock.php ================================================ createdAtTraitConstruct(); $this->user = $user; $this->magazine = $magazine; } public function getId(): ?int { return $this->id; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/MagazineLog.php ================================================ MagazineLogEntryDeleted::class, 'entry_restored' => MagazineLogEntryRestored::class, 'entry_comment_deleted' => MagazineLogEntryCommentDeleted::class, 'entry_comment_restored' => MagazineLogEntryCommentRestored::class, 'entry_pinned' => MagazineLogEntryPinned::class, 'entry_unpinned' => MagazineLogEntryUnpinned::class, 'post_deleted' => MagazineLogPostDeleted::class, 'post_restored' => MagazineLogPostRestored::class, 'post_comment_deleted' => MagazineLogPostCommentDeleted::class, 'post_comment_restored' => MagazineLogPostCommentRestored::class, 'ban' => MagazineLogBan::class, 'moderator_add' => MagazineLogModeratorAdd::class, 'moderator_remove' => MagazineLogModeratorRemove::class, 'entry_locked' => MagazineLogEntryLocked::class, 'entry_unlocked' => MagazineLogEntryUnlocked::class, 'post_locked' => MagazineLogPostLocked::class, 'post_unlocked' => MagazineLogPostUnlocked::class, ]; public const CHOICES = [ 'entry_deleted', 'entry_restored', 'entry_comment_deleted', 'entry_comment_restored', 'entry_pinned', 'entry_unpinned', 'post_deleted', 'post_restored', 'post_comment_deleted', 'post_comment_restored', 'ban', 'moderator_add', 'moderator_remove', 'entry_locked', 'entry_unlocked', 'post_locked', 'post_unlocked', ]; #[ManyToOne(targetEntity: Magazine::class, inversedBy: 'logs')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] public Magazine $magazine; #[ManyToOne(targetEntity: User::class)] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] /** * Usually the acting moderator. There are 2 exceptions MagazineLogModeratorAdd and MagazineLogModeratorRemove; * in that case this is the moderator being added or removed, because the acting moderator can be null. * * @see MagazineLogModeratorAdd * @see MagazineLogModeratorRemove */ public User $user; #[Id] #[GeneratedValue] #[Column(type: 'integer')] private int $id; public function __construct(Magazine $magazine, User $user) { $this->magazine = $magazine; $this->user = $user; $this->createdAtTraitConstruct(); } abstract public function getSubject(): ?ContentInterface; abstract public function clearSubject(): MagazineLog; abstract public function getType(): string; } ================================================ FILE: src/Entity/MagazineLogBan.php ================================================ magazine, $ban->bannedBy); $this->ban = $ban; if (null !== $ban->expiredAt && $ban->expiredAt < new \DateTime()) { $this->meta = 'unban'; } } public function getType(): string { return 'log_ban'; } public function getSubject(): ?ContentInterface { return null; } public function clearSubject(): MagazineLog { $this->ban = null; return $this; } } ================================================ FILE: src/Entity/MagazineLogEntryCommentDeleted.php ================================================ magazine, $user); $this->entryComment = $comment; } public function getType(): string { return 'log_entry_comment_deleted'; } public function getComment(): EntryComment { return $this->entryComment; } public function getSubject(): ContentInterface { return $this->entryComment; } public function clearSubject(): MagazineLog { $this->entryComment = null; return $this; } } ================================================ FILE: src/Entity/MagazineLogEntryCommentRestored.php ================================================ magazine, $user); $this->entryComment = $comment; } public function getType(): string { return 'log_entry_comment_restored'; } public function getComment(): EntryComment { return $this->entryComment; } public function getSubject(): ContentInterface { return $this->entryComment; } public function clearSubject(): MagazineLog { $this->entryComment = null; return $this; } } ================================================ FILE: src/Entity/MagazineLogEntryDeleted.php ================================================ magazine, $user); $this->entry = $entry; } public function getType(): string { return 'log_entry_deleted'; } public function getSubject(): ContentInterface { return $this->entry; } public function clearSubject(): MagazineLog { $this->entry = null; return $this; } } ================================================ FILE: src/Entity/MagazineLogEntryLocked.php ================================================ magazine, $user); $this->entry = $entry; } public function getSubject(): ?ContentInterface { return $this->entry; } public function clearSubject(): MagazineLog { $this->entry = null; return $this; } public function getType(): string { return 'log_entry_locked'; } } ================================================ FILE: src/Entity/MagazineLogEntryPinned.php ================================================ user); $this->entry = $unpinnedEntry; $this->actingUser = $actingUser; } public function getSubject(): ?ContentInterface { return $this->entry; } public function clearSubject(): MagazineLog { $this->entry = null; return $this; } public function getType(): string { return 'log_entry_pinned'; } } ================================================ FILE: src/Entity/MagazineLogEntryRestored.php ================================================ magazine, $user); $this->entry = $entry; } public function getType(): string { return 'log_entry_restored'; } public function getSubject(): ContentInterface { return $this->entry; } public function clearSubject(): MagazineLog { $this->entry = null; return $this; } } ================================================ FILE: src/Entity/MagazineLogEntryUnlocked.php ================================================ magazine, $user); $this->entry = $entry; } public function getSubject(): ?ContentInterface { return $this->entry; } public function clearSubject(): MagazineLog { $this->entry = null; return $this; } public function getType(): string { return 'log_entry_unlocked'; } } ================================================ FILE: src/Entity/MagazineLogEntryUnpinned.php ================================================ user); $this->entry = $unpinnedEntry; $this->actingUser = $actingUser; } public function getSubject(): ?ContentInterface { return $this->entry; } public function clearSubject(): MagazineLog { $this->entry = null; return $this; } public function getType(): string { return 'log_entry_unpinned'; } } ================================================ FILE: src/Entity/MagazineLogModeratorAdd.php ================================================ actingUser = $actingUser; } public function getSubject(): ?ContentInterface { return null; } public function clearSubject(): MagazineLog { return $this; } public function getType(): string { return 'log_moderator_add'; } } ================================================ FILE: src/Entity/MagazineLogModeratorRemove.php ================================================ actingUser = $actingUser; } public function getSubject(): ?ContentInterface { return null; } public function clearSubject(): MagazineLog { return $this; } public function getType(): string { return 'log_moderator_remove'; } } ================================================ FILE: src/Entity/MagazineLogPostCommentDeleted.php ================================================ magazine, $user); $this->postComment = $comment; } public function getType(): string { return 'log_post_comment_deleted'; } public function getComment(): PostComment { return $this->postComment; } public function getSubject(): ContentInterface { return $this->postComment; } public function clearSubject(): MagazineLog { $this->postComment = null; return $this; } } ================================================ FILE: src/Entity/MagazineLogPostCommentRestored.php ================================================ magazine, $user); $this->postComment = $comment; } public function getType(): string { return 'log_post_comment_restored'; } public function getComment(): PostComment { return $this->postComment; } public function getSubject(): ContentInterface { return $this->postComment; } public function clearSubject(): MagazineLog { $this->postComment = null; return $this; } } ================================================ FILE: src/Entity/MagazineLogPostDeleted.php ================================================ magazine, $user); $this->post = $post; } public function getType(): string { return 'log_post_deleted'; } public function getSubject(): ContentInterface { return $this->post; } public function clearSubject(): MagazineLog { $this->post = null; return $this; } } ================================================ FILE: src/Entity/MagazineLogPostLocked.php ================================================ magazine, $user); $this->post = $post; } public function getSubject(): ?ContentInterface { return $this->post; } public function clearSubject(): MagazineLog { $this->post = null; return $this; } public function getType(): string { return 'log_post_locked'; } } ================================================ FILE: src/Entity/MagazineLogPostRestored.php ================================================ magazine, $user); $this->post = $post; } public function getType(): string { return 'log_post_restored'; } public function getSubject(): ContentInterface { return $this->post; } public function clearSubject(): MagazineLog { $this->post = null; return $this; } } ================================================ FILE: src/Entity/MagazineLogPostUnlocked.php ================================================ magazine, $user); $this->post = $post; } public function getSubject(): ?ContentInterface { return $this->post; } public function clearSubject(): MagazineLog { $this->post = null; return $this; } public function getType(): string { return 'log_post_unlocked'; } } ================================================ FILE: src/Entity/MagazineOwnershipRequest.php ================================================ magazine = $magazine; $this->user = $user; $this->createdAtTraitConstruct(); } public function getId(): int { return $this->id; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/MagazineSubscription.php ================================================ createdAtTraitConstruct(); $this->user = $user; $this->magazine = $magazine; } public function getId(): ?int { return $this->id; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/MagazineSubscriptionRequest.php ================================================ createdAtTraitConstruct(); $this->user = $user; $this->magazine = $magazine; } public function getId(): ?int { return $this->id; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/MagazineUnBanNotification.php ================================================ ban = $ban; } public function getSubject(): MagazineBan { return $this->ban; } public function getType(): string { return 'magazine_unban_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = $trans->trans('you_are_no_longer_banned_from_magazine', ['%m' => $this->ban->magazine->name], locale: $locale); $slash = $this->ban->magazine->icon && !str_starts_with('/', $this->ban->magazine->icon->filePath) ? '/' : ''; $avatarUrl = $this->ban->magazine->icon ? '/media/cache/resolve/avatar_thumb'.$slash.$this->ban->magazine->icon->filePath : null; return new PushNotification($this->getId(), $message, $trans->trans('notification_title_ban', locale: $locale), avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/Message.php ================================================ thread = $thread; $this->sender = $sender; $this->body = $body; $this->notifications = new ArrayCollection(); $this->uuid = Uuid::v4()->toRfc4122(); $this->apId = $apId; $thread->addMessage($this); $this->createdAtTraitConstruct(); } public function getId(): int { return $this->id; } public function getTitle(): string { $firstLine = preg_replace('/^# |\R.*/', '', $this->body); if (grapheme_strlen($firstLine) <= 80) { return $firstLine; } return grapheme_substr($firstLine, 0, 80).'…'; } public function getUser(): User { return $this->sender; } } ================================================ FILE: src/Entity/MessageNotification.php ================================================ message = $message; } public function getSubject(): Message { return $this->message; } public function getType(): string { return 'message_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s: %s', $this->message->sender->username, $trans->trans('wrote_message'), $this->message->body); $slash = $this->message->sender->avatar && !str_starts_with('/', $this->message->sender->avatar->filePath) ? '/' : ''; $avatarUrl = $this->message->sender->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->message->sender->avatar->filePath : null; $url = $urlGenerator->generate('messages_single', ['id' => $this->message->thread->getId()]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_message', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl, category: EPushNotificationType::Message); } } ================================================ FILE: src/Entity/MessageThread.php ================================================ 'ASC'])] public Collection $messages; #[Id] #[GeneratedValue] #[Column(type: 'integer')] private int $id; #[Pure] public function __construct(User ...$participants) { $this->participants = new ArrayCollection($participants); $this->messages = new ArrayCollection(); } public function getId(): ?int { return $this->id; } public function getOtherParticipants(User $self): array { return $this->participants->filter( static function (User $user) use ($self) { return $user !== $self; } )->getValues(); } public function getNewMessages(User $user): Collection { $criteria = Criteria::create() ->where(Criteria::expr()->eq('status', Message::STATUS_NEW)) ->andWhere(Criteria::expr()->neq('sender', $user)); return $this->messages->matching($criteria); } public function countNewMessages(User $user): int { $criteria = Criteria::create() ->where(Criteria::expr()->eq('status', Message::STATUS_NEW)) ->andWhere(Criteria::expr()->neq('sender', $user)); return $this->messages->matching($criteria)->count(); } public function addMessage(Message $message): void { if (!$this->messages->contains($message)) { if (!$this->userIsParticipant($message->sender)) { throw new \DomainException('Sender is not allowed to participate'); } $this->messages->add($message); } } public function userIsParticipant($user): bool { return $this->participants->contains($user); } public function removeMessage(Message $message): void { $this->messages->removeElement($message); } public function getLastMessage(): ?Message { if (0 === $this->messages->count()) { return null; } return $this->messages[$this->messages->count() - 1]; } public function getTitle(): string { $body = $this->messages[0]->body; $firstLine = preg_replace('/^# |\R.*/', '', $body); if (grapheme_strlen($firstLine) <= 80) { return $firstLine; } return grapheme_substr($firstLine, 0, 80).'…'; } } ================================================ FILE: src/Entity/Moderator.php ================================================ magazine = $magazine; $this->user = $user; $this->addedByUser = $addedByUser; $this->isOwner = $isOwner; $this->isConfirmed = $isConfirmed; $magazine->moderators->add($this); $user->moderatorTokens->add($this); $this->createdAtTraitConstruct(); } public function getId(): int { return $this->id; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/ModeratorRequest.php ================================================ magazine = $magazine; $this->user = $user; $this->createdAtTraitConstruct(); } public function getId(): int { return $this->id; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/MonitoringCurlRequest.php ================================================ createdAtTraitConstruct(); } } ================================================ FILE: src/Entity/MonitoringExecutionContext.php ================================================ */ #[OneToMany(mappedBy: 'context', targetEntity: MonitoringQuery::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $queries; /** * @var Collection */ #[OneToMany(mappedBy: 'context', targetEntity: MonitoringCurlRequest::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $curlRequests; /** * @var Collection */ #[OneToMany(mappedBy: 'context', targetEntity: MonitoringTwigRender::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $twigRenders; public function __construct() { $this->createdAtTraitConstruct(); } /** * @return Collection */ public function getQueriesSorted(string $sortBy = 'durationMilliseconds', string $sortDirection = 'DESC'): Collection { $criteria = new Criteria(orderings: [$sortBy => $sortDirection]); return $this->queries->matching($criteria); } /** * @return GroupedMonitoringQueryDto[] */ public function getGroupedQueries(): array { /** @var array $groupedQueries */ $groupedQueries = []; foreach ($this->getQueriesSorted() as $query) { $hash = $query->queryString->queryHash; if (!\array_key_exists($hash, $groupedQueries)) { $groupedQueries[$hash] = []; } $groupedQueries[$hash][] = $query; } $dtos = []; foreach ($groupedQueries as $hash => $queries) { $dto = new GroupedMonitoringQueryDto(); $dto->query = $queries[0]->queryString->query; $minTime = 10000000000; $maxTime = 0; $addedTime = 0; $queryCount = 0; foreach ($queries as $query) { $duration = $query->getDuration(); if ($minTime > $duration) { $minTime = $duration; } if ($maxTime < $duration) { $maxTime = $duration; } $addedTime += $duration; ++$queryCount; } $dto->count = $queryCount; $dto->maxExecutionTime = $maxTime; $dto->minExecutionTime = $minTime; $dto->meanExecutionTime = $addedTime / $queryCount; $dto->totalExecutionTime = $addedTime; $dtos[] = $dto; } usort($dtos, function (GroupedMonitoringQueryDto $a, GroupedMonitoringQueryDto $b) { if ($a->totalExecutionTime === $b->totalExecutionTime) { return 0; } return $b->totalExecutionTime < $a->totalExecutionTime ? -1 : 1; }); return $dtos; } /** * @return Collection */ public function getRootTwigRenders(): Collection { $criteria = new Criteria(Criteria::expr()->isNull('parent')); return $this->twigRenders->matching($criteria); } /** * @return Collection */ public function getRequestsSorted(string $sortBy = 'durationMilliseconds', string $sortDirection = 'DESC'): Collection { $criteria = new Criteria(orderings: [$sortBy => $sortDirection]); return $this->curlRequests->matching($criteria); } } ================================================ FILE: src/Entity/MonitoringQuery.php ================================================ createdAtTraitConstruct(); } public function cleanParameterArray(): void { if (null !== $this->parameters) { $json = json_encode($this->parameters, JSON_INVALID_UTF8_IGNORE | JSON_UNESCAPED_UNICODE); $newParameters = json_decode($json, true); $newParameters2 = []; foreach ($newParameters as $newParameter) { if (\is_string($newParameter)) { $newParameter = preg_replace('/[[:cntrl:]]/', '', $newParameter); } $newParameters2[] = $newParameter; } $this->parameters = $newParameters2; } } } ================================================ FILE: src/Entity/MonitoringQueryString.php ================================================ createdAtTraitConstruct(); } public function getPercentageOfParentDuration(): float { if (null === $this->parent) { return 100 / $this->context->twigRenderDurationMilliseconds * $this->durationMilliseconds; } return 100 / $this->parent->durationMilliseconds * $this->durationMilliseconds; } public function getPercentageOfTotalDuration(): float { return 100 / $this->context->twigRenderDurationMilliseconds * $this->durationMilliseconds; } public function getColorBasedOnPercentageDuration(bool $compareToParent = true): string { if ($compareToParent) { $percentage = $this->getPercentageOfParentDuration() / 100; } else { $percentage = $this->getPercentageOfTotalDuration() / 100; } $baseline = 50; $rFactor = 1; $gFactor = 0.25; $bFactor = 0.1; $valueR = ($rFactor * (255 - $baseline) * $percentage) + $baseline; $valueG = ($gFactor * (255 - $baseline) * $percentage) + $baseline; $valueB = ($bFactor * (255 - $baseline) * $percentage) + $baseline; return "rgb($valueR, $valueG, $valueB)"; } } ================================================ FILE: src/Entity/NewSignupNotification.php ================================================ newUser; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = str_replace('%u%', $this->newUser->username, $trans->trans('notification_body_new_signup', locale: $locale)); $title = $trans->trans('notification_title_new_signup', locale: $locale); $url = $urlGenerator->generate('user_overview', ['username' => $this->newUser->username]); $slash = $this->newUser->avatar && !str_starts_with('/', $this->newUser->avatar->filePath) ? '/' : ''; $avatarUrl = $this->newUser->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->newUser->avatar->filePath : null; return new PushNotification($this->getId(), $message, $title, actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/Notification.php ================================================ 'EntryCreatedNotification', 'entry_edited' => 'EntryEditedNotification', 'entry_deleted' => 'EntryDeletedNotification', 'entry_mentioned' => 'EntryMentionedNotification', 'entry_comment_created' => 'EntryCommentCreatedNotification', 'entry_comment_edited' => 'EntryCommentEditedNotification', 'entry_comment_reply' => 'EntryCommentReplyNotification', 'entry_comment_deleted' => 'EntryCommentDeletedNotification', 'entry_comment_mentioned' => 'EntryCommentMentionedNotification', 'post_created' => 'PostCreatedNotification', 'post_edited' => 'PostEditedNotification', 'post_deleted' => 'PostDeletedNotification', 'post_mentioned' => 'PostMentionedNotification', 'post_comment_created' => 'PostCommentCreatedNotification', 'post_comment_edited' => 'PostCommentEditedNotification', 'post_comment_reply' => 'PostCommentReplyNotification', 'post_comment_deleted' => 'PostCommentDeletedNotification', 'post_comment_mentioned' => 'PostCommentMentionedNotification', 'message' => 'MessageNotification', 'ban' => 'MagazineBanNotification', 'unban' => 'MagazineUnBanNotification', 'report_created' => 'ReportCreatedNotification', 'report_approved' => 'ReportApprovedNotification', 'report_rejected' => 'ReportRejectedNotification', 'new_signup' => 'NewSignupNotification', ])] abstract class Notification { use CreatedAtTrait { CreatedAtTrait::__construct as createdAtTraitConstruct; } public const STATUS_NEW = 'new'; public const STATUS_READ = 'read'; #[ManyToOne(targetEntity: User::class, inversedBy: 'notifications')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] public User $user; #[Column(type: 'string')] public string $status = self::STATUS_NEW; #[Id] #[GeneratedValue] #[Column(type: 'integer')] private int $id; public function __construct(User $receiver) { $this->user = $receiver; $this->createdAtTraitConstruct(); } public function getId(): int { return $this->id; } abstract public function getType(): string; abstract public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification; } ================================================ FILE: src/Entity/NotificationSettings.php ================================================ ENotificationStatus::Default->value])] private string $notificationStatus = ENotificationStatus::Default->value; public function __construct(User $user, Entry|Post|User|Magazine $target, ENotificationStatus $status) { $this->user = $user; $this->setStatus($status); if ($target instanceof User) { $this->targetUser = $target; } elseif ($target instanceof Magazine) { $this->magazine = $target; } elseif ($target instanceof Entry) { $this->entry = $target; } elseif ($target instanceof Post) { $this->post = $target; } } public function setStatus(ENotificationStatus $status): void { $this->notificationStatus = $status->value; } public function getStatus(): ENotificationStatus { return ENotificationStatus::getFromString($this->notificationStatus); } } ================================================ FILE: src/Entity/OAuth2ClientAccess.php ================================================ id; } public function getClient(): ?Client { return $this->client; } public function setClient(?Client $client): self { $this->client = $client; return $this; } public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } public function setCreatedAt(\DateTimeImmutable $createdAt): self { $this->createdAt = $createdAt; return $this; } public function getPath(): ?string { return $this->path; } public function setPath(string $path): self { $this->path = $path; return $this; } } ================================================ FILE: src/Entity/OAuth2UserConsent.php ================================================ 'oauth2.grant.read.general', // Grants all content create and edit permissions 'write' => 'oauth2.grant.write.general', // Grants all content delete permissions 'delete' => 'oauth2.grant.delete.general', // Grants all report permissions 'report' => 'oauth2.grant.report.general', // Grants all vote/boost permissions 'vote' => 'oauth2.grant.vote.general', // Grants all subscription/follow permissions 'subscribe' => 'oauth2.grant.subscribe.general', // Grants all block permissions 'block' => 'oauth2.grant.block.general', // Grants allowing applications to (un)subscribe or (un)block domains on behalf of the user 'domain' => 'oauth2.grant.domain.all', 'domain:subscribe' => 'oauth2.grant.domain.subscribe', 'domain:block' => 'oauth2.grant.domain.block', // Grants allowing the application to create, edit, delete, (up/down)vote, boost, or report entries on behalf of the user 'entry' => 'oauth2.grant.entry.all', 'entry:create' => 'oauth2.grant.entry.create', 'entry:edit' => 'oauth2.grant.entry.edit', 'entry:delete' => 'oauth2.grant.entry.delete', 'entry:vote' => 'oauth2.grant.entry.vote', 'entry:report' => 'oauth2.grant.entry.report', // Grants allowing the application to create, edit, delete, (up/down)vote, boost, or report entry comments on behalf of the user 'entry_comment' => 'oauth2.grant.entry_comment.all', 'entry_comment:create' => 'oauth2.grant.entry_comment.create', 'entry_comment:edit' => 'oauth2.grant.entry_comment.edit', 'entry_comment:delete' => 'oauth2.grant.entry_comment.delete', 'entry_comment:vote' => 'oauth2.grant.entry_comment.vote', 'entry_comment:report' => 'oauth2.grant.entry_comment.report', // Grants allowing the application to (un)subscribe or (un)block magazines on behalf of the user 'magazine' => 'oauth2.grant.magazine.all', 'magazine:subscribe' => 'oauth2.grant.magazine.subscribe', 'magazine:block' => 'oauth2.grant.magazine.block', // Grants allowing the application to create, edit, delete, (up/down)vote, boost, or report posts on behalf of the user 'post' => 'oauth2.grant.post.all', 'post:create' => 'oauth2.grant.post.create', 'post:edit' => 'oauth2.grant.post.edit', 'post:delete' => 'oauth2.grant.post.delete', 'post:vote' => 'oauth2.grant.post.vote', 'post:report' => 'oauth2.grant.post.report', // Grants allowing the application to create, edit, delete, (up/down)vote, boost, or report post comments on behalf of the user 'post_comment' => 'oauth2.grant.post_comment.all', 'post_comment:create' => 'oauth2.grant.post_comment.create', 'post_comment:edit' => 'oauth2.grant.post_comment.edit', 'post_comment:delete' => 'oauth2.grant.post_comment.delete', 'post_comment:vote' => 'oauth2.grant.post_comment.vote', 'post_comment:report' => 'oauth2.grant.post_comment.report', // Various grants related to reading and writing information about the current user, // messages they've sent and received, notifications they have, who they follow, and who they block 'user' => 'oauth2.grant.user.all', 'bookmark' => 'oauth2.grant.bookmark', 'bookmark:add' => 'oauth2.grant.bookmark.add', 'bookmark:remove' => 'oauth2.grant.bookmark.remove', 'bookmark_list' => 'oauth2.grant.bookmark_list', 'bookmark_list:read' => 'oauth2.grant.bookmark_list.read', 'bookmark_list:edit' => 'oauth2.grant.bookmark_list.edit', 'bookmark_list:delete' => 'oauth2.grant.bookmark_list.delete', 'user:profile' => 'oauth2.grant.user.profile.all', 'user:profile:read' => 'oauth2.grant.user.profile.read', 'user:profile:edit' => 'oauth2.grant.user.profile.edit', 'user:message' => 'oauth2.grant.user.message.all', 'user:message:read' => 'oauth2.grant.user.message.read', 'user:message:create' => 'oauth2.grant.user.message.create', 'user:notification' => 'oauth2.grant.user.notification.all', 'user:notification:read' => 'oauth2.grant.user.notification.read', 'user:notification:delete' => 'oauth2.grant.user.notification.delete', 'user:notification:edit' => 'oauth2.grant.user.notification.edit', 'user:oauth_clients' => 'oauth2.grant.user.oauth_clients.all', 'user:oauth_clients:read' => 'oauth2.grant.user.oauth_clients.read', 'user:oauth_clients:edit' => 'oauth2.grant.user.oauth_clients.edit', 'user:follow' => 'oauth2.grant.user.follow', 'user:block' => 'oauth2.grant.user.block', // Moderation grants 'moderate' => 'oauth2.grant.moderate.all', // Entry moderation grants 'moderate:entry' => 'oauth2.grant.moderate.entry.all', 'moderate:entry:language' => 'oauth2.grant.moderate.entry.change_language', 'moderate:entry:pin' => 'oauth2.grant.moderate.entry.pin', 'moderate:entry:lock' => 'oauth2.grant.moderate.entry.lock', 'moderate:entry:set_adult' => 'oauth2.grant.moderate.entry.set_adult', 'moderate:entry:trash' => 'oauth2.grant.moderate.entry.trash', // Entry comment moderation grants 'moderate:entry_comment' => 'oauth2.grant.moderate.entry_comment.all', 'moderate:entry_comment:language' => 'oauth2.grant.moderate.entry_comment.change_language', 'moderate:entry_comment:set_adult' => 'oauth2.grant.moderate.entry_comment.set_adult', 'moderate:entry_comment:trash' => 'oauth2.grant.moderate.entry_comment.trash', // Post moderation grants 'moderate:post' => 'oauth2.grant.moderate.post.all', 'moderate:post:language' => 'oauth2.grant.moderate.post.change_language', 'moderate:post:pin' => 'oauth2.grant.moderate.post.pin', 'moderate:post:lock' => 'oauth2.grant.moderate.post.lock', 'moderate:post:set_adult' => 'oauth2.grant.moderate.post.set_adult', 'moderate:post:trash' => 'oauth2.grant.moderate.post.trash', // Post comment moderation grants 'moderate:post_comment' => 'oauth2.grant.moderate.post_comment.all', 'moderate:post_comment:language' => 'oauth2.grant.moderate.post_comment.change_language', 'moderate:post_comment:set_adult' => 'oauth2.grant.moderate.post_comment.set_adult', 'moderate:post_comment:trash' => 'oauth2.grant.moderate.post_comment.trash', // Magazine moderation grants 'moderate:magazine' => 'oauth2.grant.moderate.magazine.all', 'moderate:magazine:ban' => 'oauth2.grant.moderate.magazine.ban.all', 'moderate:magazine:ban:read' => 'oauth2.grant.moderate.magazine.ban.read', 'moderate:magazine:ban:create' => 'oauth2.grant.moderate.magazine.ban.create', 'moderate:magazine:ban:delete' => 'oauth2.grant.moderate.magazine.ban.delete', 'moderate:magazine:list' => 'oauth2.grant.moderate.magazine.list', 'moderate:magazine:reports' => 'oauth2.grant.moderate.magazine.reports.all', 'moderate:magazine:reports:read' => 'oauth2.grant.moderate.magazine.reports.read', 'moderate:magazine:reports:action' => 'oauth2.grant.moderate.magazine.reports.action', 'moderate:magazine:trash:read' => 'oauth2.grant.moderate.magazine.trash.read', // Magazine owner moderation grants 'moderate:magazine_admin' => 'oauth2.grant.moderate.magazine_admin.all', 'moderate:magazine_admin:create' => 'oauth2.grant.moderate.magazine_admin.create', 'moderate:magazine_admin:delete' => 'oauth2.grant.moderate.magazine_admin.delete', 'moderate:magazine_admin:update' => 'oauth2.grant.moderate.magazine_admin.update', 'moderate:magazine_admin:theme' => 'oauth2.grant.moderate.magazine_admin.edit_theme', 'moderate:magazine_admin:moderators' => 'oauth2.grant.moderate.magazine_admin.moderators', 'moderate:magazine_admin:badges' => 'oauth2.grant.moderate.magazine_admin.badges', 'moderate:magazine_admin:tags' => 'oauth2.grant.moderate.magazine_admin.tags', 'moderate:magazine_admin:stats' => 'oauth2.grant.moderate.magazine_admin.stats', // Admin grants 'admin' => 'oauth2.grant.admin.all', // Purge content entirely from the instance 'admin:entry:purge' => 'oauth2.grant.admin.entry.purge', 'admin:entry_comment:purge' => 'oauth2.grant.admin.entry_comment.purge', 'admin:post:purge' => 'oauth2.grant.admin.post.purge', 'admin:post_comment:purge' => 'oauth2.grant.admin.post_comment.purge', // Administrate magazines 'admin:magazine' => 'oauth2.grant.admin.magazine.all', 'admin:magazine:move_entry' => 'oauth2.grant.admin.magazine.move_entry', 'admin:magazine:purge' => 'oauth2.grant.admin.magazine.purge', 'admin:magazine:moderate' => 'oauth2.grant.admin.magazine.moderate', // Administrate users 'admin:user' => 'oauth2.grant.admin.user.all', 'admin:user:ban' => 'oauth2.grant.admin.user.ban', 'admin:user:verify' => 'oauth2.grant.admin.user.verify', 'admin:user:delete' => 'oauth2.grant.admin.user.delete', 'admin:user:purge' => 'oauth2.grant.admin.user.purge', // Administrate site information 'admin:instance' => 'oauth2.grant.admin.instance.all', 'admin:instance:stats' => 'oauth2.grant.admin.instance.stats', 'admin:instance:settings' => 'oauth2.grant.admin.instance.settings.all', 'admin:instance:settings:read' => 'oauth2.grant.admin.instance.settings.read', 'admin:instance:settings:edit' => 'oauth2.grant.admin.instance.settings.edit', // Update About, FAQ, Contact, ToS, and Privacy Policy 'admin:instance:information:edit' => 'oauth2.grant.admin.instance.information.edit', // Administrate federation with other instances 'admin:federation' => 'oauth2.grant.admin.federation.all', 'admin:federation:read' => 'oauth2.grant.admin.federation.read', 'admin:federation:update' => 'oauth2.grant.admin.federation.update', // Administrate oauth applications 'admin:oauth_clients' => 'oauth2.grant.admin.oauth_clients.all', 'admin:oauth_clients:read' => 'oauth2.grant.admin.oauth_clients.read', 'admin:oauth_clients:revoke' => 'oauth2.grant.admin.oauth_clients.revoke', ]; #[Id] #[GeneratedValue] #[Column] private ?int $id = null; #[ManyToOne(inversedBy: 'oAuth2UserConsents')] #[JoinColumn(name: 'user_id', nullable: false, onDelete: 'CASCADE')] private ?User $user = null; #[ManyToOne(inversedBy: 'oAuth2UserConsents')] #[JoinColumn(name: 'client_identifier', referencedColumnName: 'identifier', nullable: false)] private ?Client $client = null; #[Column] private ?\DateTimeImmutable $createdAt = null; #[Column] private ?\DateTimeImmutable $expiresAt = null; #[Column(type: Types::JSON)] private array $scopes = []; #[Column] private ?string $ipAddress = null; public function getId(): ?int { return $this->id; } public function getUser(): ?User { return $this->user; } public function setUser(?User $user): self { $this->user = $user; return $this; } public function getClient(): ?Client { return $this->client; } public function setClient(?Client $client): self { $this->client = $client; return $this; } public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } public function setCreatedAt(\DateTimeImmutable $createdAt): self { $this->createdAt = $createdAt; return $this; } public function getExpiresAt(): ?\DateTimeImmutable { return $this->expiresAt; } public function setExpiresAt(\DateTimeImmutable $expiresAt): self { $this->expiresAt = $expiresAt; return $this; } public function getScopes(): array { return $this->scopes; } public function setScopes(array $scopes): self { $this->scopes = $scopes; return $this; } public function getIpAddress(): string { return $this->ipAddress; } public function setIpAddress(string $ipAddress): self { $this->ipAddress = $ipAddress; return $this; } } ================================================ FILE: src/Entity/Post.php ================================================ 0])] public int $favouriteCount = 0; #[Column(type: 'integer', nullable: false)] public int $score = 0; #[Column(type: 'boolean', nullable: false)] public bool $isAdult = false; #[Column(type: 'boolean', nullable: false, options: ['default' => false])] public bool $sticky = false; #[Column(type: 'boolean', nullable: false, options: ['default' => false])] public bool $isLocked = false; #[Column(type: 'datetimetz')] public ?\DateTime $lastActive; #[Column(type: 'string', nullable: true)] public ?string $ip = null; #[Column(type: Types::JSONB, nullable: true)] public ?array $mentions = null; #[OneToMany(mappedBy: 'post', targetEntity: PostComment::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $comments; #[OneToMany(mappedBy: 'post', targetEntity: PostVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $votes; #[OneToMany(mappedBy: 'post', targetEntity: PostReport::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $reports; #[OneToMany(mappedBy: 'post', targetEntity: PostFavourite::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $favourites; #[OneToMany(mappedBy: 'post', targetEntity: PostCreatedNotification::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $notifications; #[OneToMany(mappedBy: 'post', targetEntity: HashtagLink::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $hashtags; public array $children = []; #[Id] #[GeneratedValue] #[Column(type: 'integer')] private int $id; #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => 'english'])] private $bodyTs; public function __construct( ?string $body, Magazine $magazine, User $user, bool $isAdult, ?string $ip = null, ) { $this->body = $body; $this->magazine = $magazine; $this->user = $user; $this->isAdult = $isAdult; $this->ip = $ip; $this->comments = new ArrayCollection(); $this->votes = new ArrayCollection(); $this->reports = new ArrayCollection(); $this->favourites = new ArrayCollection(); $this->notifications = new ArrayCollection(); $user->addPost($this); $this->createdAtTraitConstruct(); $this->updateLastActive(); } public function updateLastActive(): void { $this->comments->get(-1); $criteria = Criteria::create() ->orderBy(['createdAt' => 'DESC']) ->setMaxResults(1); $lastComment = $this->comments->matching($criteria)->first(); if ($lastComment) { $this->lastActive = \DateTime::createFromImmutable($lastComment->createdAt); } else { $this->lastActive = \DateTime::createFromImmutable($this->getCreatedAt()); } } public function getId(): int { return $this->id; } public function getApId(): ?string { return $this->apId; } public function getBestComments(?User $user = null): Collection { $criteria = Criteria::create() ->orderBy(['upVotes' => 'DESC', 'createdAt' => 'ASC']); $comments = $this->comments->matching($criteria); $comments = $this->handlePrivateComments($comments, $user); $comments = new ArrayCollection($comments->slice(0, 2)); if (!\count(array_filter($comments->toArray(), fn ($comment) => $comment->countUpVotes() > 0))) { return $this->getLastComments(); } $iterator = $comments->getIterator(); $iterator->uasort(function ($a, $b) { return ($a->createdAt < $b->createdAt) ? -1 : 1; }); return new ArrayCollection(iterator_to_array($iterator)); } private function handlePrivateComments(ArrayCollection $comments, ?User $user): ArrayCollection { return $comments->filter(function (PostComment $val) use ($user) { if ($user && VisibilityInterface::VISIBILITY_PRIVATE === $val->visibility) { return $user->isFollower($val->user); } return VisibilityInterface::VISIBILITY_VISIBLE === $val->visibility; }); } public function getLastComments(?User $user = null): Collection { $criteria = Criteria::create() ->orderBy(['createdAt' => 'ASC']); $comments = $this->comments->matching($criteria); $comments = $this->handlePrivateComments($comments, $user); return new ArrayCollection($comments->slice(-2, 2)); } public function addComment(PostComment $comment): self { if (!$this->comments->contains($comment)) { $this->comments->add($comment); $comment->post = $this; } $this->updateCounts(); $this->updateRanking(); $this->updateLastActive(); return $this; } public function updateCounts(): self { $criteria = Criteria::create() ->andWhere(Criteria::expr()->eq('visibility', VisibilityInterface::VISIBILITY_VISIBLE)); $this->commentCount = $this->comments->matching($criteria)->count(); $this->favouriteCount = $this->favourites->count(); return $this; } public function removeComment(PostComment $comment): self { if ($this->comments->removeElement($comment)) { if ($comment->post === $this) { $comment->post = null; } } $this->updateCounts(); $this->updateRanking(); $this->updateLastActive(); return $this; } public function softDelete(): void { $this->visibility = VisibilityInterface::VISIBILITY_SOFT_DELETED; } public function trash(): void { $this->visibility = VisibilityInterface::VISIBILITY_TRASHED; } public function restore(): void { $this->visibility = VisibilityInterface::VISIBILITY_VISIBLE; } public function updateScore(): self { $this->score = $this->favouriteCount + $this->getUpVotes()->count() - $this->getDownVotes()->count(); return $this; } public function addVote(Vote $vote): self { Assert::isInstanceOf($vote, PostVote::class); if (!$this->votes->contains($vote)) { $this->votes->add($vote); $vote->post = $this; } $this->updateScore(); $this->updateRanking(); return $this; } public function removeVote(Vote $vote): self { Assert::isInstanceOf($vote, PostVote::class); if ($this->votes->removeElement($vote)) { if ($vote->getPost() === $this) { $vote->setPost(null); } } $this->updateScore(); $this->updateRanking(); return $this; } public function isAuthor(User $user): bool { return $user === $this->user; } public function getShortTitle(?int $length = 60): string { $body = wordwrap($this->body ?? '', $length); $body = explode("\n", $body); return trim($body[0]).(isset($body[1]) ? '...' : ''); } public function getCommentCount(): int { return $this->commentCount; } public function getUniqueCommentCount(): int { $users = []; $count = 0; foreach ($this->comments as $comment) { if (!\in_array($comment->user, $users)) { $users[] = $comment->user; ++$count; } } return $count; } public function getScore(): int { return $this->score; } public function getMagazine(): ?Magazine { return $this->magazine; } public function getUser(): ?User { return $this->user; } public function isFavored(User $user): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('user', $user)); return $this->favourites->matching($criteria)->count() > 0; } public function isAdult(): bool { return $this->isAdult || $this->magazine->isAdult; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/PostComment.php ================================================ 0])] public int $favouriteCount = 0; #[Column(type: 'datetimetz')] public ?\DateTime $lastActive; #[Column(type: 'string', nullable: true)] public ?string $ip = null; #[Column(type: Types::JSONB, nullable: true)] public ?array $mentions = null; #[Column(type: 'boolean', nullable: false)] public bool $isAdult = false; #[Column(type: 'boolean', nullable: false, options: ['default' => false])] public ?bool $updateMark = false; #[OneToMany(mappedBy: 'parent', targetEntity: PostComment::class, orphanRemoval: true)] #[OrderBy(['createdAt' => 'ASC'])] public Collection $children; #[OneToMany(mappedBy: 'root', targetEntity: PostComment::class, orphanRemoval: true)] #[OrderBy(['createdAt' => 'ASC'])] public Collection $nested; #[OneToMany(mappedBy: 'comment', targetEntity: PostCommentVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $votes; #[OneToMany(mappedBy: 'postComment', targetEntity: PostCommentReport::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $reports; #[OneToMany(mappedBy: 'postComment', targetEntity: PostCommentFavourite::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $favourites; #[OneToMany(mappedBy: 'postComment', targetEntity: PostCommentCreatedNotification::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $notifications; #[OneToMany(mappedBy: 'postComment', targetEntity: HashtagLink::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $hashtags; #[Id] #[GeneratedValue] #[Column(type: 'integer')] private int $id; #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => 'english'])] private $bodyTs; public function __construct(string $body, ?Post $post, User $user, ?PostComment $parent = null, ?string $ip = null) { $this->body = $body; $this->post = $post; $this->user = $user; $this->parent = $parent; $this->ip = $ip; $this->votes = new ArrayCollection(); $this->children = new ArrayCollection(); $this->reports = new ArrayCollection(); $this->favourites = new ArrayCollection(); if ($parent) { $this->root = $parent->root ?? $parent; } $this->createdAtTraitConstruct(); $this->updateLastActive(); } public function updateLastActive(): void { $this->lastActive = \DateTime::createFromImmutable($this->createdAt); $this->post->lastActive = \DateTime::createFromImmutable($this->createdAt); } public function getId(): int { return $this->id; } public function getApId(): ?string { return $this->apId; } public function addVote(Vote $vote): self { Assert::isInstanceOf($vote, PostCommentVote::class); if (!$this->votes->contains($vote)) { $this->votes->add($vote); $vote->setComment($this); } return $this; } public function removeVote(Vote $vote): self { Assert::isInstanceOf($vote, PostCommentVote::class); if ($this->votes->removeElement($vote)) { // set the owning side to null (unless already changed) if ($vote->getComment() === $this) { $vote->setComment(null); } } return $this; } public function getChildrenRecursive(int &$startIndex = 0): \Traversable { foreach ($this->children as $child) { yield $startIndex++ => $child; yield from $child->getChildrenRecursive($startIndex); } } public function softDelete(): void { $this->visibility = VisibilityInterface::VISIBILITY_SOFT_DELETED; } public function trash(): void { $this->visibility = VisibilityInterface::VISIBILITY_TRASHED; } public function restore(): void { $this->visibility = VisibilityInterface::VISIBILITY_VISIBLE; } public function isAuthor(User $user): bool { return $user === $this->user; } public function getShortTitle(?int $length = 60): string { $body = wordwrap($this->body ?? '', $length); $body = explode("\n", $body); return trim($body[0]).(isset($body[1]) ? '...' : ''); } public function getMagazine(): ?Magazine { return $this->magazine; } public function getUser(): ?User { return $this->user; } public function updateCounts(): self { $this->favouriteCount = $this->favourites->count(); return $this; } public function isFavored(User $user): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('user', $user)); return $this->favourites->matching($criteria)->count() > 0; } public function getTags(): array { return array_values($this->tags ?? []); } public function __sleep() { return []; } public function updateRanking(): void { } public function updateScore(): self { return $this; } public function getParentSubject(): ?ContentInterface { return $this->post; } public function containsBannedHashtags(): bool { foreach ($this->hashtags as /** @var HashtagLink $hashtag */ $hashtag) { if ($hashtag->hashtag->banned) { return true; } } return false; } /** * @param 'profile'|'comments' $filterRealm */ public function containsFilteredWords(User $loggedInUser, string $filterRealm): bool { foreach ($loggedInUser->getCurrentFilterLists() as $list) { if (!$list->$filterRealm) { continue; } foreach ($list->words as $word) { if ($word['exactMatch']) { if (str_contains($this->body, $word['word'])) { return true; } } else { if (str_contains(strtolower($this->body), strtolower($word['word']))) { return true; } } } } return false; } /** * @param 'profile'|'comments' $filterRealm */ public function getChildrenByCriteria(MbinCriteria $postCommentCriteria, ?User $loggedInUser, string $filterRealm): array { $criteria = Criteria::create(); if ($postCommentCriteria->languages) { $criteria->andwhere(Criteria::expr()->in('lang', $postCommentCriteria->languages)); } if (MbinCriteria::AP_LOCAL === $postCommentCriteria->federation) { $criteria->andWhere(Criteria::expr()->isNull('apId')); } elseif (MbinCriteria::AP_FEDERATED === $postCommentCriteria->federation) { $criteria->andWhere(Criteria::expr()->isNotNull('apId')); } if (MbinCriteria::TIME_ALL !== $postCommentCriteria->time) { $criteria->andWhere(Criteria::expr()->gte('createdAt', $postCommentCriteria->getSince())); } $children = $this->children ->matching($criteria) ->filter(fn (PostComment $comment) => !$comment->containsBannedHashtags() && (!$loggedInUser || !$comment->containsFilteredWords($loggedInUser, $filterRealm))) ->toArray(); // id sort uasort($children, fn ($a, $b) => ArrayUtils::numCompareAscending($a->id, $b->id)); switch ($postCommentCriteria->sortOption) { case MbinCriteria::SORT_TOP: case MbinCriteria::SORT_HOT: uasort($children, fn (PostComment $a, PostComment $b) => ArrayUtils::numCompareDescending($a->upVotes + $a->favouriteCount, $b->upVotes + $b->favouriteCount)); break; case MbinCriteria::SORT_ACTIVE: uasort($children, fn (PostComment $a, PostComment $b) => ArrayUtils::numCompareDescending($a->lastActive->getTimestamp(), $b->lastActive->getTimestamp())); break; case MbinCriteria::SORT_OLD: uasort($children, fn (PostComment $a, PostComment $b) => ArrayUtils::numCompareDescending($a->createdAt->getTimestamp(), $b->createdAt->getTimestamp())); break; case MbinCriteria::SORT_NEW: uasort($children, fn (PostComment $a, PostComment $b) => ArrayUtils::numCompareAscending($a->createdAt->getTimestamp(), $b->createdAt->getTimestamp())); break; default: } return $children; } } ================================================ FILE: src/Entity/PostCommentCreatedNotification.php ================================================ postComment = $comment; } public function getSubject(): PostComment { return $this->postComment; } public function getComment(): PostComment { return $this->postComment; } public function getType(): string { return 'post_comment_created_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $this->postComment->user->username, $trans->trans('added_new_comment'), $this->postComment->getShortTitle()); $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->postComment->user->avatar->filePath : null; $url = $urlGenerator->generate('post_single', [ 'magazine_name' => $this->postComment->post->magazine->name, 'post_id' => $this->postComment->post->getId(), 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, ]).'#post-comment-'.$this->postComment->getId(); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/PostCommentDeletedNotification.php ================================================ postComment = $comment; } public function getSubject(): PostComment { return $this->postComment; } public function getComment(): PostComment { return $this->postComment; } public function getType(): string { return 'post_comment_deleted_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $trans->trans('comment'), $this->postComment->getShortTitle(), $this->postComment->isTrashed() ? $trans->trans('removed') : $trans->trans('deleted')); $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$this->postComment->user->avatar->filePath : null; $url = $urlGenerator->generate('post_single', [ 'magazine_name' => $this->postComment->post->magazine->name, 'post_id' => $this->postComment->post->getId(), 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, ]).'#post-comment-'.$this->postComment->getId(); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_removed_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/PostCommentEditedNotification.php ================================================ postComment = $comment; } public function getSubject(): PostComment { return $this->postComment; } public function getComment(): PostComment { return $this->postComment; } public function getType(): string { return 'post_comment_edited_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $this->postComment->user->username, $trans->trans('edited_comment'), $this->postComment->getShortTitle()); $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->postComment->user->avatar->filePath : null; $url = $urlGenerator->generate('post_single', [ 'magazine_name' => $this->postComment->post->magazine->name, 'post_id' => $this->postComment->post->getId(), 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, ]).'#post-comment-'.$this->postComment->getId(); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_edited_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/PostCommentFavourite.php ================================================ magazine = $comment->magazine; $this->postComment = $comment; } public function getSubject(): PostComment { return $this->postComment; } public function clearSubject(): Favourite { $this->postComment = null; return $this; } public function getType(): string { return 'post_comment'; } } ================================================ FILE: src/Entity/PostCommentMentionedNotification.php ================================================ postComment = $comment; } public function getSubject(): PostComment { return $this->postComment; } public function getComment(): PostComment { return $this->postComment; } public function getType(): string { return 'post_comment_mentioned_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $this->postComment->user->username, $trans->trans('mentioned_you'), $this->postComment->getShortTitle()); $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->postComment->user->avatar->filePath : null; $url = $urlGenerator->generate('post_single', [ 'magazine_name' => $this->postComment->post->magazine->name, 'post_id' => $this->postComment->post->getId(), 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, ]).'#post-comment-'.$this->postComment->getId(); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_mention', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/PostCommentReplyNotification.php ================================================ postComment = $comment; } public function getSubject(): PostComment { return $this->postComment; } public function getComment(): PostComment { return $this->postComment; } public function getType(): string { return 'post_comment_reply_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $this->postComment->user->username, $trans->trans('replied_to_your_comment'), $this->postComment->getShortTitle()); $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->postComment->user->avatar->filePath : null; $url = $urlGenerator->generate('post_single', [ 'magazine_name' => $this->postComment->post->magazine->name, 'post_id' => $this->postComment->post->getId(), 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, ]).'#post-comment-'.$this->postComment->getId(); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_reply', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/PostCommentReport.php ================================================ user, $comment->magazine, $reason); $this->postComment = $comment; } public function getSubject(): PostComment { return $this->postComment; } public function clearSubject(): Report { $this->postComment = null; return $this; } public function getType(): string { return 'post_comment'; } } ================================================ FILE: src/Entity/PostCommentVote.php ================================================ user); $this->comment = $comment; } public function getComment(): PostComment { return $this->comment; } public function setComment(?PostComment $comment): self { $this->comment = $comment; return $this; } public function getSubject(): VotableInterface { return $this->comment; } } ================================================ FILE: src/Entity/PostCreatedNotification.php ================================================ post = $post; } public function getSubject(): Post { return $this->post; } public function getType(): string { return 'post_created_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $this->post->user->username, $trans->trans('added_new_post'), $this->post->getShortTitle()); $slash = $this->post->user->avatar && !str_starts_with('/', $this->post->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->post->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->post->user->avatar->filePath : null; $url = $urlGenerator->generate('post_single', [ 'magazine_name' => $this->post->magazine->name, 'post_id' => $this->post->getId(), 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, ]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_post', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/PostDeletedNotification.php ================================================ post = $post; } public function getSubject(): Post { return $this->post; } public function getType(): string { return 'post_deleted_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $trans->trans('post'), $this->post->getShortTitle(), $this->post->isTrashed() ? $trans->trans('removed') : $trans->trans('deleted')); $slash = $this->post->user->avatar && !str_starts_with('/', $this->post->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->post->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->post->user->avatar->filePath : null; $url = $urlGenerator->generate('post_single', [ 'magazine_name' => $this->post->magazine->name, 'post_id' => $this->post->getId(), 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, ]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_removed_post', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/PostEditedNotification.php ================================================ post = $post; } public function getSubject(): Post { return $this->post; } public function getType(): string { return 'post_edited_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $this->post->user->username, $trans->trans('edited_post'), $this->post->getShortTitle()); $slash = $this->post->user->avatar && !str_starts_with('/', $this->post->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->post->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->post->user->avatar->filePath : null; $url = $urlGenerator->generate('post_single', [ 'magazine_name' => $this->post->magazine->name, 'post_id' => $this->post->getId(), 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, ]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_edited_post', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/PostFavourite.php ================================================ magazine = $post->magazine; $this->post = $post; } public function getSubject(): Post { return $this->post; } public function clearSubject(): Favourite { $this->post = null; return $this; } public function getType(): string { return 'post'; } } ================================================ FILE: src/Entity/PostMentionedNotification.php ================================================ post = $post; } public function getSubject(): Post { return $this->post; } public function getType(): string { return 'post_mentioned_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { $message = \sprintf('%s %s - %s', $this->post->user->username, $trans->trans('mentioned_you'), $this->post->getShortTitle()); $slash = $this->post->user->avatar && !str_starts_with('/', $this->post->user->avatar->filePath) ? '/' : ''; $avatarUrl = $this->post->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->post->user->avatar->filePath : null; $url = $urlGenerator->generate('post_single', [ 'magazine_name' => $this->post->magazine->name, 'post_id' => $this->post->getId(), 'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug, ]); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_mention', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl); } } ================================================ FILE: src/Entity/PostReport.php ================================================ user, $post->magazine, $reason); $this->post = $post; } public function getSubject(): Post { return $this->post; } public function clearSubject(): Report { $this->post = null; return $this; } public function getType(): string { return 'post'; } } ================================================ FILE: src/Entity/PostVote.php ================================================ user); $this->post = $post; } public function getSubject(): VotableInterface { return $this->post; } } ================================================ FILE: src/Entity/Report.php ================================================ 'EntryReport', 'entry_comment' => 'EntryCommentReport', 'post' => 'PostReport', 'post_comment' => 'PostCommentReport', ])] #[UniqueConstraint(name: 'report_uuid_idx', columns: ['uuid'])] abstract class Report { use CreatedAtTrait { CreatedAtTrait::__construct as createdAtTraitConstruct; } use ConsideredAtTrait; public const STATUS_PENDING = 'pending'; public const STATUS_APPROVED = 'approved'; public const STATUS_REJECTED = 'rejected'; public const STATUS_APPEAL = 'appeal'; public const STATUS_CLOSED = 'closed'; public const STATUS_ANY = 'any'; public const STATUS_OPTIONS = [ self::STATUS_ANY, self::STATUS_APPEAL, self::STATUS_APPROVED, self::STATUS_CLOSED, self::STATUS_PENDING, self::STATUS_REJECTED, ]; #[ManyToOne(targetEntity: Magazine::class, inversedBy: 'reports')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] public Magazine $magazine; #[ManyToOne(targetEntity: User::class, inversedBy: 'reports')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] public User $reporting; #[ManyToOne(targetEntity: User::class, inversedBy: 'violations')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] public User $reported; #[ManyToOne(targetEntity: User::class)] #[JoinColumn(nullable: true, onDelete: 'CASCADE')] public ?User $consideredBy = null; #[Column(type: 'string', nullable: true)] public ?string $reason = null; #[Column(type: 'integer', nullable: false)] public int $weight = 1; #[Column(type: 'string', nullable: false)] public string $status = self::STATUS_PENDING; // this is nullable to be compatible with previous versions #[Column(type: 'string', unique: true, nullable: true)] public string $uuid; #[Id] #[GeneratedValue] #[Column(type: 'integer')] private int $id; public function __construct(User $reporting, User $reported, Magazine $magazine, ?string $reason = null) { $this->reporting = $reporting; $this->reported = $reported; $this->magazine = $magazine; $this->reason = $reason; $this->uuid = Uuid::v4()->toRfc4122(); $this->createdAtTraitConstruct(); } public function getId(): int { return $this->id; } public function increaseWeight(): self { ++$this->weight; return $this; } abstract public function getType(): string; abstract public function getSubject(): ?ReportInterface; abstract public function clearSubject(): Report; } ================================================ FILE: src/Entity/ReportApprovedNotification.php ================================================ report = $report; } public function getType(): string { return 'report_approved_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { /** @var Entry|EntryComment|Post|PostComment $subject */ $subject = $this->report->getSubject(); $linkToSubject = $this->getSubjectLink($this->report->getSubject(), $urlGenerator); $linkToReport = $urlGenerator->generate('magazine_panel_reports', ['name' => $this->report->magazine->name, 'status' => Report::STATUS_APPROVED]); if ($this->report->reporting->getId() === $this->user->getId()) { $title = $trans->trans('own_report_accepted', locale: $locale); $message = \sprintf('%s: %s', $trans->trans('report_subject', locale: $locale), $subject->getShortTitle()); $actionUrl = $linkToSubject; } elseif ($this->report->reported->getId() === $this->user->getId()) { $title = $trans->trans('own_content_reported_accepted', locale: $locale); $message = \sprintf('%s: %s', $trans->trans('report_subject', locale: $locale), $subject->getShortTitle()); $actionUrl = $linkToSubject; } else { $title = $trans->trans('report_accepted', locale: $locale); $message = \sprintf('%s: %s\n%s: %s\n%s: %s - %s', $trans->trans('reported_user', locale: $locale), $this->report->reported->username, $trans->trans('reporting_user', locale: $locale), $this->report->reporting->username, $trans->trans('report_subject', locale: $locale), $subject->getShortTitle(), $linkToSubject ); $actionUrl = $linkToReport; } return new PushNotification($this->getId(), $message, $title, actionUrl: $actionUrl); } private function getSubjectLink(ReportInterface $subject, UrlGeneratorInterface $urlGenerator): string { if ($subject instanceof Entry) { return $urlGenerator->generate('entry_single', ['magazine_name' => $subject->magazine->name, 'entry_id' => $subject->getId(), 'slug' => $subject->slug]); } elseif ($subject instanceof EntryComment) { return $urlGenerator->generate('entry_comment_view', ['magazine_name' => $subject->magazine->name, 'entry_id' => $subject->entry->getId(), 'slug' => $subject->entry->slug, 'comment_id' => $subject->getId()]); } elseif ($subject instanceof Post) { return $urlGenerator->generate('post_single', ['magazine_name' => $subject->magazine->name, 'post_id' => $subject->getId(), 'slug' => $subject->slug]); } elseif ($subject instanceof PostComment) { return $urlGenerator->generate('post_single', ['magazine_name' => $subject->magazine->name, 'post_id' => $subject->post->getId(), 'slug' => $subject->post->slug]).'#post-comment-'.$subject->getId(); } return ''; } } ================================================ FILE: src/Entity/ReportCreatedNotification.php ================================================ report = $report; } public function getType(): string { return 'report_created_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { /** @var Entry|EntryComment|Post|PostComment $subject */ $subject = $this->report->getSubject(); $reportLink = $urlGenerator->generate('magazine_panel_reports', ['name' => $this->report->magazine->name, 'status' => Report::STATUS_PENDING]).'#report-id-'.$this->report->getId(); $message = \sprintf('%s %s %s\n%s: %s', $this->report->reporting->username, $trans->trans('reported', locale: $locale), $this->report->reported->username, $trans->trans('report_subject', locale: $locale), $subject->getShortTitle()); return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_report'), actionUrl: $reportLink); } } ================================================ FILE: src/Entity/ReportRejectedNotification.php ================================================ report = $report; } public function getType(): string { return 'report_rejected_notification'; } public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification { /** @var Entry|EntryComment|Post|PostComment $subject */ $subject = $this->report->getSubject(); $message = \sprintf('%s: %s\n%s: %s', $trans->trans('reported_user', locale: $locale), $this->report->reported->username, $trans->trans('report_subject', locale: $locale), $subject->getShortTitle() ); return new PushNotification($this->getId(), $message, $trans->trans('own_report_rejected', locale: $locale), actionUrl: $this->getSubjectLink($subject, $urlGenerator)); } private function getSubjectLink(ReportInterface $subject, UrlGeneratorInterface $urlGenerator): string { if ($subject instanceof Entry) { return $urlGenerator->generate('entry_single', ['magazine_name' => $subject->magazine->name, 'entry_id' => $subject->getId(), 'slug' => $subject->slug]); } elseif ($subject instanceof EntryComment) { return $urlGenerator->generate('entry_comment_view', ['magazine_name' => $subject->magazine->name, 'entry_id' => $subject->entry->getId(), 'slug' => $subject->entry->slug, 'comment_id' => $subject->getId()]); } elseif ($subject instanceof Post) { return $urlGenerator->generate('post_single', ['magazine_name' => $subject->magazine->name, 'post_id' => $subject->getId(), 'slug' => $subject->slug]); } elseif ($subject instanceof PostComment) { return $urlGenerator->generate('post_single', ['magazine_name' => $subject->magazine->name, 'post_id' => $subject->post->getId(), 'slug' => $subject->post->slug]).'#post-comment-'.$subject->getId(); } return ''; } } ================================================ FILE: src/Entity/ResetPasswordRequest.php ================================================ user = $user; $this->initialize($expiresAt, $selector, $hashedToken); } public function getId(): ?int { return $this->id; } public function getUser(): object { return $this->user; } } ================================================ FILE: src/Entity/Settings.php ================================================ name = $name; if (\is_array($value)) { $this->json = $value; } else { $this->value = $value; } } public function getId(): int { return $this->id; } } ================================================ FILE: src/Entity/Site.php ================================================ id; } } ================================================ FILE: src/Entity/Traits/ActivityPubActivityTrait.php ================================================ privateKey; } public function getPublicKey(): ?string { return $this->publicKey; } public function rotatePrivateKey(bool $revert = false): void { if (!$revert) { $this->oldPrivateKey = $this->privateKey; $this->oldPublicKey = $this->publicKey; // set new private and public key KeysGenerator::generate($this); } else { if (null === $this->oldPrivateKey || null === $this->oldPublicKey) { throw new \InvalidArgumentException('you cannot revert if there is no old key'); } $newerPrivateKey = $this->privateKey; $newerPublicKey = $this->publicKey; $this->privateKey = $this->oldPrivateKey; $this->publicKey = $this->oldPublicKey; $this->oldPrivateKey = $newerPrivateKey; $this->oldPublicKey = $newerPublicKey; } $this->lastKeyRotationDate = new \DateTime(); } } ================================================ FILE: src/Entity/Traits/ConsideredAtTrait.php ================================================ consideredAt; } public function setConsideredAt(): \DateTimeImmutable { $this->consideredAt = new \DateTimeImmutable('@'.time()); return $this->consideredAt; } } ================================================ FILE: src/Entity/Traits/CreatedAtTrait.php ================================================ createdAt = new \DateTimeImmutable('@'.time()); } public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } public function isNew(): bool { $days = self::NEW_FOR_DAYS; return $this->getCreatedAt() >= new \DateTime("now -$days days"); } public function isCakeDay(): bool { $now = new \DateTime(); return $this->getCreatedAt()->format('d') === $now->format('d') && $this->getCreatedAt()->format('m') === $now->format('m'); } } ================================================ FILE: src/Entity/Traits/EditedAtTrait.php ================================================ editedAt; } } ================================================ FILE: src/Entity/Traits/MonitoringPerformanceTrait.php ================================================ startedAt; } public function setStartedAt(): void { $this->startedAt = new \DateTimeImmutable(); $this->startedAtMicroseconds = microtime(true); } public function setEndedAt(): void { $this->endedAt = new \DateTimeImmutable(); $this->endedAtMicroseconds = microtime(true); } public function getEndedAt(): \DateTimeImmutable { return $this->endedAt; } public function setDuration(): void { $this->durationMilliseconds = ($this->endedAtMicroseconds - $this->startedAtMicroseconds) * 1000; } public function getDuration(): float { return $this->durationMilliseconds; } } ================================================ FILE: src/Entity/Traits/RankingTrait.php ================================================ getScore(); $scoreAdvantage = $score * self::NETSCORE_MULTIPLIER; if ($score > self::DOWNVOTED_CUTOFF) { $commentAdvantage = $this->getCommentCount() * self::COMMENT_MULTIPLIER; $commentAdvantage += $this->getUniqueCommentCount() * self::COMMENT_UNIQUE_MULTIPLIER; } else { $commentAdvantage = $this->getCommentCount() * self::COMMENT_DOWNVOTED_MULTIPLIER; $commentAdvantage += $this->getUniqueCommentCount() * self::COMMENT_DOWNVOTED_MULTIPLIER; } $advantage = max(min($scoreAdvantage + $commentAdvantage, self::MAX_ADVANTAGE), -self::MAX_PENALTY); // cap max date advantage at the time of calculation to cope with posts // that have funny dates (e.g. 4200-06-09) // which can cause int overflow (int32?) on ranking score $dateAdvantage = min($this->getCreatedAt()->getTimestamp(), (new \DateTimeImmutable())->getTimestamp()); // also cap the final score to not exceed int32 size for the time being $this->ranking = min($dateAdvantage + $advantage, 2 ** 31 - 1); } public function getRanking(): int { return $this->ranking; } public function setRanking(int $ranking): void { $this->ranking = $ranking; } } ================================================ FILE: src/Entity/Traits/UpdatedAtTrait.php ================================================ updatedAt; } public function setUpdatedAt(): \DateTimeImmutable { $this->updatedAt = new \DateTimeImmutable('@'.time()); return $this->updatedAt; } } ================================================ FILE: src/Entity/Traits/VisibilityTrait.php ================================================ VisibilityInterface::VISIBILITY_VISIBLE])] public string $visibility = VisibilityInterface::VISIBILITY_VISIBLE; #[Pure] public function isVisible(): bool { return VisibilityInterface::VISIBILITY_VISIBLE === $this->getVisibility(); } public function getVisibility(): string { return $this->visibility; } #[Pure] public function isSoftDeleted(): bool { return VisibilityInterface::VISIBILITY_SOFT_DELETED === $this->getVisibility(); } #[Pure] public function isTrashed(): bool { return VisibilityInterface::VISIBILITY_TRASHED === $this->getVisibility(); } #[Pure] public function isPrivate(): bool { return VisibilityInterface::VISIBILITY_PRIVATE === $this->getVisibility(); } } ================================================ FILE: src/Entity/Traits/VotableTrait.php ================================================ apShareCount ?? $this->upVotes; } public function countDownVotes(): int { return $this->apDislikeCount ?? $this->downVotes; } public function countVotes(): int { return $this->countDownVotes() + $this->countUpVotes(); } public function getUserChoice(User $user): int { $vote = $this->getUserVote($user); return $vote ? $vote->choice : VotableInterface::VOTE_NONE; } public function getUserVote(User $user): ?Vote { $criteria = Criteria::create() ->where(Criteria::expr()->eq('user', $user)); return $this->votes->matching($criteria)->first() ?: null; } public function updateVoteCounts(): self { $this->upVotes = $this->getUpVotes()->count(); $this->downVotes = $this->getDownVotes()->count(); return $this; } public function getUpVotes(): Collection { $this->votes->get(-1); $criteria = Criteria::create() ->where(Criteria::expr()->eq('choice', self::VOTE_UP)); return $this->votes->matching($criteria); } public function getDownVotes(): Collection { $this->votes->get(-1); $criteria = Criteria::create() ->where(Criteria::expr()->eq('choice', self::VOTE_DOWN)); return $this->votes->matching($criteria); } } ================================================ FILE: src/Entity/User.php ================================================ self::HOMEPAGE_ALL])] public string $homepage = self::HOMEPAGE_ALL; #[Column(type: 'boolean', nullable: false, options: ['default' => false])] public bool $showBoostsOfFollowing = false; #[Column(type: 'enumSortOptions', nullable: false, options: ['default' => ESortOptions::Hot->value])] public string $frontDefaultSort = ESortOptions::Hot->value; #[Column(type: 'enumFrontContentOptions', nullable: true)] public ?string $frontDefaultContent = null; #[Column(type: 'enumSortOptions', nullable: false, options: ['default' => ESortOptions::Hot->value])] public string $commentDefaultSort = ESortOptions::Hot->value; #[Column(type: 'enumDirectMessageSettings', nullable: false, options: ['default' => EDirectMessageSettings::Everyone->value])] public string $directMessageSetting = EDirectMessageSettings::Everyone->value; #[Column(type: 'text', nullable: true)] public ?string $about = null; #[Column(type: 'datetimetz')] public ?\DateTime $lastActive = null; #[Column(type: 'datetimetz', nullable: true)] public ?\DateTime $markedForDeletionAt = null; #[Column(type: Types::JSONB, nullable: true)] public ?array $fields = null; #[Column(type: 'string', nullable: true)] public ?string $oauthAzureId = null; #[Column(type: 'string', nullable: true)] public ?string $oauthGithubId = null; #[Column(type: 'string', nullable: true)] public ?string $oauthGoogleId = null; #[Column(type: 'string', nullable: true)] public ?string $oauthFacebookId = null; #[Column(name: 'oauth_privacyportal_id', type: 'string', nullable: true)] public ?string $oauthPrivacyPortalId = null; #[Column(type: 'string', nullable: true)] public ?string $oauthKeycloakId = null; #[Column(type: 'string', nullable: true)] public ?string $oauthSimpleLoginId = null; #[Column(type: 'string', nullable: true)] public ?string $oauthDiscordId = null; #[Column(type: 'string', nullable: true)] public ?string $oauthZitadelId = null; #[Column(type: 'string', nullable: true)] public ?string $oauthAuthentikId = null; #[Column(type: 'boolean', nullable: false, options: ['default' => true])] public bool $hideAdult = true; #[Column(type: Types::JSONB, nullable: false, options: ['default' => '[]'])] public array $preferredLanguages = []; #[Column(type: 'simple_array', nullable: true)] public ?array $featuredMagazines = null; #[Column(type: 'boolean', nullable: false, options: ['default' => true])] public bool $showProfileSubscriptions = false; #[Column(type: 'boolean', nullable: false, options: ['default' => true])] public bool $showProfileFollowings = true; #[Column(type: 'boolean', nullable: false)] public bool $notifyOnNewEntry = false; #[Column(type: 'boolean', nullable: false)] public bool $notifyOnNewEntryReply = true; #[Column(type: 'boolean', nullable: false)] public bool $notifyOnNewEntryCommentReply = true; #[Column(type: 'boolean', nullable: false)] public bool $notifyOnNewPost = false; #[Column(type: 'boolean', nullable: false)] public bool $notifyOnNewPostReply = true; #[Column(type: 'boolean', nullable: false)] public bool $notifyOnNewPostCommentReply = true; #[Column(type: 'boolean', nullable: false, options: ['default' => true])] public bool $notifyOnUserSignup = true; #[Column(type: 'boolean', nullable: false, options: ['default' => false])] public bool $addMentionsEntries = false; #[Column(type: 'boolean', nullable: false, options: ['default' => true])] public bool $addMentionsPosts = true; #[Column(type: 'boolean', nullable: false, options: ['default' => false])] public bool $isBanned = false; #[Column(type: 'string', nullable: true, options: ['default' => null])] public ?string $banReason = null; #[Column(type: 'boolean', nullable: false)] public bool $isVerified = false; #[Column(type: 'boolean', nullable: false, options: ['default' => false])] public bool $isDeleted = false; #[Column(type: 'text', nullable: true)] public ?string $customCss = null; #[Column(type: 'boolean', nullable: false, options: ['default' => false])] public bool $ignoreMagazinesCustomCss = false; #[OneToMany(mappedBy: 'user', targetEntity: Moderator::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $moderatorTokens; #[OneToMany(mappedBy: 'user', targetEntity: MagazineOwnershipRequest::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $magazineOwnershipRequests; #[OneToMany(mappedBy: 'user', targetEntity: ModeratorRequest::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $moderatorRequests; #[OneToMany(mappedBy: 'user', targetEntity: Entry::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $entries; #[OneToMany(mappedBy: 'user', targetEntity: EntryVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $entryVotes; #[OneToMany(mappedBy: 'user', targetEntity: EntryComment::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $entryComments; // @todo #[OneToMany(mappedBy: 'user', targetEntity: EntryCommentVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $entryCommentVotes; #[OneToMany(mappedBy: 'user', targetEntity: Post::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $posts; #[OneToMany(mappedBy: 'user', targetEntity: PostVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $postVotes; #[OneToMany(mappedBy: 'user', targetEntity: PostComment::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $postComments; #[OneToMany(mappedBy: 'user', targetEntity: PostCommentVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $postCommentVotes; #[OneToMany(mappedBy: 'user', targetEntity: MagazineSubscription::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $subscriptions; #[OneToMany(mappedBy: 'user', targetEntity: DomainSubscription::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $subscribedDomains; #[OneToMany(mappedBy: 'follower', targetEntity: UserFollow::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $follows; #[OneToMany(mappedBy: 'following', targetEntity: UserFollow::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $followers; #[OneToMany(mappedBy: 'blocker', targetEntity: UserBlock::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $blocks; #[OneToMany(mappedBy: 'blocked', targetEntity: UserBlock::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public ?Collection $blockers; #[OneToMany(mappedBy: 'user', targetEntity: MagazineBlock::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $blockedMagazines; #[OneToMany(mappedBy: 'user', targetEntity: DomainBlock::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $blockedDomains; #[OneToMany(mappedBy: 'reporting', targetEntity: Report::class, cascade: ['persist'], fetch: 'EXTRA_LAZY')] #[OrderBy(['createdAt' => 'DESC'])] public Collection $reports; #[OneToMany(mappedBy: 'user', targetEntity: Favourite::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $favourites; #[OneToMany(mappedBy: 'reported', targetEntity: Report::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $violations; #[OneToMany(mappedBy: 'user', targetEntity: Notification::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $notifications; #[OneToMany(mappedBy: 'user', targetEntity: UserPushSubscription::class, fetch: 'EXTRA_LAZY')] public Collection $pushSubscriptions; #[OneToMany(mappedBy: 'user', targetEntity: BookmarkList::class, fetch: 'EXTRA_LAZY')] public Collection $bookmarkLists; #[OneToMany(targetEntity: UserFilterList::class, mappedBy: 'user', fetch: 'LAZY')] public Collection $filterLists; #[Id] #[GeneratedValue] #[Column(type: 'integer')] private int $id; #[Column(type: 'string', nullable: false)] private string $password; #[Column(type: 'string', nullable: true)] private ?string $totpSecret = null; #[Column(type: Types::JSONB, nullable: false, options: ['default' => '[]'])] private array $totpBackupCodes = []; #[OneToMany(mappedBy: 'user', targetEntity: OAuth2UserConsent::class, orphanRemoval: true)] private Collection $oAuth2UserConsents; #[Column(type: 'string', nullable: false, options: ['default' => self::USER_TYPE_PERSON])] public string $type; #[Column(type: 'text', nullable: true)] public ?string $applicationText; #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => null])] private ?string $usernameTs; #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => null])] private ?string $titleTs; #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => null])] private ?string $aboutTs; #[Column(type: 'enumApplicationStatus', nullable: false, options: ['default' => EApplicationStatus::Approved->value])] private string $applicationStatus; public function __construct( string $email, string $username, string $password, string $type, ?string $apProfileId = null, ?string $apId = null, EApplicationStatus $applicationStatus = EApplicationStatus::Approved, ?string $applicationText = null, ) { $this->email = $email; $this->password = $password; $this->username = $username; $this->type = $type; $this->apProfileId = $apProfileId; $this->apId = $apId; $this->moderatorTokens = new ArrayCollection(); $this->magazineOwnershipRequests = new ArrayCollection(); $this->moderatorRequests = new ArrayCollection(); $this->entries = new ArrayCollection(); $this->entryVotes = new ArrayCollection(); $this->entryComments = new ArrayCollection(); $this->entryCommentVotes = new ArrayCollection(); $this->posts = new ArrayCollection(); $this->postVotes = new ArrayCollection(); $this->postComments = new ArrayCollection(); $this->postCommentVotes = new ArrayCollection(); $this->subscriptions = new ArrayCollection(); $this->subscribedDomains = new ArrayCollection(); $this->follows = new ArrayCollection(); $this->followers = new ArrayCollection(); $this->blocks = new ArrayCollection(); $this->blockers = new ArrayCollection(); $this->blockedMagazines = new ArrayCollection(); $this->blockedDomains = new ArrayCollection(); $this->reports = new ArrayCollection(); $this->favourites = new ArrayCollection(); $this->violations = new ArrayCollection(); $this->notifications = new ArrayCollection(); $this->lastActive = new \DateTime(); $this->createdAtTraitConstruct(); $this->oAuth2UserConsents = new ArrayCollection(); $this->setApplicationStatus($applicationStatus); $this->applicationText = $applicationText; $this->filterLists = new ArrayCollection(); } public function getId(): int { return $this->id; } public function getApId(): ?string { return $this->apId; } public function getUsername(): string { return $this->username; } public function getEmail(): string { return $this->email; } public function getTotpSecret(): ?string { return $this->totpSecret; } public function setOrRemoveAdminRole(bool $remove = false): self { $this->roles = ['ROLE_ADMIN']; if ($remove) { $this->roles = []; } return $this; } public function setOrRemoveModeratorRole(bool $remove = false): self { $this->roles = ['ROLE_MODERATOR']; if ($remove) { $this->roles = []; } return $this; } public function getPassword(): string { return (string) $this->password; } public function setPassword(string $password): self { $this->password = $password; return $this; } public function getSalt(): ?string { // not needed when using the "bcrypt" algorithm in security.yaml return null; } #[\Deprecated] public function eraseCredentials(): void { // If you store any temporary, sensitive data on the user, clear it here // $this->plainPassword = null; } public function getModeratedMagazines(): Collection { // Tokens $this->moderatorTokens->get(-1); $criteria = Criteria::create() ->andWhere(Criteria::expr()->eq('isConfirmed', true)); $tokens = $this->moderatorTokens->matching($criteria); // Magazines $magazines = $tokens->map(fn ($token) => $token->magazine); $criteria = Criteria::create() ->orderBy(['lastActive' => Order::Descending]); return $magazines->matching($criteria); } public function addEntry(Entry $entry): self { if ($entry->user !== $this) { throw new \DomainException('Entry must belong to user'); } if (!$this->entries->contains($entry)) { $this->entries->add($entry); } return $this; } public function addEntryComment(EntryComment $comment): self { if (!$this->entryComments->contains($comment)) { $this->entryComments->add($comment); $comment->user = $this; } return $this; } public function addPost(Post $post): self { if ($post->user !== $this) { throw new \DomainException('Post must belong to user'); } if (!$this->posts->contains($post)) { $this->posts->add($post); } return $this; } public function addPostComment(PostComment $comment): self { if (!$this->entryComments->contains($comment)) { $this->entryComments->add($comment); $comment->user = $this; } return $this; } public function addSubscription(MagazineSubscription $subscription): self { if (!$this->subscriptions->contains($subscription)) { $this->subscriptions->add($subscription); $subscription->setUser($this); } return $this; } public function removeSubscription(MagazineSubscription $subscription): self { if ($this->subscriptions->removeElement($subscription)) { if ($subscription->user === $this) { $subscription->user = null; } } return $this; } public function isFollower(User $user): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('follower', $this)); return $user->followers->matching($criteria)->count() > 0; } public function follow(User $following): self { $this->unblock($following); if (!$this->isFollowing($following)) { $this->followers->add($follower = new UserFollow($this, $following)); if (!$following->followers->contains($follower)) { $following->followers->add($follower); } } $following->updateFollowCounts(); return $this; } public function unblock(User $blocked): void { $criteria = Criteria::create() ->where(Criteria::expr()->eq('blocked', $blocked)); /** * @var UserBlock $userBlock */ $userBlock = $this->blocks->matching($criteria)->first(); if ($this->blocks->removeElement($userBlock)) { if ($userBlock->blocker === $this) { $blocked->blockers->removeElement($this); } } } public function isFollowing(User $user): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('following', $user)); return $this->follows->matching($criteria)->count() > 0; } public function updateFollowCounts(): void { if (null !== $this->apFollowersCount) { $criteria = Criteria::create(); if ($this->apFetchedAt) { $criteria->where(Criteria::expr()->gt('createdAt', \DateTimeImmutable::createFromMutable($this->apFetchedAt))); } $newFollowers = $this->followers->matching($criteria)->count(); $this->followersCount = $this->apFollowersCount + $newFollowers; } else { $this->followersCount = $this->followers->count(); } } public function unfollow(User $following): void { $followingUser = $following; $criteria = Criteria::create() ->where(Criteria::expr()->eq('following', $following)); /** * @var UserFollow $following */ $following = $this->follows->matching($criteria)->first(); if ($this->follows->removeElement($following)) { if ($following->follower === $this) { $following->follower = null; $followingUser->followers->removeElement($following); } } $followingUser->updateFollowCounts(); } public function toggleTheme(): self { $this->theme = self::THEME_LIGHT === $this->theme ? self::THEME_DARK : self::THEME_LIGHT; return $this; } public function isBlocker(User $user): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('blocker', $user)); return $user->blockers->matching($criteria)->count() > 0; } public function block(User $blocked): self { if (!$this->isBlocked($blocked)) { $this->blocks->add($userBlock = new UserBlock($this, $blocked)); if (!$blocked->blockers->contains($userBlock)) { $blocked->blockers->add($userBlock); } } return $this; } /** * Returns whether or not the given user is blocked by the user this method is called on. */ public function isBlocked(User $user): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('blocked', $user)); return $this->blocks->matching($criteria)->count() > 0; } public function blockMagazine(Magazine $magazine): self { if (!$this->isBlockedMagazine($magazine)) { $this->blockedMagazines->add(new MagazineBlock($this, $magazine)); } return $this; } public function isBlockedMagazine(Magazine $magazine): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('magazine', $magazine)); return $this->blockedMagazines->matching($criteria)->count() > 0; } public function unblockMagazine(Magazine $magazine): void { $criteria = Criteria::create() ->where(Criteria::expr()->eq('magazine', $magazine)); /** * @var MagazineBlock $magazineBlock */ $magazineBlock = $this->blockedMagazines->matching($criteria)->first(); if ($this->blockedMagazines->removeElement($magazineBlock)) { if ($magazineBlock->user === $this) { $magazineBlock->magazine = null; $this->blockedMagazines->removeElement($magazineBlock); } } } public function blockDomain(Domain $domain): self { if (!$this->isBlockedDomain($domain)) { $this->blockedDomains->add(new DomainBlock($this, $domain)); } return $this; } public function isBlockedDomain(Domain $domain): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('domain', $domain)); return $this->blockedDomains->matching($criteria)->count() > 0; } public function unblockDomain(Domain $domain): void { $criteria = Criteria::create() ->where(Criteria::expr()->eq('domain', $domain)); /** * @var DomainBlock $domainBlock */ $domainBlock = $this->blockedDomains->matching($criteria)->first(); if ($this->blockedDomains->removeElement($domainBlock)) { if ($domainBlock->user === $this) { $domainBlock->domain = null; $this->blockedMagazines->removeElement($domainBlock); } } } public function getNewNotifications(): Collection { return $this->notifications->matching($this->getNewNotificationsCriteria()); } private function getNewNotificationsCriteria(): Criteria { return Criteria::create() ->where(Criteria::expr()->eq('status', Notification::STATUS_NEW)); } public function getNewEntryNotifications(User $user, Entry $entry): ?Notification { $criteria = $this->getNewNotificationsCriteria() ->andWhere(Criteria::expr()->eq('user', $user)) ->andWhere(Criteria::expr()->eq('entry', $entry)) ->andWhere(Criteria::expr()->eq('type', 'new_entry')); return $this->notifications->matching($criteria)->first(); } public function countNewNotifications(): int { return $this->notifications ->matching($this->getNewNotificationsCriteria()) ->filter(fn ($notification) => 'message_notification' !== $notification->getType()) ->count(); } public function countNewMessages(): int { $criteria = Criteria::create() ->where(Criteria::expr()->eq('status', Notification::STATUS_NEW)); return $this->notifications ->matching($criteria) ->filter(fn ($notification) => 'message_notification' === $notification->getType()) ->count(); } public function isAdmin(): bool { return \in_array('ROLE_ADMIN', $this->getRoles()); } public function isModerator(): bool { return \in_array('ROLE_MODERATOR', $this->getRoles()); } public function getRoles(): array { $roles = $this->roles; // guarantee every user at least has ROLE_USER $roles[] = 'ROLE_USER'; return array_unique($roles); } public function isAccountDeleted(): bool { return $this->isDeleted; } public function getUserIdentifier(): string { return $this->username; } public function __call(string $name, array $arguments) { // TODO: Implement @method string getUserIdentifier() } /** * This method is used by Symfony to determine whether a session needs to be refreshed. * Every security relevant information needs to be in there. * In order to check these parameters you need to add them to the __serialize function. * * @see User::__serialize() */ public function isEqualTo(UserInterface $user): bool { $pa = PropertyAccess::createPropertyAccessor(); $theirTotpSecret = $pa->getValue($user, 'totpSecret') ?? ''; return $pa->getValue($user, 'isBanned') === $this->isBanned && $pa->getValue($user, 'isDeleted') === $this->isDeleted && $pa->getValue($user, 'markedForDeletionAt') === $this->markedForDeletionAt && $pa->getValue($user, 'username') === $this->username && $pa->getValue($user, 'password') === $this->password && ($theirTotpSecret === $this->totpSecret || $theirTotpSecret === hash('sha256', $this->totpSecret) || hash('sha256', $theirTotpSecret) === $this->totpSecret); } public function getApName(): string { return $this->username; } public function isActiveNow(): bool { $delay = new \DateTime('1 day ago'); return $this->lastActive > $delay; } public function getShowProfileFollowings(): bool { if ($this->apId) { return true; } return $this->showProfileFollowings; } public function getShowProfileSubscriptions(): bool { if ($this->apId) { return false; } return $this->showProfileSubscriptions; } /** * @return Collection */ public function getOAuth2UserConsents(): Collection { return $this->oAuth2UserConsents; } public function addOAuth2UserConsent(OAuth2UserConsent $oAuth2UserConsent): self { if (!$this->oAuth2UserConsents->contains($oAuth2UserConsent)) { $this->oAuth2UserConsents->add($oAuth2UserConsent); $oAuth2UserConsent->setUser($this); } return $this; } public function removeOAuth2UserConsent(OAuth2UserConsent $oAuth2UserConsent): self { if ($this->oAuth2UserConsents->removeElement($oAuth2UserConsent)) { // set the owning side to null (unless already changed) if ($oAuth2UserConsent->getUser() === $this) { $oAuth2UserConsent->setUser(null); } } return $this; } public function isSsoControlled(): bool { return $this->oauthAzureId || $this->oauthGithubId || $this->oauthGoogleId || $this->oauthDiscordId || $this->oauthFacebookId || $this->oauthKeycloakId || $this->oauthSimpleLoginId || $this->oauthZitadelId || $this->oauthAuthentikId || $this->oauthPrivacyPortalId; } public function getCustomCss(): ?string { return $this->customCss; } public function setCustomCss(?string $customCss): static { $this->customCss = $customCss; return $this; } public function setTotpSecret(?string $totpSecret): void { $this->totpSecret = $totpSecret; } public function isTotpAuthenticationEnabled(): bool { return (bool) $this->totpSecret; } public function getTotpAuthenticationUsername(): string { return $this->username; } public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface { return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6); } /** * @param string[]|null $codes */ public function setBackupCodes(?array $codes): void { $this->totpBackupCodes = $codes; } public function isBackupCode(string $code): bool { return \in_array($code, $this->totpBackupCodes); } public function invalidateBackupCode(string $code): void { $this->totpBackupCodes = array_values( array_filter($this->totpBackupCodes, function ($existingCode) use ($code) { return $code !== $existingCode; }) ); } public function softDelete(): void { $this->markedForDeletionAt = new \DateTime('now + 30days'); $this->visibility = VisibilityInterface::VISIBILITY_SOFT_DELETED; $this->isDeleted = true; } public function isSoftDeleted(): bool { return self::VISIBILITY_SOFT_DELETED === $this->visibility; } public function trash(): void { $this->visibility = self::VISIBILITY_TRASHED; } public function isTrashed(): bool { return self::VISIBILITY_TRASHED === $this->visibility; } public function restore(): void { $this->markedForDeletionAt = null; $this->visibility = VisibilityInterface::VISIBILITY_VISIBLE; $this->isDeleted = false; } public function hasModeratorRequest(Magazine $magazine): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('magazine', $magazine)); return $this->moderatorRequests->matching($criteria)->count() > 0; } public function hasMagazineOwnershipRequest(Magazine $magazine): bool { $criteria = Criteria::create() ->where(Criteria::expr()->eq('magazine', $magazine)); return $this->magazineOwnershipRequests->matching($criteria)->count() > 0; } public function getFollowerUrl(ApHttpClientInterface $client, UrlGeneratorInterface $urlGenerator, bool $isRemote): ?string { if ($isRemote) { $actorObject = $client->getActorObject($this->apProfileId); if ($actorObject and isset($actorObject['followers']) and \is_string($actorObject['followers'])) { return $actorObject['followers']; } return null; } else { return $urlGenerator->generate( 'ap_user_followers', ['username' => $this->username], UrlGeneratorInterface::ABSOLUTE_URL ); } } public function canUpdateUser(User $actor): bool { if (null === $this->apId) { return null === $actor->apId && $actor->isAdmin(); } else { return $this->apDomain === $actor->apDomain; } } public function getApplicationStatus(): EApplicationStatus { return EApplicationStatus::getFromString($this->applicationStatus); } public function setApplicationStatus(EApplicationStatus $applicationStatus): void { $this->applicationStatus = $applicationStatus->value; } /** * @param User $dmAuthor the author of the direct message * * @return bool whether the $dmAuthor is allowed to send this user a direct message */ public function canReceiveDirectMessage(User $dmAuthor): bool { if (EDirectMessageSettings::Everyone->value === $this->directMessageSetting) { return true; } elseif (EDirectMessageSettings::FollowersOnly->value === $this->directMessageSetting) { $criteria = Criteria::create()->where(Criteria::expr()->eq('follower', $dmAuthor)); return $this->followers->matching($criteria)->count() > 0; } else { return false; } } /** * @return UserFilterList[] */ public function getCurrentFilterLists(): array { $criteria = Criteria::create()->where(Criteria::expr()->gte('expirationDate', new \DateTimeImmutable())) ->orWhere(Criteria::expr()->isNull('expirationDate')); return $this->filterLists->matching($criteria)->toArray(); } /** * this is used to check whether the session of a user is valid * if any of these values have changed the user needs to re-login * it should be the same values as the remember-me cookie signature in the security.yaml * also have a look at the isEqualTo function as this stuff needs to be checked there. * * @see User::isEqualTo() */ public function __serialize(): array { return [ "\0".self::class."\0id" => $this->id, "\0".self::class."\0username" => $this->username, "\0".self::class."\0password" => $this->password, "\0".self::class."\0totpSecret" => $this->totpSecret ? hash('sha256', $this->totpSecret) : '', "\0".self::class."\0isBanned" => $this->isBanned, "\0".self::class."\0isDeleted" => $this->isDeleted, "\0".self::class."\0markedForDeletionAt" => $this->markedForDeletionAt, ]; } } ================================================ FILE: src/Entity/UserBlock.php ================================================ createdAtTraitConstruct(); $this->blocker = $blocker; $this->blocked = $blocked; } public function getId(): ?int { return $this->id; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/UserFilterList.php ================================================ $words */ #[Column(type: Types::JSONB)] public array $words = []; public function getId(): int { return $this->id; } /** * @return string[] */ public function getRealmStrings(): array { $res = []; if ($this->feeds) { $res[] = 'feeds'; } if ($this->profile) { $res[] = 'profile'; } if ($this->comments) { $res[] = 'comments'; } return $res; } public function isExpired(): bool { if (null !== $this->expirationDate) { return $this->expirationDate <= new \DateTimeImmutable(); } return false; } } ================================================ FILE: src/Entity/UserFollow.php ================================================ createdAtTraitConstruct(); $this->follower = $follower; $this->following = $following; } public function getId(): ?int { return $this->id; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/UserFollowRequest.php ================================================ createdAtTraitConstruct(); $this->follower = $follower; $this->following = $following; } public function getId(): ?int { return $this->id; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/UserNote.php ================================================ createdAtTraitConstruct(); $this->user = $user; $this->target = $target; $this->body = $body; } public function getId(): ?int { return $this->id; } public function __sleep() { return []; } } ================================================ FILE: src/Entity/UserPushSubscription.php ================================================ user = $user; $this->endpoint = $endpoint; $this->serverAuthKey = $serverAuthKey; $this->contentEncryptionPublicKey = $contentEncryptionPublicKey; $this->notificationTypes = $notifications; $this->apiToken = $apiToken; } } ================================================ FILE: src/Entity/Vote.php ================================================ choice = $choice; $this->user = $user; $this->author = $author; $this->createdAtTraitConstruct(); } public function getId(): ?int { return $this->id; } public function __sleep() { return []; } public function getSubject(): VotableInterface { throw new \Exception('Not implemented'); } } ================================================ FILE: src/Enums/EApplicationStatus.php ================================================ value => self::Approved, self::Rejected->value => self::Rejected, self::Pending->value => self::Pending, default => null, }; } /** * @return string[] */ public static function getValues(): array { return [ EApplicationStatus::Approved->value, EApplicationStatus::Rejected->value, EApplicationStatus::Pending->value, ]; } } ================================================ FILE: src/Enums/EDirectMessageSettings.php ================================================ value, EDirectMessageSettings::FollowersOnly->value, EDirectMessageSettings::Nobody->value, ]; public static function getFromString(string $value): ?EDirectMessageSettings { return match ($value) { self::Everyone->value => self::Everyone, self::FollowersOnly->value => self::FollowersOnly, self::Nobody->value => self::Nobody, default => null, }; } /** * @return string[] */ public static function getValues(): array { return self::OPTIONS; } } ================================================ FILE: src/Enums/EFrontContentOptions.php ================================================ value, EFrontContentOptions::Threads->value, EFrontContentOptions::Microblog->value, ]; /** * @return string[] */ public static function getValues(): array { return [ ...self::OPTIONS, null, ]; } } ================================================ FILE: src/Enums/ENotificationStatus.php ================================================ value => self::Default, self::Muted->value => self::Muted, self::Loud->value => self::Loud, default => null, }; } public const Values = [ ENotificationStatus::Default->value, ENotificationStatus::Muted->value, ENotificationStatus::Loud->value, ]; /** * @return string[] */ public static function getValues(): array { return self::Values; } } ================================================ FILE: src/Enums/EPushNotificationType.php ================================================ value => self::Hot, self::Top->value => self::Top, self::Newest->value => self::Newest, self::Active->value => self::Active, self::Oldest->value => self::Oldest, self::Commented->value => self::Commented, default => null, }; } /** * @return string[] */ public static function getValues(): array { return [ ESortOptions::Hot->value, ESortOptions::Top->value, ESortOptions::Newest->value, ESortOptions::Active->value, ESortOptions::Oldest->value, ESortOptions::Commented->value, ]; } } ================================================ FILE: src/Event/ActivityPub/CurlRequestBeginningEvent.php ================================================ getObject(); switch ($object) { case $object instanceof Entry: $this->entryManager->purgeNotifications($object); $this->entryManager->purgeMagazineLog($object); break; case $object instanceof EntryComment: $this->entryCommentManager->purgeNotifications($object); $this->entryCommentManager->purgeMagazineLog($object); break; case $object instanceof Post: $this->postManager->purgeNotifications($object); $this->postManager->purgeMagazineLog($object); break; case $object instanceof PostComment: $this->postCommentManager->purgeNotifications($object); $this->postCommentManager->purgeMagazineLog($object); break; } } } ================================================ FILE: src/EventListener/FederationStatusListener.php ================================================ isMainRequest() || $this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $route = $event->getRequest()->attributes->get('_route'); if (str_starts_with($route, 'ap_') && 'ap_node_info' !== $route && 'ap_node_info_v2' !== $route) { throw new NotFoundHttpException(); } } } ================================================ FILE: src/EventListener/LanguageListener.php ================================================ getRequest(); if ($request->cookies->has('mbin_lang')) { $request->setLocale($request->cookies->get('mbin_lang')); return; } if (!isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { $request->setLocale($this->lang); return; } $lang = substr($_SERVER['HTTP_ACCEPT_LANGUAGE'], 0, 2); $request->setLocale($lang); $request->setDefaultLocale($lang); } } ================================================ FILE: src/EventListener/MagazineVisibilityListener.php ================================================ getArguments(), fn ($argument) => $argument instanceof Magazine); if (!$magazine) { return; } $magazine = array_values($magazine)[0]; if (VisibilityInterface::VISIBILITY_VISIBLE !== $magazine->visibility) { if (null === $this->security->getUser() || (false === $magazine->userIsOwner($this->security->getUser()) && false === $this->security->isGranted('ROLE_ADMIN') && false === $this->security->isGranted('ROLE_MODERATOR'))) { throw new NotFoundHttpException(); } } } } ================================================ FILE: src/EventListener/UserActivityListener.php ================================================ getRequestType()) { return; } if ($this->security->getToken()) { $user = $this->security->getToken()->getUser(); if (($user instanceof User) && !$user->isActiveNow()) { $user->lastActive = new \DateTime(); $this->entityManager->flush(); } } } } ================================================ FILE: src/EventSubscriber/ActivityPub/GroupWebFingerProfileSubscriber.php ================================================ 'string'])] public static function getSubscribedEvents(): array { return [ WebfingerResponseEvent::class => ['buildResponse', 999], ]; } public function buildResponse(WebfingerResponseEvent $event): void { $params = $event->params; $jsonRd = $event->jsonRd; if ( isset($params[WebFingerParameters::ACCOUNT_KEY_NAME]) && $actor = $this->getActor($params[WebFingerParameters::ACCOUNT_KEY_NAME]) ) { $accountHref = $this->urlGenerator->generate( 'ap_magazine', ['name' => $actor->name], UrlGeneratorInterface::ABSOLUTE_URL ); $link = new JsonRdLink(); $link->setRel('self') ->setType('application/activity+json') ->setHref($accountHref); $jsonRd->addLink($link); } } protected function getActor($name): ?Magazine { if ('random' === $name) { return null; } return $this->magazineRepository->findOneByName($name); } } ================================================ FILE: src/EventSubscriber/ActivityPub/GroupWebFingerSubscriber.php ================================================ 'string'])] public static function getSubscribedEvents(): array { return [ WebfingerResponseEvent::class => ['buildResponse', 195], ]; } public function buildResponse(WebfingerResponseEvent $event): void { $params = $event->params; $jsonRd = $event->jsonRd; $subject = $event->subject; if (!empty($subject)) { $jsonRd->setSubject($subject); } if ( isset($params[WebFingerParameters::ACCOUNT_KEY_NAME]) && $actor = $this->getActor($params[WebFingerParameters::ACCOUNT_KEY_NAME]) ) { $accountHref = $this->urlGenerator->generate( 'ap_magazine', ['name' => $actor->name], UrlGeneratorInterface::ABSOLUTE_URL ); $jsonRd->addAlias($accountHref); $link = new JsonRdLink(); $link->setRel('https://webfinger.net/rel/profile-page') ->setType('text/html') ->setHref($accountHref); $jsonRd->addLink($link); } } protected function getActor($name): ?Magazine { if ('random' === $name) { return null; } return $this->magazineRepository->findOneByName($name); } } ================================================ FILE: src/EventSubscriber/ActivityPub/MagazineFollowSubscriber.php ================================================ 'string'])] public static function getSubscribedEvents(): array { return [ MagazineSubscribedEvent::class => 'onMagazineFollow', ]; } public function onMagazineFollow(MagazineSubscribedEvent $event): void { $this->sqlHelpers->clearCachedUserSubscribedMagazines($event->user); if ($event->magazine->apId && !$event->user->apId) { $this->bus->dispatch( new FollowMessage($event->user->getId(), $event->magazine->getId(), $event->unfollow, true) ); } } } ================================================ FILE: src/EventSubscriber/ActivityPub/MagazineModeratorAddedRemovedSubscriber.php ================================================ sqlHelpers->clearCachedUserModeratedMagazines($event->user); // if the magazine is local then we have authority over it, otherwise the addedBy user has to be a local user if (!$event->magazine->apId or (null !== $event->addedBy and !$event->addedBy->apId)) { $this->bus->dispatch(new AddMessage($event->addedBy->getId(), $event->magazine->getId(), $event->user->getId())); } $log = new MagazineLogModeratorAdd($event->magazine, $event->user, $event->addedBy); $this->entityManager->persist($log); $this->entityManager->flush(); $this->deleteCache($event->magazine); } public function onModeratorRemoved(MagazineModeratorRemovedEvent $event): void { $this->sqlHelpers->clearCachedUserModeratedMagazines($event->user); // if the magazine is local then we have authority over it, otherwise the removedBy user has to be a local user if (!$event->magazine->apId or (null !== $event->removedBy and !$event->removedBy->apId)) { $this->bus->dispatch(new RemoveMessage($event->removedBy->getId(), $event->magazine->getId(), $event->user->getId())); } $log = new MagazineLogModeratorRemove($event->magazine, $event->user, $event->removedBy); $this->entityManager->persist($log); $this->entityManager->flush(); $this->deleteCache($event->magazine); } public static function getSubscribedEvents(): array { return [ MagazineModeratorAddedEvent::class => 'onModeratorAdded', MagazineModeratorRemovedEvent::class => 'onModeratorRemoved', ]; } private function deleteCache(Magazine $magazine): void { if (!$magazine->apId) { return; } try { $this->cache->delete('ap_'.hash('sha256', $magazine->apProfileId)); $this->cache->delete('ap_'.hash('sha256', $magazine->apId)); if (null !== $magazine->apAttributedToUrl) { $this->cache->delete('ap_'.hash('sha256', $magazine->apAttributedToUrl)); $this->cache->delete('ap_collection'.hash('sha256', $magazine->apAttributedToUrl)); } } catch (InvalidArgumentException $e) { $this->logger->warning("There was an error while clearing the cache for magazine '{$magazine->name}' ({$magazine->getId()})"); } } } ================================================ FILE: src/EventSubscriber/ActivityPub/UserFollowSubscriber.php ================================================ 'string'])] public static function getSubscribedEvents(): array { return [ UserFollowEvent::class => 'onUserFollow', ]; } public function onUserFollow(UserFollowEvent $event): void { $this->sqlHelpers->clearCachedUserFollows($event->follower); if (!$event->follower->apId && $event->following->apId) { $this->bus->dispatch( new FollowMessage($event->follower->getId(), $event->following->getId(), $event->unfollow) ); } $this->cache->invalidateTags(['user_follow_'.$event->follower->getId()]); } } ================================================ FILE: src/EventSubscriber/ActivityPub/UserWebFingerProfileSubscriber.php ================================================ 'string'])] public static function getSubscribedEvents(): array { return [ WebfingerResponseEvent::class => ['buildResponse', 999], ]; } public function buildResponse(WebfingerResponseEvent $event): void { $params = $event->params; $jsonRd = $event->jsonRd; if (isset($params[WebFingerParameters::ACCOUNT_KEY_NAME])) { $query = $params[WebFingerParameters::ACCOUNT_KEY_NAME]; $this->logger->debug("got webfinger query for $query"); $domain = $this->settingsManager->get('KBIN_DOMAIN'); if ($domain === $query) { $accountHref = $this->urlGenerator->generate('ap_instance', [], UrlGeneratorInterface::ABSOLUTE_URL); $link = new JsonRdLink(); $link->setRel('self') ->setType('application/activity+json') ->setHref($accountHref); $jsonRd->addLink($link); return; } $actor = $this->getActor($query); if ($actor) { $accountHref = $this->urlGenerator->generate( 'ap_user', ['username' => $actor->getUserIdentifier()], UrlGeneratorInterface::ABSOLUTE_URL ); $link = new JsonRdLink(); $link->setRel('self') ->setType('application/activity+json') ->setHref($accountHref); $jsonRd->addLink($link); if ($actor->avatar) { $link = new JsonRdLink(); $link->setRel('https://webfinger.net/rel/avatar') ->setHref( $this->imageManager->getUrl($actor->avatar), ); // @todo media url $jsonRd->addLink($link); } } } } protected function getActor($name): ?UserInterface { return $this->userRepository->findOneByUsername($name); } } ================================================ FILE: src/EventSubscriber/ActivityPub/UserWebFingerSubscriber.php ================================================ 'string'])] public static function getSubscribedEvents(): array { return [ WebfingerResponseEvent::class => ['buildResponse', 1000], ]; } public function buildResponse(WebfingerResponseEvent $event): void { $params = $event->params; $jsonRd = $event->jsonRd; $subject = $event->subject; if (!empty($subject)) { $jsonRd->setSubject($subject); } if ( isset($params[WebFingerParameters::ACCOUNT_KEY_NAME]) && $actor = $this->getActor($params[WebFingerParameters::ACCOUNT_KEY_NAME]) ) { $accountHref = $this->urlGenerator->generate( 'ap_user', ['username' => $actor->getUserIdentifier()], UrlGeneratorInterface::ABSOLUTE_URL ); $jsonRd->addAlias($accountHref); $link = new JsonRdLink(); $link->setRel('https://webfinger.net/rel/profile-page') ->setType('text/html') ->setHref($accountHref); $jsonRd->addLink($link); } } protected function getActor($name): ?UserInterface { return $this->userRepository->findOneByUsername($name); } } ================================================ FILE: src/EventSubscriber/AuthorizationCodeSubscriber.php ================================================ security = $security; $this->urlGenerator = $urlGenerator; $this->requestStack = $requestStack; } public function onLeagueOauth2ServerEventAuthorizationRequestResolve(AuthorizationRequestResolveEvent $event): void { $request = $this->requestStack->getCurrentRequest(); $user = $this->security->getUser(); $firewallName = $this->security->getFirewallConfig($request)->getName(); $this->saveTargetPath($request->getSession(), $firewallName, $request->getUri()); $response = new RedirectResponse($this->urlGenerator->generate('app_login'), 307); if ($user instanceof UserInterface) { if (null !== $request->getSession()->get('consent_granted')) { $event->resolveAuthorization($request->getSession()->get('consent_granted')); $request->getSession()->remove('consent_granted'); return; } $response = new RedirectResponse($this->urlGenerator->generate('app_consent', $request->query->all()), 307); } $event->setResponse($response); } public static function getSubscribedEvents(): array { return [ 'league.oauth2_server.event.authorization_request_resolve' => 'onLeagueOauth2ServerEventAuthorizationRequestResolve', ]; } } ================================================ FILE: src/EventSubscriber/ContentCountSubscriber.php ================================================ 'onEntryDeleted', EntryCommentCreatedEvent::class => 'onEntryCommentCreated', EntryCommentDeletedEvent::class => 'onEntryCommentDeleted', EntryCommentPurgedEvent::class => 'onEntryCommentPurged', PostCommentCreatedEvent::class => 'onPostCommentCreated', PostCommentDeletedEvent::class => 'onPostCommentDeleted', PostCommentPurgedEvent::class => 'onPostCommentPurged', ]; } public function onEntryDeleted(EntryDeletedEvent $event): void { $event->entry->magazine->updateEntryCounts(); $this->entityManager->flush(); } public function onEntryCommentCreated(EntryCommentCreatedEvent $event): void { $magazine = $event->comment->entry->magazine; $magazine->entryCommentCount = $this->entryRepository->countEntryCommentsByMagazine($magazine); $this->entityManager->flush(); } public function onEntryCommentDeleted(EntryCommentDeletedEvent $event): void { $magazine = $event->comment->entry->magazine; $magazine->entryCommentCount = $this->entryRepository->countEntryCommentsByMagazine($magazine) - 1; $event->comment->entry->updateCounts(); $this->entityManager->flush(); } public function onEntryCommentPurged(EntryCommentPurgedEvent $event): void { $event->magazine->entryCommentCount = $this->entryRepository->countEntryCommentsByMagazine($event->magazine); $this->entityManager->flush(); } public function onPostCommentCreated(PostCommentCreatedEvent $event): void { $magazine = $event->comment->post->magazine; $magazine->postCommentCount = $this->postRepository->countPostCommentsByMagazine($magazine); $this->entityManager->flush(); } public function onPostCommentDeleted(PostCommentDeletedEvent $event): void { $magazine = $event->comment->post->magazine; $magazine->postCommentCount = $this->postRepository->countPostCommentsByMagazine($magazine) - 1; $event->comment->post->updateCounts(); $this->entityManager->flush(); } public function onPostCommentPurged(PostCommentPurgedEvent $event): void { $event->magazine->postCommentCount = $this->postRepository->countPostCommentsByMagazine($event->magazine); $this->entityManager->flush(); } } ================================================ FILE: src/EventSubscriber/Domain/DomainBlockSubscriber.php ================================================ 'handleDomainBlockedEvent']; } public function handleDomainBlockedEvent(DomainBlockedEvent $event): void { $this->sqlHelpers->clearCachedUserDomainBlocks($event->user); } } ================================================ FILE: src/EventSubscriber/Domain/DomainFollowSubscriber.php ================================================ 'handleDomainSubscribedEvent']; } public function handleDomainSubscribedEvent(DomainSubscribedEvent $event): void { $this->sqlHelpers->clearCachedUserSubscribedDomains($event->user); } } ================================================ FILE: src/EventSubscriber/Entry/EntryCreateSubscriber.php ================================================ 'onEntryCreated', ]; } public function onEntryCreated(EntryCreatedEvent $event): void { $event->entry->magazine->entryCount = $this->entryRepository->countEntriesByMagazine($event->entry->magazine); $this->entityManager->flush(); $this->manager->extract($event->entry); $this->bus->dispatch(new EntryEmbedMessage($event->entry->getId())); $threshold = new \DateTimeImmutable('now - 2 days'); if ($event->entry->createdAt > $threshold) { $this->bus->dispatch(new EntryCreatedNotificationMessage($event->entry->getId())); } if ($event->entry->body) { $this->bus->dispatch(new LinkEmbedMessage($event->entry->body)); } if (!$event->entry->apId) { $this->bus->dispatch(new CreateMessage($event->entry->getId(), \get_class($event->entry))); } } } ================================================ FILE: src/EventSubscriber/Entry/EntryDeleteSubscriber.php ================================================ 'onEntryDeleted', EntryBeforePurgeEvent::class => 'onEntryBeforePurge', EntryBeforeDeletedEvent::class => 'onEntryBeforeDelete', ]; } public function onEntryDeleted(EntryDeletedEvent $event): void { $this->bus->dispatch(new EntryDeletedNotificationMessage($event->entry->getId())); } public function onEntryBeforePurge(EntryBeforePurgeEvent $event): void { $event->entry->magazine->entryCount = $this->entryRepository->countEntriesByMagazine( $event->entry->magazine ) - 1; $this->onEntryBeforeDeleteImpl($event->user, $event->entry); } public function onEntryBeforeDelete(EntryBeforeDeletedEvent $event): void { $this->onEntryBeforeDeleteImpl($event->user, $event->entry); } public function onEntryBeforeDeleteImpl(?User $user, Entry $entry): void { $this->bus->dispatch(new EntryDeletedNotificationMessage($entry->getId())); $this->deleteService->announceIfNecessary($user, $entry); } } ================================================ FILE: src/EventSubscriber/Entry/EntryEditSubscriber.php ================================================ 'onEntryEdited', ]; } public function onEntryEdited(EntryEditedEvent $event): void { $this->bus->dispatch(new EntryEditedNotificationMessage($event->entry->getId())); if ($event->entry->body) { $this->bus->dispatch(new LinkEmbedMessage($event->entry->body)); } if (!$event->entry->apId) { $this->bus->dispatch(new UpdateMessage($event->entry->getId(), \get_class($event->entry), $event->editedBy->getId())); } } } ================================================ FILE: src/EventSubscriber/Entry/EntryPinSubscriber.php ================================================ 'onEntryPin', ]; } public function onEntryPin(EntryPinEvent $event): void { if ($event->actor && null === $event->actor->apId && $event->entry->magazine->userIsModerator($event->actor)) { $this->logger->debug('entry {e} got {p} by {u}, dispatching new EntryPinMessage', ['e' => $event->entry->title, 'p' => $event->entry->sticky ? 'pinned' : 'unpinned', 'u' => $event->actor?->username ?? 'system']); $this->bus->dispatch(new EntryPinMessage($event->entry->getId(), $event->entry->sticky, $event->actor?->getId())); } elseif (null === $event->entry->magazine->apId && $event->actor && $event->entry->magazine->userIsModerator($event->actor)) { if (null !== $event->actor->apId) { // do not do the announce of the pin here, but in the AddHandler instead } else { $this->logger->debug('entry {e} got {p} by {u}, dispatching new EntryPinMessage', ['e' => $event->entry->title, 'p' => $event->entry->sticky ? 'pinned' : 'unpinned', 'u' => $event->actor?->username ?? 'system']); $this->bus->dispatch(new EntryPinMessage($event->entry->getId(), $event->entry->sticky, $event->actor?->getId())); } } } } ================================================ FILE: src/EventSubscriber/Entry/EntryShowSubscriber.php ================================================ 'string'])] public static function getSubscribedEvents(): array { return [ EntryHasBeenSeenEvent::class => 'onShowEntry', ]; } public function onShowEntry(EntryHasBeenSeenEvent $event): void { $this->readMessage($event->entry); } private function readMessage(Entry $entry): void { if (!$this->security->getUser()) { return; } $notifications = $this->repository->findUnreadEntryNotifications($this->security->getUser(), $entry); if (!\count($notifications)) { return; } array_map(fn ($notification) => $notification->status = Notification::STATUS_READ, $notifications); $this->entityManager->flush(); } } ================================================ FILE: src/EventSubscriber/Entry/LockSubscriber.php ================================================ 'onEntryLock', PostLockEvent::class => 'onPostLock', ]; } public function onEntryLock(EntryLockEvent $event): void { if ($event->actor && null === $event->actor->apId && ($event->entry->magazine->userIsModerator($event->actor) || $event->entry->user === $event->actor)) { $this->logger->debug('entry {e} got {p} by {u}, dispatching new EntryLockMessage', ['e' => $event->entry->title, 'p' => $event->entry->isLocked ? 'locked' : 'unlocked', 'u' => $event->actor?->username ?? 'system']); $this->bus->dispatch(new LockMessage($event->actor->getId(), $event->entry->getId(), null)); } elseif (null === $event->entry->magazine->apId && $event->actor && ($event->entry->magazine->userIsModerator($event->actor) || $event->entry->user === $event->actor)) { if (null !== $event->actor->apId) { // do not do the announce of the lock here, but in the LockHandler instead } else { $this->logger->debug('entry {e} got {p} by {u}, dispatching new EntryLockMessage', ['e' => $event->entry->title, 'p' => $event->entry->sticky ? 'locked' : 'unlocked', 'u' => $event->actor?->username ?? 'system']); $this->bus->dispatch(new LockMessage($event->actor->getId(), $event->entry->getId(), null)); } } } public function onPostLock(PostLockEvent $event): void { if ($event->actor && null === $event->actor->apId && ($event->post->magazine->userIsModerator($event->actor) || $event->post->user === $event->actor)) { $this->logger->debug('post {e} got {p} by {u}, dispatching new EntryLockMessage', ['e' => $event->post->getShortTitle(), 'p' => $event->post->isLocked ? 'locked' : 'unlocked', 'u' => $event->actor?->username ?? 'system']); $this->bus->dispatch(new LockMessage($event->actor->getId(), null, $event->post->getId())); } elseif (null === $event->post->magazine->apId && $event->actor && ($event->post->magazine->userIsModerator($event->actor) || $event->post->user === $event->actor)) { if (null !== $event->actor->apId) { // do not do the announce of the lock here, but in the LockHandler instead } else { $this->logger->debug('post {e} got {p} by {u}, dispatching new EntryLockMessage', ['e' => $event->post->getShortTitle(), 'p' => $event->post->sticky ? 'locked' : 'unlocked', 'u' => $event->actor?->username ?? 'system']); $this->bus->dispatch(new LockMessage($event->actor->getId(), null, $event->post->getId())); } } } } ================================================ FILE: src/EventSubscriber/EntryComment/EntryCommentCreateSubscriber.php ================================================ 'onEntryCommentCreated', ]; } public function onEntryCommentCreated(EntryCommentCreatedEvent $event): void { $this->cache->invalidateTags(['entry_comment_'.$event->comment->root?->getId() ?? $event->comment->getId()]); $threshold = new \DateTimeImmutable('now - 2 days'); if ($event->comment->createdAt > $threshold) { $this->bus->dispatch(new EntryCommentCreatedNotificationMessage($event->comment->getId())); } if ($event->comment->body) { $this->bus->dispatch(new LinkEmbedMessage($event->comment->body)); } if (!$event->comment->apId) { $this->bus->dispatch(new CreateMessage($event->comment->getId(), \get_class($event->comment))); } } } ================================================ FILE: src/EventSubscriber/EntryComment/EntryCommentDeleteSubscriber.php ================================================ 'onEntryCommentDeleted', EntryCommentBeforePurgeEvent::class => 'onEntryCommentBeforePurge', EntryCommentBeforeDeletedEvent::class => 'onEntryCommentBeforeDelete', ]; } public function onEntryCommentDeleted(EntryCommentDeletedEvent $event): void { $this->cache->invalidateTags(['entry_comment_'.$event->comment->root?->getId() ?? $event->comment->getId()]); $this->bus->dispatch(new EntryCommentDeletedNotificationMessage($event->comment->getId())); } public function onEntryCommentBeforePurge(EntryCommentBeforePurgeEvent $event): void { $this->onEntryCommentBeforeDeleteImpl($event->user, $event->comment); } public function onEntryCommentBeforeDelete(EntryCommentBeforeDeletedEvent $event): void { $this->onEntryCommentBeforeDeleteImpl($event->user, $event->comment); } public function onEntryCommentBeforeDeleteImpl(?User $user, EntryComment $comment): void { $this->cache->invalidateTags(['entry_comment_'.$comment->root?->getId() ?? $comment->getId()]); $this->bus->dispatch(new EntryCommentDeletedNotificationMessage($comment->getId())); $this->deleteService->announceIfNecessary($user, $comment); } } ================================================ FILE: src/EventSubscriber/EntryComment/EntryCommentEditSubscriber.php ================================================ 'onEntryCommentEdited', ]; } public function onEntryCommentEdited(EntryCommentEditedEvent $event): void { $this->cache->invalidateTags(['entry_comment_'.$event->comment->root?->getId() ?? $event->comment->getId()]); $this->bus->dispatch(new EntryCommentEditedNotificationMessage($event->comment->getId())); if ($event->comment->body) { $this->bus->dispatch(new LinkEmbedMessage($event->comment->body)); } if (!$event->comment->apId) { $this->bus->dispatch(new UpdateMessage($event->comment->getId(), \get_class($event->comment), $event->editedByUser->getId())); } } } ================================================ FILE: src/EventSubscriber/FavouriteHandleSubscriber.php ================================================ 'string'])] public static function getSubscribedEvents(): array { return [ FavouriteEvent::class => 'onFavourite', ]; } public function onFavourite(FavouriteEvent $event): void { $subject = $event->subject; $choice = $event->subject->getUserVote($event->user)?->choice; if (VotableInterface::VOTE_DOWN === $choice && $subject->isFavored($event->user)) { $this->voteManager->removeVote($subject, $event->user); } $this->bus->dispatch( new FavouriteNotificationMessage( $subject->getId(), SqlHelpers::getRealClassName($this->entityManager, $subject), ) ); $this->deleteFavouriteCache($subject); match (\get_class($subject)) { EntryComment::class => $this->clearEntryCommentCache($subject), PostComment::class => $this->clearPostCommentCache($subject), default => null, }; if (!$event->user->apId) { $this->bus->dispatch( new LikeMessage( $event->user->getId(), $subject->getId(), \get_class($subject), $event->removeLike ), ); } } private function deleteFavouriteCache(FavouriteInterface $subject) { $this->cache->delete($this->cacheService->getFavouritesCacheKey($subject)); } private function clearEntryCommentCache(EntryComment $comment): void { $this->cache->invalidateTags(['entry_comment_'.$comment->root?->getId() ?? $comment->getId()]); } private function clearPostCommentCache(PostComment $comment) { $this->cache->invalidateTags([ 'post_'.$comment->post->getId(), 'post_comment_'.$comment->root?->getId() ?? $comment->getId(), ]); } } ================================================ FILE: src/EventSubscriber/Image/ExifCleanerSubscriber.php ================================================ uploadedCleanMode = $params->get('exif_clean_mode_uploaded'); $this->externalCleanMode = $params->get('exif_clean_mode_external'); } public static function getSubscribedEvents(): array { return [ ImagePostProcessEvent::class => ['cleanExif'], ]; } public function cleanExif(ImagePostProcessEvent $event) { $mode = $this->getCleanMode($event->origin); $this->logger->debug( 'ImagePostProcessEvent:ExifCleanerSubscriber: cleaning image:', [ 'source' => $event->source, 'origin' => $event->origin, 'sha256' => hash_file('sha256', $event->source, false), 'mode' => $mode, ] ); $this->cleaner->cleanImage($event->source, $mode); } private function getCleanMode(ImageOrigin $origin): ExifCleanMode { return match ($origin) { ImageOrigin::Uploaded => $this->uploadedCleanMode, ImageOrigin::External => $this->externalCleanMode, }; } } ================================================ FILE: src/EventSubscriber/Image/ImageCompressSubscriber.php ================================================ 'compressImage', ]; } public function compressImage(ImagePostProcessEvent $event): void { $extension = pathinfo($event->targetFilePath, PATHINFO_EXTENSION); if (!$this->imageManager->compressUntilSize($event->source, $extension, $this->settingsManager->getMaxImageBytes())) { if (filesize($event->source) > $this->settingsManager->getMaxImageBytes()) { $this->logger->warning('Was not able to compress image {i} to size {b}', ['i' => $event->source, 'b' => $this->settingsManager->getMaxImageBytes()]); } } } } ================================================ FILE: src/EventSubscriber/Instance/InstanceBanSubscriber.php ================================================ 'onInstanceBan', ]; } public function onInstanceBan(InstanceBanEvent $event): void { if (!$event->bannedUser->apId && !$event->bannedByUser->apId) { // local user banning another local user $this->bus->dispatch(new BlockMessage(magazineBanId: null, bannedUserId: $event->bannedUser->getId(), actor: $event->bannedByUser->getId())); } } } ================================================ FILE: src/EventSubscriber/LogoutSubscriber.php ================================================ 'onLogout']; } public function onLogout(LogoutEvent $event): void { $token = $event->getToken(); $user = $token->getUser(); if (null !== $user->oauthKeycloakId) { $event->setResponse( new RedirectResponse( $this->clientRegistry->getClient('keycloak')->getOAuth2Provider()->getLogoutUrl() ) ); } } } ================================================ FILE: src/EventSubscriber/Magazine/MagazineBanSubscriber.php ================================================ 'onBan', ]; } public function onBan(MagazineBanEvent $event): void { $this->bus->dispatch(new MagazineBanNotificationMessage($event->ban->getId())); $this->logger->debug('[MagazineBanSubscriber::onBan] got ban event: banned: {u}, magazine {m}, expires: {e}, bannedBy: {u2}', [ 'u' => $event->ban->user->username, 'm' => $event->ban->magazine->name, 'e' => $event->ban->expiredAt, 'u2' => $event->ban->bannedBy->username, ]); if (null !== $event->ban->bannedBy && null === $event->ban->bannedBy->apId) { // bannedBy not null and a local user $this->bus->dispatch(new BlockMessage(magazineBanId: $event->ban->getId(), bannedUserId: null, actor: null)); } } } ================================================ FILE: src/EventSubscriber/Magazine/MagazineBlockSubscriber.php ================================================ 'handleMagazineBlockedEvent']; } public function handleMagazineBlockedEvent(MagazineBlockedEvent $event): void { $this->sqlHelpers->clearCachedUserMagazineBlocks($event->user); } } ================================================ FILE: src/EventSubscriber/Magazine/MagazineLogSubscriber.php ================================================ 'onEntryDeleted', EntryRestoredEvent::class => 'onEntryRestored', EntryCommentDeletedEvent::class => 'onEntryCommentDeleted', EntryCommentRestoredEvent::class => 'onEntryCommentRestored', PostDeletedEvent::class => 'onPostDeleted', PostRestoredEvent::class => 'onPostRestored', PostCommentDeletedEvent::class => 'onPostCommentDeleted', PostCommentRestoredEvent::class => 'onPostCommentRestored', MagazineBanEvent::class => 'onBan', ]; } public function onEntryDeleted(EntryDeletedEvent $event): void { if (!$event->entry->isTrashed()) { return; } if (!$event->user || $event->entry->isAuthor($event->user)) { return; } $log = new MagazineLogEntryDeleted($event->entry, $event->user); $this->entityManager->persist($log); $this->entityManager->flush(); } public function onEntryRestored(EntryRestoredEvent $event): void { if ($event->entry->isTrashed()) { return; } if (!$event->user || $event->entry->isAuthor($event->user)) { return; } $log = new MagazineLogEntryRestored($event->entry, $event->user); $this->entityManager->persist($log); $this->entityManager->flush(); } public function onEntryCommentDeleted(EntryCommentDeletedEvent $event): void { if (!$event->comment->isTrashed()) { return; } if (!$event->user || $event->comment->isAuthor($event->user)) { return; } $log = new MagazineLogEntryCommentDeleted($event->comment, $event->user); $this->entityManager->persist($log); $this->entityManager->flush(); } public function onEntryCommentRestored(EntryCommentRestoredEvent $event): void { if ($event->comment->isTrashed()) { return; } if (!$event->user || $event->comment->isAuthor($event->user)) { return; } $log = new MagazineLogEntryCommentRestored($event->comment, $event->user); $this->entityManager->persist($log); $this->entityManager->flush(); } public function onPostDeleted(PostDeletedEvent $event): void { if (!$event->post->isTrashed()) { return; } if (!$event->user || $event->post->isAuthor($event->user)) { return; } $log = new MagazineLogPostDeleted($event->post, $event->user); $this->entityManager->persist($log); $this->entityManager->flush(); } public function onPostRestored(PostRestoredEvent $event): void { if ($event->post->isTrashed()) { return; } if (!$event->user || $event->post->isAuthor($event->user)) { return; } $log = new MagazineLogPostRestored($event->post, $event->user); $this->entityManager->persist($log); $this->entityManager->flush(); } public function onPostCommentDeleted(PostCommentDeletedEvent $event): void { if (!$event->comment->isTrashed()) { return; } if (!$event->user || $event->comment->isAuthor($event->user)) { return; } $log = new MagazineLogPostCommentDeleted($event->comment, $event->user); $this->entityManager->persist($log); $this->entityManager->flush(); } public function onPostCommentRestored(PostCommentRestoredEvent $event): void { if ($event->comment->isTrashed()) { return; } if (!$event->user || $event->comment->isAuthor($event->user)) { return; } $log = new MagazineLogPostCommentRestored($event->comment, $event->user); $this->entityManager->persist($log); $this->entityManager->flush(); } public function onBan(MagazineBanEvent $event): void { $log = new MagazineLogBan($event->ban); $this->entityManager->persist($log); $this->entityManager->flush(); } } ================================================ FILE: src/EventSubscriber/Magazine/MagazineUpdatedSubscriber.php ================================================ 'onMagazineUpdated', ]; } public function onMagazineUpdated(MagazineUpdatedEvent $event): void { $mag = $event->magazine; if (null === $mag->apId) { $activity = $this->updateWrapper->buildForActor($mag, $event->editedBy); $this->bus->dispatch(new GenericAnnounceMessage($mag->getId(), null, $event->editedBy->apDomain, $activity->uuid->toString(), null)); } elseif (null !== $event->editedBy && null === $event->editedBy->apId) { $this->bus->dispatch(new UpdateMessage($mag->getId(), Magazine::class, $event->editedBy->getId())); } } } ================================================ FILE: src/EventSubscriber/Monitoring/CurlRequestSubscriber.php ================================================ ['onCurlRequestBeginning'], CurlRequestFinishedEvent::class => ['onCurlRequestFinished'], ]; } public function onCurlRequestBeginning(CurlRequestBeginningEvent $event): void { if (!$this->monitor->shouldRecordCurlRequests() || null === $this->monitor->currentContext) { return; } $this->monitor->startCurlRequest($event->targetUrl, $event->method); } public function onCurlRequestFinished(CurlRequestFinishedEvent $event): void { if (!$this->monitor->shouldRecordCurlRequests() || null === $this->monitor->currentContext) { return; } $this->monitor->endCurlRequest($event->url, $event->wasSuccessful, $event->exception); } } ================================================ FILE: src/EventSubscriber/Monitoring/KernelEventsSubscriber.php ================================================ ['onKernelRequest'], KernelEvents::CONTROLLER => ['onKernelController'], KernelEvents::EXCEPTION => ['onKernelException'], KernelEvents::RESPONSE => ['onKernelResponse'], KernelEvents::TERMINATE => ['onKernelResponseSent'], ]; } public const array ROUTES_TO_IGNORE = [ 'ajax_fetch_user_notifications_count', 'liip_imagine_filter', 'custom_style', 'admin_monitoring', 'admin_monitoring_single_context', '_wdt', '_wdt_stylesheet', ]; public function onKernelRequest(RequestEvent $event): void { if (!$this->monitor->shouldRecord()) { return; } $request = $event->getRequest(); $acceptHeaders = $request->headers->all('Accept'); if (0 < \sizeof($acceptHeaders) && (\in_array('application/activity+json', $acceptHeaders) || \in_array('application/ld+json', $acceptHeaders))) { $user = 'activity_pub'; } elseif ($request->isXmlHttpRequest()) { $user = 'ajax'; } elseif ($this->security->getUser()) { $user = 'user'; } else { $user = 'anonymous'; } try { $routeInfo = $this->router->matchRequest($request); $routeName = $routeInfo['_route']; if (\in_array($routeName, self::ROUTES_TO_IGNORE)) { return; } if (str_starts_with($routeName, 'ap_')) { $user = 'activity_pub'; } } catch (\Exception) { } $this->monitor->startNewExecutionContext('request', $user, $routeName ?? $request->getRequestUri(), ''); } public function onKernelController(ControllerEvent $event): void { if (!$this->monitor->shouldRecord() || null === $this->monitor->currentContext) { return; } $controller = $event->getController(); if (\is_array($controller)) { $this->monitor->currentContext->handler = \get_class($controller[0]).'->'.$controller[1]; } elseif (\is_object($controller)) { $this->monitor->currentContext->handler = \get_class($controller).'->__invoke'; } elseif (\is_string($controller)) { $this->monitor->currentContext->handler = $controller; } } public function onKernelException(ExceptionEvent $event): void { if (!$this->monitor->shouldRecord()) { return; } if (null !== $this->monitor->currentContext) { $this->monitor->currentContext->exception = \get_class($event->getThrowable()); $this->monitor->currentContext->stacktrace = $event->getThrowable()->getTraceAsString(); } } public function onKernelResponse(ResponseEvent $event): void { if (!$this->monitor->shouldRecord() || null === $this->monitor->currentContext) { return; } $this->monitor->startSendingResponse(); } public function onKernelResponseSent(TerminateEvent $event): void { if (!$this->monitor->shouldRecord() || null === $this->monitor->currentContext) { return; } $this->monitor->endSendingResponse(); $response = $event->getResponse(); $this->monitor->endCurrentExecutionContext($response->getStatusCode()); } } ================================================ FILE: src/EventSubscriber/Monitoring/MessengerEventsSubscriber.php ================================================ 'onWorkerMessageReceived', WorkerMessageFailedEvent::class => 'onWorkerMessageFailed', WorkerMessageHandledEvent::class => 'onWorkerMessageHandled', ]; } public function onWorkerMessageReceived(WorkerMessageReceivedEvent $event): void { if (!$this->monitor->shouldRecord()) { return; } $message = $event->getEnvelope()->getMessage(); $this->monitor->startNewExecutionContext('messenger', 'anonymous', \get_class($message), $event->getReceiverName()); } public function onWorkerMessageFailed(WorkerMessageFailedEvent $event): void { if (!$this->monitor->shouldRecord()) { return; } $throwable = $event->getThrowable(); $this->monitor->currentContext->exception = \get_class($throwable); $this->monitor->currentContext->stacktrace = $throwable->getTraceAsString(); } public function onWorkerMessageHandled(WorkerMessageHandledEvent $event): void { if (!$this->monitor->shouldRecord()) { return; } $this->monitor->endCurrentExecutionContext(); } } ================================================ FILE: src/EventSubscriber/NotificationCreatedSubscriber.php ================================================ 'onNotificationCreated']; } public function onNotificationCreated(NotificationCreatedEvent $event): void { try { $this->pushSubscriptionManager->sendTextToUser($event->notification->user, $event->notification); } catch (\ErrorException $e) { $this->logger->error('there was an exception while sending a {t} to {u}. {e} - {m}', [ 't' => \get_class($event->notification), 'u' => $event->notification->user->username, 'e' => \get_class($e), 'm' => $e->getMessage(), ]); } } } ================================================ FILE: src/EventSubscriber/Post/PostCreateSubscriber.php ================================================ 'onPostCreated', ]; } public function onPostCreated(PostCreatedEvent $event): void { $event->post->magazine->postCount = $this->postRepository->countPostsByMagazine($event->post->magazine); $this->entityManager->flush(); if (!$event->post->apId) { $this->bus->dispatch(new CreateMessage($event->post->getId(), \get_class($event->post))); } else { $this->handleMagazine($event->post); } $threshold = new \DateTimeImmutable('now - 2 days'); if ($event->post->createdAt > $threshold) { $this->bus->dispatch(new PostCreatedNotificationMessage($event->post->getId())); } if ($event->post->body) { $this->bus->dispatch(new LinkEmbedMessage($event->post->body)); } } private function handleMagazine(Post $post): void { if ('random' !== $post->magazine->name) { // do not overwrite matched magazines return; } $tags = $this->tagLinkRepository->getTagsOfContent($post); foreach ($tags as $tag) { if ($magazine = $this->magazineRepository->findByTag($tag)) { $this->postManager->changeMagazine($post, $magazine); break; } } } } ================================================ FILE: src/EventSubscriber/Post/PostDeleteSubscriber.php ================================================ 'onPostDeleted', PostBeforePurgeEvent::class => 'onPostBeforePurge', PostBeforeDeletedEvent::class => 'onPostBeforeDelete', ]; } public function onPostDeleted(PostDeletedEvent $event) { $this->bus->dispatch(new PostDeletedNotificationMessage($event->post->getId())); } public function onPostBeforePurge(PostBeforePurgeEvent $event): void { $event->post->magazine->postCount = $this->postRepository->countPostsByMagazine($event->post->magazine) - 1; $this->onPostBeforeDeleteImpl($event->user, $event->post); } public function onPostBeforeDelete(PostBeforeDeletedEvent $event): void { $this->onPostBeforeDeleteImpl($event->user, $event->post); } public function onPostBeforeDeleteImpl(?User $user, Post $post): void { $this->bus->dispatch(new PostDeletedNotificationMessage($post->getId())); $this->deleteService->announceIfNecessary($user, $post); } } ================================================ FILE: src/EventSubscriber/Post/PostEditSubscriber.php ================================================ 'onPostEdited', ]; } public function onPostEdited(PostEditedEvent $event): void { $this->bus->dispatch(new PostEditedNotificationMessage($event->post->getId())); if ($event->post->body) { $this->bus->dispatch(new LinkEmbedMessage($event->post->body)); } if (!$event->post->apId) { $this->bus->dispatch(new UpdateMessage($event->post->getId(), \get_class($event->post), $event->editedBy->getId())); } } } ================================================ FILE: src/EventSubscriber/Post/PostShowSubscriber.php ================================================ 'string'])] public static function getSubscribedEvents(): array { return [ PostHasBeenSeenEvent::class => 'onShowEntry', ]; } public function onShowEntry(PostHasBeenSeenEvent $event): void { $this->readMessage($event->post); } private function readMessage(Post $post): void { if (!$this->security->getUser()) { return; } $notifications = $this->repository->findUnreadPostNotifications($this->security->getUser(), $post); if (!$notifications) { return; } array_map(fn ($notification) => $notification->status = Notification::STATUS_READ, $notifications); $this->entityManager->flush(); } } ================================================ FILE: src/EventSubscriber/PostComment/PostCommentCreateSubscriber.php ================================================ 'onPostCommentCreated', ]; } public function onPostCommentCreated(PostCommentCreatedEvent $event) { $this->cache->invalidateTags([ 'post_'.$event->comment->post->getId(), 'post_comment_'.$event->comment->root?->getId() ?? $event->comment->getId(), ]); $threshold = new \DateTimeImmutable('now - 2 days'); if ($event->comment->createdAt > $threshold) { $this->bus->dispatch(new PostCommentCreatedNotificationMessage($event->comment->getId())); } if ($event->comment->body) { $this->bus->dispatch(new LinkEmbedMessage($event->comment->body)); } if (!$event->comment->apId) { $this->bus->dispatch(new CreateMessage($event->comment->getId(), \get_class($event->comment))); } } } ================================================ FILE: src/EventSubscriber/PostComment/PostCommentDeleteSubscriber.php ================================================ 'onPostCommentDeleted', PostCommentBeforePurgeEvent::class => 'onPostCommentBeforePurge', PostCommentBeforeDeletedEvent::class => 'onPostBeforeDelete', ]; } public function onPostCommentDeleted(PostCommentDeletedEvent $event): void { $this->cache->invalidateTags([ 'post_'.$event->comment->post->getId(), 'post_comment_'.$event->comment->root?->getId() ?? $event->comment->getId(), ]); $this->bus->dispatch(new PostCommentDeletedNotificationMessage($event->comment->getId())); } public function onPostBeforeDelete(PostCommentBeforeDeletedEvent $event): void { $this->onPostCommentBeforeDeleteImpl($event->user, $event->comment); } public function onPostCommentBeforePurge(PostCommentBeforePurgeEvent $event): void { $this->onPostCommentBeforeDeleteImpl($event->user, $event->comment); } public function onPostCommentBeforeDeleteImpl(?User $user, PostComment $comment): void { $this->cache->invalidateTags([ 'post_'.$comment->post->getId(), 'post_comment_'.$comment->root?->getId() ?? $comment->getId(), ]); $this->bus->dispatch(new PostCommentDeletedNotificationMessage($comment->getId())); $this->deleteService->announceIfNecessary($user, $comment); } } ================================================ FILE: src/EventSubscriber/PostComment/PostCommentEditSubscriber.php ================================================ 'onPostCommentEdited', ]; } public function onPostCommentEdited(PostCommentEditedEvent $event) { $this->cache->invalidateTags([ 'post_'.$event->comment->post->getId(), 'post_comment_'.$event->comment->root?->getId() ?? $event->comment->getId(), ]); $this->bus->dispatch(new PostCommentEditedNotificationMessage($event->comment->getId())); if ($event->comment->body) { $this->bus->dispatch(new LinkEmbedMessage($event->comment->body)); } if (!$event->comment->apId) { $this->bus->dispatch(new UpdateMessage($event->comment->getId(), \get_class($event->comment), $event->editedBy->getId())); } } } ================================================ FILE: src/EventSubscriber/ReportApprovedSubscriber.php ================================================ notificationManager->sendReportApprovedNotification($reportedEvent->report); } public static function getSubscribedEvents(): array { return [ ReportApprovedEvent::class => 'onReportApproved', ]; } } ================================================ FILE: src/EventSubscriber/ReportHandleSubscriber.php ================================================ 'onEntryDeleted', EntryCommentDeletedEvent::class => 'onEntryCommentDeleted', PostDeletedEvent::class => 'onPostDeleted', PostCommentDeletedEvent::class => 'onPostCommentDeleted', ]; } public function onEntryDeleted(EntryDeletedEvent $event): void { $this->handleReport($event->entry, $event->user); $this->entityManager->flush(); } public function onEntryCommentDeleted(EntryCommentDeletedEvent $event): void { $this->handleReport($event->comment, $event->user); $this->entityManager->flush(); } public function onPostDeleted(PostDeletedEvent $event): void { $this->handleReport($event->post, $event->user); $this->entityManager->flush(); } public function onPostCommentDeleted(PostCommentDeletedEvent $event): void { $this->handleReport($event->comment, $event->user); $this->entityManager->flush(); } private function handleReport(ReportInterface $subject, ?User $user): void { $report = $this->repository->findBySubject($subject); if (!$report) { return; } // If the user deletes their own post when a report has been lodged against it // the report should not be considered approved if ($user && $user->getId() === $subject->getUser()->getId()) { $report->status = Report::STATUS_CLOSED; } else { $report->status = Report::STATUS_APPROVED; $report->consideredBy = $user; $report->consideredAt = new \DateTimeImmutable(); } // @todo Notification for reporting, reported user // @todo Reputation points for reporting user } } ================================================ FILE: src/EventSubscriber/ReportRejectedSubscriber.php ================================================ notificationManager->sendReportRejectedNotification($reportedEvent->report); } public static function getSubscribedEvents(): array { return [ ReportRejectedEvent::class => 'onReportRejected', ]; } } ================================================ FILE: src/EventSubscriber/SubjectReportedSubscriber.php ================================================ logger->debug($reportedEvent->report->reported->username.' was reported for '.$reportedEvent->report->reason); $this->notificationManager->sendReportCreatedNotification($reportedEvent->report); if (!$reportedEvent->report->magazine->apId and 'random' !== $reportedEvent->report->magazine->name) { return; } if ($reportedEvent->report->magazine->apId) { $this->logger->debug('was on a remote magazine, dispatching a new FlagMessage'); } elseif ('random' === $reportedEvent->report->magazine->name) { $this->logger->debug('was on the random magazine, dispatching a new FlagMessage'); } $this->bus->dispatch(new FlagMessage($reportedEvent->report->getId())); } public static function getSubscribedEvents(): array { return [ SubjectReportedEvent::class => 'onSubjectReported', ]; } } ================================================ FILE: src/EventSubscriber/TwigGlobalSubscriber.php ================================================ 'registerTwigGlobalUserSettings', ]; } public function registerTwigGlobalUserSettings(RequestEvent $request) { // determine the comment reply position, factoring in the infinite scroll setting (comment reply always on top when infinite scroll enabled) $infiniteScroll = $request->getRequest()->cookies->get(ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL, ThemeSettingsController::FALSE); $commentReplyPosition = $request->getRequest()->cookies->get(ThemeSettingsController::KBIN_COMMENTS_REPLY_POSITION, ThemeSettingsController::TOP); if (ThemeSettingsController::TRUE === $infiniteScroll) { $commentReplyPosition = ThemeSettingsController::TOP; } $userSettings = [ 'comment_reply_position' => $commentReplyPosition, ]; $this->twig->addGlobal('user_settings', $userSettings); } } ================================================ FILE: src/EventSubscriber/User/UserApplicationSubscriber.php ================================================ 'onUserApplicationRejected', UserApplicationApprovedEvent::class => 'onUserApplicationApproved', ]; } public function onUserApplicationApproved(UserApplicationApprovedEvent $event): void { $this->logger->debug('Got a UserApplicationApprovedEvent for {u}', ['u' => $event->user->username]); $this->bus->dispatch(new UserApplicationAnswerMessage($event->user->getId(), approved: true)); } public function onUserApplicationRejected(UserApplicationRejectedEvent $event): void { $this->logger->debug('Got a UserApplicationRejectedEvent for {u}', ['u' => $event->user->username]); $this->bus->dispatch(new UserApplicationAnswerMessage($event->user->getId(), approved: false)); } } ================================================ FILE: src/EventSubscriber/User/UserBlockSubscriber.php ================================================ 'onUserBlock', ]; } public function onUserBlock(UserBlockEvent $event): void { $this->sqlHelpers->clearCachedUserBlocks($event->blocker); } } ================================================ FILE: src/EventSubscriber/User/UserEditedSubscriber.php ================================================ 'onUserEdited', ]; } public function onUserEdited(UserEditedEvent $event): void { $user = $this->userRepository->findOneBy(['id' => $event->userId]); if (null === $user->apId) { $this->bus->dispatch(new UpdateMessage($user->getId(), User::class, $user->getId())); } } } ================================================ FILE: src/EventSubscriber/VoteHandleSubscriber.php ================================================ 'string'])] public static function getSubscribedEvents(): array { return [ VoteEvent::class => 'onVote', ]; } public function onVote(VoteEvent $event): void { if (VotableInterface::VOTE_DOWN === $event->vote->choice) { $this->favouriteManager->toggle($event->vote->user, $event->votable, DownvotesMode::Disabled !== $this->settingsManager->getDownvotesMode() ? FavouriteManager::TYPE_UNLIKE : null); } $this->clearCache($event->votable); $this->bus->dispatch( new VoteNotificationMessage( $event->votable->getId(), SqlHelpers::getRealClassName($this->entityManager, $event->votable) ) ); if (!$event->vote->user->apId && VotableInterface::VOTE_UP === $event->vote->choice && !$event->votedAgain) { $this->bus->dispatch( new AnnounceMessage( $event->vote->user->getId(), null, $event->votable->getId(), \get_class($event->votable), ), ); } } public function clearCache(VotableInterface $votable) { $this->cache->delete($this->cacheService->getVotersCacheKey($votable)); if ($votable instanceof Entry) { $this->cache->invalidateTags([ 'entry_'.$votable->getId(), ]); } if ($votable instanceof PostComment) { $this->cache->invalidateTags([ 'post_'.$votable->post->getId(), 'post_comment_'.$votable?->root?->getId() ?? $votable->getId(), ]); } if ($votable instanceof EntryComment && $votable->root) { $this->cache->invalidateTags(['entry_comment_'.$votable?->root?->getId() ?? $votable->getId()]); } } } ================================================ FILE: src/Exception/BadRequestDtoException.php ================================================ errors = $errors; parent::__construct($message); } public function getErrors(): ConstraintViolationList { return $this->errors; } } ================================================ FILE: src/Exception/BadUrlException.php ================================================ messageStart; $additions = []; if ($url) { $additions[] = $url; } if ($responseCode) { $additions[] = "status code: $responseCode"; } if ($payload) { $jsonPayload = json_encode($this->payload); $additions[] = $jsonPayload; } if (0 < \sizeof($additions)) { $message .= ': '.implode(', ', $additions); } parent::__construct($message, $code, $previous); } } ================================================ FILE: src/Exception/InvalidApSignatureException.php ================================================ actor instanceof User) { $username = $this->actor->getUsername(); } else { $username = $this->actor->name; } $m = \sprintf('Posting in magazine %s is restricted to mods and %s is not a mod', $this->magazine->apId ?? $this->magazine->name, $username); parent::__construct($m, 0, null); } } ================================================ FILE: src/Exception/SubjectHasBeenReportedException.php ================================================ username tried to sent a direct message to $recipient->username but they cannot receive it"; parent::__construct($message, $code, $previous); } } ================================================ FILE: src/Exception/UserDeletedException.php ================================================ $this->pageFactory->create($activity, $this->tagLinkRepository->getTagsOfContent($activity), $context), $activity instanceof EntryComment => $this->entryNoteFactory->create($activity, $this->tagLinkRepository->getTagsOfContent($activity), $context), $activity instanceof Post => $this->postNoteFactory->create($activity, $this->tagLinkRepository->getTagsOfContent($activity), $context), $activity instanceof PostComment => $this->postCommentNoteFactory->create($activity, $this->tagLinkRepository->getTagsOfContent($activity), $context), $activity instanceof Message => $this->messageFactory->build($activity, $context), default => throw new \LogicException('Cannot handle activity of type '.\get_class($activity)), }; } } ================================================ FILE: src/Factory/ActivityPub/AddRemoveFactory.php ================================================ apId ? $magazine->apAttributedToUrl : $this->urlGenerator->generate( 'ap_magazine_moderators', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); return $this->build($actor, $added, $magazine, 'Add', $url); } public function buildRemoveModerator(User $actor, User $removed, Magazine $magazine): Activity { $url = null !== $magazine->apId ? $magazine->apAttributedToUrl : $this->urlGenerator->generate( 'ap_magazine_moderators', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); return $this->build($actor, $removed, $magazine, 'Remove', $url); } public function buildAddPinnedPost(User $actor, Entry $added): Activity { $url = null !== $added->magazine->apId ? $added->magazine->apFeaturedUrl : $this->urlGenerator->generate( 'ap_magazine_pinned', ['name' => $added->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); return $this->build($actor, $added, $added->magazine, 'Add', $url); } public function buildRemovePinnedPost(User $actor, Entry $removed): Activity { $url = null !== $removed->magazine->apId ? $removed->magazine->apFeaturedUrl : $this->urlGenerator->generate( 'ap_magazine_pinned', ['name' => $removed->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); return $this->build($actor, $removed, $removed->magazine, 'Remove', $url); } private function build(User $actor, User|Entry $targetObject, Magazine $magazine, string $type, string $collectionUrl): Activity { $activity = new Activity($type); $activity->audience = $magazine; $activity->setActor($actor); $activity->setObject($targetObject); $activity->targetString = $collectionUrl; $this->entityManager->persist($activity); $this->entityManager->flush(); return $activity; } } ================================================ FILE: src/Factory/ActivityPub/BlockFactory.php ================================================ audience = $magazineBan->magazine; $activity->setActor($magazineBan->bannedBy); $activity->setObject($magazineBan); $this->entityManager->persist($activity); return $activity; } public function createActivityFromInstanceBan(User $bannedUser, User $actor): Activity { $activity = new Activity('Block'); $activity->setActor($actor); $activity->setObject($bannedUser); $this->entityManager->persist($activity); return $activity; } } ================================================ FILE: src/Factory/ActivityPub/CollectionFactory.php ================================================ 'string', 'type' => 'string', 'id' => 'string', 'first' => 'string', 'totalItems' => 'int', ])] public function getUserOutboxCollection(User $user, bool $includeContext = true): array { $fanta = $this->activityRepository->getOutboxActivitiesOfUser($user); return $this->collectionInfoWrapper->build( 'ap_user_outbox', ['username' => $user->username], $fanta->count(), $includeContext, ); } #[ArrayShape([ '@context' => 'string', 'type' => 'string', 'partOf' => 'string', 'id' => 'string', 'totalItems' => 'int', 'orderedItems' => 'array', ])] public function getUserOutboxCollectionItems(User $user, int $page, bool $includeContext = true): array { $activity = $this->activityRepository->getOutboxActivitiesOfUser($user); $activity->setCurrentPage($page); $activity->setMaxPerPage(10); $items = []; foreach ($activity as $item) { $json = $this->activityJsonBuilder->buildActivityJson($item); unset($json['@context']); $items[] = $json; } return $this->collectionItemsWrapper->build( 'ap_user_outbox', ['username' => $user->username], $activity, $items, $page, $includeContext, ); } #[ArrayShape([ '@context' => 'array', 'type' => 'string', 'id' => 'string', 'totalItems' => 'int', 'orderedItems' => 'array', ])] public function getMagazineModeratorCollection(Magazine $magazine, bool $includeContext = true): array { $moderators = $this->magazineRepository->findModerators($magazine, perPage: $magazine->moderators->count()); $items = []; foreach ($moderators->getCurrentPageResults() as /* @var Moderator $mod */ $mod) { $actor = $mod->user; $items[] = $this->manager->getActorProfileId($actor); } $result = [ '@context' => $this->contextsProvider->referencedContexts(), 'type' => 'OrderedCollection', 'id' => $this->urlGenerator->generate('ap_magazine_moderators', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL), 'totalItems' => \sizeof($items), 'orderedItems' => $items, ]; if (!$includeContext) { unset($result['@context']); } return $result; } #[ArrayShape([ '@context' => 'array', 'type' => 'string', 'id' => 'string', 'totalItems' => 'int', 'orderedItems' => 'array', ])] public function getMagazinePinnedCollection(Magazine $magazine, bool $includeContext = true): array { $pinned = $this->entryRepository->findPinned($magazine); $items = []; foreach ($pinned as $entry) { $items[] = $this->entryFactory->create($entry, $this->tagLinkRepository->getTagsOfContent($entry)); } $result = [ '@context' => $this->contextsProvider->referencedContexts(), 'type' => 'OrderedCollection', 'id' => $this->urlGenerator->generate('ap_magazine_pinned', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL), 'totalItems' => \sizeof($items), 'orderedItems' => $items, ]; if (!$includeContext) { unset($result['@context']); } return $result; } } ================================================ FILE: src/Factory/ActivityPub/EntryCommentNoteFactory.php ================================================ contextProvider->referencedContexts(); } if ('random' !== $comment->magazine->name && !$comment->magazine->apId) { // @todo $tags[] = $comment->magazine->name; } $cc = [$this->groupFactory->getActivityPubId($comment->magazine)]; if ($followersUrl = $comment->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $comment->apId)) { $cc[] = $followersUrl; } $note = array_merge($note ?? [], [ 'id' => $this->getActivityPubId($comment), 'type' => 'Note', 'attributedTo' => $this->activityPubManager->getActorProfileId($comment->user), 'inReplyTo' => $this->getReplyTo($comment), 'to' => [ ActivityPubActivityInterface::PUBLIC_URL, ], 'cc' => $cc, 'audience' => $this->groupFactory->getActivityPubId($comment->magazine), 'sensitive' => $comment->entry->isAdult(), 'content' => $this->markdownConverter->convertToHtml( $comment->body, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub] ), 'mediaType' => 'text/html', 'source' => $comment->body ? [ 'content' => $comment->body, 'mediaType' => 'text/markdown', ] : null, 'url' => $this->getActivityPubId($comment), 'tag' => array_merge( $this->tagsWrapper->build($tags), $this->mentionsWrapper->build($comment->mentions ?? [], $comment->body) ), 'published' => $comment->createdAt->format(DATE_ATOM), ]); $note['contentMap'] = [ $comment->lang => $note['content'], ]; if ($comment->image) { $note = $this->imageWrapper->build($note, $comment->image, $comment->getShortTitle()); } $mentions = []; foreach ($comment->mentions ?? [] as $mention) { try { $profileId = $this->activityPubManager->findActorOrCreate($mention)?->apProfileId; if ($profileId) { $mentions[] = $profileId; } } catch (\Exception $e) { continue; } } $note['to'] = array_values( array_unique( array_merge( $note['to'], $mentions, $this->activityPubManager->createCcFromBody($comment->body), [$this->getReplyToAuthor($comment)], ) ) ); return $note; } public function getActivityPubId(EntryComment $comment): string { if ($comment->apId) { return $comment->apId; } return $this->urlGenerator->generate( 'ap_entry_comment', [ 'magazine_name' => $comment->magazine->name, 'entry_id' => $comment->entry->getId(), 'comment_id' => $comment->getId(), ], UrlGeneratorInterface::ABSOLUTE_URL ); } private function getReplyTo(EntryComment $comment): string { return $comment->parent ? $this->getActivityPubId($comment->parent) : $this->pageFactory->getActivityPubId($comment->entry); } private function getReplyToAuthor(EntryComment $comment): string { return $comment->parent ? $this->activityPubManager->getActorProfileId($comment->parent->user) : $this->activityPubManager->getActorProfileId($comment->entry->user); } } ================================================ FILE: src/Factory/ActivityPub/EntryPageFactory.php ================================================ contextProvider->referencedContexts(); } if ('random' !== $entry->magazine->name && !$entry->magazine->apId) { // @todo $tags[] = $entry->magazine->name; } $cc = []; if ($followersUrl = $entry->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $entry->apId)) { $cc[] = $followersUrl; } $page = array_merge($page ?? [], [ 'id' => $this->getActivityPubId($entry), 'type' => 'Page', 'attributedTo' => $this->activityPubManager->getActorProfileId($entry->user), 'inReplyTo' => null, 'to' => [ $this->groupFactory->getActivityPubId($entry->magazine), ActivityPubActivityInterface::PUBLIC_URL, ], 'cc' => $cc, 'name' => $entry->title, 'audience' => $this->groupFactory->getActivityPubId($entry->magazine), 'content' => $entry->body ? $this->markdownConverter->convertToHtml( $entry->body, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub] ) : null, 'summary' => $entry->getShortTitle().' '.implode( ' ', array_map(fn ($val) => '#'.$val, $tags) ), 'mediaType' => 'text/html', 'source' => $entry->body ? [ 'content' => $entry->body, 'mediaType' => 'text/markdown', ] : null, 'tag' => array_merge( $this->tagsWrapper->build($tags), $this->mentionsWrapper->build($entry->mentions ?? [], $entry->body) ), 'commentsEnabled' => !$entry->isLocked, 'sensitive' => $entry->isAdult(), 'stickied' => $entry->sticky, 'published' => $entry->createdAt->format(DATE_ATOM), ]); $page['contentMap'] = [ $entry->lang => $page['content'], ]; if ($entry->url) { $page['source'] = $entry->url; $page['attachment'][] = [ 'href' => $entry->url, 'type' => 'Link', ]; } if ($entry->image) { // We do not know whether the image comes from an embed. // Even if $entry->hasEmbed is true that does not mean that the image is from the embed $page = $this->imageWrapper->build($page, $entry->image, $entry->title); } if ($entry->body) { $page['to'] = array_unique( array_merge($page['to'], $this->activityPubManager->createCcFromBody($entry->body)) ); } return $page; } public function getActivityPubId(Entry $entry): string { if ($entry->apId) { return $entry->apId; } return $this->urlGenerator->generate( 'ap_entry', ['magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId()], UrlGeneratorInterface::ABSOLUTE_URL ); } } ================================================ FILE: src/Factory/ActivityPub/FlagFactory.php ================================================ setObject($report->getSubject()); $activity->objectUser = $report->reported; $activity->setActor($report->reporting); $activity->contentString = $report->reason; $activity->audience = $report->magazine; $this->entityManager->persist($activity); $this->entityManager->flush(); return $activity; } } ================================================ FILE: src/Factory/ActivityPub/GroupFactory.php ================================================ description ?? ''; if (!empty($magazine->rules)) { $markdownSummary .= (!empty($markdownSummary) ? "\r\n\r\n" : '')."### Rules\r\n\r\n".$magazine->rules; } $summary = !empty($markdownSummary) ? $this->markdownConverter->convertToHtml( $markdownSummary, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub], ) : ''; $group = [ 'type' => 'Group', '@context' => $this->contextProvider->referencedContexts(), 'id' => $this->getActivityPubId($magazine), 'name' => $magazine->title, // lemmy 'preferredUsername' => $magazine->name, 'inbox' => $this->urlGenerator->generate( 'ap_magazine_inbox', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ), 'outbox' => $this->urlGenerator->generate( 'ap_magazine_outbox', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ), 'followers' => $this->getActivityPubFollowersId($magazine), 'featured' => $this->urlGenerator->generate( 'ap_magazine_pinned', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ), 'url' => $this->getActivityPubId($magazine), 'publicKey' => [ 'owner' => $this->getActivityPubId($magazine), 'id' => $this->getActivityPubId($magazine).'#main-key', 'publicKeyPem' => $magazine->publicKey, ], 'summary' => $summary, 'source' => [ 'content' => $markdownSummary, 'mediaType' => 'text/markdown', ], 'sensitive' => $magazine->isAdult, 'attributedTo' => $this->urlGenerator->generate( 'ap_magazine_moderators', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ), 'postingRestrictedToMods' => $magazine->postingRestrictedToMods, 'discoverable' => $magazine->apDiscoverable, 'indexable' => $magazine->apIndexable, 'endpoints' => [ 'sharedInbox' => $this->urlGenerator->generate( 'ap_shared_inbox', [], UrlGeneratorInterface::ABSOLUTE_URL ), ], 'published' => $magazine->createdAt->format(DATE_ATOM), 'updated' => $magazine->lastActive ? $magazine->lastActive->format(DATE_ATOM) : $magazine->createdAt->format(DATE_ATOM), ]; if ($magazine->icon) { $group['icon'] = [ 'type' => 'Image', 'url' => $this->imageManager->getUrl($magazine->icon), ]; } if ($magazine->banner) { $group['image'] = [ 'type' => 'Image', 'url' => $this->imageManager->getUrl($magazine->banner), ]; } if (!$includeContext) { unset($group['@context']); } return $group; } public function getActivityPubId(Magazine $magazine): string { if ($magazine->apId) { return $magazine->apProfileId; } return $this->urlGenerator->generate( 'ap_magazine', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); } public function getActivityPubFollowersId(Magazine $magazine): string { if ($magazine->apId) { return $magazine->apFollowersUrl; } return $this->urlGenerator->generate( 'ap_magazine_followers', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); } } ================================================ FILE: src/Factory/ActivityPub/InstanceFactory.php ================================================ urlGenerator->generate('ap_instance', [], UrlGeneratorInterface::ABSOLUTE_URL); $result = [ '@context' => $this->contextProvider->referencedContexts(), 'id' => $actor, 'type' => 'Application', 'name' => 'Mbin', 'inbox' => $this->urlGenerator->generate('ap_instance_inbox', [], UrlGeneratorInterface::ABSOLUTE_URL), 'outbox' => $this->urlGenerator->generate('ap_instance_outbox', [], UrlGeneratorInterface::ABSOLUTE_URL), 'preferredUsername' => $this->kbinDomain, 'manuallyApprovesFollowers' => true, 'publicKey' => [ 'id' => $actor.'#main-key', 'owner' => $actor, 'publicKeyPem' => $this->client->getInstancePublicKey(), ], 'published' => ($this->userRepository->findOldestUser()?->createdAt ?? new \DateTimeImmutable())->format(DATE_ATOM), ]; if (!$includeContext) { unset($result['@context']); } return $result; } public function getTargetUrl(): string { return 'https://'.$this->kbinDomain; } } ================================================ FILE: src/Factory/ActivityPub/LockFactory.php ================================================ audience = $targetObject->magazine; $activity->setActor($actor); $activity->setObject($targetObject); $this->entityManager->persist($activity); $this->entityManager->flush(); return $activity; } } ================================================ FILE: src/Factory/ActivityPub/MessageFactory.php ================================================ sender->apId ? $this->urlGenerator->generate('ap_user', ['username' => $message->sender->username], UrlGeneratorInterface::ABSOLUTE_URL) : $message->sender->apPublicUrl; $toUsers = array_values(array_filter($message->thread->participants->toArray(), fn (User $item) => $item->getId() !== $message->sender->getId())); $to = array_map(fn (User $user) => !$user->apId ? $this->urlGenerator->generate('ap_user', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL) : $user->apPublicUrl, $toUsers); $result = [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_message', ['uuid' => $message->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'attributedTo' => $actorUrl, 'to' => $to, 'cc' => [], 'type' => 'ChatMessage', 'published' => $message->createdAt->format(DATE_ATOM), 'content' => $this->markdownConverter->convertToHtml($message->body, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub]), 'mediaType' => 'text/html', 'source' => [ 'mediaType' => 'text/markdown', 'content' => $message->body, ], ]; if (!$includeContext) { unset($result['@context']); } return $result; } } ================================================ FILE: src/Factory/ActivityPub/NodeInfoFactory.php ================================================ $this->projectInfo->getName(), 'version' => $this->projectInfo->getVersion(), ]; break; case '2.1': default: // Used for 2.1 and as fallback $software = [ 'name' => $this->projectInfo->getName(), 'version' => $this->projectInfo->getVersion(), 'repository' => $this->projectInfo->getRepositoryURL(), ]; break; } return [ 'version' => $version, 'software' => $software, 'protocols' => [ self::NODE_PROTOCOL, ], 'services' => [ 'outbound' => [], 'inbound' => [], ], 'usage' => [ 'users' => [ 'total' => $this->repository->countUsers(), 'activeHalfyear' => $this->repository->countUsers((new \DateTime('now'))->modify('-6 months')), 'activeMonth' => $this->repository->countUsers((new \DateTime('now'))->modify('-1 month')), ], 'localPosts' => $this->repository->countLocalPosts(), 'localComments' => $this->repository->countLocalComments(), ], 'openRegistrations' => $this->settingsManager->get('KBIN_REGISTRATIONS_ENABLED'), 'metadata' => [ 'nodeName' => $this->settingsManager->get('KBIN_META_TITLE'), 'nodeDescription' => $this->settingsManager->get('KBIN_META_DESCRIPTION'), ], ]; } } ================================================ FILE: src/Factory/ActivityPub/PersonFactory.php ================================================ contextProvider->referencedContexts(); } $person = array_merge( $person ?? [], [ 'id' => $this->getActivityPubId($user), 'type' => $user->type, 'name' => $user->title ?? $user->getUsername(), 'preferredUsername' => $user->username, 'inbox' => $this->urlGenerator->generate( 'ap_user_inbox', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL ), 'outbox' => $this->urlGenerator->generate( 'ap_user_outbox', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL ), 'url' => $this->getActivityPubId($user), 'manuallyApprovesFollowers' => false, 'discoverable' => $user->apDiscoverable, 'indexable' => $user->apIndexable, 'published' => $user->createdAt->format(DATE_ATOM), 'following' => $this->urlGenerator->generate( 'ap_user_following', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL ), 'followers' => $this->getActivityPubFollowersId($user), 'publicKey' => [ 'owner' => $this->getActivityPubId($user), 'id' => $this->getActivityPubId($user).'#main-key', 'publicKeyPem' => $user->publicKey, ], 'endpoints' => [ 'sharedInbox' => $this->urlGenerator->generate( 'ap_shared_inbox', [], UrlGeneratorInterface::ABSOLUTE_URL ), ], ] ); if ($user->about) { $person['summary'] = $this->markdownConverter->convertToHtml( $user->about, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub], ); } if ($user->cover) { $person['image'] = [ 'type' => 'Image', 'url' => $this->imageManager->getUrl($user->cover), // @todo media url ]; } if ($user->avatar) { $person['icon'] = [ 'type' => 'Image', 'url' => $this->imageManager->getUrl($user->avatar), // @todo media url ]; } return $person; } public function getActivityPubId(User $user): string { if ($user->apId) { return $user->apProfileId; } return $this->urlGenerator->generate( 'ap_user', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL ); } public function getActivityPubFollowersId(User $user): string { if ($user->apId) { return $user->apFollowersUrl; } return $this->urlGenerator->generate( 'ap_user_followers', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL ); } } ================================================ FILE: src/Factory/ActivityPub/PostCommentNoteFactory.php ================================================ contextProvider->referencedContexts(); } if ('random' !== $comment->magazine->name && !$comment->magazine->apId) { // @todo $tags[] = $comment->magazine->name; } $cc = [$this->groupFactory->getActivityPubId($comment->magazine)]; if ($followersUrl = $comment->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $comment->apId)) { $cc[] = $followersUrl; } $note = array_merge($note ?? [], [ 'id' => $this->getActivityPubId($comment), 'type' => 'Note', 'attributedTo' => $this->activityPubManager->getActorProfileId($comment->user), 'inReplyTo' => $this->getReplyTo($comment), 'to' => [ ActivityPubActivityInterface::PUBLIC_URL, ], 'cc' => $cc, 'audience' => $this->groupFactory->getActivityPubId($comment->magazine), 'sensitive' => $comment->post->isAdult(), 'content' => $this->markdownConverter->convertToHtml( $comment->body, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub], ), 'mediaType' => 'text/html', 'source' => $comment->body ? [ 'content' => $comment->body, 'mediaType' => 'text/markdown', ] : null, 'url' => $this->getActivityPubId($comment), 'tag' => array_merge( $this->tagsWrapper->build($tags), $this->mentionsWrapper->build($comment->mentions ?? [], $comment->body) ), 'published' => $comment->createdAt->format(DATE_ATOM), ]); $note['contentMap'] = [ $comment->lang => $note['content'], ]; if ($comment->image) { $note = $this->imageWrapper->build($note, $comment->image, $comment->getShortTitle()); } $mentions = []; foreach ($comment->mentions ?? [] as $mention) { try { $profileId = $this->activityPubManager->findActorOrCreate($mention)?->apProfileId; if ($profileId) { $mentions[] = $profileId; } } catch (\Exception $e) { continue; } } $note['to'] = array_values( array_unique( array_merge( $note['to'], $mentions, $this->activityPubManager->createCcFromBody($comment->body), [$this->getReplyToAuthor($comment)], ) ) ); return $note; } public function getActivityPubId(PostComment $comment): string { if ($comment->apId) { return $comment->apId; } return $this->urlGenerator->generate( 'ap_post_comment', [ 'magazine_name' => $comment->magazine->name, 'post_id' => $comment->post->getId(), 'comment_id' => $comment->getId(), ], UrlGeneratorInterface::ABSOLUTE_URL ); } private function getReplyTo(PostComment $comment): string { return $comment->parent ? $this->getActivityPubId($comment->parent) : $this->postNoteFactory->getActivityPubId($comment->post); } private function getReplyToAuthor(PostComment $comment): string { return $comment->parent ? $this->activityPubManager->getActorProfileId($comment->parent->user) : $this->activityPubManager->getActorProfileId($comment->post->user); } } ================================================ FILE: src/Factory/ActivityPub/PostNoteFactory.php ================================================ contextProvider->referencedContexts(); } if ('random' !== $post->magazine->name && !$post->magazine->apId) { // @todo $tags[] = $post->magazine->name; } $body = $this->tagExtractor->joinTagsToBody( $post->body, $tags ); $cc = []; if ($followersUrl = $post->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $post->apId)) { $cc[] = $followersUrl; } $note = array_merge($note ?? [], [ 'id' => $this->getActivityPubId($post), 'type' => 'Note', 'attributedTo' => $this->activityPubManager->getActorProfileId($post->user), 'inReplyTo' => null, 'to' => [ $this->groupFactory->getActivityPubId($post->magazine), ActivityPubActivityInterface::PUBLIC_URL, ], 'cc' => $cc, 'audience' => $this->groupFactory->getActivityPubId($post->magazine), 'sensitive' => $post->isAdult(), 'stickied' => $post->sticky, 'content' => $this->markdownConverter->convertToHtml( $body, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub], ), 'mediaType' => 'text/html', 'source' => $post->body ? [ 'content' => $body, 'mediaType' => 'text/markdown', ] : null, 'url' => $this->getActivityPubId($post), 'tag' => array_merge( $this->tagsWrapper->build($tags), $this->mentionsWrapper->build($post->mentions ?? [], $post->body) ), 'commentsEnabled' => !$post->isLocked, 'published' => $post->createdAt->format(DATE_ATOM), ]); $note['contentMap'] = [ $post->lang => $note['content'], ]; if ($post->image) { $note = $this->imageWrapper->build($note, $post->image, $post->getShortTitle()); } $note['to'] = array_unique(array_merge($note['to'], $this->activityPubManager->createCcFromBody($post->body))); return $note; } public function getActivityPubId(Post $post): string { if ($post->apId) { return $post->apId; } return $this->urlGenerator->generate( 'ap_post', ['magazine_name' => $post->magazine->name, 'post_id' => $post->getId()], UrlGeneratorInterface::ABSOLUTE_URL ); } } ================================================ FILE: src/Factory/ActivityPub/TombstoneFactory.php ================================================ $id, 'type' => 'Tombstone', ]; } public function createForUser(User $user): array { return $this->create($this->urlGenerator->generate('ap_user', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL)); } } ================================================ FILE: src/Factory/BadgeFactory.php ================================================ magazine, $badge->name, $badge->getId(), ); } } ================================================ FILE: src/Factory/ClientConsentsFactory.php ================================================ getId(), $consent->getClient()->getName(), $consent->getClient()->getDescription(), $consent->getClient()->getImage() ? $this->imageFactory->createDto($consent->getClient()->getImage()) : null, $consent->getScopes(), array_map(fn (Scope $scope) => (string) $scope, $consent->getClient()->getScopes()), ); } } ================================================ FILE: src/Factory/ClientFactory.php ================================================ getIdentifier(), $client->getSecret(), $client->getName(), $client->getUser() ? $this->userFactory->createSmallDto($client->getUser()) : null, $client->getContactEmail(), $client->getDescription(), array_map(fn (RedirectUri $redirectUri) => (string) $redirectUri, $client->getRedirectUris()), array_map(fn (Grant $grant) => (string) $grant, $client->getGrants()), array_map(fn (Scope $scope) => (string) $scope, $client->getScopes()), $client->getImage() ? $this->imageFactory->createDto($client->getImage()) : null, ); } } ================================================ FILE: src/Factory/ContentActivityDtoFactory.php ================================================ getUpVotes() as $upvote) { $dto->boosts[] = $this->userFactory->createSmallDto($upvote->user); } if (property_exists($subject, 'favourites')) { /* @var Favourite $favourite */ foreach ($subject->favourites as $favourite) { $dto->upvotes[] = $this->userFactory->createSmallDto($favourite->user); } } else { $dto->upvotes = null; } return $dto; } } ================================================ FILE: src/Factory/ContentManagerFactory.php ================================================ entryManager; } elseif ($subject instanceof EntryComment) { return $this->entryCommentManager; } elseif ($subject instanceof Post) { return $this->postManager; } elseif ($subject instanceof PostComment) { return $this->postCommentManager; } throw new \LogicException("Unsupported subject type: '".\get_class($subject)."'"); } } ================================================ FILE: src/Factory/DomainFactory.php ================================================ name, $domain->entryCount, $domain->subscriptionsCount, $domain->getId(), ); /** @var User $currentUser */ $currentUser = $this->security->getUser(); // Only return the user's vote if permission to control voting has been given $dto->isUserSubscribed = $this->security->isGranted('ROLE_OAUTH2_DOMAIN:SUBSCRIBE') ? $domain->isSubscribed($currentUser) : null; $dto->isBlockedByUser = $this->security->isGranted('ROLE_OAUTH2_DOMAIN:BLOCK') ? $currentUser->isBlockedDomain($domain) : null; return $dto; } } ================================================ FILE: src/Factory/EntryCommentFactory.php ================================================ body, $dto->entry, $user, $dto->parent, $dto->ip ); } public function createResponseDto(EntryCommentDto|EntryComment $comment, array $tags, int $childCount = 0): EntryCommentResponseDto { $dto = $comment instanceof EntryComment ? $this->createDto($comment) : $comment; return EntryCommentResponseDto::create( $dto->getId(), $this->userFactory->createSmallDto($dto->user), $this->magazineFactory->createSmallDto($dto->magazine), $dto->entry->getId(), $dto->parent?->getId(), $dto->parent?->root?->getId() ?? $dto->parent?->getId(), $dto->image, $dto->body, $dto->lang, $dto->isAdult, $dto->uv, $dto->dv, $dto->favouriteCount, $dto->visibility, $dto->apId, $dto->mentions, $tags, $dto->createdAt, $dto->editedAt, $dto->lastActive, $childCount, bookmarks: $this->bookmarkListRepository->getBookmarksOfContentInterface($comment), isAuthorModeratorInMagazine: $dto->magazine->userIsModerator($dto->user), ); } public function createResponseTree(EntryComment $comment, EntryCommentPageView $commentPageView, int $depth = -1, ?bool $canModerate = null): EntryCommentResponseDto { $commentDto = $this->createDto($comment); $toReturn = $this->createResponseDto($commentDto, $this->tagLinkRepository->getTagsOfContent($comment), array_reduce($comment->children->toArray(), EntryCommentResponseDto::class.'::recursiveChildCount', 0)); $toReturn->isFavourited = $commentDto->isFavourited; $toReturn->userVote = $commentDto->userVote; $toReturn->canAuthUserModerate = $canModerate; if (0 === $depth) { return $toReturn; } $user = $this->security->getUser(); foreach ($comment->getChildrenByCriteria($commentPageView, $this->settingsManager->getDownvotesMode(), $user, 'comments') as $childComment) { \assert($childComment instanceof EntryComment); if ($user instanceof User) { if ($user->isBlocked($childComment->user)) { continue; } } $child = $this->createResponseTree($childComment, $commentPageView, $depth > 0 ? $depth - 1 : -1, $canModerate); $toReturn->children[] = $child; } return $toReturn; } public function createDto(EntryComment $comment): EntryCommentDto { $dto = new EntryCommentDto(); $dto->magazine = $comment->magazine; $dto->entry = $comment->entry; $dto->user = $comment->user; $dto->body = $comment->body; $dto->lang = $comment->lang; $dto->parent = $comment->parent; $dto->isAdult = $comment->isAdult; $dto->image = $comment->image ? $this->imageFactory->createDto($comment->image) : null; $dto->visibility = $comment->visibility; $dto->uv = $comment->countUpVotes(); $dto->dv = $comment->countDownVotes(); $dto->favouriteCount = $comment->favouriteCount; $dto->mentions = $comment->mentions; $dto->createdAt = $comment->createdAt; $dto->editedAt = $comment->editedAt; $dto->lastActive = $comment->lastActive; $dto->apId = $comment->apId; $dto->apLikeCount = $comment->apLikeCount; $dto->apDislikeCount = $comment->apDislikeCount; $dto->apShareCount = $comment->apShareCount; $dto->setId($comment->getId()); $currentUser = $this->security->getUser(); // Only return the user's vote if permission to control voting has been given $dto->isFavourited = $this->security->isGranted('ROLE_OAUTH2_ENTRY_COMMENT:VOTE') ? $comment->isFavored($currentUser) : null; $dto->userVote = $this->security->isGranted('ROLE_OAUTH2_ENTRY_COMMENT:VOTE') ? $comment->getUserChoice($currentUser) : null; return $dto; } } ================================================ FILE: src/Factory/EntryFactory.php ================================================ title, $dto->url, $dto->body, $dto->magazine, $user, $dto->isAdult, $dto->isOc, $dto->lang, $dto->ip, ); } public function createResponseDto(EntryDto|Entry $entry, array $tags, ?array $crosspostedEntries = null): EntryResponseDto { $dto = $entry instanceof Entry ? $this->createDto($entry) : $entry; $badges = $dto->badges ? array_map(fn (Badge $badge) => $this->badgeFactory->createDto($badge), $dto->badges->toArray()) : null; return EntryResponseDto::create( $dto->getId(), $this->magazineFactory->createSmallDto($dto->magazine), $this->userFactory->createSmallDto($dto->user), $dto->domain, $dto->title, $dto->url, $dto->image, $dto->body, $dto->lang, $tags, $badges, $dto->comments, $dto->uv, $dto->dv, $dto->isPinned, $dto->isLocked, $dto->visibility, $dto->favouriteCount, $dto->isOc, $dto->isAdult, $dto->createdAt, $dto->editedAt, $dto->lastActive, $dto->type, $dto->slug, $dto->apId, bookmarks: $this->bookmarkListRepository->getBookmarksOfContentInterface($entry), crosspostedEntries: $crosspostedEntries, isAuthorModeratorInMagazine: $dto->magazine->userIsModerator($dto->user), ); } public function createDto(Entry $entry): EntryDto { $dto = new EntryDto(); $dto->magazine = $entry->magazine; $dto->user = $entry->user; $dto->image = $entry->image ? $this->imageFactory->createDto($entry->image) : null; $dto->domain = $entry->domain ? $this->domainFactory->createDto($entry->domain) : null; $dto->title = $entry->title; $dto->url = $entry->url; $dto->body = $entry->body; $dto->comments = $entry->commentCount; $dto->uv = $entry->countUpVotes(); $dto->dv = $entry->countDownVotes(); $dto->favouriteCount = $entry->favouriteCount; $dto->isAdult = $entry->isAdult; $dto->isLocked = $entry->isLocked; $dto->isOc = $entry->isOc; $dto->lang = $entry->lang; $dto->badges = $entry->badges; $dto->slug = $entry->slug; $dto->score = $entry->score; $dto->visibility = $entry->visibility; $dto->ip = $entry->ip; $dto->createdAt = $entry->createdAt; $dto->editedAt = $entry->editedAt; $dto->lastActive = $entry->lastActive; $dto->setId($entry->getId()); $dto->isPinned = $entry->sticky; $dto->type = $entry->type; $dto->apId = $entry->apId; $dto->apLikeCount = $entry->apLikeCount; $dto->apDislikeCount = $entry->apDislikeCount; $dto->apShareCount = $entry->apShareCount; $dto->tags = $this->tagLinkRepository->getTagsOfContent($entry); $currentUser = $this->security->getUser(); // Only return the user's vote if permission to control voting has been given $dto->isFavourited = $this->security->isGranted('ROLE_OAUTH2_ENTRY:VOTE') ? $entry->isFavored($currentUser) : null; $dto->userVote = $this->security->isGranted('ROLE_OAUTH2_ENTRY:VOTE') ? $entry->getUserChoice($currentUser) : null; return $dto; } } ================================================ FILE: src/Factory/FavouriteFactory.php ================================================ entityManager->getClassMetadata(\get_class($subject))->name.'Favourite'; return new $className($user, $subject); } } ================================================ FILE: src/Factory/ImageFactory.php ================================================ entityManager->contains($image)) { $this->entityManager->persist($image); $this->entityManager->flush(); } return ImageDto::create( $image->getId(), $image->filePath, $image->width, $image->height, $image->altText, $image->sourceUrl, $this->imageManager->getUrl($image), $image->blurhash, ); } } ================================================ FILE: src/Factory/MagazineFactory.php ================================================ name, $dto->title, $user, $dto->description, $dto->rules, $dto->isAdult, $dto->isPostingRestrictedToMods, $dto->icon ); } public function createDto(Magazine $magazine): MagazineDto { $dto = new MagazineDto(); $dto->setOwner($magazine->getOwner()); $dto->icon = $magazine->icon ? $this->imageFactory->createDto($magazine->icon) : null; $dto->banner = $magazine->banner ? $this->imageFactory->createDto($magazine->banner) : null; $dto->name = $magazine->name; $dto->title = $magazine->title; $dto->description = $magazine->description; $dto->rules = $magazine->rules; $dto->subscriptionsCount = $magazine->subscriptionsCount; $dto->entryCount = $magazine->entryCount; $dto->entryCommentCount = $magazine->entryCommentCount; $dto->postCount = $magazine->postCount; $dto->postCommentCount = $magazine->postCommentCount; $dto->isAdult = $magazine->isAdult; $dto->isPostingRestrictedToMods = $magazine->postingRestrictedToMods; $dto->discoverable = $magazine->apDiscoverable; $dto->indexable = $magazine->apIndexable; $dto->tags = $magazine->tags; $dto->badges = $magazine->badges; $dto->moderators = $magazine->moderators; $dto->apId = $magazine->apId; $dto->apProfileId = $magazine->apProfileId; $dto->apFeaturedUrl = $magazine->apFeaturedUrl; $dto->setId($magazine->getId()); /** @var User $currentUser */ $currentUser = $this->security->getUser(); // Only return the user's vote if permission to control voting has been given $dto->isUserSubscribed = $this->security->isGranted('ROLE_OAUTH2_MAGAZINE:SUBSCRIBE') ? $magazine->isSubscribed($currentUser) : null; $dto->isBlockedByUser = $this->security->isGranted('ROLE_OAUTH2_MAGAZINE:BLOCK') ? $currentUser->isBlockedMagazine($magazine) : null; $subs = $this->magazineSubscriptionRepository->findMagazineSubscribers(1, $magazine)->count(); $dto->localSubscribers = $subs; $instance = $this->instanceRepository->getInstanceOfMagazine($magazine); if ($instance) { $dto->serverSoftware = $instance->software; $dto->serverSoftwareVersion = $instance->version; } return $dto; } public function createSmallDto(Magazine|MagazineDto $magazine): MagazineSmallResponseDto { $dto = $magazine instanceof Magazine ? $this->createDto($magazine) : $magazine; return new MagazineSmallResponseDto($dto); } public function createBanDto(MagazineBan $ban): MagazineBanResponseDto { return MagazineBanResponseDto::create( $ban->getId(), $ban->reason, $ban->expiredAt, $this->createSmallDto($ban->magazine), $this->userFactory->createSmallDto($ban->user), $this->userFactory->createSmallDto($ban->bannedBy), ); } public function createLogDto(MagazineLog $log): MagazineLogResponseDto { $magazine = $this->createSmallDto($log->magazine); $type = $log->getType(); $createdAt = $log->createdAt; if ($log instanceof MagazineLogModeratorAdd || $log instanceof MagazineLogModeratorRemove) { $moderator = $this->userFactory->createSmallDto($log->actingUser); $moderatorSubject = $this->userFactory->createSmallDto($log->user); return MagazineLogResponseDto::createModeratorAddRemove($magazine, $moderator, $createdAt, $type, $moderatorSubject); } elseif ($log instanceof MagazineLogBan) { $moderator = $this->userFactory->createSmallDto($log->user); $banSubject = $this->createBanDto($log->ban); if ('unban' === $log->meta) { $type = 'log_unban'; } return MagazineLogResponseDto::createBanUnban($magazine, $moderator, $createdAt, $type, $banSubject); } else { $moderator = $this->userFactory->createSmallDto($log->user); return MagazineLogResponseDto::create($magazine, $moderator, $createdAt, $type); } } public function createResponseDto(MagazineDto|Magazine $magazine): MagazineResponseDto { $dto = $magazine instanceof Magazine ? $this->createDto($magazine) : $magazine; // Ensure that magazine is an actual magazine and not a DTO $magazine = $this->magazineRepository->find($magazine->getId()); if (null === $magazine) { throw new NotFoundHttpException('Magazine was not found!'); } return MagazineResponseDto::create( $dto->getOwner() ? $this->moderatorFactory->createDtoWithUser($dto->getOwner(), $magazine) : null, $dto->icon, $dto->banner, $dto->name, $dto->title, $dto->description, $dto->rules, $dto->subscriptionsCount, $dto->entryCount, $dto->entryCommentCount, $dto->postCount, $dto->postCommentCount, $dto->isAdult, $dto->isUserSubscribed, $dto->isBlockedByUser, $dto->tags, array_map(fn (Badge|BadgeDto $badge) => new BadgeResponseDto($badge), $dto->badges?->toArray() ?? []), array_map(fn (Moderator $moderator) => $this->moderatorFactory->createDto($moderator), $dto->moderators?->toArray() ?? []), $dto->apId, $dto->apProfileId, $dto->getId(), $dto->serverSoftware, $dto->serverSoftwareVersion, $dto->isPostingRestrictedToMods, $dto->localSubscribers, $dto->discoverable, $dto->indexable, ); } public function createDtoFromAp(string $actorUrl, ?string $apId): MagazineDto { $dto = new MagazineDto(); $dto->name = $apId; $dto->title = $apId; $dto->apId = $apId; $dto->apProfileId = $actorUrl; return $dto; } } ================================================ FILE: src/Factory/MessageFactory.php ================================================ userFactory->createSmallDto($message->sender), $message->body, $message->status, $message->thread->getId(), $message->createdAt, $message->getId() ); } public function createThreadResponseDto(MessageThread $thread, int $depth): MessageThreadResponseDto { $participants = array_map(fn (User $participant) => new UserResponseDto($this->userFactory->createDto($participant)), $thread->participants->toArray()); $messages = $thread->messages->toArray(); usort($messages, fn (Message $a, Message $b) => $a->createdAt < $b->createdAt ? 1 : -1); $messageResponses = array_map(fn (Message $message) => $this->createResponseDto($message), $messages); return MessageThreadResponseDto::create( $participants, $thread->messages->count(), $messageResponses, $thread->getId() ); } } ================================================ FILE: src/Factory/ModeratorFactory.php ================================================ magazine->getId(), $moderator->user->getId(), $moderator->user->username, $moderator->user->apId, $moderator->user->avatar ? $this->imageFactory->createDto($moderator->user->avatar) : null, ); } public function createDtoWithUser(User $user, Magazine $magazine): ModeratorResponseDto { return ModeratorResponseDto::create( $magazine->getId(), $user->getId(), $user->username, $user->apId, $user->avatar ? $this->imageFactory->createDto($user->avatar) : null, ); } } ================================================ FILE: src/Factory/PostCommentFactory.php ================================================ body, $dto->post, $user, $dto->parent, $dto->ip ); } public function createResponseDto(PostCommentDto|PostComment $comment, array $tags, int $childCount = 0): PostCommentResponseDto { $dto = $comment instanceof PostComment ? $this->createDto($comment) : $comment; return PostCommentResponseDto::create( $dto->getId(), $this->userFactory->createSmallDto($dto->user), $this->magazineFactory->createSmallDto($dto->magazine), $this->postRepository->find($dto->post->getId()), $dto->parent, $childCount, $dto->image, $dto->body, $dto->lang, $dto->isAdult, $dto->uv, $dto->dv, $dto->favourites, $dto->visibility, $dto->apId, $dto->mentions, $tags, $dto->createdAt, $dto->editedAt, $dto->lastActive, bookmarks: $this->bookmarkListRepository->getBookmarksOfContentInterface($comment), isAuthorModeratorInMagazine: $dto->magazine->userIsModerator($dto->user), ); } public function createResponseTree(PostComment $comment, PostCommentPageView $criteria, int $depth, ?bool $canModerate = null): PostCommentResponseDto { $commentDto = $this->createDto($comment); $toReturn = $this->createResponseDto($commentDto, $this->tagLinkRepository->getTagsOfContent($comment), array_reduce($comment->children->toArray(), PostCommentResponseDto::class.'::recursiveChildCount', 0)); $toReturn->isFavourited = $commentDto->isFavourited; $toReturn->userVote = $commentDto->userVote; $toReturn->canAuthUserModerate = $canModerate; if (0 === $depth) { return $toReturn; } $user = $this->security->getUser(); foreach ($comment->getChildrenByCriteria($criteria, $user, 'comments') as /** @var PostComment $childComment */ $childComment) { \assert($childComment instanceof PostComment); if ($user instanceof User) { if ($user->isBlocked($childComment->user)) { continue; } } $child = $this->createResponseTree($childComment, $criteria, $depth > 0 ? $depth - 1 : -1, $canModerate); $toReturn->children[] = $child; } return $toReturn; } public function createDto(PostComment $comment): PostCommentDto { $dto = new PostCommentDto(); $dto->magazine = $comment->magazine; $dto->post = $comment->post; $dto->user = $comment->user; $dto->body = $comment->body; $dto->lang = $comment->lang; $dto->image = $comment->image ? $this->imageFactory->createDto($comment->image) : null; $dto->isAdult = $comment->isAdult; $dto->uv = $comment->countUpVotes(); $dto->dv = $comment->countDownVotes(); $dto->favourites = $comment->favouriteCount; $dto->visibility = $comment->visibility; $dto->createdAt = $comment->createdAt; $dto->editedAt = $comment->editedAt; $dto->lastActive = $comment->lastActive; $dto->setId($comment->getId()); $dto->parent = $comment->parent; $dto->mentions = $comment->mentions; $dto->apId = $comment->apId; $dto->apLikeCount = $comment->apLikeCount; $dto->apDislikeCount = $comment->apDislikeCount; $dto->apShareCount = $comment->apShareCount; $currentUser = $this->security->getUser(); // Only return the user's vote if permission to control voting has been given $dto->isFavourited = $this->security->isGranted('ROLE_OAUTH2_POST_COMMENT:VOTE') ? $comment->isFavored($currentUser) : null; $dto->userVote = $this->security->isGranted('ROLE_OAUTH2_POST_COMMENT:VOTE') ? $comment->getUserChoice($currentUser) : null; return $dto; } } ================================================ FILE: src/Factory/PostFactory.php ================================================ body, $dto->magazine, $user, $dto->isAdult, $dto->ip ); } public function createResponseDto(PostDto|Post $post, array $tags): PostResponseDto { $dto = $post instanceof Post ? $this->createDto($post) : $post; return PostResponseDto::create( $dto->getId(), $this->userFactory->createSmallDto($dto->user), $this->magazineFactory->createSmallDto($dto->magazine), $dto->image, $dto->body, $dto->lang, $dto->isAdult, $dto->isPinned, $dto->isLocked, $dto->comments, $dto->uv, $dto->dv, $dto->favouriteCount, $dto->visibility, $tags, $dto->mentions, $dto->apId, $dto->createdAt, $dto->editedAt, $dto->lastActive, $dto->slug, bookmarks: $this->bookmarkListRepository->getBookmarksOfContentInterface($post), isAuthorModeratorInMagazine: $dto->magazine->userIsModerator($dto->user), ); } public function createDto(Post $post): PostDto { $dto = new PostDto(); $dto->magazine = $post->magazine; $dto->user = $post->user; $dto->image = $post->image ? $this->imageFactory->createDto($post->image) : null; $dto->body = $post->body; $dto->lang = $post->lang; $dto->isAdult = $post->isAdult; $dto->isPinned = $post->sticky; $dto->isLocked = $post->isLocked; $dto->slug = $post->slug; $dto->comments = $post->commentCount; $dto->uv = $post->countUpVotes(); $dto->dv = $post->countDownVotes(); $dto->favouriteCount = $post->favouriteCount; $dto->visibility = $post->visibility; $dto->createdAt = $post->createdAt; $dto->editedAt = $post->editedAt; $dto->lastActive = $post->lastActive; $dto->ip = $post->ip; $dto->mentions = $post->mentions; $dto->apId = $post->apId; $dto->apLikeCount = $post->apLikeCount; $dto->apDislikeCount = $post->apDislikeCount; $dto->apShareCount = $post->apShareCount; $dto->setId($post->getId()); $currentUser = $this->security->getUser(); // Only return the user's vote if permission to control voting has been given $dto->isFavourited = $this->security->isGranted('ROLE_OAUTH2_POST:VOTE') ? $post->isFavored($currentUser) : null; $dto->userVote = $this->security->isGranted('ROLE_OAUTH2_POST:VOTE') ? $post->getUserChoice($currentUser) : null; return $dto; } } ================================================ FILE: src/Factory/ReportFactory.php ================================================ entityManager->getClassMetadata(\get_class($dto->getSubject()))->name.'Report'; return new $className($dto->getSubject()->user, $dto->getSubject(), $dto->reason); } public function createResponseDto(Report $report): ReportResponseDto { $toReturn = ReportResponseDto::create( $report->getId(), $this->magazineFactory->createSmallDto($report->magazine), $this->userFactory->createSmallDto($report->reported), $this->userFactory->createSmallDto($report->reporting), $report->reason, $report->status, $report->weight, $report->createdAt, $report->consideredAt, $report->consideredBy ? $this->userFactory->createSmallDto($report->consideredBy) : null ); $subject = $report->getSubject(); switch (\get_class($report)) { case EntryReport::class: \assert($subject instanceof Entry); $toReturn->subject = $this->entryFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfContent($subject)); break; case EntryCommentReport::class: \assert($subject instanceof EntryComment); $toReturn->subject = $this->entryCommentFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfContent($subject)); break; case PostReport::class: \assert($subject instanceof Post); $toReturn->subject = $this->postFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfContent($subject)); break; case PostCommentReport::class: \assert($subject instanceof PostComment); $toReturn->subject = $this->postCommentFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfContent($subject)); break; default: throw new \LogicException(); } return $toReturn; } } ================================================ FILE: src/Factory/UserFactory.php ================================================ security->getUser(); $dto = UserDto::create( $user->username, $user->email, $user->avatar ? $this->imageFactory->createDto($user->avatar) : null, $user->cover ? $this->imageFactory->createDto($user->cover) : null, $user->about, $user->createdAt, $user->fields, $user->apId, $user->apProfileId, $user->getId(), $user->followersCount, 'Service' === $user->type, // setting isBot $user->isAdmin(), $user->isModerator(), $currentUser && ($currentUser->isAdmin() || $currentUser->isModerator()) ? $user->applicationText : null, reputationPoints: $reputationPoints, discoverable: $user->apDiscoverable, indexable: $user->apIndexable, title: $user->title, ); // Only return the user's vote if permission to control voting has been given $dto->isFollowedByUser = $this->security->isGranted('ROLE_OAUTH2_USER:FOLLOW') ? $currentUser->isFollowing($user) : null; $dto->isFollowerOfUser = $this->security->isGranted('ROLE_OAUTH2_USER:FOLLOW') && $user->showProfileFollowings ? $user->isFollowing($currentUser) : null; $dto->isBlockedByUser = $this->security->isGranted('ROLE_OAUTH2_USER:BLOCK') ? $currentUser->isBlocked($user) : null; $instance = $this->instanceRepository->getInstanceOfUser($user); if ($instance) { $dto->serverSoftware = $instance->software; $dto->serverSoftwareVersion = $instance->version; } return $dto; } public function createSmallDto(User|UserDto $user): UserSmallResponseDto { $dto = $user instanceof User ? $this->createDto($user) : $user; return new UserSmallResponseDto($dto); } public function createSignupResponseDto(User|UserDto $user): UserSignupResponseDto { $dto = $user instanceof User ? $this->createDto($user) : $user; return new UserSignupResponseDto($dto); } public function createDtoFromAp($apProfileId, $apId): UserDto { $dto = (new UserDto())->create('@'.$apId, $apId, null, null, null, null, null, $apId, $apProfileId); $dto->plainPassword = bin2hex(random_bytes(20)); return $dto; } } ================================================ FILE: src/Factory/VoteFactory.php ================================================ new EntryVote($choice, $user, $votable), $votable instanceof EntryComment => new EntryCommentVote($choice, $user, $votable), $votable instanceof Post => new PostVote($choice, $user, $votable), $votable instanceof PostComment => new PostCommentVote($choice, $user, $votable), default => throw new \LogicException(), }; $votable->addVote($vote); return $vote; } } ================================================ FILE: src/Feed/Provider.php ================================================ manager->getFeed($request); } protected function getItems(): \Generator { yield $this->manager->getItems(); } } ================================================ FILE: src/Form/BadgeType.php ================================================ add('name') ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => BadgeDto::class, ] ); } } ================================================ FILE: src/Form/BookmarkListType.php ================================================ add('name', TextType::class) ->add('isDefault', CheckboxType::class, [ 'required' => false, ]) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => BookmarkListDto::class, ] ); } } ================================================ FILE: src/Form/ChangePasswordFormType.php ================================================ add('plainPassword', RepeatedType::class, [ 'type' => PasswordType::class, 'first_options' => [ 'attr' => ['autocomplete' => 'new-password'], 'constraints' => [ new NotBlank([ 'message' => 'Please enter a password', ]), new Length([ 'min' => 6, 'minMessage' => 'Your password should be at least {{ limit }} characters', // max length allowed by Symfony for security reasons 'max' => 4096, ]), ], 'label' => 'New password', ], 'second_options' => [ 'attr' => ['autocomplete' => 'new-password'], 'label' => 'Repeat Password', ], 'invalid_message' => 'The password fields must match.', // Instead of being set onto the object directly, // this is read and encoded in the controller 'mapped' => false, ]); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([]); } } ================================================ FILE: src/Form/ConfirmDefederationType.php ================================================ add('confirm', CheckboxType::class) ->add('submit', SubmitType::class) ; } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults(['data_class' => ConfirmDefederationDto::class]); } } ================================================ FILE: src/Form/Constraint/ImageConstraint.php ================================================ add('name') ->add('surname') ->add('email', EmailType::class) ->add('message', TextareaType::class) ->add('submit', SubmitType::class); $builder->addEventSubscriber($this->captchaListener); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => ContactDto::class, ] ); } } ================================================ FILE: src/Form/DataTransformer/BadgeCollectionToStringTransformer.php ================================================ toArray(); natcasesort($value); } elseif (null !== $value) { throw new \TypeError(\sprintf('$value must be array or NULL, %s given', get_debug_type($value))); } return implode(', ', $value ?? []); } public function reverseTransform($value): ArrayCollection { if (\is_string($value)) { return new ArrayCollection(preg_split('/\s*,\s*/', trim($value), -1, PREG_SPLIT_NO_EMPTY)); } if (null !== $value) { throw new \TypeError(\sprintf('$value must be string or NULL, %s given', get_debug_type($value))); } return new ArrayCollection(); } } ================================================ FILE: src/Form/DataTransformer/FeaturedMagazinesBarTransformer.php ================================================ getUsername(); } if (null !== $value) { throw new \InvalidArgumentException('$value must be '.User::class.' or null'); } return null; } public function reverseTransform($value): ?User { if (null === $value || '' === $value) { return null; } return $this->repository->findOneByUsername($value); } } ================================================ FILE: src/Form/EntryCommentType.php ================================================ add('body', TextareaType::class, ['required' => false, 'empty_data' => '']) ->add('lang', LanguageType::class, ['priorityLanguage' => $options['parentLanguage']]) ->add( 'image', FileType::class, [ 'constraints' => ImageConstraint::default(), 'mapped' => false, 'required' => false, ] ) ->add('imageUrl', UrlType::class, ['required' => false, 'default_protocol' => 'https']) ->add('imageAlt', TextareaType::class, ['required' => false]) ->add('submit', SubmitType::class); $builder->addEventSubscriber($this->defaultLanguage); $builder->addEventSubscriber($this->imageListener); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => EntryCommentDto::class, 'parentLanguage' => $this->settingsManager->get('KBIN_DEFAULT_LANG'), ] ); $resolver->addAllowedTypes('parentLanguage', 'string'); } public function buildView(FormView $view, FormInterface $form, array $options): void { parent::buildView($view, $form, $options); $view->vars['id'] .= '_'.uniqid('', true); } } ================================================ FILE: src/Form/EntryEditType.php ================================================ add('url', UrlType::class, [ 'required' => false, 'default_protocol' => 'https', ]) ->add('title', TextareaType::class, [ 'required' => true, ]) ->add('body', TextareaType::class, [ 'required' => false, ]) ->add('magazine', MagazineAutocompleteType::class) ->add('tags', TextType::class, [ 'required' => false, 'autocomplete' => true, 'tom_select_options' => [ 'create' => true, 'createOnBlur' => true, 'delimiter' => ' ', ], ]) // ->add( // 'badges', // BadgesType::class, // [ // 'required' => false, // ] // ) ->add( 'image', FileType::class, [ 'constraints' => ImageConstraint::default(), 'mapped' => false, 'required' => false, ] ) ->add('imageUrl', UrlType::class, [ 'required' => false, 'default_protocol' => 'https', ]) ->add('imageAlt', TextType::class, [ 'required' => false, ]) ->add('isAdult', CheckboxType::class, [ 'required' => false, ]) ->add('lang', LanguageType::class) ->add('isOc', CheckboxType::class, [ 'required' => false, ]) ->add('submit', SubmitType::class); $builder->get('tags')->addModelTransformer( new TagTransformer() ); $builder->addEventSubscriber($this->defaultLanguage); $builder->addEventSubscriber($this->disableFieldsOnEntryEdit); $builder->addEventSubscriber($this->imageListener); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => EntryDto::class, ] ); } } ================================================ FILE: src/Form/EntryType.php ================================================ add('url', UrlType::class, [ 'required' => false, 'default_protocol' => 'https', ]) ->add('title', TextareaType::class, [ 'required' => true, ]) ->add('body', TextareaType::class, [ 'required' => false, ]) ->add('magazine', MagazineAutocompleteType::class) ->add('tags', TextType::class, [ 'required' => false, 'autocomplete' => true, 'tom_select_options' => [ 'create' => true, 'createOnBlur' => true, 'delimiter' => ' ', ], ]) // ->add( // 'badges', // BadgesType::class, // [ // 'required' => false, // ] // ) ->add( 'image', FileType::class, [ 'required' => false, 'constraints' => ImageConstraint::default(), 'mapped' => false, ] ) ->add('imageUrl', UrlType::class, [ 'required' => false, 'default_protocol' => 'https', ]) ->add('imageAlt', TextType::class, [ 'required' => false, ]) ->add('isAdult', CheckboxType::class, [ 'required' => false, ]) ->add('lang', LanguageType::class) ->add('isOc', CheckboxType::class, [ 'required' => false, ]) ->add('submit', SubmitType::class); $builder->get('tags')->addModelTransformer( new TagTransformer() ); $builder->addEventSubscriber($this->defaultLanguage); $builder->addEventSubscriber($this->disableFieldsOnEntryEdit); $builder->addEventSubscriber($this->imageListener); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => EntryDto::class, ] ); } } ================================================ FILE: src/Form/EventListener/AddFieldsOnUserEdit.php ================================================ 'preSetData']; } public function preSetData(FormEvent $event): void { $user = $event->getData(); $form = $event->getForm(); if (!$user || null === $user->getId()) { return; } $form->add( 'avatar', FileType::class, [ 'required' => false, 'constraints' => $this->getConstraint(), 'mapped' => false, ] ); $form->add( 'cover', FileType::class, [ 'required' => false, 'constraints' => $this->getConstraint('10M'), 'mapped' => false, ] ); } private function getConstraint(string $maxSize = '2M'): ImageConstraint { return new ImageConstraint( [ 'detectCorrupted' => true, 'groups' => ['upload'], 'maxSize' => $maxSize, 'mimeTypes' => ImageManager::IMAGE_MIMETYPES, ] ); } } ================================================ FILE: src/Form/EventListener/AvatarListener.php ================================================ ['onPostSubmit', -200], ]; } public function onPostSubmit(PostSubmitEvent $event): void { if (!$event->getForm()->isValid()) { return; } $data = $event->getData(); $fieldName = $this->fieldName ?? 'avatar'; if (!$event->getForm()->has($fieldName)) { return; } $upload = $event->getForm()->get($fieldName)->getData(); if ($upload) { // This could throw an error (be sure to catch it in your controller) $image = $this->images->findOrCreateFromUpload($upload); if ($image) { $data->$fieldName = $this->factory->createDto($image); } } } public function setFieldName(string $fieldName): self { $this->fieldName = $fieldName; return $this; } } ================================================ FILE: src/Form/EventListener/CaptchaListener.php ================================================ 'preSetData']; } public function preSetData(FormEvent $event): void { if (!$this->settingsManager->get('KBIN_CAPTCHA_ENABLED')) { return; } $form = $event->getForm(); $form->add('captcha', HCaptchaType::class, [ 'label' => 'Captcha', ]); } } ================================================ FILE: src/Form/EventListener/DefaultLanguage.php ================================================ 'preSetData']; } public function preSetData(FormEvent $event): void { $dto = $event->getData(); if (null !== $dto && null === $dto->lang) { $dto->lang = $event->getForm()->getConfig()->getOption( 'parentLanguage', $this->requestStack->getCurrentRequest()?->getLocale(), ); $event->setData($dto); } } } ================================================ FILE: src/Form/EventListener/DisableFieldsOnEntryEdit.php ================================================ 'preSetData']; } public function preSetData(FormEvent $event): void { $entry = $event->getData(); $form = $event->getForm(); if (!$entry || null === $entry->getId()) { return; } $field = $form->get('magazine'); $attrs = $field->getConfig()->getOptions(); $attrs['disabled'] = 'disabled'; $form->remove($field->getName()); $form->add( $field->getName(), \get_class($field->getConfig()->getType()->getInnerType()), $attrs ); } } ================================================ FILE: src/Form/EventListener/DisableFieldsOnMagazineEdit.php ================================================ 'preSetData']; } public function preSetData(FormEvent $event): void { $magazine = $event->getData(); $form = $event->getForm(); if (!$magazine || null === $magazine->getId()) { return; } $field = $form->get('name'); $attrs = $field->getConfig()->getOptions(); $attrs['disabled'] = 'disabled'; $form->remove($field->getName()); $form->add( $field->getName(), \get_class($field->getConfig()->getType()->getInnerType()), $attrs ); $form->remove($form->get('nameAsTag')->getName()); } } ================================================ FILE: src/Form/EventListener/DisableFieldsOnUserEdit.php ================================================ 'preSetData']; } public function preSetData(FormEvent $event): void { $user = $event->getData(); $form = $event->getForm(); if (!$user || null === $user->getId()) { return; } if ($this->security->isGranted('edit_username', $this->repository->find($user->id))) { return; } $field = $form->get('username'); $attrs = $field->getConfig()->getOptions(); $attrs['disabled'] = 'disabled'; $form->remove($field->getName()); $form->add( $field->getName(), \get_class($field->getConfig()->getType()->getInnerType()), $attrs ); } } ================================================ FILE: src/Form/EventListener/ImageListener.php ================================================ ['onPostSubmit', -200], ]; } public function onPostSubmit(PostSubmitEvent $event): void { if (!$event->getForm()->isValid()) { return; } $data = $event->getData(); $fieldName = $this->fieldName ?? 'image'; if (!$event->getForm()->has($fieldName)) { return; } $upload = $event->getForm()->get($fieldName)->getData(); if ($upload) { // This could throw an error (be sure to catch it in your controller) $image = $this->images->findOrCreateFromUpload($upload); if ($image) { $data->$fieldName = $this->factory->createDto($image); } } } public function setFieldName(string $fieldName): self { $this->fieldName = $fieldName; return $this; } } ================================================ FILE: src/Form/EventListener/RemoveFieldsOnEntryImageEdit.php ================================================ 'preSetData']; } public function preSetData(FormEvent $event): void { $entry = $event->getData(); $form = $event->getForm(); if (!$entry || null === $entry->getId()) { return; } $form->remove($form->get('image')->getName()); } } ================================================ FILE: src/Form/EventListener/RemoveFieldsOnEntryLinkCreate.php ================================================ 'preSetData']; } public function preSetData(FormEvent $event): void { $entry = $event->getData(); $form = $event->getForm(); if ($entry && $entry->getId()) { return; } $form->remove($form->get('image')->getName()); } } ================================================ FILE: src/Form/EventListener/RemoveRulesFieldIfEmpty.php ================================================ 'preSetData']; } public function preSetData(FormEvent $event): void { /** @var Magazine $magazine */ $magazine = $event->getData(); $form = $event->getForm(); $field = $form->get('rules'); if (!$field->isEmpty() || !empty($magazine?->rules)) { return; } $form->remove($field->getName()); } } ================================================ FILE: src/Form/Extension/NoValidateExtension.php ================================================ $options */ public function buildView(FormView $view, FormInterface $form, array $options): void { $attr = !$this->html5Validation ? ['novalidate' => 'novalidate'] : []; $view->vars['attr'] = array_merge($view->vars['attr'], $attr); } } ================================================ FILE: src/Form/FederationSettingsType.php ================================================ add('federationEnabled', CheckboxType::class, ['required' => false]) ->add('federationUsesAllowList', CheckboxType::class, [ 'required' => false, 'help' => 'federation_page_use_allowlist_help', ], ) ->add('federationPageEnabled', CheckboxType::class, ['required' => false]) ->add('submit', SubmitType::class) ; } } ================================================ FILE: src/Form/LangType.php ================================================ add('lang', LanguageType::class) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([]); } } ================================================ FILE: src/Form/MagazineBanType.php ================================================ add('reason', TextareaType::class) ->add( 'expiredAt', DateTimeType::class, ['widget' => 'single_text', 'input' => 'datetime_immutable', 'required' => false] ) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => MagazineBanDto::class, ] ); } } ================================================ FILE: src/Form/MagazinePageViewType.php ================================================ add('query', TextType::class, [ 'attr' => [ 'placeholder' => 'type_search_term', ], 'required' => false, ]) ->add('fields', ChoiceType::class, [ 'choices' => [ 'filter.fields.only_names' => MagazinePageView::FIELDS_NAMES, 'filter.fields.names_and_descriptions' => MagazinePageView::FIELDS_NAMES_DESCRIPTIONS, ], ]) ->add('federation', ChoiceType::class, [ 'choices' => [ 'local_and_federated' => Criteria::AP_ALL, 'local' => Criteria::AP_LOCAL, ], ]) ->add('adult', ChoiceType::class, [ 'choices' => [ 'filter.adult.hide' => MagazinePageView::ADULT_HIDE, 'filter.adult.show' => MagazinePageView::ADULT_SHOW, 'filter.adult.only' => MagazinePageView::ADULT_ONLY, ], ]); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => MagazinePageView::class, 'csrf_protection' => false, 'method' => 'GET', ] ); } public function getBlockPrefix(): string { return ''; } } ================================================ FILE: src/Form/MagazineTagsType.php ================================================ add('tags') ->add('submit', SubmitType::class) ->add('tags', TextType::class, [ 'required' => false, 'autocomplete' => true, 'tom_select_options' => [ 'create' => true, 'createOnBlur' => true, 'delimiter' => ' ', ], ]); $builder->get('tags')->addModelTransformer( new TagTransformer() ); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => Magazine::class, ] ); } } ================================================ FILE: src/Form/MagazineThemeType.php ================================================ add( 'icon', FileType::class, [ 'constraints' => ImageConstraint::default(), 'mapped' => false, 'required' => false, 'help' => 'magazine_theme_appearance_icon', ] ) ->add( 'banner', FileType::class, [ 'constraints' => ImageConstraint::default(), 'mapped' => false, 'required' => false, 'help' => 'magazine_theme_appearance_banner', ] ) ->add('customCss', TextareaType::class, [ 'required' => false, 'help' => 'magazine_theme_appearance_custom_css', ] ) ->add('backgroundImage', ChoiceType::class, [ 'multiple' => false, 'expanded' => true, 'data' => 'none', 'choices' => [ 'none' => 'none', 'shape 1' => 'shape1', 'shape 2' => 'shape2', ], 'help' => 'magazine_theme_appearance_background_image', ]) ->add('submit', SubmitType::class); $builder->addEventSubscriber($this->avatarListener->setFieldName('icon')); $builder->addEventSubscriber($this->imageListener->setFieldName('banner')); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => MagazineThemeDto::class, ] ); } } ================================================ FILE: src/Form/MagazineType.php ================================================ add('name', TextType::class, ['required' => true]) ->add('title', TextType::class, ['required' => true]) ->add('description', TextareaType::class, ['required' => false]) ->add('rules', TextareaType::class, [ 'required' => false, 'help' => 'magazine_rules_deprecated', ]) ->add('isAdult', CheckboxType::class, ['required' => false]) ->add('isPostingRestrictedToMods', CheckboxType::class, ['required' => false]) ->add('discoverable', CheckboxType::class, [ 'required' => false, 'help' => 'magazine_discoverable_help', ]) ->add('indexable', CheckboxType::class, [ 'required' => false, 'help' => 'magazine_indexable_by_search_engines_help', ]) // this is removed through the event subscriber below on magazine edit ->add('nameAsTag', CheckboxType::class, [ 'required' => false, 'help' => 'magazine_name_as_tag_help', ]) ->add('submit', SubmitType::class); $builder->addEventSubscriber(new DisableFieldsOnMagazineEdit()); $builder->addEventSubscriber(new RemoveRulesFieldIfEmpty()); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => MagazineDto::class, ] ); } } ================================================ FILE: src/Form/MessageType.php ================================================ add('body', TextareaType::class) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => MessageDto::class, ] ); } } ================================================ FILE: src/Form/ModeratorType.php ================================================ add('user', options: [ 'attr' => ['autocomplete' => 'new-password'], ]) ->add('submit', SubmitType::class); $builder->get('user')->addModelTransformer( new UserTransformer($this->userRepository) ); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => ModeratorDto::class, ] ); } } ================================================ FILE: src/Form/ModlogFilterType.php ================================================ add('types', ChoiceType::class, [ 'choices' => [ $this->translator->trans('modlog_type_entry_deleted') => 'entry_deleted', $this->translator->trans('modlog_type_entry_restored') => 'entry_restored', $this->translator->trans('modlog_type_entry_comment_deleted') => 'entry_comment_deleted', $this->translator->trans('modlog_type_entry_comment_restored') => 'entry_comment_restored', $this->translator->trans('modlog_type_entry_pinned') => 'entry_pinned', $this->translator->trans('modlog_type_entry_unpinned') => 'entry_unpinned', $this->translator->trans('modlog_type_post_deleted') => 'post_deleted', $this->translator->trans('modlog_type_post_restored') => 'post_restored', $this->translator->trans('modlog_type_post_comment_deleted') => 'post_comment_deleted', $this->translator->trans('modlog_type_post_comment_restored') => 'post_comment_restored', $this->translator->trans('modlog_type_ban') => 'ban', $this->translator->trans('modlog_type_moderator_add') => 'moderator_add', $this->translator->trans('modlog_type_moderator_remove') => 'moderator_remove', $this->translator->trans('modlog_type_entry_lock') => 'entry_locked', $this->translator->trans('modlog_type_entry_unlock') => 'entry_unlocked', $this->translator->trans('modlog_type_post_lock') => 'post_locked', $this->translator->trans('modlog_type_post_unlock') => 'post_unlocked', ], 'multiple' => true, 'required' => false, 'autocomplete' => true, ]) ->add('magazine', MagazineAutocompleteType::class, ['required' => false]); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => ModlogFilterDto::class, ] ); } } ================================================ FILE: src/Form/MonitoringExecutionContextFilterType.php ================================================ add('executionType', ChoiceType::class, [ 'label' => $this->translator->trans('monitoring_execution_type'), 'required' => false, 'choices' => [ $this->translator->trans('monitoring_request') => 'request', $this->translator->trans('monitoring_messenger') => 'messenger', ], ]) ->add('userType', ChoiceType::class, [ 'label' => $this->translator->trans('monitoring_user_type'), 'required' => false, 'choices' => [ $this->translator->trans('monitoring_anonymous') => 'anonymous', $this->translator->trans('monitoring_user') => 'user', $this->translator->trans('monitoring_activity_pub') => 'activity_pub', $this->translator->trans('monitoring_ajax') => 'ajax', ], ]) ->add('path', TextType::class, [ 'label' => $this->translator->trans('monitoring_path'), 'required' => false, ]) ->add('handler', TextType::class, [ 'label' => $this->translator->trans('monitoring_handler'), 'required' => false, ]) ->add('createdFrom', DateTimeType::class, [ 'label' => $this->translator->trans('monitoring_created_from'), 'required' => false, ]) ->add('createdTo', DateTimeType::class, [ 'label' => $this->translator->trans('monitoring_created_to'), 'required' => false, ]) ->add('durationMinimum', NumberType::class, [ 'label' => $this->translator->trans('monitoring_duration_minimum'), 'required' => false, ]) ->add('hasException', ChoiceType::class, [ 'label' => $this->translator->trans('monitoring_has_exception'), 'required' => false, 'choices' => [ $this->translator->trans('yes') => true, $this->translator->trans('no') => false, ], ]) ->add('chartOrdering', ChoiceType::class, [ 'label' => $this->translator->trans('monitoring_chart_ordering'), 'required' => false, 'choices' => [ $this->translator->trans('monitoring_total_duration') => 'total', $this->translator->trans('monitoring_mean_duration') => 'mean', ], ]) ->add('submit', SubmitType::class, ['label' => $this->translator->trans('monitoring_submit')]) ; } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => MonitoringExecutionContextFilterDto::class, 'csrf_protection' => false, ] ); } } ================================================ FILE: src/Form/PageType.php ================================================ add('body', TextareaType::class, ['required' => false]) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => PageDto::class, ] ); } } ================================================ FILE: src/Form/PostCommentType.php ================================================ add('body', TextareaType::class, ['required' => false, 'empty_data' => '']) ->add('lang', LanguageType::class, ['priorityLanguage' => $options['parentLanguage']]) ->add( 'image', FileType::class, [ 'constraints' => ImageConstraint::default(), 'mapped' => false, 'required' => false, ] ) ->add('imageUrl', UrlType::class, ['required' => false, 'default_protocol' => 'https']) ->add('imageAlt', TextareaType::class, ['required' => false]) ->add('submit', SubmitType::class); $builder->addEventSubscriber($this->defaultLanguage); $builder->addEventSubscriber($this->imageListener); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => PostCommentDto::class, 'parentLanguage' => $this->settingsManager->get('KBIN_DEFAULT_LANG'), ] ); $resolver->addAllowedTypes('parentLanguage', 'string'); } public function buildView(FormView $view, FormInterface $form, array $options): void { parent::buildView($view, $form, $options); $view->vars['id'] .= '_'.uniqid('', true); } } ================================================ FILE: src/Form/PostType.php ================================================ add('body', TextareaType::class, ['required' => false, 'empty_data' => '']) ->add( 'image', FileType::class, [ 'constraints' => ImageConstraint::default(), 'mapped' => false, 'required' => false, ] ) ->add('magazine', MagazineAutocompleteType::class) ->add('lang', LanguageType::class) ->add('imageUrl', UrlType::class, ['required' => false, 'default_protocol' => 'https']) ->add('imageAlt', TextareaType::class, ['required' => false]) ->add('isAdult', CheckboxType::class, ['required' => false]) ->add('submit', SubmitType::class); $builder->addEventSubscriber($this->defaultLanguage); $builder->addEventSubscriber($this->imageListener); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => PostDto::class, ] ); } } ================================================ FILE: src/Form/ReportType.php ================================================ add('reason', TextareaType::class) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => ReportDto::class, ] ); } } ================================================ FILE: src/Form/ResendEmailActivationFormType.php ================================================ add('email', EmailType::class, [ 'required' => true, ]) ->add('submit', SubmitType::class, [ 'label' => 'resend_account_activation_email', 'attr' => [ 'class' => 'btn btn__primary', ], ]); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([]); } } ================================================ FILE: src/Form/ResetPasswordRequestFormType.php ================================================ add('email', EmailType::class, [ 'attr' => ['autocomplete' => 'email'], 'constraints' => [ new NotBlank([ 'message' => 'Please enter your email', ]), ], ]); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([]); } } ================================================ FILE: src/Form/SearchType.php ================================================ setMethod('GET') ->add('q', TextType::class, [ 'required' => true, 'attr' => [ 'placeholder' => 'type_search_term_url_handle', ], ]) ->add('magazine', MagazineAutocompleteType::class, ['required' => false, 'placeholder' => 'type_search_magazine']) ->add('user', UserAutocompleteType::class, ['required' => false, 'placeholder' => 'type_search_user']) ->add('type', ChoiceType::class, [ 'choices' => [ 'search_type_all' => null, 'search_type_entry' => 'entry', 'search_type_post' => 'post', 'search_type_magazine' => 'magazine', 'search_type_user' => 'user', 'search_type_actors' => 'users+magazines', 'search_type_content' => 'entry+post', ], ]) ->add('since', DateTimeType::class, ['required' => false]) ; } } ================================================ FILE: src/Form/SettingsType.php ================================================ settingsManager->getDto(); $this->logger->debug('downvotes mode is: {mode}', ['mode' => $dto->MBIN_DOWNVOTES_MODE]); $builder ->add('KBIN_DOMAIN') ->add('KBIN_CONTACT_EMAIL', EmailType::class) ->add('KBIN_TITLE') ->add('KBIN_META_TITLE') ->add('KBIN_META_DESCRIPTION') ->add('KBIN_META_KEYWORDS') ->add('MBIN_DEFAULT_THEME', ChoiceType::class, [ 'choices' => Criteria::THEME_OPTIONS, ]) ->add('KBIN_HEADER_LOGO', CheckboxType::class, ['required' => false]) ->add('KBIN_REGISTRATIONS_ENABLED', CheckboxType::class, ['required' => false]) ->add('MBIN_SSO_REGISTRATIONS_ENABLED', CheckboxType::class, ['required' => false]) ->add('KBIN_CAPTCHA_ENABLED', CheckboxType::class, ['required' => false]) ->add('KBIN_MERCURE_ENABLED', CheckboxType::class, ['required' => false]) ->add('KBIN_ADMIN_ONLY_OAUTH_CLIENTS', CheckboxType::class, ['required' => false]) ->add('MBIN_SSO_ONLY_MODE', CheckboxType::class, ['required' => false]) ->add('MBIN_PRIVATE_INSTANCE', CheckboxType::class, ['required' => false]) ->add('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', CheckboxType::class, ['required' => false]) ->add('MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY', CheckboxType::class, ['required' => false]) ->add('MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY', CheckboxType::class, ['required' => false]) ->add('MBIN_RESTRICT_MAGAZINE_CREATION', CheckboxType::class, ['required' => false]) ->add('MBIN_SSO_SHOW_FIRST', CheckboxType::class, ['required' => false]) ->add('MBIN_DOWNVOTES_MODE', ChoiceType::class, [ 'choices' => DownvotesMode::GetChoices(), 'choice_attr' => [ $dto->MBIN_DOWNVOTES_MODE => ['checked' => true], ], ]) ->add('MBIN_NEW_USERS_NEED_APPROVAL', CheckboxType::class, ['required' => false]) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => SettingsDto::class, ] ); } } ================================================ FILE: src/Form/Type/BadgesType.php ================================================ addModelTransformer($this->badgeArrayToStringTransformer); } public function getParent(): string { return TextType::class; } } ================================================ FILE: src/Form/Type/LanguageType.php ================================================ setDefaults( [ 'choice_loader' => function (Options $options) { $this->preferredLanguages = $this->security->getUser()?->preferredLanguages ?? []; $this->priorityLanguage = $options['priorityLanguage']; if (0 === \count($this->preferredLanguages)) { $this->preferredLanguages = [$this->requestStack->getCurrentRequest()?->getLocale()]; } return ChoiceList::loader($this, new CallbackChoiceLoader(function () { foreach (Languages::getLanguageCodes() as $languageCode) { try { $choices[$languageCode] = Languages::getName($languageCode, $languageCode); } catch (MissingResourceException) { } } natcasesort($choices); return array_flip($choices); }), [$this->preferredLanguages, $this->priorityLanguage]); }, 'preferred_choices' => ChoiceList::preferred($this, function (string $choice): bool { if (\in_array($choice, $this->preferredLanguages) || $this->priorityLanguage === $choice) { return true; } return false; }), 'required' => true, 'autocomplete' => false, 'priorityLanguage' => '', ] ); $resolver->addAllowedTypes('priorityLanguage', 'string'); } public function getParent(): string { return ChoiceType::class; } } ================================================ FILE: src/Form/Type/MagazineAutocompleteType.php ================================================ setDefaults([ 'class' => Magazine::class, 'choice_label' => 'name', 'placeholder' => 'select_magazine', 'filter_query' => function (QueryBuilder $qb, string $query) { if ($currentUser = $this->security->getUser()) { $qb ->andWhere( \sprintf( 'entity.id NOT IN (SELECT IDENTITY(mb.magazine) FROM %s mb WHERE mb.user = :user)', MagazineBlock::class, ) ) ->setParameter('user', $currentUser); } if (!$query) { return; } $qb->andWhere('entity.name LIKE :filter OR entity.title LIKE :filter') ->andWhere('entity.visibility = :visibility') ->setParameter('filter', '%'.$query.'%') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ; }, ]); } public function getParent(): string { return BaseEntityAutocompleteType::class; } } ================================================ FILE: src/Form/Type/UserAutocompleteType.php ================================================ setDefaults([ 'class' => User::class, 'choice_label' => 'username', 'placeholder' => 'select_user', 'filter_query' => function (QueryBuilder $qb, string $query) { if ($currentUser = $this->security->getUser()) { $qb ->andWhere( \sprintf( 'entity.id NOT IN (SELECT IDENTITY(ub.blocked) FROM %s ub WHERE ub.blocker = :user)', UserBlock::class, ) ) ->setParameter('user', $currentUser); } if (!$query) { return; } $qb->andWhere('entity.username LIKE :filter') ->andWhere('entity.visibility = :visibility') ->setParameter('filter', '%'.$query.'%') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ; }, ]); } public function getParent(): string { return BaseEntityAutocompleteType::class; } } ================================================ FILE: src/Form/UserAccountDeletionType.php ================================================ add('currentPassword', PasswordType::class, [ 'mapped' => false, 'row_attr' => [ 'class' => 'password-preview', 'data-controller' => 'password-preview', ], ]) ->add('instantDelete', CheckboxType::class, ['required' => false]) ->add('submit', SubmitType::class); } } ================================================ FILE: src/Form/UserBasicType.php ================================================ add('username', TextType::class, ['required' => false]) ->add('title', TextType::class, ['required' => false]) ->add('about', TextareaType::class, ['required' => false]) ->add('submit', SubmitType::class); $builder->addEventSubscriber($this->disableUsernameFieldOnUserEdit); $builder->addEventSubscriber($this->addAvatarFieldOnUserEdit); $builder->addEventSubscriber($this->avatarListener->setFieldName('avatar')); $builder->addEventSubscriber($this->imageListener->setFieldName('cover')); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => UserDto::class, ] ); } } ================================================ FILE: src/Form/UserDisable2FAType.php ================================================ add('currentPassword', PasswordType::class, [ 'label' => 'current_password', 'mapped' => false, 'row_attr' => [ 'class' => 'password-preview', 'data-controller' => 'password-preview', ], ]) ->add('totpCode', TextType::class, [ 'label' => '2fa.authentication_code.label', 'mapped' => false, 'attr' => [ 'autocomplete' => 'one-time-code', 'inputmode' => 'numeric', 'pattern' => '[0-9]*', ], ], ) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => UserDto::class, ] ); } } ================================================ FILE: src/Form/UserEmailType.php ================================================ add( 'email', TextType::class, ['mapped' => false] ) ->add('newEmail', RepeatedType::class, [ 'type' => EmailType::class, 'mapped' => false, 'required' => true, 'first_options' => ['label' => 'new_email'], 'second_options' => ['label' => 'new_email_repeat'], ]) ->add('currentPassword', PasswordType::class, [ 'mapped' => false, 'row_attr' => [ 'class' => 'password-preview', 'data-controller' => 'password-preview', ], ]) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => UserDto::class, ] ); } } ================================================ FILE: src/Form/UserFilterListType.php ================================================ add('name', TextType::class) ->add('expirationDate', DateType::class, [ 'required' => false, 'row_attr' => [ 'class' => 'checkbox', ], ]) ->add('feeds', CheckboxType::class, [ 'required' => false, 'row_attr' => [ 'class' => 'checkbox', ], 'help' => 'filter_lists_feeds_help', ]) ->add('comments', CheckboxType::class, [ 'required' => false, 'row_attr' => [ 'class' => 'checkbox', ], 'help' => 'filter_lists_comments_help', ]) ->add('profile', CheckboxType::class, [ 'required' => false, 'row_attr' => [ 'class' => 'checkbox', ], 'help' => 'filter_lists_profile_help', ]) ->add('words', CollectionType::class, [ 'entry_type' => UserFilterWordType::class, 'allow_add' => true, 'allow_delete' => true, 'attr' => [ 'class' => 'existing-words', ], 'label' => 'filter_lists_filter_words', ]) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => UserFilterListDto::class, ] ); } } ================================================ FILE: src/Form/UserFilterWordType.php ================================================ add('word', TextType::class, [ 'label' => false, 'required' => false, ]) ->add('exactMatch', CheckboxType::class, [ 'required' => false, 'label' => 'filter_lists_word_exact_match', 'row_attr' => [ 'class' => 'checkbox', ], ]); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => UserFilterWordDto::class, ] ); } } ================================================ FILE: src/Form/UserNoteType.php ================================================ add('body', TextareaType::class) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => UserNoteDto::class, ] ); } } ================================================ FILE: src/Form/UserPasswordType.php ================================================ add('currentPassword', PasswordType::class, [ 'label' => 'current_password', 'mapped' => false, 'row_attr' => [ 'class' => 'password-preview', 'data-controller' => 'password-preview', ], ]) ->add('totpCode', TextType::class, [ 'label' => '2fa.authentication_code.label', 'mapped' => false, 'attr' => [ 'autocomplete' => 'one-time-code', 'inputmode' => 'numeric', 'pattern' => '[0-9]*', ], ], ) ->add( 'plainPassword', RepeatedType::class, [ 'type' => PasswordType::class, 'required' => true, 'first_options' => [ 'label' => 'new_password', 'row_attr' => [ 'class' => 'password-preview', 'data-controller' => 'password-preview', ], ], 'second_options' => [ 'label' => 'new_password_repeat', 'row_attr' => [ 'class' => 'password-preview', 'data-controller' => 'password-preview', ], ], ] ) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => UserDto::class, ] ); } } ================================================ FILE: src/Form/UserRegenerate2FABackupType.php ================================================ add('currentPassword', PasswordType::class, [ 'label' => 'current_password', 'mapped' => false, 'row_attr' => [ 'class' => 'password-preview', 'data-controller' => 'password-preview', ], ]) ->add('totpCode', TextType::class, [ 'label' => '2fa.authentication_code.label', 'mapped' => false, 'attr' => [ 'autocomplete' => 'one-time-code', 'inputmode' => 'numeric', 'pattern' => '[0-9]*', ], ], ) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => UserDto::class, ] ); } } ================================================ FILE: src/Form/UserRegisterType.php ================================================ add('username') ->add('email', EmailType::class) ->add( 'plainPassword', RepeatedType::class, [ 'type' => PasswordType::class, 'required' => true, 'first_options' => [ 'label' => 'password', 'row_attr' => [ 'class' => 'password-preview', 'data-controller' => 'password-preview', ], ], 'second_options' => [ 'label' => 'repeat_password', 'row_attr' => [ 'class' => 'password-preview', 'data-controller' => 'password-preview', ], ], ] ) ->add( 'agreeTerms', CheckboxType::class, [ 'label_html' => true, ] ) ->add('submit', SubmitType::class); if ($this->settingsManager->getNewUsersNeedApproval()) { $builder ->add('applicationText', TextareaType::class, ['required' => true]); } $builder->addEventSubscriber($this->disableUsernameFieldOnUserEdit); $builder->addEventSubscriber($this->captchaListener); $builder->addEventSubscriber($this->addAvatarFieldOnUserEdit); $builder->addEventSubscriber($this->imageListener->setFieldName('avatar')); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => UserDto::class, ] ); } } ================================================ FILE: src/Form/UserSettingsType.php ================================================ translator->trans($option)] = $option; } $commentDefaultSortChoices = []; foreach (EntryCommentPageView::SORT_OPTIONS as $option) { $commentDefaultSortChoices[$this->translator->trans($option)] = $option; } $directMessageSettingChoices = []; foreach (EDirectMessageSettings::getValues() as $option) { $directMessageSettingChoices[$this->translator->trans($option)] = $option; } $frontDefaultContentChoices = [ $this->translator->trans('default_content_default') => null, ]; foreach (EFrontContentOptions::OPTIONS as $option) { $frontDefaultContentChoices[$this->translator->trans('default_content_'.$option)] = $option; } $builder ->add( 'hideAdult', CheckboxType::class, ['required' => false] ) ->add('homepage', ChoiceType::class, [ 'autocomplete' => true, 'choices' => [ $this->translator->trans('all') => User::HOMEPAGE_ALL, $this->translator->trans('subscriptions') => User::HOMEPAGE_SUB, $this->translator->trans('favourites') => User::HOMEPAGE_FAV, $this->translator->trans('moderated') => User::HOMEPAGE_MOD, ], ] ) ->add('frontDefaultSort', ChoiceType::class, [ 'autocomplete' => true, 'choices' => $frontDefaultSortChoices, ]) ->add('frontDefaultContent', ChoiceType::class, [ 'autocomplete' => true, 'choices' => $frontDefaultContentChoices, ]) ->add('commentDefaultSort', ChoiceType::class, [ 'autocomplete' => true, 'choices' => $commentDefaultSortChoices, ]) ->add('directMessageSetting', ChoiceType::class, [ 'autocomplete' => true, 'choices' => $directMessageSettingChoices, ]) ->add('showFollowingBoosts', CheckboxType::class, [ 'required' => false, 'help' => 'show_boost_following_help', ]) ->add('discoverable', CheckboxType::class, [ 'required' => false, 'help' => 'user_discoverable_help', ]) ->add('indexable', CheckboxType::class, [ 'required' => false, 'help' => 'user_indexable_by_search_engines_help', ]) ->add('featuredMagazines', TextareaType::class, ['required' => false]) ->add('preferredLanguages', LanguageType::class, [ 'required' => false, 'preferred_choices' => [$this->translator->getLocale()], 'autocomplete' => true, 'multiple' => true, 'choice_self_translation' => true, ]) ->add('customCss', TextareaType::class, [ 'required' => false, ]) ->add( 'ignoreMagazinesCustomCss', CheckboxType::class, ['required' => false] ) ->add( 'showProfileSubscriptions', CheckboxType::class, ['required' => false] ) ->add( 'showProfileFollowings', CheckboxType::class, ['required' => false] ) ->add( 'notifyOnNewEntry', CheckboxType::class, ['required' => false] ) ->add( 'notifyOnNewEntryReply', CheckboxType::class, ['required' => false] ) ->add( 'notifyOnNewEntryCommentReply', CheckboxType::class, ['required' => false] ) ->add( 'notifyOnNewPost', CheckboxType::class, ['required' => false] ) ->add( 'notifyOnNewPostReply', CheckboxType::class, ['required' => false] ) ->add( 'notifyOnNewPostCommentReply', CheckboxType::class, ['required' => false] ) ->add( 'addMentionsEntries', CheckboxType::class, ['required' => false] ) ->add( 'addMentionsPosts', CheckboxType::class, ['required' => false] ) ->add('submit', SubmitType::class); /** @var User $user */ $user = $this->security->getUser(); if ($user->isAdmin() or $user->isModerator()) { $builder->add('notifyOnUserSignup', CheckboxType::class, ['required' => false]); } $builder->get('featuredMagazines')->addModelTransformer( new FeaturedMagazinesBarTransformer() ); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => UserSettingsDto::class, ] ); } } ================================================ FILE: src/Form/UserTwoFactorType.php ================================================ add( 'totpCode', TextType::class, [ 'label' => '2fa.verify_authentication_code.label', 'mapped' => false, 'attr' => [ 'autocomplete' => 'one-time-code', 'inputmode' => 'numeric', 'pattern' => '[0-9]*', ], ], ) ->add('currentPassword', PasswordType::class, [ 'label' => 'current_password', 'mapped' => false, 'row_attr' => [ 'class' => 'password-preview', 'data-controller' => 'password-preview', ], ]) ->add('submit', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'data_class' => UserDto::class, ] ); } } ================================================ FILE: src/Kernel.php ================================================ getProjectDir(); $routes->import($projectDir.'/config/{routes}/'.$this->environment.'/*.yaml'); $routes->import($projectDir.'/config/{mbin_routes}/*.yaml'); $routes->import($projectDir.'/config/{routes}/*.yaml'); if (is_file($projectDir.'/config/routes.yaml')) { $routes->import($projectDir.'/config/routes.yaml'); } else { $routes->import($projectDir.'/config/{routes}.php'); } } #[Override] protected function build(ContainerBuilder $container): void { $container->addCompilerPass(new class implements CompilerPassInterface { public function process(ContainerBuilder $container): void { $container->getDefinition('doctrine.orm.default_configuration') ->addMethodCall( 'setIdentityGenerationPreferences', [ [ PostgreSQLPlatform::class => ClassMetadata::GENERATOR_TYPE_SEQUENCE, ], ] ); } }); } } ================================================ FILE: src/Markdown/CommonMark/CommunityLinkParser.php ================================================ getCursor(); $cursor->advanceBy($ctx->getFullMatchLength()); $matches = $ctx->getSubMatches(); $handle = $matches['0']; $domain = $matches['1'] ?? $this->settingsManager->get('KBIN_DOMAIN'); $fullHandle = $handle.'@'.$domain; $isRemote = $this->isRemoteCommunity($domain); $magazine = $this->magazineRepository->findOneByName($isRemote ? $fullHandle : $handle); $this->removeSurroundingLink($ctx, $handle, $domain); if ($magazine) { $ctx->getContainer()->appendChild( new CommunityLink( $this->urlGenerator->generate('front_magazine', ['name' => $magazine->name]), '!'.$handle, '!'.($isRemote ? $magazine->apId : $magazine->name), $isRemote ? $magazine->apId : $magazine->name, $isRemote ? MentionType::RemoteMagazine : MentionType::Magazine, ), ); return true; } if ($isRemote) { $ctx->getContainer()->appendChild( new ActorSearchLink( $this->urlGenerator->generate('search', ['search[q]' => $fullHandle], UrlGeneratorInterface::ABSOLUTE_URL), '!'.$fullHandle, '!'.$fullHandle, ) ); return true; } // unable to resolve a local '!' link so don't even try. $ctx->getContainer()->appendChild(new UnresolvableLink('!'.$handle)); return true; } private function isRemoteCommunity(?string $domain): bool { return $domain !== $this->settingsManager->get('KBIN_DOMAIN'); } /** * Removes a surrounding link from the parsing container if the link contains $handle and $domain. * * @param string $handle the user handle in [!@]handle@domain * @param string $domain the domain in [!@]handle@domain */ public static function removeSurroundingLink(InlineParserContext $ctx, string $handle, string $domain): void { $cursor = $ctx->getCursor(); $prev = $cursor->peek(-1 - $ctx->getFullMatchLength()); $next = $cursor->peek(0); $nextNext = $cursor->peek(1); if ('[' === $prev && ']' === $next && '(' === $nextNext) { $closing = null; $link = ''; for ($i = 2; null !== ($char = $cursor->peek($i)); ++$i) { if (')' === $char) { $closing = $i; break; } $link .= $char; } if (null !== $closing && str_contains($link, $handle) && str_contains($link, $domain)) { // this is probably a lemmy community link a lá [!magazine@domain.tld](https://domain.tld/c/magazine] $container = $ctx->getContainer(); $prev = $container->lastChild(); if ('[' === $prev->getLiteral()) { $prev->detach(); } $ctx->getDelimiterStack()->removeBracket(); $cursor->advanceBy($closing + 1); $current = $cursor->peek(0); } } } } ================================================ FILE: src/Markdown/CommonMark/DetailsBlockParser.php ================================================ block = new DetailsBlock($title, $fenceLength, $fenceOffset); } public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue { // Check for closing fence if (!$cursor->isIndented() && DetailsBlock::FENCE_CHAR === $cursor->getNextNonSpaceCharacter()) { $match = RegexHelper::matchFirst( self::FENCE_END_PATTERN, $cursor->getLine(), $cursor->getNextNonSpacePosition() ); if (null !== $match && \strlen($match[0]) >= $this->block->getLength()) { // closing fence found - finalize block return BlockContinue::finished(); } } // Skip optional spaces of fence offset // Optimization: don't attempt to match if we're at a non-space position if ($cursor->getNextNonSpacePosition() > $cursor->getPosition()) { $cursor->match('/^ {0,'.$this->block->getOffset().'}/'); } return BlockContinue::at($cursor); } public function closeBlock(): void { $title = $this->block->getTitle(); if ($title && preg_match('/^spoiler\b/', $title)) { $this->block->setSpoiler(true); $title = preg_replace('/^spoiler\s*/', '', $title); $this->block->setTitle($title); } } public function parseInlines(InlineParserEngineInterface $inlineParser): void { $titleBlock = new Paragraph(); $inlineParser->parse($this->block->getTitle(), $titleBlock); if ($titleBlock->hasChildren()) { $titleBlock->data->set('section', 'summary'); $this->block->prependChild($titleBlock); } } public function getBlock(): DetailsBlock { return $this->block; } public function isContainer(): bool { return true; } public function canContain(AbstractBlock $childBlock): bool { if ($childBlock instanceof DetailsBlock) { return $this->block->getLength() > $childBlock->getLength(); } return true; } } ================================================ FILE: src/Markdown/CommonMark/DetailsBlockRenderer.php ================================================ data->get('attributes'); $summary = $node->getSummary(); $contents = $node->getContents(); return new HtmlElement( 'details', $attrs, [ new HtmlElement( 'summary', [], $summary ? $childRenderer->renderNodes($summary->children()) : '', ), new HtmlElement( 'div', [ 'class' => 'content', ], $childRenderer->renderNodes($contents), ), ] ); } } ================================================ FILE: src/Markdown/CommonMark/DetailsBlockStartParser.php ================================================ isIndented() || DetailsBlock::FENCE_CHAR !== $cursor->getNextNonSpaceCharacter()) { return BlockStart::none(); } $indent = $cursor->getIndent(); $fence = $cursor->match(self::FENCE_START_PATTERN); if (null === $fence) { return BlockStart::none(); } // todo: maybe move title parsing into DetailsBlockParser $title = ltrim($cursor->getRemainder()); $cursor->advanceToEnd(); $fence = ltrim($fence, " \t"); return BlockStart::of(new DetailsBlockParser($title, \strlen($fence), $indent))->at($cursor); } } ================================================ FILE: src/Markdown/CommonMark/EmbedElement.php ================================================ 'preview', 'data-controller' => 'preview', ], [ new HtmlElement( 'button', [ 'class' => 'show-preview', 'data-action' => 'preview#show', 'data-preview-url-param' => $url, 'data-preview-ratio-param' => DomainManager::shouldRatio($url) ? '1' : '0', 'aria-label' => 'Show preview', ], new HtmlElement( 'i', [ 'class' => 'fas fa-photo-video', ], ), ), new HtmlElement( 'a', [ 'href' => $url, 'rel' => 'nofollow noopener noreferrer', 'target' => '_blank', ], $label ), new HtmlElement( 'span', [ 'class' => 'preview-target hidden', 'data-preview-target' => 'container', ] ), ] ); } public static function buildDestructed(string $url, ?string $label = null): HtmlElement { return new HtmlElement( 'span', [], [ new HtmlElement('i', ['class' => 'fas fa-photo-video']), $label ? \sprintf(' %s (%s)', $label, $url) : ' '.$url, ] ); } } ================================================ FILE: src/Markdown/CommonMark/ExternalImagesRenderer.php ================================================ config = $configuration; } /** * @param Image $node */ public function render( Node $node, ChildNodeRendererInterface $childRenderer, ): HtmlElement { Image::assertInstanceOf($node); $renderTarget = $this->config->get('kbin')[MarkdownConverter::RENDER_TARGET]; $url = $node->getUrl(); $label = null; if (RenderTarget::Page === $renderTarget) { // skip rendering links inside the label (not allowed) if ($node->hasChildren()) { $cnodes = []; foreach ($node->children() as $n) { if ( ($n instanceof Link && $n instanceof StringContainerInterface) || $n instanceof UnresolvableLink ) { $cnodes[] = new Text($n->getLiteral()); } else { $cnodes[] = $n; } } $label = $childRenderer->renderNodes($cnodes); } // self destructs rendering if parent is a link // because while commonmark permits putting image inside link label, // html does not allow nested interactive contents inside // see: https://spec.commonmark.org/0.30/#example-516 // and: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#technical_summary if ($node->parent() && $node->parent() instanceof Link) { return EmbedElement::buildDestructed($node->getUrl(), $this->getAltText($node)); } return EmbedElement::buildEmbed($url, $label ?? $url); } return new HtmlElement( 'img', [ 'src' => $url, 'alt' => $node->hasChildren() ? $this->getAltText($node) : false, ], '', true ); } // literally lifted from league/commonmark ImageRenderer // see: https://github.com/thephpleague/commonmark/blob/7af3307679b2942d825562bfad202a52a03b4513/src/Extension/CommonMark/Renderer/Inline/ImageRenderer.php#L93 private function getAltText(Image $node): string { $altText = ''; foreach ((new NodeIterator($node)) as $n) { if ($n instanceof StringContainerInterface) { $altText .= $n->getLiteral(); } elseif ($n instanceof Newline) { $altText .= ' '; } } return $altText; } } ================================================ FILE: src/Markdown/CommonMark/ExternalLinkRenderer.php ================================================ config = $configuration; $this->logger->debug('[ExternalLinkRenderer] config initialized with: {v}', ['v' => $configuration->get('kbin')]); } private function getRenderTarget(): RenderTarget { $val = $this->getFromConfig(MarkdownConverter::RENDER_TARGET); if ($val instanceof RenderTarget) { return $val; } return RenderTarget::ActivityPub; } private function showRichMentions(): bool { return $this->getBoolFromConfig('richMention', true); } private function showRichMagazineMentions(): bool { return $this->getBoolFromConfig('richMagazineMention', true); } private function showRichAPLinks(): bool { return $this->getBoolFromConfig('richAPLink', true); } private function getBoolFromConfig(string $key, bool $default): bool { $value = $this->getFromConfig($key); return null !== $value ? \boolval($value) : $default; } private function getFromConfig(string $key): mixed { try { $kbinConfig = $this->config->get('kbin'); if (\array_key_exists($key, $kbinConfig)) { return $kbinConfig[$key]; } } catch (\Throwable $e) { } return null; } public function render(Node $node, ChildNodeRendererInterface $childRenderer): HtmlElement|string { /* @var Link $node */ Link::assertInstanceOf($node); $renderTarget = $this->getRenderTarget(); $isApRequest = RenderTarget::ActivityPub === $renderTarget; if (!$isApRequest && $node instanceof MentionLink && $this->isExistingMentionType($node)) { $this->logger->debug("Got node of class {c}: username: '{k}', title: '{t}', type: '{ty}', url: '{url}'", [ 'c' => \get_class($node), 'k' => $node->getKbinUsername(), 't' => $node->getTitle(), 'ty' => $node->getType(), 'url' => $node->getUrl(), ]); return new HtmlElement('span', contents: $this->renderMentionType($node, $childRenderer)); } else { $this->logger->debug("Got node of class {c}: title: '{t}', url: '{url}'", [ 'c' => \get_class($node), 't' => $node->getTitle(), 'url' => $node->getUrl(), ]); } // skip rendering links inside the label (not allowed) $childContent = null; if ($node->hasChildren()) { $cnodes = []; foreach ($node->children() as $n) { if ( ($n instanceof Link && $n instanceof StringContainerInterface) || $n instanceof UnresolvableLink ) { $cnodes[] = new Text($n->getLiteral()); } else { $cnodes[] = $n; } $this->logger->debug('child node type {t}', ['t' => \get_class($n)]); } $childContent = $childRenderer->renderNodes($cnodes); } $url = $node->getUrl(); $apLink = null; if (filter_var($url, FILTER_VALIDATE_URL)) { $apActivity = null; try { $apActivity = $this->activityRepository->findByObjectId($url); } catch (\Error|\Exception $e) { $this->logger->warning("There was an error finding the activity pub object for url '{q}': {e}", ['q' => $url, 'e' => \get_class($e).' - '.$e->getMessage()]); } if (!$isApRequest && null !== $apActivity && 0 !== $apActivity['id'] && Message::class !== $apActivity['type']) { $this->logger->debug('Found activity with url {u}: {t} - {id}', [ 'u' => $node->getUrl(), 't' => $apActivity['type'], 'id' => $apActivity['id'], ]); /** @var Entry|EntryComment|Post|PostComment $entity */ $entity = $this->entityManager->getRepository($apActivity['type'])->find($apActivity['id']); if (null !== $entity) { if (null === $node->getTitle() && (null === $childContent || $url === $childContent)) { return $this->renderInlineEntity($entity); } else { $apLink = $this->activityRepository->getLocalUrlOfEntity($entity); } } else { $this->logger->warning('[ExternalLinkRenderer::render] Could not find an entity for type {t} with id {id} from url {url}', ['t' => $apActivity['type'], 'id' => $apActivity['id'], 'url' => $url]); return new HtmlElement('div'); } } } else { $this->logger->debug('Got an invalid url {u}', ['u' => $url]); } $url = match ($node::class) { RoutedMentionLink::class => $this->generateUrlForRoute($node, $renderTarget), default => $apLink ?? $node->getUrl(), }; $title = $childContent ?? $url; if (RegexHelper::isLinkPotentiallyUnsafe($url)) { return new HtmlElement( 'span', ['class' => 'unsafe-link'], $title ); } if ( !$this->isMentionType($node) && (ImageManager::isImageUrl($url) || $this->isEmbed($url, $title)) && RenderTarget::Page === $renderTarget ) { return EmbedElement::buildEmbed($url, $title); } // create attributes for link $attr = $this->generateAttr($node, $renderTarget); // open non-local links in a new tab if (false !== filter_var($url, FILTER_VALIDATE_URL) && !$this->settingsManager->isLocalUrl($url) && RenderTarget::ActivityPub !== $renderTarget ) { $attr['rel'] = 'noopener noreferrer nofollow'; $attr['target'] = '_blank'; } return new HtmlElement( 'a', ['href' => $url] + $attr, $title ); } /** * @return array{ * class: string, * title?: string, * data-action?: string, * data-mentions-username-param?: string, * rel?: string, * } */ private function generateAttr(Link $node, RenderTarget $renderTarget): array { $attr = match ($node::class) { ActivityPubMentionLink::class => $this->generateMentionLinkAttr($node), ActorSearchLink::class => [], CommunityLink::class => $this->generateCommunityLinkAttr($node), RoutedMentionLink::class => $this->generateMentionLinkAttr($node), TagLink::class => [ 'class' => 'hashtag tag', 'rel' => 'tag', ], default => [ 'class' => 'kbin-media-link', ], }; if (RenderTarget::ActivityPub === $renderTarget) { $attr = array_intersect_key($attr, ['class', 'title', 'rel']); } return $attr; } /** * @return array{ * class: string, * title: string, * data-action: string, * data-mentions-username-param: string, * } */ private function generateMentionLinkAttr(MentionLink $link): array { $data = [ 'class' => 'mention', 'title' => $link->getTitle(), 'data-mentions-username-param' => $link->getKbinUsername(), ]; if (MentionType::Magazine === $link->getType() || MentionType::RemoteMagazine === $link->getType()) { $data['class'] = $data['class'].' mention--magazine'; $data['data-action'] = 'mentions#navigateMagazine'; } if (MentionType::User === $link->getType() || MentionType::RemoteUser === $link->getType()) { $data['class'] = $data['class'].' u-url mention--user'; $data['data-action'] = 'mouseover->mentions#userPopup mouseout->mentions#userPopupOut mentions#navigateUser'; } return $data; } /** * @return array{ * class: string, * title: string, * data-action: string, * data-mentions-username-param: string, * } */ private function generateCommunityLinkAttr(CommunityLink $link): array { $data = [ 'class' => 'mention mention--magazine', 'title' => $link->getTitle(), 'data-mentions-username-param' => $link->getKbinUsername(), 'data-action' => 'mentions#navigateMagazine', ]; return $data; } private function generateUrlForRoute(RoutedMentionLink $routedMentionLink, RenderTarget $renderTarget): string { return $this->urlGenerator->generate( $routedMentionLink->getRoute(), [$routedMentionLink->getParamName() => $routedMentionLink->getUrl()], RenderTarget::ActivityPub === $renderTarget ? UrlGeneratorInterface::ABSOLUTE_URL : UrlGeneratorInterface::ABSOLUTE_PATH ); } private function isEmbed(string $url, string $title): bool { $embed = false; if (filter_var($url, FILTER_VALIDATE_URL) && $entity = $this->embedRepository->findOneBy(['url' => $url])) { $embed = $entity->hasEmbed; } return (bool) $embed; } private function isMentionType(Link $link): bool { $types = [ ActivityPubMentionLink::class, ActorSearchLink::class, CommunityLink::class, RoutedMentionLink::class, TagLink::class, ]; foreach ($types as $type) { if ($link instanceof $type) { return true; } } return false; } private function isExistingMentionType(Link $link): bool { if ($link instanceof CommunityLink || $link instanceof ActivityPubMentionLink || $link instanceof RoutedMentionLink) { if (MentionType::Unresolvable !== $link->getType() && MentionType::Search !== $link->getType()) { return true; } } return false; } private function renderMentionType(MentionLink $node, ChildNodeRendererInterface $childRenderer): string { if (MentionType::User === $node->getType() || MentionType::RemoteUser === $node->getType()) { return $this->renderUserNode($node, $childRenderer); } elseif (MentionType::Magazine === $node->getType() || MentionType::RemoteMagazine === $node->getType()) { return $this->renderMagazineNode($node, $childRenderer); } else { throw new \LogicException('dont know type of '.\get_class($node)); } } private function renderUserNode(MentionLink $node, ChildNodeRendererInterface $childRenderer): string { $username = $node->getKbinUsername(); $user = $this->userRepository->findOneBy(['username' => $username]); if (!$user) { $this->logger->error('cannot render {o}, couldn\'t find user {u}', ['o' => $node, 'u' => $username]); return ''; } return $this->renderUser($user); } private function renderUser(?User $user): string { return $this->twig->render('components/user_inline_md.html.twig', [ 'user' => $user, 'showAvatar' => true, 'showNewIcon' => true, 'fullName' => ThemeSettingsController::getShowUserFullName($this->requestStack->getCurrentRequest()), 'rich' => $this->showRichMentions(), ]); } private function renderMagazineNode(MentionLink $node, ChildNodeRendererInterface $childRenderer): string { $magName = $node->getKbinUsername(); $magazine = $this->magazineRepository->findOneByName($magName); if (!$magazine) { $this->logger->error('cannot render {o}, couldn\'t find magazine {m}', ['o' => $node, 'm' => $magName]); return ''; } return $this->renderMagazine($magazine); } private function renderMagazine(Magazine $magazine) { return $this->twig->render('components/magazine_inline_md.html.twig', [ 'magazine' => $magazine, 'stretchedLink' => false, 'fullName' => ThemeSettingsController::getShowMagazineFullName($this->requestStack->getCurrentRequest()), 'showAvatar' => true, 'rich' => $this->showRichMagazineMentions(), ]); } private function renderInlineEntity(Entry|EntryComment|Post|PostComment $entity): string { if ($entity instanceof Entry) { return $this->twig->render('components/entry_inline_md.html.twig', [ 'entry' => $entity, 'userFullName' => ThemeSettingsController::getShowUserFullName($this->requestStack->getCurrentRequest()), 'magazineFullName' => ThemeSettingsController::getShowMagazineFullName($this->requestStack->getCurrentRequest()), 'rich' => $this->showRichAPLinks(), ]); } elseif ($entity instanceof EntryComment) { return $this->twig->render('components/entry_comment_inline_md.html.twig', [ 'comment' => $entity, 'userFullName' => ThemeSettingsController::getShowUserFullName($this->requestStack->getCurrentRequest()), 'magazineFullName' => ThemeSettingsController::getShowMagazineFullName($this->requestStack->getCurrentRequest()), 'rich' => $this->showRichAPLinks(), ]); } elseif ($entity instanceof Post) { return $this->twig->render('components/post_inline_md.html.twig', [ 'post' => $entity, 'userFullName' => ThemeSettingsController::getShowUserFullName($this->requestStack->getCurrentRequest()), 'magazineFullName' => ThemeSettingsController::getShowMagazineFullName($this->requestStack->getCurrentRequest()), 'rich' => $this->showRichAPLinks(), ]); } elseif ($entity instanceof PostComment) { return $this->twig->render('components/post_comment_inline_md.html.twig', [ 'comment' => $entity, 'userFullName' => ThemeSettingsController::getShowUserFullName($this->requestStack->getCurrentRequest()), 'magazineFullName' => ThemeSettingsController::getShowMagazineFullName($this->requestStack->getCurrentRequest()), 'rich' => $this->showRichAPLinks(), ]); } throw new \LogicException('This code should be unreachable'); } } ================================================ FILE: src/Markdown/CommonMark/MentionLinkParser.php ================================================ getCursor(); $cursor->advanceBy($ctx->getFullMatchLength()); $matches = $ctx->getSubMatches(); $username = $matches['0']; $domain = $matches['1'] ?? $this->settingsManager->get('KBIN_DOMAIN'); $fullUsername = $username.'@'.$domain; CommunityLinkParser::removeSurroundingLink($ctx, $username, $domain); [$type, $data] = $this->resolveType($username, $domain); if ($data instanceof User && $data->apPublicUrl) { $ctx->getContainer()->appendChild( new ActivityPubMentionLink( $data->apPublicUrl, '@'.$username, '@'.$data->apId, '@'.$data->apId, MentionType::RemoteUser, ) ); return true; } [$routeDetails, $slug, $label, $title, $kbinUsername] = match ($type) { MentionType::RemoteUser => [$this->resolveRouteDetails($type), '@'.$fullUsername, '@'.$username, '@'.$username, '@'.$fullUsername], MentionType::RemoteMagazine => [$this->resolveRouteDetails($type), $fullUsername, '@'.$username, '@'.$fullUsername, $fullUsername], MentionType::Magazine => [$this->resolveRouteDetails($type), $username, '@'.$username, '@'.$username, $username], MentionType::Search => [$this->resolveRouteDetails($type), $fullUsername, '@'.$username, '@'.$fullUsername, $fullUsername], MentionType::Unresolvable => [['route' => '', 'param' => ''], '', '@'.$username, '@'.$fullUsername, ''], MentionType::User => [$this->resolveRouteDetails($type), $username, '@'.$username, '@'.$fullUsername, $username], }; $ctx->getContainer()->appendChild( $this->generateNode( ...$routeDetails, slug: $slug, label: $label, title: $title, kbinUsername: $kbinUsername, type: $type, ) ); return true; } private function generateNode(string $route, string $param, string $slug, string $label, string $title, string $kbinUsername, MentionType $type): Node { if (MentionType::Unresolvable === $type) { return new UnresolvableLink($label); } return new RoutedMentionLink($route, $param, $slug, $label, $title, $kbinUsername, $type); } private function isRemoteMention(?string $domain): bool { return $domain !== $this->settingsManager->get('KBIN_DOMAIN'); } /** * @return array{type: MentionType, data: User|Magazine|null} */ private function resolveType(string $handle, ?string $domain): array { if ($this->isRemoteMention($domain)) { return $this->resolveRemoteType($handle.'@'.$domain); } if (null !== $this->userRepository->findOneByUsername($handle)) { return [MentionType::User, null]; } if (null !== $this->magazineRepository->findOneByName($handle)) { return [MentionType::Magazine, null]; } return [MentionType::Unresolvable, null]; } /** * @return array{type: MentionType, data: User|Magazine|null} */ private function resolveRemoteType($fullyQualifiedHandle): array { $user = $this->userRepository->findOneByUsername('@'.$fullyQualifiedHandle); // we're aware of this account, link to it directly if ($user && $user->apPublicUrl) { return [MentionType::RemoteUser, $user]; } $magazine = $this->magazineRepository->findOneByName($fullyQualifiedHandle); // we're aware of this magazine, link to it directly if ($magazine && $magazine->apPublicUrl) { return [MentionType::RemoteMagazine, $magazine]; } // take thee to search return [MentionType::Search, null]; } /** * @return array{route: string, param: string} */ private function resolveRouteDetails(MentionType $type): array { return match ($type) { MentionType::Magazine => ['route' => 'front_magazine', 'param' => 'name'], MentionType::RemoteMagazine => ['route' => 'front_magazine', 'param' => 'name'], MentionType::RemoteUser => ['route' => 'user_overview', 'param' => 'username'], MentionType::Search => ['route' => 'search', 'param' => 'search[q]'], MentionType::User => ['route' => 'user_overview', 'param' => 'username'], }; } } ================================================ FILE: src/Markdown/CommonMark/MentionType.php ================================================ kbinUsername; } public function getType(): MentionType { return $this->type; } } ================================================ FILE: src/Markdown/CommonMark/Node/ActorSearchLink.php ================================================ title; } public function setTitle(string $title) { $this->title = $title; } private function isSummaryBlock(Node $node): bool { return 'summary' === $node->data->get('section', ''); } public function getSummary(): ?Node { foreach ($this->children() as $cnode) { if ($this->isSummaryBlock($cnode)) { return $cnode; } } return null; } /** @return iterable */ public function getContents(): iterable { $children = []; foreach ($this->children() as $cnode) { if (!$this->isSummaryBlock($cnode)) { $children[] = $cnode; } } return $children; } public function isSpoiler(): bool { return $this->spoiler; } public function setSpoiler(bool $spoiler): void { $this->spoiler = $spoiler; if ($spoiler) { $this->data->append('attributes/class', 'spoiler'); } else { $classes = $this->data->get('attributes/class', ''); $this->data->set('attributes/class', array_diff(explode(' ', $classes), ['spoiler'])); } } public function getLength(): int { return $this->length; } public function setLength(int $length): void { $this->length = $length; } public function getOffset(): int { return $this->offset; } public function setOffset(int $offset): void { $this->offset = $offset; } } ================================================ FILE: src/Markdown/CommonMark/Node/MentionLink.php ================================================ kbinUsername; } public function getRoute(): string { return $this->route; } public function getParamName(): string { return $this->paramName; } public function getType(): MentionType { return $this->type; } } ================================================ FILE: src/Markdown/CommonMark/Node/TagLink.php ================================================ getCursor(); $cursor->advanceBy($ctx->getFullMatchLength()); [$tag] = $ctx->getSubMatches(); $url = $this->urlGenerator->generate( 'tag_overview', ['name' => $tag], UrlGeneratorInterface::ABSOLUTE_URL, ); $ctx->getContainer()->appendChild(new TagLink($url, '#'.$tag)); return true; } } ================================================ FILE: src/Markdown/CommonMark/UnresolvableLinkRenderer.php ================================================ 'mention mention--unresolvable', ], $node->getLiteral(), ); } } ================================================ FILE: src/Markdown/Event/BuildCacheContext.php ================================================ request, $this->convertMarkdownEvent->getSourceType()); $this->addToContext('content', $convertMarkdownEvent->getMarkdown()); $this->addToContext('target', $convertMarkdownEvent->getRenderTarget()->name); $this->addToContext('userFullName', ThemeSettingsController::getShowUserFullName($this->request) ? '1' : '0'); $this->addToContext('magazineFullName', ThemeSettingsController::getShowMagazineFullName($this->request) ? '1' : '0'); $this->addToContext('richMention', $richMdConfig['richMention'] ? '1' : '0'); $this->addToContext('richMagazineMention', $richMdConfig['richMagazineMention'] ? '1' : '0'); $this->addToContext('richAPLink', $richMdConfig['richAPLink'] ? '1' : '0'); $this->addToContext('apRequest', UrlUtils::isActivityPubRequest($this->request) ? '1' : '0'); } public function addToContext(string $key, ?string $value = null): void { $this->context[$key] = $value; } public function getConvertMarkdownEvent(): ConvertMarkdown { return $this->convertMarkdownEvent; } public function getCacheKey(): string { ksort($this->context); $jsonContext = json_encode($this->context); $hash = hash('sha256', $jsonContext); return "md_$hash"; } public function hasContext(string $key, ?string $value = null): bool { if (!\array_key_exists($key, $this->context)) { return false; } if (\func_num_args() < 2) { return true; } return $this->context[$key] === $value; } } ================================================ FILE: src/Markdown/Event/ConvertMarkdown.php ================================================ markdown; } public function getSourceType(): string { return $this->sourceType; } public function getRenderedContent(): RenderedContentInterface { return $this->renderedContent; } public function setRenderedContent(RenderedContentInterface $renderedContent): void { $this->renderedContent = $renderedContent; } public function getRenderTarget(): RenderTarget { return $this->getAttribute(MarkdownConverter::RENDER_TARGET) ?? RenderTarget::Page; } /** * @return mixed|null */ public function getAttribute(string $key) { return $this->attributes[$key] ?? null; } public function addAttribute(string $key, $data): void { $this->attributes[$key] = $data; } public function addTag(string $tag): void { $this->attributes['tags'] ??= []; $this->attributes['tags'][] = $tag; } /** * @return string[] */ public function getTags(): array { return $this->attributes['tags'] ?? []; } public function mergeAttributes(array $attributes): void { $this->attributes = array_replace($this->attributes, $attributes); } public function removeAttribute(string $key): void { unset($this->attributes[$key]); } } ================================================ FILE: src/Markdown/Factory/ConverterFactory.php ================================================ config['kbin'] = [ 'render_target' => $renderTarget, 'richMention' => $richMention, 'richMagazineMention' => $richMagazineMention, 'richAPLink' => $richAPLink, ]; $env = new Environment($this->config); $env->addInlineParser($this->container->get(UrlAutolinkParser::class)) ->addExtension($this->container->get(CommonMarkCoreExtension::class)) ->addExtension($this->container->get(StrikethroughExtension::class)) ->addExtension($this->container->get(TableExtension::class)) ->addExtension($this->container->get(KbinMarkdownExtension::class)); return $env; } } ================================================ FILE: src/Markdown/Listener/CacheMarkdownListener.php ================================================ // // SPDX-License-Identifier: Zlib declare(strict_types=1); namespace App\Markdown\Listener; use App\Markdown\Event\BuildCacheContext; use App\Markdown\Event\ConvertMarkdown; use App\Repository\ApActivityRepository; use App\Repository\MagazineRepository; use App\Repository\UserRepository; use App\Service\MentionManager; use App\Utils\RegPatterns; use App\Utils\UrlUtils; use League\CommonMark\Output\RenderedContentInterface; use Psr\Cache\CacheException; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\RequestStack; /** * Fetch and store rendered HTML given the raw input and a generated context. */ final class CacheMarkdownListener implements EventSubscriberInterface { private const ATTR_CACHE_ITEM = __CLASS__.' cache item'; public const ATTR_NO_CACHE_STORE = 'no_cache_store'; public function __construct( private readonly CacheItemPoolInterface $pool, private readonly EventDispatcherInterface $dispatcher, private readonly RequestStack $requestStack, private readonly LoggerInterface $logger, private readonly ApActivityRepository $activityRepository, private readonly MentionManager $mentionManager, private readonly UserRepository $userRepository, private readonly MagazineRepository $magazineRepository, ) { } public static function getSubscribedEvents(): array { return [ ConvertMarkdown::class => [ ['preConvertMarkdown', 64], ['postConvertMarkdown', -64], ], ]; } public function preConvertMarkdown(ConvertMarkdown $event): void { $request = $this->requestStack->getCurrentRequest(); if (null === $request) { $this->logger->debug('[ConvertMarkdown] request is null]'); } $cacheEvent = new BuildCacheContext($event, $request); $this->dispatcher->dispatch($cacheEvent); $key = $cacheEvent->getCacheKey(); $item = $this->pool->getItem($key); if ($item->isHit()) { $content = $item->get(); if ($content instanceof RenderedContentInterface) { $event->setRenderedContent($content); $event->stopPropagation(); return; } } if (!$event->getAttribute(self::ATTR_NO_CACHE_STORE)) { $event->addAttribute(self::ATTR_CACHE_ITEM, $item); } } public function postConvertMarkdown(ConvertMarkdown $event): void { if ($event->getAttribute(self::ATTR_NO_CACHE_STORE)) { return; } $item = $event->getAttribute(self::ATTR_CACHE_ITEM); \assert($item instanceof CacheItemInterface); $item->set($event->getRenderedContent()); try { if (method_exists($item, 'tag')) { $md = $event->getMarkdown(); $urls = array_map(fn ($item) => UrlUtils::getCacheKeyForMarkdownUrl($item), $this->getMissingUrlsFromMarkdown($md)); $mentions = array_map(fn ($item) => UrlUtils::getCacheKeyForMarkdownUserMention($item), $this->getMissingMentionsFromMarkdown($md)); $magazineMentions = array_map(fn ($item) => UrlUtils::getCacheKeyForMarkdownMagazineMention($item), $this->getMissingMagazineMentions($md)); $tags = array_unique(array_merge($urls, $mentions, $magazineMentions)); $this->logger->debug('added tags {t} to markdown "{m}"', ['t' => join(', ', $tags), 'm' => $md]); $item->tag($tags); } } catch (CacheException) { } $this->pool->save($item); $event->removeAttribute(self::ATTR_CACHE_ITEM); } /** @return string[] */ private function getMissingUrlsFromMarkdown(string $markdown): array { $urls = []; foreach (UrlUtils::extractUrlsFromString($markdown) as $url) { $entity = $this->activityRepository->findByObjectId($url); if (null === $entity) { $urls[] = $url; } } return $urls; } /** @return string[] */ private function getMissingMentionsFromMarkdown(string $markdown): array { $remoteMentions = $this->mentionManager->extract($markdown, MentionManager::REMOTE) ?? []; $missingMentions = []; foreach ($remoteMentions as $mention) { if (null === $this->userRepository->findOneBy(['apId' => $mention])) { $missingMentions[] = $mention; } } return $missingMentions; } /** @return string[] */ private function getMissingMagazineMentions(string $markdown): array { // No-break space is causing issues with word splitting. So replace a no-break (0xc2 0xa0) by a normal space first. $words = preg_split('/[ \n\[\]()]/', str_replace(\chr(194).\chr(160), ' ', $markdown)); $missingCommunityMentions = []; foreach ($words as $word) { $matches = null; // Remove newline (\n), tab (\t), carriage return (\r), etc. $word2 = preg_replace('/[[:cntrl:]]/', '', $word); if (preg_match('/'.RegPatterns::COMMUNITY_REGEX.'/', $word2, $matches)) { // Check if the required matched array keys exist if (!isset($matches[1]) || !isset($matches[2])) { $this->logger->warning('Invalid community mention format: {word}', ['word' => $word2]); // Just skip and continue continue; } $apId = "$matches[1]@$matches[2]"; $this->logger->debug("searching for magazine '{m}', original word: '{w}', word without cntrl: '{w2}'", ['m' => $apId, 'w' => $word, 'w2' => $word2]); try { $magazine = $this->magazineRepository->findOneBy(['apId' => $apId]); if (!$magazine) { $missingCommunityMentions[] = $apId; } } catch (\Exception $e) { $this->logger->error('An error occurred while looking for magazine "{m}": {t} - {msg}', ['m' => $apId, 't' => \get_class($e), 'msg' => $e->getMessage()]); } } } return $missingCommunityMentions; } } ================================================ FILE: src/Markdown/Listener/ConvertMarkdownListener.php ================================================ ['onConvertMarkdown'], ]; } public function onConvertMarkdown(ConvertMarkdown $event): void { $request = $this->requestStack->getCurrentRequest(); $richMdConfig = MarkdownExtension::getMdRichConfig($request, $event->getSourceType()); $environment = $this->environmentFactory->createEnvironment( $event->getRenderTarget(), $richMdConfig['richMention'], $richMdConfig['richMagazineMention'], $richMdConfig['richAPLink'], ); $converter = $this->converterFactory->createConverter($environment); $html = $converter->convert($event->getMarkdown()); $event->setRenderedContent($html); } } ================================================ FILE: src/Markdown/MarkdownConverter.php ================================================ mergeAttributes($context); $this->dispatcher->dispatch($event); return (string) $event->getRenderedContent(); } } ================================================ FILE: src/Markdown/MarkdownExtension.php ================================================ addSchema('kbin', Expect::structure([ 'render_target' => Expect::type(RenderTarget::class), 'richMention' => Expect::bool(true), 'richMagazineMention' => Expect::bool(true), 'richAPLink' => Expect::bool(true), ])); } public function register(EnvironmentBuilderInterface $environment): void { $environment->addBlockStartParser($this->detailsBlockStartParser); $environment->addInlineParser($this->communityLinkParser); $environment->addInlineParser($this->mentionLinkParser); $environment->addInlineParser($this->tagLinkParser); $environment->addRenderer(Link::class, $this->linkRenderer, 1); $environment->addRenderer(Image::class, $this->imagesRenderer, 1); $environment->addRenderer(UnresolvableLink::class, $this->unresolvableLinkRenderer, 1); $environment->addRenderer(DetailsBlock::class, $this->detailsBlockRenderer, 1); } /** * @return array{richMention: bool, richMagazineMention: bool, richAPLink: bool} */ public static function getMdRichConfig(?Request $request, string $sourceType = ''): array { if ('entry' === $sourceType) { return [ 'richMention' => ThemeSettingsController::getShowRichMentionEntry($request), 'richMagazineMention' => ThemeSettingsController::getShowRichMagazineMentionEntry($request), 'richAPLink' => ThemeSettingsController::getShowRichAPLinkEntries($request), ]; } elseif ('post' === $sourceType) { return [ 'richMention' => ThemeSettingsController::getShowRichMentionPosts($request), 'richMagazineMention' => ThemeSettingsController::getShowRichMagazineMentionPosts($request), 'richAPLink' => ThemeSettingsController::getShowRichAPLinkPosts($request), ]; } else { return [ 'richMention' => true, 'richMagazineMention' => true, 'richAPLink' => true, ]; } } } ================================================ FILE: src/Markdown/RenderTarget.php ================================================ 'mixed', 'type' => 'string', 'actor' => 'mixed', 'to' => 'mixed', 'object' => 'mixed', 'audience' => 'string', 'summary' => 'string', ])] public array $payload; public function __construct(array $payload) { $this->payload = $payload; } } ================================================ FILE: src/Message/ActivityPub/Inbox/FollowMessage.php ================================================ payload = $payload; } } ================================================ FILE: src/Message/ActivityPub/Inbox/RemoveMessage.php ================================================ entityManager, $this->kernel); } public function __invoke(ActivityMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof ActivityMessage)) { throw new \LogicException("ActivityHandler called, but is wasn\'t an ActivityMessage. Type: ".\get_class($message)); } $payload = @json_decode($message->payload, true); if (null === $payload) { $this->logger->warning('[ActivityHandler::doWork] Activity message from was empty or invalid JSON. Truncated content: {content}, ignoring it', [ 'content' => substr($message->payload ?? 'No payload provided', 0, 200), ]); throw new UnrecoverableMessageHandlingException('Activity message from was empty or invalid JSON'); } if ($message->request && $message->headers) { try { $this->signatureValidator->validate($message->request, $message->headers, $message->payload); } catch (InboxForwardingException $exception) { $this->logger->info("[ActivityHandler::doWork] The message was forwarded by {receivedFrom}. Dispatching a new activity message '{origin}'", ['receivedFrom' => $exception->receivedFrom, 'origin' => $exception->realOrigin]); if (!$this->settingsManager->isBannedInstance($exception->realOrigin)) { $body = $this->apHttpClient->getActivityObject($exception->realOrigin, false); $this->bus->dispatch(new ActivityMessage($body)); } else { $this->logger->info('[ActivityHandler::doWork] The instance is banned, url: {url}', ['url' => $exception->realOrigin]); } return; } catch (InvalidUserPublicKeyException $exception) { $this->logger->warning("[ActivityHandler::doWork] Unable to extract public key for '{user}'.", ['user' => $exception->apProfileId]); return; } } if (null === $payload['id']) { $this->logger->warning('[ActivityHandler::doWork] Activity message has no id field which is required: {json}', ['json' => json_encode($message->payload)]); throw new UnrecoverableMessageHandlingException('Activity message has no id field'); } $idHost = parse_url($payload['id'], PHP_URL_HOST); if ($idHost) { $instance = $this->instanceRepository->findOneBy(['domain' => $idHost]); if (!$instance) { $instance = new Instance($idHost); $instance->setLastSuccessfulReceive(); $this->entityManager->persist($instance); $this->entityManager->flush(); } else { $lastDate = $instance->getLastSuccessfulReceive(); if ($lastDate < new \DateTimeImmutable('now - 5 minutes')) { $instance->setLastSuccessfulReceive(); $this->entityManager->persist($instance); $this->entityManager->flush(); } } $this->remoteInstanceManager->updateInstance($instance); } if (isset($payload['payload'])) { $payload = $payload['payload']; } try { if (isset($payload['actor']) || isset($payload['attributedTo'])) { if (!$this->verifyInstanceDomain($payload['actor'] ?? $this->activityPubManager->getSingleActorFromAttributedTo($payload['attributedTo']))) { return; } $user = $this->activityPubManager->findActorOrCreate($payload['actor'] ?? $this->activityPubManager->getSingleActorFromAttributedTo($payload['attributedTo'])); } else { if (!$this->verifyInstanceDomain($payload['id'])) { return; } $user = $this->activityPubManager->findActorOrCreate($payload['id']); } } catch (\Exception $e) { $this->logger->error('[ActivityHandler::doWork] Payload: '.json_encode($payload)); return; } if ($user instanceof User && $user->isBanned) { return; } if (null === $user) { $this->logger->warning('[ActivityHandler::doWork] Could not find an actor discarding ActivityMessage {m}', ['m' => $message->payload]); return; } $this->handle($payload); } private function handle(?array $payload) { if (\is_null($payload)) { return; } if ('Announce' === $payload['type']) { // we check for an array here, because boosts are announces with an url (string) as the object if (\is_array($payload['object'])) { $actorObject = $this->activityPubManager->findActorOrCreate($payload['actor']); if ($actorObject instanceof Magazine && $actorObject->lastOriginUpdate < (new \DateTime())->modify('-3 hours')) { if (isset($payload['object']['type']) && 'Create' === $payload['object']['type']) { $actorObject->lastOriginUpdate = new \DateTime(); $this->entityManager->persist($actorObject); $this->entityManager->flush(); } } $payload = $payload['object']; $actor = $payload['actor'] ?? $payload['attributedTo'] ?? null; if ($actor) { $user = $this->activityPubManager->findActorOrCreate($actor); if ($user instanceof User && null === $user->apId) { // don't do anything if we get an announce activity for something a local user did (unless it's a boost, see comment above) $this->logger->warning('[ActivityHandler::handle] Ignoring this message because it announces an activity from a local user'); return; } } } } $this->logger->debug('[ActivityHandler::handle] Got activity message of type {type}: {message}', ['type' => $payload['type'], 'message' => json_encode($payload)]); switch ($payload['type']) { case 'Create': $this->bus->dispatch(new CreateMessage($payload['object'], fullCreatePayload: $payload)); break; case 'Note': case 'Page': case 'Article': case 'Question': case 'Video': $this->bus->dispatch(new CreateMessage($payload)); // no break case 'Announce': $this->bus->dispatch(new AnnounceMessage($payload)); break; case 'Like': $this->bus->dispatch(new LikeMessage($payload)); break; case 'Dislike': $this->bus->dispatch(new DislikeMessage($payload)); break; case 'Follow': $this->bus->dispatch(new FollowMessage($payload)); break; case 'Delete': $this->bus->dispatch(new DeleteMessage($payload)); break; case 'Undo': $this->handleUndo($payload); break; case 'Accept': case 'Reject': $this->handleAcceptAndReject($payload); break; case 'Update': $this->bus->dispatch(new UpdateMessage($payload)); break; case 'Add': $this->bus->dispatch(new AddMessage($payload)); break; case 'Remove': $this->bus->dispatch(new RemoveMessage($payload)); break; case 'Flag': $this->bus->dispatch(new FlagMessage($payload)); break; case 'Block': $this->bus->dispatch(new BlockMessage($payload)); break; case 'Lock': $this->bus->dispatch(new LockMessage($payload)); break; } } private function handleUndo(array $payload): void { if (\is_array($payload['object'])) { $type = $payload['object']['type']; } else { $type = $payload['type']; } switch ($type) { case 'Follow': $this->bus->dispatch(new FollowMessage($payload)); break; case 'Announce': $this->bus->dispatch(new AnnounceMessage($payload)); break; case 'Like': $this->bus->dispatch(new LikeMessage($payload)); break; case 'Dislike': $this->bus->dispatch(new DislikeMessage($payload)); break; case 'Block': $this->bus->dispatch(new BlockMessage($payload)); break; case 'Lock': $this->bus->dispatch(new LockMessage($payload)); break; } } private function handleAcceptAndReject(array $payload): void { if (\is_array($payload['object'])) { $type = $payload['object']['type']; } else { $type = $payload['type']; } if ('Follow' === $type) { $this->bus->dispatch(new FollowMessage($payload)); } } private function verifyInstanceDomain(?string $id): bool { if (!\is_null($id) && \in_array( str_replace('www.', '', parse_url($id, PHP_URL_HOST)), $this->instanceRepository->getBannedInstanceUrls() )) { return false; } return true; } } ================================================ FILE: src/MessageHandler/ActivityPub/Inbox/AddHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(AddMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof AddMessage)) { throw new \LogicException("AddHandler called, but is wasn\'t an AddMessage. Type: ".\get_class($message)); } $payload = $message->payload; $actor = $this->activityPubManager->findUserActorOrCreateOrThrow($payload['actor']); $targetMag = $this->magazineRepository->getMagazineFromModeratorsUrl($payload['target']); if ($targetMag) { $this->handleModeratorAdd($targetMag, $actor, $payload['object'], $payload); return; } $targetMag = $this->magazineRepository->getMagazineFromPinnedUrl($payload['target']); if ($targetMag) { $this->handlePinnedAdd($targetMag, $actor, $payload['object'], $payload); return; } throw new \LogicException("could not find a magazine with moderators url like: '{$payload['target']}'"); } public function handleModeratorAdd(Magazine $targetMag, Magazine|User $actor, $object1, array $messagePayload): void { if (!$targetMag->userIsModerator($actor) and !$targetMag->hasSameHostAsUser($actor)) { throw new \LogicException("the user '$actor->username' ({$actor->getId()}) is not a moderator of '$targetMag->name' ({$targetMag->getId()}) and is not from the same instance. They can therefore not add moderators"); } $object = $this->activityPubManager->findUserActorOrCreateOrThrow($object1); if ($targetMag->userIsModerator($object)) { $this->logger->warning('the user "{added}" ({addedId}) already is a moderator of "{magName}" ({magId}). Discarding message', [ 'added' => $object->username, 'addedId' => $object->getId(), 'magName' => $targetMag->name, 'magId' => $targetMag->getId(), ]); return; } $this->logger->info('[AddHandler::handleModeratorAdd] "{actor}" ({actorId}) added "{added}" ({addedId}) as moderator to "{magName}" ({magId})', [ 'actor' => $actor->username, 'actorId' => $actor->getId(), 'added' => $object->username, 'addedId' => $object->getId(), 'magName' => $targetMag->name, 'magId' => $targetMag->getId(), ]); $this->magazineManager->addModerator(new ModeratorDto($targetMag, $object, $actor)); if (null === $targetMag->apId) { $activityToAnnounce = $messagePayload; unset($activityToAnnounce['@context']); $activity = $this->activityRepository->createForRemoteActivity($activityToAnnounce); $this->bus->dispatch(new GenericAnnounceMessage($targetMag->getId(), null, parse_url($actor->apDomain, PHP_URL_HOST), $activity->uuid->toString(), null)); } } private function handlePinnedAdd(Magazine $targetMag, User $actor, mixed $object, array $messagePayload): void { if (!$targetMag->userIsModerator($actor) && !$targetMag->hasSameHostAsUser($actor)) { throw new \LogicException("the user '$actor->username' ({$actor->getId()}) is not a moderator of $targetMag->name ({$targetMag->getId()}) and is not from the same instance. They can therefore not add pinned entries"); } if ('random' === $targetMag->name) { // do not pin anything in the random magazine return; } $apId = null; if (\is_string($object)) { $apId = $object; } elseif (\is_array($object)) { $apId = $object['id']; } else { throw new \LogicException('the added object is neither a string or an array'); } if ($this->settingsManager->isLocalUrl($apId)) { $pair = $this->apActivityRepository->findLocalByApId($apId); if (Entry::class === $pair['type']) { $existingEntry = $this->entryRepository->findOneBy(['id' => $pair['id']]); if ($existingEntry->magazine->getId() !== $targetMag->getId()) { $this->logger->warning('[AddHandler::handlePinnedAdd] entry {e} is not in the magazine that was targeted {m}. It was in {m2}', ['e' => $existingEntry->title, 'm' => $targetMag->name, 'm2' => $existingEntry->magazine->name]); } elseif ($existingEntry && !$existingEntry->sticky) { $this->logger->info('[AddHandler::handlePinnedAdd] Pinning entry {e} to magazine {m}', ['e' => $existingEntry->title, 'm' => $existingEntry->magazine->name]); $this->entryManager->pin($existingEntry, $actor); if (null === $targetMag->apId) { $this->announcePin($actor, $targetMag, $messagePayload); } } } } else { $existingEntry = $this->entryRepository->findOneBy(['apId' => $apId]); if ($existingEntry) { if (null !== $existingEntry->magazine->apFeaturedUrl) { $this->apHttpClient->invalidateCollectionObjectCache($existingEntry->magazine->apFeaturedUrl); } if (!$existingEntry->sticky) { $this->logger->info('[AddHandler::handlePinnedAdd] Pinning entry {e} to magazine {m}', ['e' => $existingEntry->title, 'm' => $existingEntry->magazine->name]); $this->entryManager->pin($existingEntry, $actor); if (null === $targetMag->apId) { $this->announcePin($actor, $targetMag, $messagePayload); } } } else { if (!\is_array($object)) { if (!$this->settingsManager->isBannedInstance($apId)) { $object = $this->apHttpClient->getActivityObject($apId); return; } else { $this->logger->info('[AddHandler::handlePinnedAdd] The instance is banned, url: {url}', ['url' => $apId]); } } $this->bus->dispatch(new CreateMessage($object, true)); } } } private function announcePin(User $actor, Magazine $targetMag, mixed $object): void { $activityToAnnounce = $object; unset($activityToAnnounce['@context']); $activity = $this->activityRepository->createForRemoteActivity($activityToAnnounce); $this->bus->dispatch(new GenericAnnounceMessage($targetMag->getId(), null, parse_url($actor->apDomain, PHP_URL_HOST), $activity->uuid->toString(), null)); } } ================================================ FILE: src/MessageHandler/ActivityPub/Inbox/AnnounceHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(AnnounceMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof AnnounceMessage)) { throw new \LogicException("AnnounceHandler called, but is wasn\'t an AnnounceMessage. Type: ".\get_class($message)); } $chainDispatchCallback = function (array $object, ?string $adjustedUrl) use ($message) { if ($adjustedUrl) { $this->logger->info('[AnnounceHandler::doWork] Got an adjusted url: {url}, using that instead of {old}', ['url' => $adjustedUrl, 'old' => $message->payload['object']['id'] ?? $message->payload['object']]); $message->payload['object'] = $adjustedUrl; } $this->bus->dispatch(new ChainActivityMessage([$object], announce: $message->payload)); }; if ('Announce' === $message->payload['type']) { $entity = $this->activityPubManager->getEntityObject($message->payload['object'], $message->payload, $chainDispatchCallback); if (!$entity) { return; } $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']); if ($actor instanceof User) { $this->voteManager->upvote($entity, $actor); $this->voteHandleSubscriber->clearCache($entity); } else { $entity->lastActive = new \DateTime(); $this->entityManager->flush(); } } if ('Undo' === $message->payload['type']) { return; } } } ================================================ FILE: src/MessageHandler/ActivityPub/Inbox/BlockHandler.php ================================================ workWrapper($message); } public function doWork(MessageInterface $message): void { if (!$message instanceof BlockMessage) { throw new \LogicException("BlockHandler called, but is wasn\'t a BlockMessage. Type: ".\get_class($message)); } if (!isset($message->payload['id']) || !isset($message->payload['actor']) || !isset($message->payload['object'])) { throw new UnrecoverableMessageHandlingException('Malformed block activity'); } $this->logger->debug('Got block message: {m}', ['m' => $message->payload]); $isUndo = 'Undo' === $message->payload['type']; $payload = $isUndo ? $message->payload['object'] : $message->payload; if (\is_string($payload) && filter_var($payload, FILTER_VALIDATE_URL)) { $payload = $this->apHttpClient->getActivityObject($payload); } if (!isset($payload['id']) || !isset($payload['actor']) || !isset($payload['object']) || !isset($payload['target'])) { throw new UnrecoverableMessageHandlingException('Malformed block activity'); } $actor = $this->activityPubManager->findActorOrCreate($payload['actor']); if (null === $actor) { throw new UnrecoverableMessageHandlingException("Unable to find user '{$payload['actor']}'"); } $bannedUser = $this->activityPubManager->findActorOrCreate($payload['object']); if (null === $bannedUser) { throw new UnrecoverableMessageHandlingException("Could not find user '{$payload['object']}'"); } if (!$bannedUser instanceof User) { throw new UnrecoverableMessageHandlingException('The object has to be a user'); } try { $target = $this->activityPubManager->findActorOrCreate($payload['target']); } catch (\Exception $e) { if (parse_url($payload['target'], PHP_URL_HOST) === $bannedUser->apDomain) { // if the host part of the url is the same as the users it is probably the instance actor -> ban the user completely $target = null; } else { throw $e; } } $reason = $payload['summary'] ?? ''; $expireDate = null; if (isset($payload['expires'])) { $expireDate = new \DateTimeImmutable($payload['expires']); } if (null === $target || ($target instanceof User && 'Application' === $target->type)) { $this->handleInstanceBan($bannedUser, $actor, $reason, $isUndo); } else { $this->handleMagazineBan($message->payload, $bannedUser, $actor, $target, $reason, $expireDate, $isUndo); } } private function handleInstanceBan(User $bannedUser, User $actor, string $reason, bool $isUndo): void { if ($bannedUser->apDomain !== $actor->apDomain) { throw new UnrecoverableMessageHandlingException("Only a user of the same instance can instance ban another user and the domains of the banned $bannedUser->username and the actor $actor->username do not match"); } if ($isUndo) { $this->logger->info('[BlockHandler::handleInstanceBan] {a} is unbanning {u} instance wide', ['a' => $actor->username, 'u' => $bannedUser->username]); $this->userManager->unban($bannedUser, $actor, $reason); } else { $this->logger->info('[BlockHandler::handleInstanceBan] {a} is banning {u} instance wide', ['a' => $actor->username, 'u' => $bannedUser->username]); $this->userManager->ban($bannedUser, $actor, $reason); } } private function handleMagazineBan(array $payload, User $bannedUser, User $actor, Magazine $target, string $reason, ?\DateTimeImmutable $expireDate, bool $isUndo): void { if (!$target->hasSameHostAsUser($actor) && !$target->userIsModerator($actor)) { throw new UnrecoverableMessageHandlingException("The user $actor->username is neither from the same instance as the magazine $target->name nor a moderator in it and is therefore not allowed to ban $bannedUser->username"); } $existingBan = $this->magazineBanRepository->findOneBy(['magazine' => $target, 'user' => $bannedUser]); if (null === $existingBan) { $this->logger->debug('it is a magazine ban and we do not have an existing one'); if ($isUndo) { // nothing has to be done, the user is not banned $this->logger->debug("We didn't know that {u} was banned from {m}, so we do not have to undo it", ['u' => $bannedUser->username, 'm' => $target->name]); return; } else { $ban = $this->banImpl($reason, $expireDate, $target, $bannedUser, $actor); if (null === $target->apId) { // local magazine and the user is allowed to ban users -> announce it $this->announceBan($payload, $target, $actor, $ban); } } } else { $this->logger->debug('it is a magazine ban and we do have an existing one'); if ($isUndo) { $this->logger->info("[BlockHandler::handleMagazineBan] {a} is unbanning {u} from magazine {m}. Reason: '{r}'", ['a' => $actor->username, 'u' => $bannedUser->username, 'm' => $target->name, 'r' => $reason]); $ban = $this->magazineManager->unban($target, $bannedUser); } else { $ban = $this->banImpl($reason, $expireDate, $target, $bannedUser, $actor); } if (null === $target->apId) { // local magazine and the user is allowed to ban users -> announce it $this->announceBan($payload, $target, $actor, $ban); } } } /** * @throws \Symfony\Component\Messenger\Exception\ExceptionInterface */ private function announceBan(array $payload, Magazine $target, User $actor, MagazineBan $ban): void { $activityToAnnounce = $payload; unset($activityToAnnounce['@context']); $activity = $this->activityRepository->createForRemoteActivity($payload, $ban); $activity->audience = $target; $activity->setActor($actor); $this->bus->dispatch(new GenericAnnounceMessage($target->getId(), null, $actor->apInboxUrl, $activity->uuid->toString(), null)); } private function banImpl(string $reason, ?\DateTimeImmutable $expireDate, Magazine $target, User $bannedUser, User $actor): MagazineBan { $dto = new MagazineBanDto(); $dto->reason = $reason; $dto->expiredAt = $expireDate; try { $this->logger->info("[BlockHandler::handleMagazineBan] {a} is banning {u} from magazine {m}. Reason: '{r}'", ['a' => $actor->username, 'u' => $bannedUser->username, 'm' => $target->name, 'r' => $reason]); $ban = $this->magazineManager->ban($target, $bannedUser, $actor, $dto); } catch (UserCannotBeBanned) { throw new UnrecoverableMessageHandlingException("$bannedUser->username is either an admin or a moderator of $target->name and can therefor not be banned from it"); } return $ban; } } ================================================ FILE: src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(ChainActivityMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof ChainActivityMessage)) { throw new \LogicException("ChainActivityHandler called, but is wasn\'t a ChainActivityMessage. Type: ".\get_class($message)); } $this->logger->debug('Got chain activity message: {m}', ['m' => $message]); if (!$message->chain || 0 === \sizeof($message->chain)) { return; } $validObjectTypes = ['Page', 'Note', 'Article', 'Question', 'Video']; $object = $message->chain[0]; if (!\in_array($object['type'], $validObjectTypes)) { $this->logger->error('[ChainActivityHandler::doWork] Cannot get the dependencies of the object, its type {t} is not one we can handle. {m]', ['t' => $object['type'], 'm' => $message]); return; } try { $entity = $this->retrieveObject($object['id']); } catch (InstanceBannedException) { $this->logger->info('[ChainActivityHandler::doWork] The instance is banned, url: {url}', ['url' => $object['id']]); return; } if (!$entity) { $this->logger->error('[ChainActivityHandler::doWork] Could not retrieve all the dependencies of {o}', ['o' => $object['id']]); return; } if ($message->announce) { $this->bus->dispatch(new AnnounceMessage($message->announce)); } if ($message->like) { $this->bus->dispatch(new LikeMessage($message->like)); } if ($message->dislike) { $this->bus->dispatch(new DislikeMessage($message->dislike)); } } /** * @throws \Exception if there was an unexpected exception */ private function retrieveObject(string $apUrl): Entry|EntryComment|Post|PostComment|null { if ($this->settingsManager->isBannedInstance($apUrl)) { throw new InstanceBannedException(); } try { $object = $this->client->getActivityObject($apUrl); if (!$object) { $this->logger->warning('[ChainActivityHandler::retrieveObject] Got an empty object for {url}', ['url' => $apUrl]); return null; } if (!\is_array($object)) { $this->logger->warning("[ChainActivityHandler::retrieveObject] Didn't get an array for {url}. Got '{val}' instead, exiting", ['url' => $apUrl, 'val' => $object]); return null; } if (\array_key_exists('inReplyTo', $object) && null !== $object['inReplyTo']) { $parentUrl = \is_string($object['inReplyTo']) ? $object['inReplyTo'] : $object['inReplyTo']['id']; $meta = $this->repository->findByObjectId($parentUrl); if (!$meta) { $this->retrieveObject($parentUrl); } $meta = $this->repository->findByObjectId($parentUrl); if (!$meta) { $this->logger->warning('[ChainActivityHandler::retrieveObject] Fetching the parent object ({parent}) did not work for {url}, aborting', ['parent' => $parentUrl, 'url' => $apUrl]); return null; } } switch ($object['type']) { case 'Question': case 'Note': $this->logger->debug('[ChainActivityHandler::retrieveObject] Creating note {o}', ['o' => $object]); return $this->note->create($object); case 'Page': case 'Article': case 'Video': $this->logger->debug('[ChainActivityHandler::retrieveObject] Creating page {o}', ['o' => $object]); return $this->page->create($object); default: $this->logger->warning('[ChainActivityHandler::retrieveObject] Could not create an object from type {t} on {url}: {o}', ['t' => $object['type'], 'url' => $apUrl, 'o' => $object]); } } catch (UserBannedException) { $this->logger->info('[ChainActivityHandler::retrieveObject] The user is banned, url: {url}', ['url' => $apUrl]); } catch (UserDeletedException) { $this->logger->info('[ChainActivityHandler::retrieveObject] The user is deleted, url: {url}', ['url' => $apUrl]); } catch (TagBannedException) { $this->logger->info('[ChainActivityHandler::retrieveObject] One of the used tags is banned, url: {url}', ['url' => $apUrl]); } catch (InstanceBannedException) { $this->logger->info('[ChainActivityHandler::retrieveObject] The instance is banned, url: {url}', ['url' => $apUrl]); } catch (EntryLockedException) { $this->logger->error('[ChainActivityHandler::retrieveObject] The entry in which this comment should be created, is locked: {url}', ['url' => $apUrl]); } catch (PostLockedException) { $this->logger->error('[ChainActivityHandler::retrieveObject] The post in which this comment should be created, is locked: {url}', ['url' => $apUrl]); } catch (EntityNotFoundException $e) { $this->logger->error('[ChainActivityHandler::retrieveObject] There was an exception while getting {url}: {ex} - {m}. {o}', ['url' => $apUrl, 'ex' => \get_class($e), 'm' => $e->getMessage(), 'o' => $e]); } return null; } } ================================================ FILE: src/MessageHandler/ActivityPub/Inbox/CreateHandler.php ================================================ entityManager, $this->kernel); } /** * @throws \Exception */ public function __invoke(CreateMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof CreateMessage)) { throw new \LogicException("CreateHandler called, but is wasn\'t a CreateMessage. Type: ".\get_class($message)); } $object = $message->payload; $stickyIt = $message->stickyIt; $this->logger->debug('Got a CreateMessage of type {t}, {m}', ['t' => $message->payload['type'], 'm' => $message->payload]); $entryTypes = ['Page', 'Article', 'Video']; $postTypes = ['Question', 'Note']; try { if ('ChatMessage' === $object['type']) { $this->handlePrivateMessage($object); } elseif (\in_array($object['type'], $postTypes)) { $this->handleChain($object, $stickyIt, $message->fullCreatePayload); if (method_exists($this->cache, 'invalidateTags')) { // clear markdown renders that are tagged with the id of the post $tag = UrlUtils::getCacheKeyForMarkdownUrl($object['id']); $this->cache->invalidateTags([$tag]); $this->logger->debug('cleared cached items with tag {t}', ['t' => $tag]); } } elseif (\in_array($object['type'], $entryTypes)) { $this->handlePage($object, $stickyIt, $message->fullCreatePayload); if (method_exists($this->cache, 'invalidateTags')) { // clear markdown renders that are tagged with the id of the entry $tag = UrlUtils::getCacheKeyForMarkdownUrl($object['id']); $this->cache->invalidateTags([$tag]); $this->logger->debug('cleared cached items with tag {t}', ['t' => $tag]); } } else { $this->logger->warning('received Create activity for unknown type {t} of object {o}; ignoring', [ 't' => $object['type'], 'o' => $object['id'] ?? '', ]); } } catch (UserBannedException) { $this->logger->info('[CreateHandler::doWork] Did not create the post, because the user is banned'); } catch (UserDeletedException) { $this->logger->info('[CreateHandler::doWork] Did not create the post, because the user is deleted'); } catch (TagBannedException) { $this->logger->info('[CreateHandler::doWork] Did not create the post, because one of the used tags is banned'); } catch (PostingRestrictedException $e) { if ($e->actor instanceof User) { $username = $e->actor->getUsername(); } else { $username = $e->actor->name; } $this->logger->info('[CreateHandler::doWork] Did not create the post, because the magazine {m} restricts posting to mods and {u} is not a mod', ['m' => $e->magazine, 'u' => $username]); } catch (InstanceBannedException $e) { $this->logger->info('[CreateHandler::doWork] Did not create the post, because the user\'s instance is banned'); } catch (UserBlockedException $e) { $this->logger->info('[CreateHandler::doWork] Did not create the message, because the user is blocked by one of the receivers'); } catch (EntryLockedException|PostLockedException) { $this->logger->info('[CreateHandler::doWork] Did not create the comment, because the entry/post is locked'); } } /** * @throws TagBannedException * @throws UserBannedException * @throws UserDeletedException * @throws InstanceBannedException * @throws EntryLockedException * @throws PostLockedException */ private function handleChain(array $object, bool $stickyIt, ?array $fullCreatePayload): void { if (isset($object['inReplyTo']) && $object['inReplyTo']) { $existed = $this->repository->findByObjectId($object['inReplyTo']); if (!$existed) { $this->bus->dispatch(new ChainActivityMessage([$object])); return; } } $note = $this->note->create($object, stickyIt: $stickyIt); if ($note instanceof EntryComment || $note instanceof Post || $note instanceof PostComment) { if (null !== $note->apId and null === $note->magazine->apId and 'random' !== $note->magazine->name) { $createActivity = $this->activityRepository->findFirstActivitiesByTypeAndObject('Create', $note); if (null === $createActivity) { if (null !== $fullCreatePayload) { $this->activityRepository->createForRemoteActivity($fullCreatePayload, $note); } else { $this->logger->warning('[CreateHandler::handleChain] Could not create the activity with the full create payload because it was just missing...'); } } // local magazine, but remote post. Random magazine is ignored, as it should not be federated at all $this->bus->dispatch(new AnnounceMessage(null, $note->magazine->getId(), $note->getId(), \get_class($note))); } } } /** * @throws \Exception * @throws UserBannedException * @throws UserDeletedException * @throws TagBannedException * @throws PostingRestrictedException * @throws InstanceBannedException */ private function handlePage(array $object, bool $stickyIt, ?array $createPayload): void { $page = $this->page->create($object, stickyIt: $stickyIt); if ($page instanceof Entry) { if (null !== $page->apId and null === $page->magazine->apId and 'random' !== $page->magazine->name) { $createActivity = $this->activityRepository->findFirstActivitiesByTypeAndObject('Create', $page); if (null === $createActivity) { if (null !== $createPayload) { $this->activityRepository->createForRemoteActivity($createPayload, $page); } else { $this->logger->warning('[CreateHandler::handlePage] Could not create the activity with the full create payload because it was just missing...'); } } // local magazine, but remote post. Random magazine is ignored, as it should not be federated at all $this->bus->dispatch(new AnnounceMessage(null, $page->magazine->getId(), $page->getId(), \get_class($page))); } } } /** * @throws InvalidApPostException * @throws InvalidArgumentException * @throws UserDeletedException * @throws InvalidWebfingerException * @throws Exception */ private function handlePrivateMessage(array $object): void { $this->messageManager->createMessage($object); } private function handlePrivateMentions(): void { // TODO implement private mentions } } ================================================ FILE: src/MessageHandler/ActivityPub/Inbox/DeleteHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(DeleteMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof DeleteMessage)) { throw new \LogicException("DeleteHandler called, but is wasn\'t a DeleteMessage. Type: ".\get_class($message)); } $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']); $id = \is_array($message->payload['object']) ? $message->payload['object']['id'] : $message->payload['object']; $object = $this->apActivityRepository->findByObjectId($id); if (!$object && $actor) { $user = $this->userRepository->findOneBy(['apProfileId' => $id]); if ($actor instanceof User && $user instanceof User && $actor->apDomain === $user->apDomain) { // only users of the same server can delete each other. // Since the server of both is in charge as to which user can delete each other. $object = [ 'type' => User::class, 'id' => $user->getId(), ]; } } if (!$object) { return; } $entity = $this->entityManager->getRepository($object['type'])->find((int) $object['id']); if ($entity instanceof Entry || $entity instanceof EntryComment || $entity instanceof Post || $entity instanceof PostComment) { if (!$entity->magazine->apId || ($actor->apId && !$entity->user->apId)) { // local magazine or remote actor for a local users content -> need to announce it later $this->activityRepository->createForRemoteActivity($message->payload, $entity); } } if ($entity instanceof Entry) { $this->deleteEntry($entity, $actor); } elseif ($entity instanceof EntryComment) { $this->deleteEntryComment($entity, $actor); } elseif ($entity instanceof Post) { $this->deletePost($entity, $actor); } elseif ($entity instanceof PostComment) { $this->deletePostComment($entity, $actor); } elseif ($entity instanceof User) { $this->bus->dispatch(new DeleteUserMessage($entity->getId())); } } private function deleteEntry(Entry $entry, User $user): void { $this->entryManager->delete($user, $entry); } private function deleteEntryComment(EntryComment $comment, User $user): void { $this->entryCommentManager->delete($user, $comment); } private function deletePost(Post $post, User $user): void { $this->postManager->delete($user, $post); } private function deletePostComment(PostComment $comment, User $user): void { $this->postCommentManager->delete($user, $comment); } } ================================================ FILE: src/MessageHandler/ActivityPub/Inbox/DislikeHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(DislikeMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof DislikeMessage)) { throw new \LogicException("DislikeHandler called, but is wasn\'t a DislikeMessage. Type: ".\get_class($message)); } if (!isset($message->payload['type'])) { return; } if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) { return; } $chainDispatchCallback = function (array $object, ?string $adjustedUrl) use ($message) { if ($adjustedUrl) { $this->logger->info('[DislikeHandler::doWork] got an adjusted url: {url}, using that instead of {old}', ['url' => $adjustedUrl, 'old' => $message->payload['object']['id'] ?? $message->payload['object']]); $message->payload['object'] = $adjustedUrl; } $this->bus->dispatch(new ChainActivityMessage([$object], dislike: $message->payload)); }; if ('Dislike' === $message->payload['type']) { $entity = $this->activityPubManager->getEntityObject($message->payload['object'], $message->payload, $chainDispatchCallback); if (!$entity) { return; } $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']); // Check if actor and entity aren't empty if (!empty($actor) && !empty($entity)) { if ($actor instanceof User && ($entity instanceof Entry || $entity instanceof EntryComment || $entity instanceof Post || $entity instanceof PostComment)) { $this->voteManager->vote(VotableInterface::VOTE_DOWN, $entity, $actor); if (null === $entity->magazine->apId && null !== $actor->apId) { // local magazine, remote user $dislikeActivity = $this->activityRepository->createForRemoteActivity($message->payload); $this->bus->dispatch(new GenericAnnounceMessage($entity->magazine->getId(), null, $actor->apInboxUrl, $dislikeActivity->uuid->toString(), null)); } } } } elseif ('Undo' === $message->payload['type']) { if ('Dislike' === $message->payload['object']['type']) { $entity = $this->activityPubManager->getEntityObject($message->payload['object']['object'], $message->payload, $chainDispatchCallback); if (!$entity) { return; } $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']); // Check if actor and entity aren't empty if ($actor instanceof User && ($entity instanceof Entry || $entity instanceof EntryComment || $entity instanceof Post || $entity instanceof PostComment)) { $this->voteManager->removeVote($entity, $actor); if (null === $entity->magazine->apId && null !== $actor->apId) { // local magazine, remote user $dislikeActivity = $this->activityRepository->createForRemoteActivity($message->payload); $this->bus->dispatch(new GenericAnnounceMessage($entity->magazine->getId(), null, $actor->apInboxUrl, $dislikeActivity->uuid->toString(), null)); } } } } } } ================================================ FILE: src/MessageHandler/ActivityPub/Inbox/FlagHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(FlagMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof FlagMessage)) { throw new \LogicException("FlagHandler called, but is wasn\'t a FlagMessage. Type: ".\get_class($message)); } $this->logger->debug('Got FlagMessage: '.json_encode($message)); $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']); $object = $message->payload['object']; $objects = \is_array($object) ? $object : [$object]; if (!$actor instanceof User) { throw new \LogicException("could not find a user actor on url '{$message->payload['actor']}'"); } foreach ($objects as $item) { if (!\is_string($item)) { continue; } if ($this->settingsManager->isLocalUrl($item)) { $subject = $this->findLocalSubject($item); } else { $subject = $this->findRemoteSubject($item); } if (null !== $subject) { try { $reason = null; if (\array_key_exists('summary', $message->payload) && \is_string($message->payload['summary'])) { $reason = $message->payload['summary']; } $this->reportManager->report(ReportDto::create($subject, $reason), $actor); } catch (SubjectHasBeenReportedException) { } } else { $this->logger->warning("could not find the subject of a report: '$item'"); } } } private function findRemoteSubject(string $apUrl): ?ReportInterface { $entry = $this->entryRepository->findOneBy(['apId' => $apUrl]); $entryComment = null; $post = null; $postComment = null; if (!$entry) { $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $apUrl]); } if (!$entry and !$entryComment) { $post = $this->postRepository->findOneBy(['apId' => $apUrl]); } if (!$entry and !$entryComment and !$post) { $postComment = $this->postCommentRepository->findOneBy(['apId' => $apUrl]); } return $entry ?? $entryComment ?? $post ?? $postComment; } private function findLocalSubject(string $apUrl): ?ReportInterface { $matches = null; if (preg_match_all("/\/m\/([a-zA-Z0-9\-_:@.]+)\/t\/([1-9][0-9]*)\/-\/comment\/([1-9][0-9]*)/", $apUrl, $matches)) { return $this->entryCommentRepository->findOneBy(['id' => $matches[3][0]]); } if (preg_match_all("/\/m\/([a-zA-Z0-9\-_:@.]+)\/t\/([1-9][0-9]*)/", $apUrl, $matches)) { return $this->entryRepository->findOneBy(['id' => $matches[2][0]]); } if (preg_match_all("/\/m\/([a-zA-Z0-9\-_:@.]+)\/p\/([1-9][0-9]*)\/-\/reply\/([1-9][0-9]*)/", $apUrl, $matches)) { return $this->postCommentRepository->findOneBy(['id' => $matches[3][0]]); } if (preg_match_all("/\/m\/([a-zA-Z0-9\-_:@.]+)\/p\/([1-9][0-9]*)/", $apUrl, $matches)) { return $this->postRepository->findOneBy(['id' => $matches[2][0]]); } return null; } } ================================================ FILE: src/MessageHandler/ActivityPub/Inbox/FollowHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(FollowMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof FollowMessage)) { throw new \LogicException("FollowHandler called, but is wasn\'t a FollowMessage. Type: ".\get_class($message)); } $this->logger->debug('got a FollowMessage: {message}', [$message]); $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']); // Check if actor is not empty if (!empty($actor)) { if ('Follow' === $message->payload['type']) { $object = $this->activityPubManager->findActorOrCreate($message->payload['object']); // Check if object is not empty if (!empty($object)) { if ($object instanceof Magazine and null === $object->apId and 'random' === $object->name) { $this->handleFollowRequest($message->payload, $object, isReject: true); } else { $this->handleFollow($object, $actor); // @todo manually Accept $this->handleFollowRequest($message->payload, $object); } } return; } if (isset($message->payload['object'])) { switch ($message->payload['type']) { case 'Undo': $this->handleUnfollow( $actor, $this->activityPubManager->findActorOrCreate($message->payload['object']['object']) ); break; case 'Accept': if ($actor instanceof User) { $this->handleAccept( $actor, $this->activityPubManager->findActorOrCreate($message->payload['object']['actor']) ); } break; case 'Reject': $this->handleReject( $actor, $this->activityPubManager->findActorOrCreate($message->payload['object']['actor']) ); break; default: break; } } } } private function handleFollow(User|Magazine $object, User $actor): void { match (true) { $object instanceof User => $this->userManager->follow($actor, $object), $object instanceof Magazine => $this->magazineManager->subscribe($object, $actor), default => throw new \LogicException(), }; } private function handleFollowRequest(array $payload, User|Magazine $object, bool $isReject = false): void { $activity = $this->followResponseWrapper->build($object, $payload, $isReject); $response = $this->activityJsonBuilder->buildActivityJson($activity); $this->client->post($this->client->getInboxUrl($payload['actor']), $object, $response); } private function handleUnfollow(User $actor, User|Magazine|null $object): void { if (!empty($object)) { match (true) { $object instanceof User => $this->userManager->unfollow($actor, $object), $object instanceof Magazine => $this->magazineManager->unsubscribe($object, $actor), default => throw new \LogicException(), }; } } private function handleAccept(User $actor, User|Magazine|null $object): void { if (!empty($object)) { if ($object instanceof User) { $this->userManager->acceptFollow($object, $actor); } // if ($object instanceof Magazine) { // $this->magazineManager->acceptFollow($actor, $object); // } } } private function handleReject(User $actor, User|Magazine|null $object): void { if (!empty($object)) { match (true) { $object instanceof User => $this->userManager->rejectFollow($object, $actor), $object instanceof Magazine => $this->magazineManager->unsubscribe($object, $actor), default => throw new \LogicException(), }; } } } ================================================ FILE: src/MessageHandler/ActivityPub/Inbox/LikeHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(LikeMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof LikeMessage)) { throw new \LogicException("LikeHandler called, but is wasn\'t a LikeMessage. Type: ".\get_class($message)); } if (!isset($message->payload['type'])) { return; } $chainDispatchCallback = function (array $object, ?string $adjustedUrl) use ($message) { if ($adjustedUrl) { $this->logger->info('[LikeHandler::doWork] Got an adjusted url: {url}, using that instead of {old}', ['url' => $adjustedUrl, 'old' => $message->payload['object']['id'] ?? $message->payload['object']]); $message->payload['object'] = $adjustedUrl; } $this->bus->dispatch(new ChainActivityMessage([$object], like: $message->payload)); }; if ('Like' === $message->payload['type']) { $entity = $this->activityPubManager->getEntityObject($message->payload['object'], $message->payload, $chainDispatchCallback); if (!$entity) { return; } $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']); // Check if actor and entity aren't empty if (!empty($actor) && !empty($entity)) { $this->favouriteManager->toggle($actor, $entity, FavouriteManager::TYPE_LIKE); } } elseif ('Undo' === $message->payload['type']) { if ('Like' === $message->payload['object']['type']) { $entity = $this->activityPubManager->getEntityObject($message->payload['object']['object'], $message->payload, $chainDispatchCallback); if (!$entity) { return; } $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']); // Check if actor and entity aren't empty if (!empty($actor) && !empty($entity)) { $this->favouriteManager->toggle($actor, $entity, FavouriteManager::TYPE_UNLIKE); $this->voteManager->removeVote($entity, $actor); } } } if (isset($entity) and isset($actor) and ($entity instanceof Entry or $entity instanceof EntryComment or $entity instanceof Post or $entity instanceof PostComment)) { if (!$entity->magazine->apId and $actor->apId and 'random' !== $entity->magazine->name) { // local magazine, but remote user. Don't announce for random magazine $this->bus->dispatch(new AnnounceLikeMessage($actor->getId(), $entity->getId(), \get_class($entity), 'Undo' === $message->payload['type'], $message->payload['id'])); } } } } ================================================ FILE: src/MessageHandler/ActivityPub/Inbox/LockHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(LockMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof LockMessage)) { throw new \LogicException(); } $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']); if (null === $actor) { $this->logger->warning('[LockHandler::doWork] Could not find an actor for activity {id}. Supplied actor: "{actor}"', ['id' => $message->payload['id'], 'actor' => $message->payload['actor']]); return; } $isUndo = 'Undo' === $message->payload['type']; $payload = $message->payload; if ($isUndo) { $payload = $message->payload['object']; } $objectId = \is_array($payload['object']) ? $payload['object']['id'] : $payload['object']; $object = $this->apActivityRepository->findByObjectId($objectId); if (null === $object) { $this->logger->warning('[LockHandler::doWork] Could not find an object for activity "{id}". Supplied object: "{object}".', ['id' => $payload['id'], 'object' => $message->payload['object']]); return; } $objectEntity = $this->entityManager->getRepository($object['type'])->find($object['id']); if ($objectEntity instanceof Entry || $objectEntity instanceof Post) { if ($objectEntity->magazine->userIsModerator($actor) || $actor->getId() === $objectEntity->user->getId() || $actor->apDomain === $objectEntity->user->apDomain || $actor->apDomain === $objectEntity->magazine->apDomain) { // actor is magazine moderator or author or from the same instance as the author (so probably an instance admin) // or from the same instance as the magazine (so probably an instance admin of the magazine) if ($isUndo && $objectEntity->isLocked || !$isUndo && !$objectEntity->isLocked) { // if it is an undo it should not be locked and if it is not an undo it should be locked, // so under these 2 conditions we need to toggle the state if ($objectEntity instanceof Entry) { $this->entryManager->toggleLock($objectEntity, $actor); } else { $this->postManager->toggleLock($objectEntity, $actor); } } if (null === $objectEntity->magazine->apId && 'random' !== $objectEntity->magazine->name) { $lockActivity = $this->activityRepository->createForRemoteActivity($message->payload, $objectEntity); $lockActivity->setActor($actor); $this->bus->dispatch(new GenericAnnounceMessage($objectEntity->magazine->getId(), null, $actor->apInboxUrl, $lockActivity->uuid->toString(), null)); } } } else { $this->logger->warning('[LockHandler::doWork] entity was not entry or post, but "{type}"', ['type' => \get_class($objectEntity)]); } } } ================================================ FILE: src/MessageHandler/ActivityPub/Inbox/RemoveHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(RemoveMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof RemoveMessage)) { throw new \LogicException("RemoveHandler called, but is wasn\'t a RemoveMessage. Type: ".\get_class($message)); } $payload = $message->payload; $actor = $this->activityPubManager->findUserActorOrCreateOrThrow($payload['actor']); $targetMag = $this->magazineRepository->getMagazineFromModeratorsUrl($payload['target']); if ($targetMag) { $this->handleModeratorRemove($payload['object'], $targetMag, $actor, $payload); return; } $targetMag = $this->magazineRepository->getMagazineFromPinnedUrl($payload['target']); if ($targetMag) { $this->handlePinnedRemove($payload['object'], $targetMag, $actor, $payload); return; } throw new \LogicException("could not find a magazine with moderators url like: '{$payload['target']}'"); } public function handleModeratorRemove($object1, Magazine $targetMag, Magazine|User $actor, array $messagePayload): void { if (!$targetMag->userIsModerator($actor) && !$targetMag->hasSameHostAsUser($actor)) { throw new \LogicException("the user '$actor->username' ({$actor->getId()}) is not a moderator of $targetMag->name ({$targetMag->getId()}) and is not from the same instance. He can therefore not remove moderators"); } $object = $this->activityPubManager->findUserActorOrCreateOrThrow($object1); $objectMod = $targetMag->getUserAsModeratorOrNull($object); $loggerParams = [ 'toRemove' => $object->username, 'toRemoveId' => $object->getId(), 'magName' => $targetMag->name, 'magId' => $targetMag->getId(), ]; if (null === $objectMod) { $this->logger->warning('the user "{toRemove}" ({toRemoveId}) is not a moderator of {magName} ({magId}) and can therefore not be removed as one. Discarding message', $loggerParams); return; } elseif ($objectMod->isOwner) { $this->logger->warning('the user "{toRemove}" ({toRemoveId}) is the owner of {magName} ({magId}) and can therefore not be removed. Discarding message', $loggerParams); return; } $this->logger->info('[RemoveHandler::handleModeratorRemove] "{actor}" ({actorId}) removed "{removed}" ({removedId}) as moderator from "{magName}" ({magId})', [ 'actor' => $actor->username, 'actorId' => $actor->getId(), 'removed' => $object->username, 'removedId' => $object->getId(), 'magName' => $targetMag->name, 'magId' => $targetMag->getId(), ]); $this->magazineManager->removeModerator($objectMod, $actor); if (null === $targetMag->apId) { $activityToAnnounce = $messagePayload; unset($activityToAnnounce['@context']); $activity = $this->activityRepository->createForRemoteActivity($activityToAnnounce); $this->bus->dispatch(new GenericAnnounceMessage($targetMag->getId(), null, parse_url($actor->apDomain, PHP_URL_HOST), $activity->uuid->toString(), null)); } } private function handlePinnedRemove(mixed $object, Magazine $targetMag, User $actor, array $messagePayload): void { if (!$targetMag->userIsModerator($actor) && !$targetMag->hasSameHostAsUser($actor)) { throw new \LogicException("the user '$actor->username' ({$actor->getId()}) is not a moderator of $targetMag->name ({$targetMag->getId()}) and is not from the same instance. They can therefore not add pinned entries"); } $apId = null; if (\is_string($object)) { $apId = $object; } elseif (\is_array($object)) { $apId = $object['id']; } else { throw new \LogicException('the added object is neither a string or an array'); } if ($this->settingsManager->isLocalUrl($apId)) { $pair = $this->apActivityRepository->findLocalByApId($apId); if (Entry::class === $pair['type']) { $existingEntry = $this->entryRepository->findOneBy(['id' => $pair['id']]); if ($existingEntry && $existingEntry->sticky) { $this->logger->info('[RemoveHandler::handlePinnedRemove] Unpinning entry {e} to magazine {m}', ['e' => $existingEntry->title, 'm' => $existingEntry->magazine->name]); $this->entryManager->pin($existingEntry, $actor); if (null === $targetMag->apId) { $this->announceRemovePin($actor, $targetMag, $messagePayload); } } } } else { $existingEntry = $this->entryRepository->findOneBy(['apId' => $apId]); if ($existingEntry && $existingEntry->sticky) { $this->logger->info('[RemoveHandler::handlePinnedRemove] Unpinning entry {e} to magazine {m}', ['e' => $existingEntry->title, 'm' => $existingEntry->magazine->name]); $this->entryManager->pin($existingEntry, $actor); if (null === $targetMag->apId) { $this->announceRemovePin($actor, $targetMag, $messagePayload); } } } } private function announceRemovePin(User $actor, Magazine $targetMag, array $object): void { $activity = $this->activityRepository->createForRemoteActivity($object); $this->bus->dispatch(new GenericAnnounceMessage($targetMag->getId(), null, parse_url($actor->apDomain, PHP_URL_HOST), $activity->uuid->toString(), null)); } } ================================================ FILE: src/MessageHandler/ActivityPub/Inbox/UpdateHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(UpdateMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof UpdateMessage)) { throw new \LogicException("UpdateHandler called, but is wasn\'t an UpdateMessage. Type: ".\get_class($message)); } $payload = $message->payload; $this->logger->debug('[UpdateHandler::doWork] received Update activity: {json}', ['json' => $payload]); try { $actor = $this->activityPubManager->findRemoteActor($payload['actor']); } catch (\Exception) { return; } $object = $this->apActivityRepository->findByObjectId($payload['object']['id']); if ($object) { $this->editActivity($object, $actor, $payload); return; } $object = $this->activityPubManager->findActorOrCreate($payload['object']['id']); if ($object instanceof User) { $this->updateUser($object, $actor); return; } if ($object instanceof Magazine) { $this->updateMagazine($object, $actor, $payload); return; } throw new \LogicException('Don\'t know what to do with the update activity concerning \''.$payload['object']['id'].'\'. We didn\'t have a local object that has this id.'); } private function editActivity(array $object, User $actor, array $payload): void { $object = $this->entityManager->getRepository($object['type'])->find((int) $object['id']); if ($object instanceof Entry) { $this->editEntry($object, $actor, $payload); if (null === $object->magazine->apId) { $this->bus->dispatch(new GenericAnnounceMessage($object->magazine->getId(), null, $actor->apInboxUrl, null, $payload['id'])); } } elseif ($object instanceof EntryComment) { $this->editEntryComment($object, $actor, $payload); if (null === $object->magazine->apId) { $this->bus->dispatch(new GenericAnnounceMessage($object->magazine->getId(), null, $actor->apInboxUrl, null, $payload['id'])); } } elseif ($object instanceof Post) { $this->editPost($object, $actor, $payload); if (null === $object->magazine->apId) { $this->bus->dispatch(new GenericAnnounceMessage($object->magazine->getId(), null, $actor->apInboxUrl, null, $payload['id'])); } } elseif ($object instanceof PostComment) { $this->editPostComment($object, $actor, $payload); if (null === $object->magazine->apId) { $this->bus->dispatch(new GenericAnnounceMessage($object->magazine->getId(), null, $actor->apInboxUrl, null, $payload['id'])); } } elseif ($object instanceof Message) { $this->editMessage($object, $actor, $payload); } } private function editEntry(Entry $entry, User $user, array $payload): void { if (!$this->entryManager->canUserEditEntry($entry, $user)) { $this->logger->warning('[UpdateHandler::editEntry] User {u} tried to edit entry {et} ({eId}), but is not allowed to', ['u' => $user->apId ?? $user->username, 'et' => $entry->title, 'eId' => $entry->getId()]); return; } $dto = $this->entryFactory->createDto($entry); $dto->title = $payload['object']['name']; $this->extractChanges($dto, $payload); $this->entryManager->edit($entry, $dto, $user); } private function editEntryComment(EntryComment $comment, User $user, array $payload): void { if (!$this->entryCommentManager->canUserEditComment($comment, $user)) { $this->logger->warning('[UpdateHandler::editEntryComment] User {u} tried to edit entry comment {et} ({eId}), but is not allowed to', ['u' => $user->apId ?? $user->username, 'et' => $comment->getShortTitle(), 'eId' => $comment->getId()]); return; } $dto = $this->entryCommentFactory->createDto($comment); $this->extractChanges($dto, $payload); $this->entryCommentManager->edit($comment, $dto, $user); } private function editPost(Post $post, User $user, array $payload): void { if (!$this->postManager->canUserEditPost($post, $user)) { $this->logger->warning('[UpdateHandler::editPost] User {u} tried to edit post {pt} ({pId}), but is not allowed to', ['u' => $user->apId ?? $user->username, 'pt' => $post->getShortTitle(), 'pId' => $post->getId()]); return; } $dto = $this->postFactory->createDto($post); $this->extractChanges($dto, $payload); $this->postManager->edit($post, $dto, $user); } private function editPostComment(PostComment $comment, User $user, array $payload): void { if (!$this->postCommentManager->canUserEditPostComment($comment, $user)) { $this->logger->warning('[UpdateHandler::editPostComment] User {u} tried to edit post comment {pt} ({pId}), but is not allowed to', ['u' => $user->apId ?? $user->username, 'pt' => $comment->getShortTitle(), 'pId' => $comment->getId()]); return; } $dto = $this->postCommentFactory->createDto($comment); $this->extractChanges($dto, $payload); $this->postCommentManager->edit($comment, $dto, $user); } private function extractChanges(EntryDto|EntryCommentDto|PostDto|PostCommentDto $dto, array $payload): void { $this->logger->debug('[UpdateHandler::extractChanges] extracting changes from {c}', ['c' => \get_class($dto)]); if (!empty($payload['object']['content'])) { $dto->body = $this->objectExtractor->getMarkdownBody($payload['object']); } else { $dto->body = null; } if (!empty($payload['object']['attachment'])) { $this->logger->debug('[UpdateHandler::extractChanges] was not empty :)'); $image = $this->activityPubManager->handleImages($payload['object']['attachment']); if (null !== $image) { $dto->image = $this->imageFactory->createDto($image); } if ($dto instanceof EntryDto) { $url = ActivityPubManager::extractUrlFromAttachment($payload['object']['attachment']); $dto->url = $url; $this->logger->debug('[UpdateHandler::extractChanges] setting url to {u} which was extracted from the attachment array', ['u' => $url]); } } $dto->apLikeCount = $this->activityPubManager->extractRemoteLikeCount($payload['object']); $dto->apDislikeCount = $this->activityPubManager->extractRemoteDislikeCount($payload['object']); $dto->apShareCount = $this->activityPubManager->extractRemoteShareCount($payload['object']); if (isset($payload['object']['commentsEnabled']) && \is_bool($payload['object']['commentsEnabled']) && ($dto instanceof EntryDto || $dto instanceof PostDto)) { $dto->isLocked = !$payload['object']['commentsEnabled']; } } private function editMessage(Message $message, User $user, array $payload): void { if ($this->messageManager->canUserEditMessage($message, $user)) { $this->messageManager->editMessage($message, $payload['object']); } else { $this->logger->warning( '[UpdateHandler::editMessage] Got an update message from a user that is not allowed to edit it. Update actor: {ua}. Original Author: {oa}', ['ua' => $user->apId ?? $user->username, 'oa' => $message->sender->apId ?? $message->sender->username] ); } } private function updateUser(User $user, User $actor): void { if ($user->canUpdateUser($actor)) { if (null !== $user->apId) { $this->bus->dispatch(new UpdateActorMessage($user->apProfileId, force: true)); } } else { $this->logger->warning('[UpdateHandler::updateUser] User {u1} wanted to update user {u2} without being allowed to do so', ['u1' => $actor->apId ?? $actor->username, 'u2' => $user->apId ?? $user->username]); } } private function updateMagazine(Magazine $magazine, User $actor, array $payload): void { if ($magazine->canUpdateMagazine($actor)) { if (null !== $magazine->apId) { $this->bus->dispatch(new UpdateActorMessage($magazine->apProfileId, force: true)); } } else { $this->logger->warning('[UpdateHandler::updateMagazine] User {u} wanted to update magazine {m} without being allowed to do so', ['u' => $actor->apId ?? $actor->username, 'm' => $magazine->apId ?? $magazine->name]); } } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/AddHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(AddMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof AddMessage)) { throw new \LogicException(); } $actor = $this->userRepository->find($message->userActorId); $added = $this->userRepository->find($message->addedUserId); $magazine = $this->magazineRepository->find($message->magazineId); if ($magazine->apId) { $audience = [$magazine->apInboxUrl]; } else { if ('random' === $magazine->name) { // do not federate the random magazine return; } $audience = $this->magazineRepository->findAudience($magazine); } $activity = $this->factory->buildAddModerator($actor, $added, $magazine); $json = $this->activityJsonBuilder->buildActivityJson($activity); $this->deliverManager->deliver($audience, $json); } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/AnnounceHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(AnnounceMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof AnnounceMessage)) { throw new \LogicException(); } if (null !== $message->userId) { $actor = $this->userRepository->find($message->userId); } elseif (null !== $message->magazineId) { $actor = $this->magazineRepository->find($message->magazineId); } else { throw new UnrecoverableMessageHandlingException('no actor was specified'); } $object = $this->entityManager->getRepository($message->objectType)->find($message->objectId); if ($actor instanceof Magazine && ($object instanceof Entry || $object instanceof Post || $object instanceof EntryComment || $object instanceof PostComment)) { $createActivity = $this->activityRepository->findFirstActivitiesByTypeAndObject('Create', $object); if (null === $createActivity) { if (null === $object->apId) { $createActivity = $this->createWrapper->build($object); } else { throw new UnrecoverableMessageHandlingException("We need a create activity to announce objects, but none was found and the object (id: '$object->apId' is from a remote instance, so we cannot build a create activity"); } } $alreadySentActivities = $this->activityRepository->findAllActivitiesByTypeObjectAndActor('Announce', $object, $actor); if (!$message->removeAnnounce && \sizeof($alreadySentActivities) > 0) { $this->logger->info('[AnnounceHandler::doWork] not sending announcing {object}, because it is not an Undo and the same actor (magazine {magazine}) already announced the Create activity {create}', [ 'object' => \get_class($object)." \"{$object->getShortTitle()}\"", 'magazine' => $actor->name, 'create' => $createActivity->uuid, ]); return; } $activity = $this->announceWrapper->build($actor, $createActivity, true); } else { $activity = $this->announceWrapper->build($actor, $object, true); } if ($message->removeAnnounce) { $activity = $this->undoWrapper->build($activity); } $inboxes = array_merge( $this->magazineRepository->findAudience($object->magazine), [$object->user->apInboxUrl, $object->magazine->apId ? $object->magazine->apInboxUrl : null] ); $json = $this->activityJsonBuilder->buildActivityJson($activity); if ($actor instanceof User) { $inboxes = array_merge( $inboxes, $this->userRepository->findAudience($actor), $this->activityPubManager->createInboxesFromCC($json, $actor), ); } elseif ($actor instanceof Magazine) { if ('random' === $actor->name) { // do not federate the random magazine return; } $createHost = parse_url($object->apId, PHP_URL_HOST); $inboxes = array_filter(array_merge( $inboxes, $this->magazineRepository->findAudience($actor), ), fn ($item) => null !== $item and $createHost !== parse_url($item, PHP_URL_HOST)); } $inboxes = array_filter(array_unique($inboxes)); $this->deliverManager->deliver($inboxes, $json); } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/AnnounceLikeHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(AnnounceLikeMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof AnnounceLikeMessage)) { throw new \LogicException(); } $user = $this->userRepository->find($message->userId); /** @var Entry|EntryComment|Post|PostComment $object */ $object = $this->entityManager->getRepository($message->objectType)->find($message->objectId); // blacklist remote magazines if (null !== $object->magazine->apId) { return; } // blacklist the random magazine if ('random' === $object->magazine->name) { return; } if (null === $message->likeMessageId) { $this->logger->warning('Got an AnnounceLikeMessage without a remote like id, probably an old message though'); return; } if (false === filter_var($message->likeMessageId, FILTER_VALIDATE_URL)) { $this->logger->warning('Got an AnnounceLikeMessage without a valid remote like id: {url}', ['url' => $message->likeMessageId]); return; } $this->logger->debug('got AnnounceLikeMessage: {m}', ['m' => json_encode($message)]); $this->logger->debug('building like activity for: {a}', ['a' => json_encode($object)]); if (!$message->undo) { $likeActivity = $message->likeMessageId; } else { // we no longer have to wrap anything, as the incoming message will already be the undo one $likeActivity = $message->likeMessageId; } $activity = $this->announceWrapper->build($object->magazine, $likeActivity); $json = $this->activityJsonBuilder->buildActivityJson($activity); // send the announcement only to the subscribers of the magazine $inboxes = array_filter( $this->magazineRepository->findAudience($object->magazine) ); $this->deliverManager->deliver($inboxes, $json); } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/BlockHandler.php ================================================ workWrapper($message); } public function doWork(MessageInterface $message): void { if (!$message instanceof BlockMessage) { throw new \LogicException(); } if (null !== $message->magazineBanId) { $ban = $this->magazineBanRepository->find($message->magazineBanId); if (null === $ban) { throw new UnrecoverableMessageHandlingException("there is no ban with id $message->magazineBanId"); } $this->handleMagazineBan($ban); } elseif (null !== $message->bannedUserId) { $bannedUser = $this->userRepository->find($message->bannedUserId); $actor = $this->userRepository->find($message->actor); if (null === $bannedUser) { throw new UnrecoverableMessageHandlingException("there is no user with id $message->bannedUserId"); } if (null === $actor) { throw new UnrecoverableMessageHandlingException("there is no user with id $message->actor"); } $this->handleUserBan($bannedUser, $actor); } else { throw new UnrecoverableMessageHandlingException('nothing to do. `magazineBanId` and `bannedUserId` are both null'); } } private function handleMagazineBan(MagazineBan $ban): void { $isUndo = null !== $ban->expiredAt && $ban->expiredAt < new \DateTime(); $actor = $ban->bannedBy; if (null === $actor) { throw new UnrecoverableMessageHandlingException('An actor is needed to ban a user'); } elseif (null !== $actor->apId) { throw new UnrecoverableMessageHandlingException("$actor->username is not a local user"); } if ($isUndo) { $activity = $this->activityRepository->findFirstActivitiesByTypeAndObject('Block', $ban) ?? $this->blockFactory->createActivityFromMagazineBan($ban); $activity = $this->undoWrapper->build($activity); } else { $activity = $this->blockFactory->createActivityFromMagazineBan($ban); } $json = $this->activityJsonBuilder->buildActivityJson($activity); $this->deliverManager->deliver($this->magazineRepository->findAudience($ban->magazine), $json); } private function handleUserBan(User $bannedUser, User $actor): void { $isUndo = !$bannedUser->isBanned; if ($isUndo) { $activity = $this->activityRepository->findFirstActivitiesByTypeAndObject('Block', $bannedUser) ?? $this->blockFactory->createActivityFromInstanceBan($bannedUser, $actor); $activity = $this->undoWrapper->build($activity); } else { $activity = $this->blockFactory->createActivityFromInstanceBan($bannedUser, $actor); } $json = $this->activityJsonBuilder->buildActivityJson($activity); $inboxes = $this->userManager->getAllInboxesOfInteractions($bannedUser); $this->deliverManager->deliver($inboxes, $json); } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/CreateHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(CreateMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof CreateMessage)) { throw new \LogicException(); } $entity = $this->entityManager->getRepository($message->type)->find($message->id); $activity = $this->createWrapper->build($entity); $json = $this->activityJsonBuilder->buildActivityJson($activity); if ($entity instanceof Message) { $receivers = $this->messageManager->findAudience($entity->thread); $this->logger->info('[CreateHandler::doWork] sending message to {p}', ['p' => $receivers]); } else { $receivers = [ ...$this->userRepository->findAudience($entity->user), ...$this->activityPubManager->createInboxesFromCC($json, $entity->user), ]; if ('random' !== $entity->magazine->name) { // only add the magazine subscribers if it is not the random magazine $receivers = array_merge($receivers, $this->magazineRepository->findAudience($entity->magazine)); } $this->logger->debug('[CreateHandler::doWork] Sending create activity to {p}', ['p' => $receivers]); } $this->deliverManager->deliver(array_filter(array_unique($receivers)), $json); } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/DeleteHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(DeleteMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof DeleteMessage)) { throw new \LogicException(); } $user = $this->userRepository->find($message->userId); $magazine = $this->magazineRepository->find($message->magazineId); $inboxes = array_filter(array_unique(array_merge( $this->userRepository->findAudience($user), $this->activityPubManager->createInboxesFromCC($message->payload, $user), ))); if ('random' !== $magazine->name) { // only add the magazine subscribers if it is not the random magazine $inboxes = array_filter(array_unique(array_merge( $inboxes, $this->magazineRepository->findAudience($magazine), ))); } $this->deliverManager->deliver($inboxes, $message->payload); } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/DeliverHandler.php ================================================ entityManager, $this->kernel); } /** * @throws InvalidApPostException */ public function __invoke(DeliverMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function workWrapper(MessageInterface $message): void { $conn = $this->entityManager->getConnection(); $conn->getNativeConnection(); // calls connect() internally $conn->beginTransaction(); try { $this->doWork($message); $conn->commit(); } catch (InvalidApPostException $e) { if (400 <= $e->responseCode && 500 > $e->responseCode && self::HTTP_RESPONSE_CODE_RATE_LIMITED !== $e->responseCode) { $conn->rollBack(); $this->logger->debug('{domain} responded with {code} for our request, rolling back the changes and not trying again, request: {body}', [ 'domain' => $e->url, 'code' => $e->responseCode, 'body' => $e->payload, ]); throw new UnrecoverableMessageHandlingException('There is a problem with the request which will stay the same, so discarding', previous: $e); } elseif (self::HTTP_RESPONSE_CODE_RATE_LIMITED === $e->responseCode) { $conn->rollBack(); // a rate limit is always recoverable throw new RecoverableMessageHandlingException(previous: $e); } else { // we don't roll back on an InvalidApPostException, so the failed delivery attempt gets written to the DB $conn->commit(); throw $e; } } catch (TransportExceptionInterface $e) { // we don't roll back on an TransportExceptionInterface, so the failed delivery attempt gets written to the DB $conn->commit(); throw $e; } catch (\Exception $e) { $conn->rollBack(); throw $e; } $conn->close(); } /** * @throws InvalidApPostException * @throws TransportExceptionInterface * @throws InvalidArgumentException * @throws InstanceBannedException * @throws InvalidWebfingerException */ public function doWork(MessageInterface $message): void { if (!($message instanceof DeliverMessage)) { throw new \LogicException(); } $instance = $this->instanceRepository->getOrCreateInstance(parse_url($message->apInboxUrl, PHP_URL_HOST)); if ($instance->isDead()) { $this->logger->debug('instance {n} is considered dead. Last successful delivery date: {dd}, failed attempts since then: {fa}', [ 'n' => $instance->domain, 'dd' => $instance->getLastSuccessfulDeliver(), 'fa' => $instance->getLastFailedDeliver(), ]); return; } if ('Announce' !== $message->payload['type']) { $url = $message->payload['object']['attributedTo'] ?? $message->payload['actor']; } else { $url = $message->payload['actor']; } $this->logger->debug("Getting Actor for url: $url"); $actor = $this->activityPubManager->findActorOrCreate($url); if (!$actor) { $this->logger->debug('got no actor :('); return; } if ($actor instanceof User && $actor->isBanned) { $this->logger->debug('got an actor, but he is banned :('); return; } try { $this->client->post($message->apInboxUrl, $actor, $message->payload, $message->useOldPrivateKey); if ($instance->getLastSuccessfulDeliver() < new \DateTimeImmutable('now - 5 minutes')) { $instance->setLastSuccessfulDeliver(); $this->entityManager->persist($instance); $this->entityManager->flush(); } } catch (InvalidApPostException|TransportExceptionInterface $e) { $instance->setLastFailedDeliver(); $this->entityManager->persist($instance); $this->entityManager->flush(); throw $e; } } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/EntryPinMessageHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(EntryPinMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof EntryPinMessage)) { throw new \LogicException(); } $entry = $this->entryRepository->findOneBy(['id' => $message->entryId]); $user = $this->userRepository->findOneBy(['id' => $message->actorId]); if (null !== $entry->magazine->apId && null !== $user->apId) { $this->logger->warning('got an EntryPinMessage for remote magazine {m} by remote user {u}. That does not need to be propagated, as this instance is not the source', ['m' => $entry->magazine->apId, 'u' => $user->apId]); return; } if ('random' === $entry->magazine->name) { // do not federate the random magazine return; } if ($message->sticky) { $activity = $this->addRemoveFactory->buildAddPinnedPost($user, $entry); } else { $activity = $this->addRemoveFactory->buildRemovePinnedPost($user, $entry); } if ($entry->magazine->apId) { $audience = [$entry->magazine->apInboxUrl]; } else { $audience = $this->magazineRepository->findAudience($entry->magazine); } $json = $this->activityJsonBuilder->buildActivityJson($activity); $this->deliverManager->deliver($audience, $json); } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/FlagHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(FlagMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof FlagMessage)) { throw new \LogicException(); } $this->logger->debug('[FlagHandler::doWork] Got a FlagMessage'); $report = $this->reportRepository->find($message->reportId); if (!$report) { $this->logger->info("[FlagHandler::doWork] Couldn't find report with id {id}", ['id' => $message->reportId]); return; } $this->logger->debug('[FlagHandler::doWork] Found the report: '.json_encode($report)); $inboxes = $this->getInboxUrls($report); if (0 === \sizeof($inboxes)) { $this->logger->info("[FlagHandler::doWork] couldn't find any inboxes to send the FlagMessage to"); return; } $activity = $this->factory->build($report); $json = $this->activityJsonBuilder->buildActivityJson($activity); $this->deliverManager->deliver($inboxes, $json); } /** * @return string[] */ private function getInboxUrls(Report $report): array { $urls = []; if (null === $report->magazine->apId) { foreach ($report->magazine->moderators as /* @var Moderator $moderator */ $moderator) { if ($moderator->user->apId and !\in_array($moderator->user->apInboxUrl, $urls)) { $urls[] = $moderator->user->apInboxUrl; } } } else { $urls[] = $report->magazine->apInboxUrl; } if ($report->reported->apId and !\in_array($report->reported->apInboxUrl, $urls)) { $urls[] = $report->reported->apInboxUrl; } return $urls; } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/FollowHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(FollowMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof FollowMessage)) { throw new \LogicException(); } $follower = $this->userRepository->find($message->followerId); if ($message->magazine) { $following = $this->magazineRepository->find($message->followingId); } else { $following = $this->userRepository->find($message->followingId); } $followObject = $this->activityRepository->findFirstActivitiesByTypeObjectAndActor('Follow', $following, $follower); if (null === $followObject) { $followObject = $this->followWrapper->build($follower, $following); } if ($message->unfollow) { $followObject = $this->undoWrapper->build($followObject); } $json = $this->activityJsonBuilder->buildActivityJson($followObject); $this->deliverManager->deliver([$following->apInboxUrl], $json); } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/GenericAnnounceHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(GenericAnnounceMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof GenericAnnounceMessage)) { throw new \LogicException(); } $magazine = $this->magazineRepository->find($message->announcingMagazineId); if (null !== $magazine->apId) { return; } if ('random' === $magazine->name) { // do not federate the random magazine return; } if (null !== $message->innerActivityUUID) { $object = $this->activityRepository->findOneBy(['uuid' => Uuid::fromString($message->innerActivityUUID)]); if (!$object) { // this should literally not be possible, but check for it anyway throw new \LogicException('could not find an Object by their Uuid '.$message->innerActivityUUID); } } elseif (null !== $message->innerActivityUrl) { $object = $message->innerActivityUrl; } else { throw new \LogicException('expect at least one of innerActivityUUID or innerActivityUrl to not be null'); } $alreadySentActivities = $this->activityRepository->findAllActivitiesByTypeObjectAndActor('Announce', $object, $magazine); if (\sizeof($alreadySentActivities) > 0) { $this->logger->info('[GenericAnnounceHandler::doWork] not announcing activity {object}, because the same actor (magazine {magazine}) already announced it', [ 'object' => $object->uuid, 'magazine' => $magazine->name, ]); return; } $announce = $this->announceWrapper->build($magazine, $object); $json = $this->activityJsonBuilder->buildActivityJson($announce); $inboxes = array_filter($this->magazineRepository->findAudience($magazine), fn ($item) => null !== $item && $item !== $message->sourceInstance); $this->deliverManager->deliver($inboxes, $json); } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/LikeHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(LikeMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof LikeMessage)) { throw new \LogicException(); } $user = $this->userRepository->find($message->userId); /** @var Entry|EntryComment|Post|PostComment $object */ $object = $this->entityManager->getRepository($message->objectType)->find($message->objectId); $activity = $this->likeWrapper->build($user, $object); if ($message->removeLike) { $activity = $this->undoWrapper->build($activity); } $inboxes = array_merge( $this->userRepository->findAudience($user), [$object->user->apInboxUrl], ); if ('random' !== $object->magazine->name) { // only add the magazine subscribers if it is not the random magazine $inboxes = array_merge($inboxes, $this->magazineRepository->findAudience($object->magazine)); } $this->deliverManager->deliver(array_filter(array_unique($inboxes)), $this->activityJsonBuilder->buildActivityJson($activity)); } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/LockHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(LockMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof LockMessage)) { throw new \LogicException(); } $actor = $this->userRepository->find($message->actorId); if (null !== $message->entryId) { $object = $this->entryRepository->find($message->entryId); } elseif (null !== $message->postId) { $object = $this->postRepository->find($message->postId); } else { throw new \LogicException('There has to be either an entry id or a post id'); } $magazine = $object->magazine; if ($magazine->apId) { $audience = [$magazine->apInboxUrl]; } else { if ('random' === $magazine->name) { // do not federate the random magazine return; } $audience = $this->magazineRepository->findAudience($magazine); } $userAudience = $this->userRepository->findAudience($actor); $audience = array_filter(array_unique(array_merge($userAudience, $audience))); if ($object->isLocked) { $activity = $this->factory->build($actor, $object); } else { $activity = $this->activityRepository->findFirstActivitiesByTypeObjectAndActor('Lock', $object, $actor); if (null === $activity) { $activity = $this->factory->build($actor, $object); } $activity = $this->undoWrapper->build($activity, $actor); } $json = $this->activityJsonBuilder->buildActivityJson($activity); $this->deliverManager->deliver($audience, $json); } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/RemoveHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(RemoveMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof RemoveMessage)) { throw new \LogicException(); } $actor = $this->userRepository->find($message->userActorId); $removed = $this->userRepository->find($message->removedUserId); $magazine = $this->magazineRepository->find($message->magazineId); if ('random' === $magazine->name) { // do not federate the random magazine return; } if ($magazine->apId) { $audience = [$magazine->apInboxUrl]; } else { $audience = $this->magazineRepository->findAudience($magazine); } $activity = $this->factory->buildRemoveModerator($actor, $removed, $magazine); $this->deliverManager->deliver($audience, $this->activityJsonBuilder->buildActivityJson($activity)); } } ================================================ FILE: src/MessageHandler/ActivityPub/Outbox/UpdateHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(UpdateMessage $message): void { if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) { return; } $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof UpdateMessage)) { throw new \LogicException(); } $entity = $this->entityManager->getRepository($message->type)->find($message->id); $editedByUser = null; if ($message->editedByUserId) { $editedByUser = $this->userRepository->findOneBy(['id' => $message->editedByUserId]); } if ($entity instanceof ActivityPubActivityInterface) { $activityObject = $this->updateWrapper->buildForActivity($entity, $editedByUser); $activity = $this->activityJsonBuilder->buildActivityJson($activityObject); if ($entity instanceof Entry || $entity instanceof EntryComment || $entity instanceof Post || $entity instanceof PostComment) { if ('random' === $entity->magazine->name) { // do not federate the random magazine return; } $inboxes = array_filter(array_unique(array_merge( $this->userRepository->findAudience($entity->user), $this->activityPubManager->createInboxesFromCC($activity, $entity->user), $this->magazineRepository->findAudience($entity->magazine) ))); } elseif ($entity instanceof Message) { if (null === $message->editedByUserId) { throw new \LogicException('a message has to be edited by someone'); } $inboxes = array_unique(array_map(fn (User $u) => $u->apInboxUrl, $entity->thread->getOtherParticipants($message->editedByUserId))); } else { throw new \LogicException('unknown activity type: '.\get_class($entity)); } } elseif ($entity instanceof ActivityPubActorInterface) { $activityObject = $this->updateWrapper->buildForActor($entity, $editedByUser); $activity = $this->activityJsonBuilder->buildActivityJson($activityObject); if ($entity instanceof User) { $inboxes = $this->userRepository->findAudience($entity); $this->logger->debug('[UpdateHandler::doWork] sending update user activity for user {u} to {i}', ['u' => $entity->username, 'i' => join(', ', $inboxes)]); } elseif ($entity instanceof Magazine) { if ('random' === $entity->name) { // do not federate the random magazine return; } if (null === $entity->apId) { $inboxes = $this->magazineRepository->findAudience($entity); if (null !== $editedByUser) { $inboxes = array_filter($inboxes, fn (string $domain) => $editedByUser->apInboxUrl !== $domain); } } else { $inboxes = [$entity->apInboxUrl]; } } else { throw new \LogicException('Unknown actor type: '.\get_class($entity)); } } else { throw new \LogicException('Unknown activity type: '.\get_class($entity)); } $this->deliverManager->deliver($inboxes, $activity); } } ================================================ FILE: src/MessageHandler/ActivityPub/UpdateActorHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(UpdateActorMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof UpdateActorMessage)) { throw new \LogicException(); } $actorUrl = $message->actorUrl; $lock = $this->lockFactory->createLock('update_actor_'.hash('sha256', $actorUrl), 60); if (!$lock->acquire()) { $this->logger->debug( 'not updating actor at {url}: ongoing actor update is already in progress', ['url' => $actorUrl] ); return; } $actor = $this->userRepository->findOneBy(['apProfileId' => $actorUrl]) ?? $this->magazineRepository->findOneBy(['apProfileId' => $actorUrl]); if ($actor) { if ($message->force) { $this->apHttpClient->invalidateActorObjectCache($actorUrl); } if ($message->force || $actor->apFetchedAt < (new \DateTime())->modify('-1 hour')) { $this->activityPubManager->updateActor($actorUrl); } else { $this->logger->debug('not updating actor {url}: last updated is recent: {fetched}', [ 'url' => $actorUrl, 'fetched' => $actor->apFetchedAt, ]); } } $lock->release(); } } ================================================ FILE: src/MessageHandler/AttachEntryEmbedHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(EntryEmbedMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof EntryEmbedMessage)) { throw new \LogicException(); } $entry = $this->entryRepository->find($message->entryId); if (!$entry) { throw new UnrecoverableMessageHandlingException('Entry not found'); } if (!$entry->url) { $this->logger->debug('[AttachEntryEmbedHandler::doWork] returning, as the entry {id} does not have a url', ['id' => $entry->getId()]); return; } try { $embed = $this->embed->fetch($entry->url); } catch (\Exception $e) { return; } $html = $embed->html; $type = $embed->getType(); $isImage = $embed->isImageUrl(); $cover = $this->fetchCover($entry, $embed); if (!$html && !$cover && !$isImage) { $this->logger->debug('[AttachEntryEmbedHandler::doWork] returning, as the embed is neither html, nor an image url and we could not extract an image from it either. URL: {u}', ['u' => $entry->url]); return; } $entry->type = $type; $entry->hasEmbed = $html || $isImage; if ($cover) { $this->logger->debug('[AttachEntryEmbedHandler::doWork] setting entry ({id}) image to new one', ['id' => $entry->getId()]); $entry->image = $cover; } $this->entityManager->flush(); } private function fetchCover(Entry $entry, Embed $embed): ?Image { if (!$entry->image) { if ($imageUrl = $this->getCoverUrl($entry, $embed)) { if ($tempFile = $this->fetchImage($imageUrl)) { $image = $this->imageRepository->findOrCreateFromPath($tempFile); if ($image && !$image->sourceUrl) { $image->sourceUrl = $imageUrl; } return $image; } } } $this->logger->debug('[AttachEntryEmbedHandler::fetchCover] returning null, as the entry ({id}) already has an image and does not have an embed', ['id' => $entry->getId()]); return null; } private function getCoverUrl(Entry $entry, Embed $embed): ?string { if ($embed->image) { return $embed->image; } elseif ($embed->isImageUrl()) { return $entry->url; } return null; } private function fetchImage(string $url): ?string { try { return $this->manager->download($url); } catch (\Exception $e) { return null; } } } ================================================ FILE: src/MessageHandler/ClearDeadMessagesHandler.php ================================================ logger->info('[ClearDeadMessagesHandler::__invoke] Clearing dead messages'); $sql = 'DELETE FROM messenger_messages WHERE queue_name = :queue_name'; $this->entityManager->createNativeQuery($sql, new ResultSetMapping()) ->setParameter('queue_name', 'dead') ->getResult(); } } ================================================ FILE: src/MessageHandler/ClearDeletedUserHandler.php ================================================ userManager->getUsersMarkedForDeletionBefore(); foreach ($users as $user) { try { $this->bus->dispatch(new DeleteUserMessage($user->getId())); } catch (\Exception|\Error $e) { $this->logger->error("[ClearDeletedUserHandler::__invoke] Couldn't delete user {user}: {message}", ['user' => $user->username, 'message' => \get_class($e).': '.$e->getMessage()]); } } } } ================================================ FILE: src/MessageHandler/DeleteImageHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(DeleteImageMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof DeleteImageMessage)) { throw new \LogicException(); } $image = $this->imageRepository->findOneBy(['id' => $message->id]); if ($image) { $this->entityManager->beginTransaction(); try { $this->entityManager->remove($image); $this->entityManager->flush(); $this->entityManager->commit(); } catch (\Exception $e) { $this->entityManager->rollback(); $this->managerRegistry->resetManager(); return; } } if ($image?->filePath) { $this->imageManager->remove($image->filePath); } } } ================================================ FILE: src/MessageHandler/DeleteUserHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(DeleteUserMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof DeleteUserMessage)) { throw new \LogicException(); } /** @var ?User $user */ $user = $this->entityManager ->getRepository(User::class) ->find($message->id); if (!$user) { throw new UnrecoverableMessageHandlingException('User not found'); } elseif ($user->isDeleted && null === $user->markedForDeletionAt) { // user already deleted return; } $isLocal = null === $user->apId; $privateKey = $user->getPrivateKey(); $publicKey = $user->getPublicKey(); $inboxes = $this->userManager->findAllKnownInboxesNotBannedNotDead(); // note: email cannot be null. For remote accounts email is set to their 'handle@domain.tld' who knows why... $userDto = UserDto::create($user->username, email: $user->username, createdAt: $user->createdAt); $userDto->plainPassword = ''.time(); if (!$isLocal) { $userDto->apId = $user->apId; $userDto->apProfileId = $user->apProfileId; } try { $this->userManager->detachAvatar($user); } catch (\Exception|\Error $e) { $this->logger->error("[ClearDeletedUserHandler::__invoke] Couldn't delete the avatar of {user} at '{path}': {message}", ['user' => $user->username, 'path' => $user->avatar?->filePath, 'message' => \get_class($e).': '.$e->getMessage()]); } try { $this->userManager->detachCover($user); } catch (\Exception|\Error $e) { $this->logger->error("[ClearDeletedUserHandler::__invoke] Couldn't delete the cover of {user} at '{path}': {message}", ['user' => $user->username, 'path' => $user->cover?->filePath, 'message' => \get_class($e).': '.$e->getMessage()]); } $filePathsOfUser = $this->userManager->getAllImageFilePathsOfUser($user); foreach ($filePathsOfUser as $path) { try { $this->imageManager->remove($path); } catch (\Exception|\Error $e) { $this->logger->error("[ClearDeletedUserHandler::__invoke] Couldn't delete image of {user} at '{path}': {message}", ['user' => $user->username, 'path' => $path, 'message' => \get_class($e).': '.$e->getMessage()]); } } $this->entityManager->beginTransaction(); try { // delete the original user, so all the content is cascade deleted $this->entityManager->remove($user); $this->entityManager->flush(); // recreate a user with the same name, so this handle is blocked $user = $this->userManager->create($userDto, verifyUserEmail: false, rateLimit: false, preApprove: true); $user->isDeleted = true; $user->markedForDeletionAt = null; $user->isVerified = false; if ($isLocal) { $user->privateKey = $privateKey; $user->publicKey = $publicKey; } $this->entityManager->persist($user); $this->entityManager->flush(); if ($isLocal) { $this->sendDeleteMessages($inboxes, $user); } $this->entityManager->commit(); } catch (\Exception $e) { $this->entityManager->rollback(); throw $e; } } private function sendDeleteMessages(array $targetInboxes, User $deletedUser): void { if (null !== $deletedUser->apId) { return; } $activity = $this->deleteWrapper->buildForUser($deletedUser); $message = $this->activityJsonBuilder->buildActivityJson($activity); foreach ($targetInboxes as $inbox) { $this->bus->dispatch(new DeliverMessage($inbox, $message)); } } } ================================================ FILE: src/MessageHandler/LinkEmbedHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(LinkEmbedMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof LinkEmbedMessage)) { throw new \LogicException(); } preg_match_all('#\bhttps?://[^,\s()<>]+(?:\([\w\d]+\)|([^,[:punct:]\s]|/))#', $message->body, $match); foreach ($match[0] as $url) { try { $embed = $this->embed->fetch($url)->html; if ($embed) { $entity = new \App\Entity\Embed($url, true); $this->embedRepository->add($entity); } } catch (\Exception $e) { $embed = false; } if (!$embed) { $entity = new \App\Entity\Embed($url, false); $this->embedRepository->add($entity); } } $this->markdownCache->deleteItem(hash('sha256', json_encode(['content' => $message->body]))); } } ================================================ FILE: src/MessageHandler/MagazinePurgeHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(MagazinePurgeMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof MagazinePurgeMessage)) { throw new \LogicException(); } $this->magazine = $this->entityManager ->getRepository(Magazine::class) ->find($message->id); if (!$this->magazine) { throw new UnrecoverableMessageHandlingException('Magazine not found'); } // TODO: This magazine delete can be improved by introducing missing // cascading in PostgreSQL schema $retry = $this->removeReports() || $this->removeEntryComments() || $this->removeEntries() || $this->removePostComments() || $this->removePosts(); if ($retry) { $this->bus->dispatch($message); } else { $this->removeModeratorRequests(); $this->removeModeratorOwnershipRequests(); if ($message->contentOnly) { return; } $this->entityManager->remove($this->magazine); $this->entityManager->flush(); } } private function removeEntryComments(): bool { $comments = $this->entityManager ->getRepository(EntryComment::class) ->findBy( [ 'magazine' => $this->magazine, ], ['id' => 'DESC'], $this->batchSize ); $retry = false; foreach ($comments as $comment) { $retry = true; $this->entryCommentManager->purge($comment->user, $comment); } return $retry; } private function removeEntries(): bool { $entries = $this->entityManager ->getRepository(Entry::class) ->findBy( [ 'magazine' => $this->magazine, ], ['id' => 'DESC'], $this->batchSize ); $retry = false; foreach ($entries as $entry) { $retry = true; $this->entryManager->purge($entry->user, $entry); } return $retry; } private function removePostComments(): bool { $comments = $this->entityManager ->getRepository(PostComment::class) ->findBy( [ 'magazine' => $this->magazine, ], ['id' => 'DESC'], $this->batchSize ); $retry = false; foreach ($comments as $comment) { $retry = true; $this->postCommentManager->purge($comment->user, $comment); } return $retry; } private function removePosts(): bool { $posts = $this->entityManager ->getRepository(Post::class) ->findBy( [ 'magazine' => $this->magazine, ], ['id' => 'DESC'], $this->batchSize ); $retry = false; foreach ($posts as $post) { $retry = true; $this->postManager->purge($post->user, $post); } return $retry; } private function removeReports(): bool { $em = $this->entityManager; $query = $em->createQuery( 'DELETE FROM '.Report::class.' r WHERE r.magazine = :magazineId' ); $query->setParameter('magazineId', $this->magazine->getId()); $query->execute(); return false; } private function removeModeratorRequests(): void { $em = $this->entityManager; $query = $em->createQuery( 'DELETE FROM '.ModeratorRequest::class.' r WHERE r.magazine = :magazineId' ); $query->setParameter('magazineId', $this->magazine->getId()); $query->execute(); } private function removeModeratorOwnershipRequests(): void { $em = $this->entityManager; $query = $em->createQuery( 'DELETE FROM '.MagazineOwnershipRequest::class.' r WHERE r.magazine = :magazineId' ); $query->setParameter('magazineId', $this->magazine->getId()); $query->execute(); } } ================================================ FILE: src/MessageHandler/MbinMessageHandler.php ================================================ kernel->getEnvironment()) { $conn = $this->entityManager->getConnection(); $conn->getNativeConnection(); // calls connect() internally $conn->transactional(fn () => $this->doWork($message)); $conn->close(); } else { $this->doWork($message); } } abstract public function doWork(MessageInterface $message): void; } ================================================ FILE: src/MessageHandler/Notification/SentEntryCommentCreatedNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(EntryCommentCreatedNotificationMessage $message) { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof EntryCommentCreatedNotificationMessage)) { throw new \LogicException(); } $comment = $this->repository->find($message->commentId); if (!$comment) { throw new UnrecoverableMessageHandlingException('Comment not found'); } $this->notificationManager->sendCreated($comment); } } ================================================ FILE: src/MessageHandler/Notification/SentEntryCommentDeletedNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(EntryCommentDeletedNotificationMessage $message) { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof EntryCommentDeletedNotificationMessage)) { throw new \LogicException(); } $comment = $this->repository->find($message->commentId); if (!$comment) { throw new UnrecoverableMessageHandlingException('Comment not found'); } $this->notificationManager->sendDeleted($comment); } } ================================================ FILE: src/MessageHandler/Notification/SentEntryCommentEditedNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(EntryCommentEditedNotificationMessage $message) { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof EntryCommentEditedNotificationMessage)) { throw new \LogicException(); } $comment = $this->repository->find($message->commentId); if (!$comment) { throw new UnrecoverableMessageHandlingException('Comment not found'); } $this->notificationManager->sendEdited($comment); } } ================================================ FILE: src/MessageHandler/Notification/SentEntryCreatedNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(EntryCreatedNotificationMessage $message) { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof EntryCreatedNotificationMessage)) { throw new \LogicException(); } $entry = $this->repository->find($message->entryId); if (!$entry) { throw new UnrecoverableMessageHandlingException('Entry not found'); } $this->notificationManager->sendCreated($entry); } } ================================================ FILE: src/MessageHandler/Notification/SentEntryDeletedNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(EntryDeletedNotificationMessage $message) { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof EntryDeletedNotificationMessage)) { throw new \LogicException(); } $entry = $this->repository->find($message->entryId); if (!$entry) { throw new UnrecoverableMessageHandlingException('Entry not found'); } $this->notificationManager->sendDeleted($entry); } } ================================================ FILE: src/MessageHandler/Notification/SentEntryEditedNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(EntryEditedNotificationMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof EntryEditedNotificationMessage)) { throw new \LogicException(); } $entry = $this->repository->find($message->entryId); if (!$entry) { throw new UnrecoverableMessageHandlingException('Entry not found'); } $this->notificationManager->sendEdited($entry); } } ================================================ FILE: src/MessageHandler/Notification/SentFavouriteNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(FavouriteNotificationMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof FavouriteNotificationMessage)) { throw new \LogicException(); } $repo = $this->resolver->resolve($message->subjectClass); $this->notifyMagazine($repo->find($message->subjectId)); } private function notifyMagazine(FavouriteInterface $subject): void { if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) { return; } try { $iri = IriGenerator::getIriFromResource($subject->magazine); $update = new Update( ['pub', $iri], $this->getNotification($subject) ); $this->publisher->publish($update); } catch (\Exception $e) { dd($e); } } private function getNotification(FavouriteInterface $fav): string { $subject = explode('\\', \get_class($fav)); return json_encode( [ 'op' => end($subject).'Favourite', 'id' => $fav->getId(), 'htmlId' => $this->classService->fromEntity($fav), 'count' => $fav->favouriteCount, ] ); } } ================================================ FILE: src/MessageHandler/Notification/SentMagazineBanNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(MagazineBanNotificationMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof MagazineBanNotificationMessage)) { throw new \LogicException(); } $ban = $this->repository->find($message->banId); if (!$ban) { throw new UnrecoverableMessageHandlingException('Ban not found'); } $this->notificationManager->sendMagazineBanNotification($ban); } } ================================================ FILE: src/MessageHandler/Notification/SentNewSignupNotificationHandler.php ================================================ workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof SentNewSignupNotificationMessage)) { throw new \LogicException(); } $user = $this->userRepository->findOneBy(['id' => $message->userId]); if (!$user) { throw new UnrecoverableMessageHandlingException('user not found'); } if (!$user->isAccountDeleted() && !$user->isSoftDeleted() && null === $user->markedForDeletionAt) { // only send notifications for new accounts if the account is not deleted, // this is necessary because we create dummy accounts to block the username when an account is deleted $this->signupNotificationManager->sendNewSignupNotification($user); } } } ================================================ FILE: src/MessageHandler/Notification/SentPostCommentCreatedNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(PostCommentCreatedNotificationMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof PostCommentCreatedNotificationMessage)) { throw new \LogicException(); } $comment = $this->repository->find($message->commentId); if (!$comment) { throw new UnrecoverableMessageHandlingException('Comment not found'); } $this->notificationManager->sendCreated($comment); } } ================================================ FILE: src/MessageHandler/Notification/SentPostCommentDeletedNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(PostCommentDeletedNotificationMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof PostCommentDeletedNotificationMessage)) { throw new \LogicException(); } $comment = $this->repository->find($message->commentId); if (!$comment) { throw new UnrecoverableMessageHandlingException('Comment not found'); } $this->notificationManager->sendDeleted($comment); } } ================================================ FILE: src/MessageHandler/Notification/SentPostCommentEditedNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(PostCommentEditedNotificationMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof PostCommentEditedNotificationMessage)) { throw new \LogicException(); } $comment = $this->repository->find($message->commentId); if (!$comment) { throw new UnrecoverableMessageHandlingException('Comment not found'); } $this->notificationManager->sendEdited($comment); } } ================================================ FILE: src/MessageHandler/Notification/SentPostCreatedNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(PostCreatedNotificationMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof PostCreatedNotificationMessage)) { throw new \LogicException(); } $post = $this->repository->find($message->postId); if (!$post) { throw new UnrecoverableMessageHandlingException('Post not found'); } $this->notificationManager->sendCreated($post); } } ================================================ FILE: src/MessageHandler/Notification/SentPostDeletedNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(PostDeletedNotificationMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof PostDeletedNotificationMessage)) { throw new \LogicException(); } $post = $this->repository->find($message->postId); if (!$post) { throw new UnrecoverableMessageHandlingException('Post not found'); } $this->notificationManager->sendDeleted($post); } } ================================================ FILE: src/MessageHandler/Notification/SentPostEditedNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(PostEditedNotificationMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof PostEditedNotificationMessage)) { throw new \LogicException(); } $post = $this->repository->find($message->postId); if (!$post) { throw new UnrecoverableMessageHandlingException('Post not found'); } $this->notificationManager->sendEdited($post); } } ================================================ FILE: src/MessageHandler/Notification/SentVoteNotificationHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(VoteNotificationMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof VoteNotificationMessage)) { throw new \LogicException(); } $repo = $this->resolver->resolve($message->subjectClass); $this->notifyMagazine($repo->find($message->subjectId)); } private function notifyMagazine(VotableInterface $votable): void { if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) { return; } try { $iri = IriGenerator::getIriFromResource($votable->magazine); $update = new Update( ['pub', $iri], $this->getNotification($votable) ); $this->publisher->publish($update); } catch (\Exception $e) { } } private function getNotification(VotableInterface $votable): string { $subject = explode('\\', \get_class($votable)); return json_encode( [ 'op' => end($subject).'Vote', 'id' => $votable->getId(), 'htmlId' => $this->classService->fromEntity($votable), 'up' => $votable->countUpVotes(), 'down' => $votable->countDownVotes(), ] ); } } ================================================ FILE: src/MessageHandler/SendApplicationAnswerMailHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(UserApplicationAnswerMessage $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof UserApplicationAnswerMessage)) { throw new \LogicException(); } $user = $this->repository->find($message->userId); if (!$user) { throw new UnrecoverableMessageHandlingException('User not found'); } $this->sendAnswerMail($user, $message->approved); } public function sendAnswerMail(User $user, bool $approved): void { $mail = (new TemplatedEmail()) ->from( new Address($this->settingsManager->get('KBIN_SENDER_EMAIL'), $this->params->get('kbin_domain')) ) ->to($user->email); if ($approved) { $mail->subject($this->translator->trans('email_application_approved_title')) ->htmlTemplate('_email/application_approved.html.twig') ->context(['user' => $user]); } else { $mail->subject($this->translator->trans('email_application_rejected_title')) ->htmlTemplate('_email/application_rejected.html.twig') ->context(['user' => $user]); } $this->mailer->send($mail); } } ================================================ FILE: src/MessageHandler/SentUserConfirmationEmailHandler.php ================================================ entityManager, $this->kernel); } public function __invoke(SendConfirmationEmailInterface $message): void { $this->workWrapper($message); } public function doWork(MessageInterface $message): void { if (!($message instanceof SendConfirmationEmailInterface)) { throw new \LogicException(); } $user = $this->repository->find($message->userId); if (!$user) { throw new UnrecoverableMessageHandlingException('User not found'); } $this->sendConfirmationEmail($user); } /** * @param User $user user that will be sent the confirmation email * * @throws \Exception */ public function sendConfirmationEmail(User $user): void { try { $this->emailVerifier->sendEmailConfirmation( 'app_verify_email', $user, (new TemplatedEmail()) ->from( new Address($this->settingsManager->get('KBIN_SENDER_EMAIL'), $this->params->get('kbin_domain')) ) ->to($user->email) ->subject($this->translator->trans('email_confirm_title')) ->htmlTemplate('_email/confirmation_email.html.twig') ->context(['user' => $user]) ); } catch (\Exception $e) { throw $e; } } } ================================================ FILE: src/Middleware/Monitoring/DoctrineConnectionMiddleware.php ================================================ monitor, $sql); } public function query(string $sql): Result { if (!$this->monitor->shouldRecordQueries() || null === $this->monitor->currentContext) { return parent::query($sql); } $this->monitor->startQuery($sql); try { return parent::query($sql); } finally { $this->monitor->endQuery(); } } public function exec(string $sql): int { if (!$this->monitor->shouldRecordQueries() || null === $this->monitor->currentContext) { return parent::exec($sql); } $this->monitor->startQuery($sql); try { return parent::exec($sql); } finally { $this->monitor->endQuery(); } } public function beginTransaction(): void { if (!$this->monitor->shouldRecordQueries() || null === $this->monitor->currentContext) { parent::beginTransaction(); return; } $this->monitor->startQuery('START TRANSACTION'); try { parent::beginTransaction(); } finally { $this->monitor->endQuery(); } } public function commit(): void { if (!$this->monitor->shouldRecordQueries() || null === $this->monitor->currentContext) { parent::commit(); return; } $this->monitor->startQuery('COMMIT'); try { parent::commit(); } finally { $this->monitor->endQuery(); } } public function rollBack(): void { if (!$this->monitor->shouldRecordQueries() || null === $this->monitor->currentContext) { parent::rollBack(); return; } $this->monitor->startQuery('ROLLBACK'); try { parent::rollBack(); } finally { $this->monitor->endQuery(); } } } ================================================ FILE: src/Middleware/Monitoring/DoctrineDriverMiddleware.php ================================================ monitor, $connection); } } ================================================ FILE: src/Middleware/Monitoring/DoctrineMiddleware.php ================================================ monitor, $driver); } } ================================================ FILE: src/Middleware/Monitoring/DoctrineStatementMiddleware.php ================================================ parameters[$param] = $value; parent::bindValue($param, $value, $type); } public function execute(): Result { if (!$this->monitor->shouldRecordQueries() || null === $this->monitor->currentContext) { return parent::execute(); } $this->monitor->startQuery($this->sql, $this->parameters); try { return parent::execute(); } finally { $this->monitor->endQuery(); } } } ================================================ FILE: src/PageView/ContentPageView.php ================================================ routes(); $defaultRoute = $routes['hot']; $user = $this->security->getUser(); if ($user instanceof User) { $defaultRoute = $user->frontDefaultSort; } return 'default' !== $value ? $routes[$value] : $defaultRoute; } } ================================================ FILE: src/PageView/EntryCommentPageView.php ================================================ routes(); $defaultRoute = $routes['hot']; $user = $this->security->getUser(); if ($user instanceof User) { $defaultRoute = $user->commentDefaultSort; } return 'default' !== $value ? $routes[$value] : $defaultRoute; } } ================================================ FILE: src/PageView/EntryPageView.php ================================================ routes(); $defaultRoute = $routes['hot']; $user = $this->security->getUser(); if ($user instanceof User) { $defaultRoute = $user->frontDefaultSort; } return 'default' !== $value ? $routes[$value] : $defaultRoute; } } ================================================ FILE: src/PageView/MagazinePageView.php ================================================ resolveSort($sortOption); } public function showOnlyLocalMagazines(): bool { return self::AP_LOCAL === $this->federation; } protected function routes(): array { return array_merge( parent::routes(), [ 'threads' => self::SORT_THREADS, 'comments' => self::SORT_COMMENTS, 'posts' => self::SORT_POSTS, ], ); } } ================================================ FILE: src/PageView/MessageThreadPageView.php ================================================ routes(); $defaultRoute = $routes['hot']; $user = $this->security->getUser(); if ($user instanceof User) { $defaultRoute = $user->commentDefaultSort; } return 'default' !== $value ? $routes[$value] : $defaultRoute; } } ================================================ FILE: src/PageView/PostPageView.php ================================================ routes(); $defaultRoute = $routes['hot']; $user = $this->security->getUser(); if ($user instanceof User) { $defaultRoute = $user->frontDefaultSort; } return 'default' !== $value ? $routes[$value] : $defaultRoute; } } ================================================ FILE: src/Pagination/AdapterFactory.php ================================================ pool, ); } } ================================================ FILE: src/Pagination/CachingQueryAdapter.php ================================================ pool->getItem($this->getCacheKey()); if ($nbResult->isHit()) { return $nbResult->get(); } $nbResult->expiresAfter(60); $nbResult->set($this->queryAdapter->getNbResults()); $this->pool->save($nbResult); return $nbResult->get(); } public function getSlice(int $offset, int $length): iterable { return $this->queryAdapter->getSlice($offset, $length); } private function getCacheKey(): string { $query = $this->queryAdapter->getQuery()->getDQL(); $values = $this->queryAdapter->getQuery()->getParameters()->map(function ($val) { $value = $val->getValue(); if (\is_object($value) && method_exists($value, 'getId')) { return \sprintf('%s::%s', \get_class($value), $value->getId()); } return $value; }); return 'pagination_count_'.hash('sha256', $query.json_encode($values->toArray())); } } ================================================ FILE: src/Pagination/Cursor/CursorAdapterInterface.php ================================================ $length * * @return iterable */ public function getSlice(mixed $cursor, mixed $cursor2, int $length): iterable; /** * Returns a slice of the results representing the previous page of items in reverse. * * @param TCursor $cursor * @param TCursor2 $cursor2 * @param int<0, max> $length * * @return iterable */ public function getPreviousSlice(mixed $cursor, mixed $cursor2, int $length): iterable; } ================================================ FILE: src/Pagination/Cursor/CursorPagination.php ================================================ |null */ private ?array $currentPageResults = null; /** * @var array|null */ private ?array $previousPageResults = null; /** * @var TCursor|null */ private mixed $currentCursor = null; /** * @var TCursor2|null */ private mixed $currentCursor2 = null; /** * @var TCursor|null */ private mixed $nextCursor = null; /** * @var TCursor2|null */ private mixed $nextCursor2 = null; /** * @param CursorAdapterInterface $adapter * @param ?string $cursor2FieldName If set the pagination will assume that the adapter uses a secondary cursor * @param ?mixed $cursor2LowerLimit The lower limit of the secondary cursor, if it is an integer field 0 is the default */ public function __construct( private readonly CursorAdapterInterface $adapter, private readonly string $cursorFieldName, private int $maxPerPage, private readonly ?string $cursor2FieldName = null, private readonly mixed $cursor2LowerLimit = 0, ) { } public function getIterator(): \Traversable { $results = $this->getCurrentPageResults(); if ($results instanceof \Iterator) { return $results; } if ($results instanceof \IteratorAggregate) { return $results->getIterator(); } if (\is_array($results)) { return new \ArrayIterator($results); } throw new \InvalidArgumentException(\sprintf('Cannot create iterator with page results of type "%s".', \get_class($results))); } public function getAdapter(): CursorAdapterInterface { return $this->adapter; } public function setMaxPerPage(int $maxPerPage): CursorPaginationInterface { $this->maxPerPage = $maxPerPage; return $this; } public function getMaxPerPage(): int { return $this->maxPerPage; } public function getCurrentPageResults(): iterable { if (null !== $this->currentPageResults) { return $this->currentPageResults; } $results = $this->adapter->getSlice($this->currentCursor, $this->currentCursor2, $this->maxPerPage); $this->currentPageResults = [...$results]; return $this->currentPageResults; } public function haveToPaginate(): bool { return $this->hasNextPage() || $this->hasPreviousPage(); } public function hasNextPage(): bool { return $this->maxPerPage === \sizeof($this->currentPageResults ?? [...$this->getCurrentPageResults()]); } /** * @return array{0: TCursor, 1: TCursor2} */ public function getNextPage(): array { if (null !== $this->nextCursor) { return $this->nextCursor; } $cursorFieldName = $this->cursorFieldName; $cursor2FieldName = $this->cursor2FieldName; $array = $this->getCurrentPageResults(); $nextCursor = null; $nextCursor2 = null; $i = 0; foreach ($array as $item) { if (\is_object($item)) { $nextCursor = $item->$cursorFieldName; } elseif (\is_array($item)) { $nextCursor = $item[$cursorFieldName]; } else { throw new \LogicException('Item has to be an object or array.'); } if (null !== $cursor2FieldName) { if (\is_object($item)) { $nextCursor2 = $item->$cursor2FieldName; } elseif (\is_array($item)) { $nextCursor2 = $item[$cursor2FieldName]; } else { throw new \LogicException('Item has to be an object or array.'); } } ++$i; } if ($this->maxPerPage === $i) { $this->nextCursor = $nextCursor; if (null !== $this->nextCursor2) { $this->nextCursor2 = $nextCursor2; } return [$nextCursor, $nextCursor2]; } throw new \LogicException('There is no next page'); } /** * Generates an iterator to automatically iterate over all pages in a result set. * * @return \Generator */ public function autoPagingIterator(): \Generator { while (true) { foreach ($this->getCurrentPageResults() as $item) { yield $item; } if (!$this->hasNextPage()) { break; } $nextCursors = $this->getNextPage(); $this->setCurrentPage($nextCursors[0], $nextCursors[1]); } } public function setCurrentPage(mixed $cursor, mixed $cursor2 = null): CursorPaginationInterface { if ($cursor !== $this->currentCursor || $cursor2 !== $this->currentCursor2) { $this->previousPageResults = null; $this->currentCursor = $cursor; $this->currentCursor2 = $cursor2; $this->currentPageResults = null; $this->nextCursor = null; $this->nextCursor2 = null; } return $this; } public function getCurrentCursor(): array { return [$this->currentCursor, $this->currentCursor2]; } public function hasPreviousPage(): bool { return \sizeof($this->getPreviousPageResults()) > 0; } /** * @return array{0: TCursor, 1: TCursor2} */ public function getPreviousPage(): array { $cursorFieldName = $this->cursorFieldName; $cursor2FieldName = $this->cursor2FieldName; $array = $this->getPreviousPageResults(); $key = array_key_last($array); $item = $array[$key]; if (\is_object($item)) { $cursor = $item->$cursorFieldName; } elseif (\is_array($item)) { $cursor = $item[$cursorFieldName]; } else { throw new \LogicException('Item has to be an object or array.'); } if (null !== $cursor2FieldName) { if (\is_object($item)) { $cursor2 = $item->$cursor2FieldName; } elseif (\is_array($item)) { $cursor2 = $item[$cursor2FieldName]; } else { throw new \LogicException('Item has to be an object or array.'); } } $currentCursors = $this->getCurrentCursor(); return $this->getPreviousCursors($currentCursors[0], $cursor, $currentCursors[1], $cursor2 ?? null); } private function getPreviousPageResults(): array { if (null === $this->previousPageResults) { $this->previousPageResults = [...$this->adapter->getPreviousSlice($this->currentCursor, $this->currentCursor2, $this->maxPerPage)]; } return $this->previousPageResults; } /** * @return array{0: \DateTimeImmutable|int|mixed, 1: \DateTimeImmutable|int|mixed} */ private function getPreviousCursors(mixed $currentCursor, mixed $cursor, mixed $currentCursor2, mixed $cursor2): array { // we need to modify the value to include the last result of the previous page in reverse, // otherwise we will always be missing one result when going back if (null === $currentCursor2) { if ($currentCursor > $cursor) { return [$this->decreaseCursor($cursor), null]; } else { return [$this->increaseCursor($cursor), null]; } } else { if ($cursor2 >= $this->cursor2LowerLimit) { if ($currentCursor2 > $cursor2 || $this->currentCursor > $cursor) { return [$cursor, $this->decreaseCursor($cursor2)]; } else { return [$cursor, $this->increaseCursor($cursor2)]; } } else { if ($currentCursor > $cursor) { return [$this->decreaseCursor($cursor), $this->cursor2LowerLimit]; } else { return [$this->increaseCursor($cursor), $this->cursor2LowerLimit]; } } } } private function decreaseCursor(mixed $cursor): mixed { if ($cursor instanceof \DateTime || $cursor instanceof \DateTimeImmutable) { return (new \DateTimeImmutable())->setTimestamp($cursor->getTimestamp() - 1); } elseif (\is_int($cursor)) { return --$cursor; } return $cursor; } private function increaseCursor(mixed $cursor): mixed { if ($cursor instanceof \DateTime || $cursor instanceof \DateTimeImmutable) { return (new \DateTimeImmutable())->setTimestamp($cursor->getTimestamp() + 1); } elseif (\is_int($cursor)) { return ++$cursor; } return $cursor; } } ================================================ FILE: src/Pagination/Cursor/CursorPaginationInterface.php ================================================ * * @method \Generator autoPagingIterator() */ interface CursorPaginationInterface extends \IteratorAggregate { /** * @return CursorAdapterInterface */ public function getAdapter(): CursorAdapterInterface; public function setMaxPerPage(int $maxPerPage): self; /** * @param TCursor $cursor * @param TCursor2 $cursor2 */ public function setCurrentPage(mixed $cursor, mixed $cursor2 = null): self; public function getMaxPerPage(): int; /** * @return iterable */ public function getCurrentPageResults(): iterable; public function haveToPaginate(): bool; public function hasNextPage(): bool; /** * @return array{0:TCursor, 1:TCursor2} * * @throws LogicException if there is no next page */ public function getNextPage(): array; public function hasPreviousPage(): bool; /** * @return array{0:TCursor, 1:TCursor2} * * @throws LogicException if there is no previous page */ public function getPreviousPage(): array; /** * @return array{0:TCursor, 1:TCursor2} */ public function getCurrentCursor(): array; } ================================================ FILE: src/Pagination/Cursor/NativeQueryCursorAdapter.php ================================================ $parameters parameter name as key, parameter value as the value * @param ResultTransformer $transformer defaults to the VoidTransformer which does not transform the result in any way * * @throws Exception */ public function __construct( private readonly Connection $conn, private string $sql, private string $forwardCursorCondition, private string $backwardCursorCondition, private string $forwardCursorSort, private string $backwardCursorSort, private readonly array $parameters, private ?string $secondaryForwardCursorCondition = null, private ?string $secondaryBackwardCursorCondition = null, private ?string $secondaryForwardCursorSort = null, private ?string $secondaryBackwardCursorSort = null, private readonly ResultTransformer $transformer = new VoidTransformer(), ) { } /** * @param TCursor $cursor * @param TCursor2 $cursor2 * * @return iterable * * @throws Exception */ public function getSlice(mixed $cursor, mixed $cursor2, int $length): iterable { $replacedSql = str_replace('%cursorSort%', $this->forwardCursorSort, $this->sql); $replacedSql = str_replace('%cursor%', $this->forwardCursorCondition, $replacedSql); if ($this->secondaryForwardCursorSort) { $replacedSql = str_replace('%cursorSort2%', $this->secondaryForwardCursorSort, $replacedSql); } if ($this->secondaryForwardCursorCondition) { $replacedSql = str_replace('%cursor2%', $this->secondaryForwardCursorCondition, $replacedSql); } $sql = $replacedSql.' LIMIT :limit'; return $this->query($sql, $cursor, $cursor2, $length); } public function getPreviousSlice(mixed $cursor, mixed $cursor2, int $length): iterable { $replacedSql = str_replace('%cursorSort%', $this->backwardCursorSort, $this->sql); $replacedSql = str_replace('%cursor%', $this->backwardCursorCondition, $replacedSql); if ($this->secondaryBackwardCursorSort) { $replacedSql = str_replace('%cursorSort2%', $this->secondaryBackwardCursorSort, $replacedSql); } if ($this->secondaryBackwardCursorCondition) { $replacedSql = str_replace('%cursor2%', $this->secondaryBackwardCursorCondition, $replacedSql); } $sql = $replacedSql.' LIMIT :limit'; return $this->query($sql, $cursor, $cursor2, $length); } /** * @throws Exception */ private function query(string $sql, mixed $cursor, mixed $cursor2, int $length): iterable { $statement = $this->conn->prepare($sql); foreach ($this->parameters as $key => $value) { $statement->bindValue($key, $value, SqlHelpers::getSqlType($value)); } $statement->bindValue('cursor', $cursor, SqlHelpers::getSqlType($cursor)); if (str_contains($sql, ':cursor2')) { $statement->bindValue('cursor2', $cursor2, SqlHelpers::getSqlType($cursor2)); } $statement->bindValue('limit', $length); return $this->transformer->transform($statement->executeQuery()->fetchAllAssociative()); } } ================================================ FILE: src/Pagination/NativeQueryAdapter.php ================================================ numOfResults) { $this->numOfResults = $this->calculateNumOfResultsCached($sql, $this->parameters); } $this->statement = $this->conn->prepare($sql.' LIMIT :limit OFFSET :offset'); foreach ($this->parameters as $key => $value) { $this->statement->bindValue($key, $value, SqlHelpers::getSqlType($value)); } } private function calculateNumOfResultsCached(string $sql, array $parameters): int { if (null === $this->cache) { return $this->calculateNumOfResults($sql, $parameters); } $sqlHash = hash('sha256', $sql); $parameterHash = hash('sha256', print_r($parameters, true)); return $this->cache->get("native_query_count_$sqlHash-$parameterHash", function (CacheItemInterface $item) use ($sql, $parameters) { $count = $this->calculateNumOfResults($sql, $parameters); if ($count > 25000) { $item->expiresAfter(new \DateInterval('PT6H')); } elseif ($count > 10000) { $item->expiresAfter(new \DateInterval('PT1H')); } elseif ($count > 1000) { $item->expiresAfter(new \DateInterval('PT10M')); } return $count; }); } private function calculateNumOfResults(string $sql, array $parameters): int { $sql2 = 'SELECT COUNT(*) as cnt FROM ('.$sql.') sub'; $stmt2 = $this->conn->prepare($sql2); foreach ($parameters as $key => $value) { $stmt2->bindValue($key, $value, SqlHelpers::getSqlType($value)); } $result = $stmt2->executeQuery()->fetchAllAssociative(); return $result[0]['cnt']; } public function getNbResults(): int { return $this->numOfResults; } public function getSlice(int $offset, int $length): iterable { $this->statement->bindValue('offset', $offset); $this->statement->bindValue('limit', $length); return $this->transformer->transform($this->statement->executeQuery()->fetchAllAssociative()); } } ================================================ FILE: src/Pagination/Pagerfanta.php ================================================ */ class Pagerfanta implements PagerfantaInterface, \JsonSerializable { /** * @var AdapterInterface */ private AdapterInterface $adapter; private bool $allowOutOfRangePages = false; private bool $normalizeOutOfRangePages = false; /** * @phpstan-var positive-int */ private int $maxPerPage = 10; /** * @phpstan-var positive-int */ private int $currentPage = 1; /** * @phpstan-var int<0, max>|null */ private ?int $nbResults = null; /** * @phpstan-var positive-int|null */ private ?int $maxNbPages = null; /** * @phpstan-var iterable|null */ private ?iterable $currentPageResults = null; /** * @param AdapterInterface $adapter */ public function __construct(AdapterInterface $adapter) { $this->adapter = $adapter; } /** * @param AdapterInterface $adapter * * @return self */ public static function createForCurrentPageWithMaxPerPage( AdapterInterface $adapter, int $currentPage, int $maxPerPage, ): self { $pagerfanta = new self($adapter); $pagerfanta->setMaxPerPage($maxPerPage); $pagerfanta->setCurrentPage($currentPage); return $pagerfanta; } /** * @return AdapterInterface */ public function getAdapter(): AdapterInterface { return $this->adapter; } /** * @return $this */ public function setAllowOutOfRangePages(bool $allowOutOfRangePages): PagerfantaInterface { $this->allowOutOfRangePages = $allowOutOfRangePages; return $this; } public function getAllowOutOfRangePages(): bool { return $this->allowOutOfRangePages; } public function setNormalizeOutOfRangePages(bool $normalizeOutOfRangePages): PagerfantaInterface { $this->normalizeOutOfRangePages = $normalizeOutOfRangePages; return $this; } public function getNormalizeOutOfRangePages(): bool { return $this->normalizeOutOfRangePages; } /** * @return $this * * @throws LessThan1MaxPerPageException if the page is less than 1 */ public function setMaxPerPage(int $maxPerPage): PagerfantaInterface { $this->filterMaxPerPage($maxPerPage); $this->maxPerPage = $maxPerPage; $this->resetForMaxPerPageChange(); $this->filterOutOfRangeCurrentPage($this->currentPage); return $this; } private function filterMaxPerPage(int $maxPerPage): void { $this->checkMaxPerPage($maxPerPage); } /** * @throws LessThan1MaxPerPageException if the page is less than 1 */ private function checkMaxPerPage(int $maxPerPage): void { if ($maxPerPage < 1) { throw new LessThan1MaxPerPageException(); } } private function resetForMaxPerPageChange(): void { $this->currentPageResults = null; } /** * @phpstan-return positive-int */ public function getMaxPerPage(): int { return $this->maxPerPage; } /** * @return $this * * @throws LessThan1CurrentPageException if the current page is less than 1 * @throws OutOfRangeCurrentPageException if It is not allowed out of range pages and they are not normalized */ public function setCurrentPage(int $currentPage): PagerfantaInterface { $this->currentPage = $this->filterCurrentPage($currentPage); $this->resetForCurrentPageChange(); return $this; } /** * @phpstan-return positive-int */ private function filterCurrentPage(int $currentPage): int { $this->checkCurrentPage($currentPage); return $this->filterOutOfRangeCurrentPage($currentPage); } /** * @throws LessThan1CurrentPageException if the current page is less than 1 */ private function checkCurrentPage(int $currentPage): void { if ($currentPage < 1) { throw new LessThan1CurrentPageException(); } } /** * @phpstan-return positive-int */ private function filterOutOfRangeCurrentPage(int $currentPage): int { if ($this->notAllowedCurrentPageOutOfRange($currentPage)) { return $this->normalizeOutOfRangeCurrentPage($currentPage); } return $currentPage; } private function notAllowedCurrentPageOutOfRange(int $currentPage): bool { return !$this->getAllowOutOfRangePages() && $this->currentPageOutOfRange($currentPage); } private function currentPageOutOfRange(int $currentPage): bool { return $currentPage > 1 && $currentPage > $this->getNbPages(); } /** * @phpstan-return positive-int * * @throws OutOfRangeCurrentPageException if the page should not be normalized */ private function normalizeOutOfRangeCurrentPage(int $currentPage): int { if ($this->getNormalizeOutOfRangePages()) { return $this->getNbPages(); } throw new OutOfRangeCurrentPageException(\sprintf('Page "%d" does not exist. The currentPage must be inferior to "%d"', $currentPage, $this->getNbPages())); } private function resetForCurrentPageChange(): void { $this->currentPageResults = null; } /** * @phpstan-return positive-int */ public function getCurrentPage(): int { return $this->currentPage; } /** * @return iterable */ public function getCurrentPageResults(): iterable { if (null === $this->currentPageResults) { $this->currentPageResults = $this->getCurrentPageResultsFromAdapter(); } return $this->currentPageResults; } public function setCurrentPageResults(?iterable $paginator): void { $this->currentPageResults = $paginator; } /** * @return iterable */ private function getCurrentPageResultsFromAdapter(): iterable { $offset = $this->calculateOffsetForCurrentPageResults(); $length = $this->getMaxPerPage(); return $this->getAdapter()->getSlice($offset, $length); } /** * @phpstan-return int<0, max> */ private function calculateOffsetForCurrentPageResults(): int { return ($this->getCurrentPage() - 1) * $this->getMaxPerPage(); } /** * @phpstan-return int<0, max> */ public function getCurrentPageOffsetStart(): int { return 0 !== $this->getNbResults() ? $this->calculateOffsetForCurrentPageResults() + 1 : 0; } /** * @phpstan-return int<0, max> */ public function getCurrentPageOffsetEnd(): int { return $this->hasNextPage() ? $this->getCurrentPage() * $this->getMaxPerPage() : $this->getNbResults(); } /** * @phpstan-return int<0, max> */ public function getNbResults(): int { if (null === $this->nbResults) { $this->nbResults = $this->getAdapter()->getNbResults(); } return $this->nbResults; } /** * @phpstan-return positive-int */ public function getNbPages(): int { $nbPages = $this->calculateNbPages(); if (0 === $nbPages) { return $this->minimumNbPages(); } if (null !== $this->maxNbPages && $this->maxNbPages < $nbPages) { return $this->maxNbPages; } return $nbPages; } /** * @phpstan-return int<0, max> */ private function calculateNbPages(): int { return (int) ceil($this->getNbResults() / $this->getMaxPerPage()); } /** * @phpstan-return positive-int */ private function minimumNbPages(): int { return 1; } /** * @return $this * * @throws LessThan1MaxPagesException if the max number of pages is less than 1 */ public function setMaxNbPages(int $maxNbPages): PagerfantaInterface { if ($maxNbPages < 1) { throw new LessThan1MaxPagesException(); } $this->maxNbPages = $maxNbPages; return $this; } /** * @return $this */ public function resetMaxNbPages(): PagerfantaInterface { $this->maxNbPages = null; return $this; } public function haveToPaginate(): bool { return $this->getNbResults() > $this->maxPerPage; } public function hasPreviousPage(): bool { return $this->currentPage > 1; } /** * @phpstan-return positive-int * * @throws LogicException if there is no previous page */ public function getPreviousPage(): int { if (!$this->hasPreviousPage()) { throw new LogicException('There is no previous page.'); } return $this->currentPage - 1; } public function hasNextPage(): bool { return $this->currentPage < $this->getNbPages(); } /** * @phpstan-return positive-int * * @throws LogicException if there is no next page */ public function getNextPage(): int { if (!$this->hasNextPage()) { throw new LogicException('There is no next page.'); } return $this->currentPage + 1; } /** * @phpstan-return int<0, max> */ public function count(): int { return $this->getNbResults(); } /** * @return \Traversable */ public function getIterator(): \Traversable { $results = $this->getCurrentPageResults(); if ($results instanceof \Iterator) { return $results; } if ($results instanceof \IteratorAggregate) { return $results->getIterator(); } if (\is_array($results)) { return new \ArrayIterator($results); } throw new \InvalidArgumentException(\sprintf('Cannot create iterator with page results of type "%s".', get_debug_type($results))); } public function jsonSerialize(): array { $results = $this->getCurrentPageResults(); if ($results instanceof \Traversable) { return iterator_to_array($results); } return $results; } /** * Get page number of the item at specified position (1-based index). * * @phpstan-param positive-int $position * * @phpstan-return positive-int * * @throws OutOfBoundsException if the item is outside the result set */ public function getPageNumberForItemAtPosition(int $position): int { if ($this->getNbResults() < $position) { throw new OutOfBoundsException(\sprintf('Item requested at position %d, but there are only %d items.', $position, $this->getNbResults())); } return (int) ceil($position / $this->getMaxPerPage()); } public function autoPagingIterator(): \Generator { while (true) { foreach ($this->getCurrentPageResults() as $item) { yield $item; } if (!$this->hasNextPage()) { break; } $this->setCurrentPage($this->getNextPage()); } } } ================================================ FILE: src/Pagination/QueryAdapter.php ================================================ */ readonly class QueryAdapter implements AdapterInterface { /** * @var Paginator */ protected Paginator $paginator; /** * @param bool $fetchJoinCollection Whether the query joins a collection (true by default) * @param bool|null $useOutputWalkers Flag indicating whether output walkers are used in the paginator */ public function __construct( private Query|QueryBuilder $query, bool $fetchJoinCollection = true, ?bool $useOutputWalkers = null, ) { $this->paginator = new Paginator($query, $fetchJoinCollection); $this->paginator->setUseOutputWalkers($useOutputWalkers); } /** * @phpstan-return int<0, max> */ public function getNbResults(): int { return $this->paginator->count(); } /** * @phpstan-param int<0, max> $offset * @phpstan-param int<0, max> $length * * @return \Traversable */ public function getSlice(int $offset, int $length): iterable { $this->paginator->getQuery() ->setFirstResult($offset) ->setMaxResults($length); return $this->paginator->getIterator(); } public function getQuery(): Query|QueryBuilder { return $this->query; } } ================================================ FILE: src/Pagination/Transformation/ContentPopulationTransformer.php ================================================ entityManager->getRepository(Entry::class); $entryCommentRepository = $this->entityManager->getRepository(EntryComment::class); $postRepository = $this->entityManager->getRepository(Post::class); $postCommentRepository = $this->entityManager->getRepository(PostComment::class); $magazineRepository = $this->entityManager->getRepository(Magazine::class); $userRepository = $this->entityManager->getRepository(User::class); $imageRepository = $this->entityManager->getRepository(Image::class); $positionsArray = $this->buildPositionArray($input); $entryIds = $this->getOverviewIds((array) $input, 'entry'); if (\count($entryIds) > 0) { $entries = $entryRepository->findBy(['id' => $entryIds]); $entryRepository->hydrate(...$entries); } $entryCommentIds = $this->getOverviewIds((array) $input, 'entry_comment'); if (\count($entryCommentIds) > 0) { $entryComments = $entryCommentRepository->findBy(['id' => $entryCommentIds]); $entryCommentRepository->hydrate(...$entryComments); } $postIds = $this->getOverviewIds((array) $input, 'post'); if (\count($postIds) > 0) { $post = $postRepository->findBy(['id' => $postIds]); $postRepository->hydrate(...$post); } $postCommentIds = $this->getOverviewIds((array) $input, 'post_comment'); if (\count($postCommentIds) > 0) { $postComment = $postCommentRepository->findBy(['id' => $postCommentIds]); $postCommentRepository->hydrate(...$postComment); } $magazineIds = $this->getOverviewIds((array) $input, 'magazine'); if (\count($magazineIds) > 0) { $magazines = $magazineRepository->findBy(['id' => $magazineIds]); } $userIds = $this->getOverviewIds((array) $input, 'user'); if (\count($userIds) > 0) { $users = $userRepository->findBy(['id' => $userIds]); } $imageIds = $this->getOverviewIds((array) $input, 'image'); if (\count($imageIds) > 0) { $images = SqlHelpers::findByAdjusted($imageRepository, 'id', $imageIds); } return $this->applyPositions($positionsArray, $entries ?? [], $entryComments ?? [], $post ?? [], $postComment ?? [], $magazines ?? [], $users ?? [], $images ?? []); } private function getOverviewIds(array $result, string $type): array { $result = array_filter($result, fn ($subject) => $subject['type'] === $type); return array_map(fn ($subject) => $subject['id'], $result); } /** * @return int[][] */ private function buildPositionArray(iterable $input): array { $entryPositions = []; $entryCommentPositions = []; $postPositions = []; $postCommentPositions = []; $userPositions = []; $magazinePositions = []; $imagePositions = []; $i = 0; foreach ($input as $current) { switch ($current['type']) { case 'entry': $entryPositions[$current['id']] = $i; break; case 'entry_comment': $entryCommentPositions[$current['id']] = $i; break; case 'post': $postPositions[$current['id']] = $i; break; case 'post_comment': $postCommentPositions[$current['id']] = $i; break; case 'magazine': $magazinePositions[$current['id']] = $i; break; case 'user': $userPositions[$current['id']] = $i; break; case 'image': $imagePositions[$current['id']] = $i; break; } ++$i; } return [ 'entry' => $entryPositions, 'entry_comment' => $entryCommentPositions, 'post' => $postPositions, 'post_comment' => $postCommentPositions, 'magazine' => $magazinePositions, 'user' => $userPositions, 'image' => $imagePositions, ]; } /** * @param int[][] $positionsArray * @param Entry[] $entries * @param EntryComment[] $entryComments * @param Post[] $posts * @param PostComment[] $postComments * @param User[] $users * @param Image[] $images */ private function applyPositions(array $positionsArray, array $entries, array $entryComments, array $posts, array $postComments, array $magazines, array $users, array $images): array { $result = []; foreach ($entries as $entry) { $result[$positionsArray['entry'][$entry->getId()]] = $entry; } foreach ($entryComments as $entryComment) { $result[$positionsArray['entry_comment'][$entryComment->getId()]] = $entryComment; } foreach ($posts as $post) { $result[$positionsArray['post'][$post->getId()]] = $post; } foreach ($postComments as $postComment) { $result[$positionsArray['post_comment'][$postComment->getId()]] = $postComment; } foreach ($magazines as $magazine) { $result[$positionsArray['magazine'][$magazine->getId()]] = $magazine; } foreach ($users as $user) { $result[$positionsArray['user'][$user->getId()]] = $user; } foreach ($images as $image) { $result[$positionsArray['image'][$image->getId()]] = $image; } ksort($result, SORT_NUMERIC); return $result; } } ================================================ FILE: src/Pagination/Transformation/ResultTransformer.php ================================================ baseUrl = $options['base_url'] ?? ''; parent::__construct($options, $collaborators); } protected function getBaseUrl(): string { return rtrim($this->baseUrl, '/').'/'; } protected function getAuthorizationHeaders($token = null): array { return ['Authorization' => 'Bearer '.$token]; } public function getBaseAuthorizationUrl(): string { return $this->getBaseUrl().'application/o/authorize/'; } public function getBaseAccessTokenUrl(array $params): string { return $this->getBaseUrl().'application/o/token/'; } public function getResourceOwnerDetailsUrl(AccessToken $token): string { return $this->getBaseUrl().'application/o/userinfo/'; } protected function getDefaultScopes(): array { return ['openid', 'profile', 'email']; } protected function checkResponse(ResponseInterface $response, $data): void { if (!empty($data['error'])) { $error = htmlentities($data['error'], ENT_QUOTES, 'UTF-8'); $message = htmlentities($data['error_description'], ENT_QUOTES, 'UTF-8'); throw new IdentityProviderException($message, $response->getStatusCode(), $response); } } protected function createResourceOwner(array $response, AccessToken $token): AuthentikResourceOwner { return new AuthentikResourceOwner($response); } protected function getScopeSeparator(): string { return ' '; } } ================================================ FILE: src/Provider/AuthentikResourceOwner.php ================================================ response = $response; } public function getId(): mixed { return $this->getResponseValue('sub'); } public function getEmail(): mixed { return $this->getResponseValue('email'); } public function getFamilyName(): mixed { return $this->getResponseValue('family_name'); } public function getGivenName(): mixed { return $this->getResponseValue('given_name'); } public function getPreferredUsername(): mixed { return $this->getResponseValue('preferred_username'); } public function getPictureUrl(): mixed { return $this->getResponseValue('picture'); } public function toArray(): array { return $this->response; } protected function getResponseValue($key): mixed { $keys = explode('.', $key); $value = $this->response; foreach ($keys as $k) { if (isset($value[$k])) { $value = $value[$k]; } else { return null; } } return $value; } } ================================================ FILE: src/Provider/SimpleLogin.php ================================================ baseUrl, '/').'/'; } protected function getAuthorizationHeaders($token = null): array { return ['Authorization' => 'Bearer '.$token]; } public function getBaseAuthorizationUrl(): string { return $this->getBaseUrl().'oauth2/authorize'; } public function getBaseAccessTokenUrl(array $params): string { return $this->getBaseUrl().'oauth2/token'; } public function getResourceOwnerDetailsUrl(AccessToken $token): string { return $this->getBaseUrl().'oauth2/userinfo'; } protected function getDefaultScopes(): array { return ['openid', 'profile', 'email']; } protected function checkResponse(ResponseInterface $response, $data): void { if (!empty($data['error'])) { $error = htmlentities($data['error'], ENT_QUOTES, 'UTF-8'); $message = htmlentities($data['error_description'], ENT_QUOTES, 'UTF-8'); throw new IdentityProviderException($message, $response->getStatusCode(), $response); } } protected function createResourceOwner(array $response, AccessToken $token): SimpleLoginResourceOwner { return new SimpleLoginResourceOwner($response); } protected function getScopeSeparator(): string { return ' '; } } ================================================ FILE: src/Provider/SimpleLoginResourceOwner.php ================================================ response = $response; } public function getId(): mixed { return $this->getResponseValue('sub'); } public function getName(): mixed { return $this->getResponseValue('name'); } public function getEmail(): mixed { return $this->getResponseValue('email'); } public function getPictureUrl(): mixed { return $this->getResponseValue('avatar_url'); } public function toArray(): array { return $this->response; } protected function getResponseValue($key): mixed { $keys = explode('.', $key); $value = $this->response; foreach ($keys as $k) { if (isset($value[$k])) { $value = $value[$k]; } else { return null; } } return $value; } } ================================================ FILE: src/Provider/Zitadel.php ================================================ baseUrl = $options['base_url'] ?? ''; parent::__construct($options, $collaborators); } protected function getBaseUrl(): string { return rtrim($this->baseUrl, '/').'/'; } protected function getAuthorizationHeaders($token = null): array { return ['Authorization' => 'Bearer '.$token]; } public function getBaseAuthorizationUrl(): string { return $this->getBaseUrl().'oauth/v2/authorize'; } public function getBaseAccessTokenUrl(array $params): string { return $this->getBaseUrl().'oauth/v2/token'; } public function getResourceOwnerDetailsUrl(AccessToken $token): string { return $this->getBaseUrl().'oidc/v1/userinfo'; } protected function getDefaultScopes(): array { return ['openid', 'profile', 'email']; } protected function checkResponse(ResponseInterface $response, $data): void { if (!empty($data['error'])) { $error = htmlentities($data['error'], ENT_QUOTES, 'UTF-8'); $message = htmlentities($data['error_description'], ENT_QUOTES, 'UTF-8'); throw new IdentityProviderException($message, $response->getStatusCode(), $response); } } protected function createResourceOwner(array $response, AccessToken $token): ZitadelResourceOwner { return new ZitadelResourceOwner($response); } protected function getScopeSeparator(): string { return ' '; } } ================================================ FILE: src/Provider/ZitadelResourceOwner.php ================================================ response = $response; } public function getId(): mixed { return $this->getResponseValue('sub'); } public function getEmail(): mixed { return $this->getResponseValue('email'); } public function getFamilyName(): mixed { return $this->getResponseValue('family_name'); } public function getGivenName(): mixed { return $this->getResponseValue('given_name'); } public function getPreferredUsername(): mixed { return $this->getResponseValue('preferred_username'); } public function getPictureUrl(): mixed { return $this->getResponseValue('picture'); } public function toArray(): array { return $this->response; } protected function getResponseValue($key): mixed { $keys = explode('.', $key); $value = $this->response; foreach ($keys as $k) { if (isset($value[$k])) { $value = $value[$k]; } else { return null; } } return $value; } } ================================================ FILE: src/Repository/.gitignore ================================================ ================================================ FILE: src/Repository/ActivityRepository.php ================================================ findAllActivitiesByTypeAndObject($type, $object); if (!empty($results)) { return $results[0]; } return null; } /** * @return Activity[]|null */ public function findAllActivitiesByTypeAndObject(string $type, ActivityPubActivityInterface|ActivityPubActorInterface|MagazineBan $object): ?array { $qb = $this->createQueryBuilder('a'); $qb->where('a.type = :type'); $qb->setParameter('type', $type); $this->addObjectFilter($qb, $object); return $qb->getQuery()->getResult(); } public function findFirstActivitiesByTypeObjectAndActor(string $type, ActivityPubActivityInterface|ActivityPubActorInterface $object, ActivityPubActorInterface $actor): ?Activity { $results = $this->findAllActivitiesByTypeObjectAndActor($type, $object, $actor); if (!empty($results)) { return $results[0]; } return null; } /** * @return Activity[]|null */ public function findAllActivitiesByTypeObjectAndActor(string $type, Activity|ActivityPubActivityInterface|ActivityPubActorInterface|string $object, ActivityPubActorInterface $actor): ?array { $qb = $this->createQueryBuilder('a'); $qb->where('a.type = :type'); $qb->setParameter('type', $type); $this->addObjectFilter($qb, $object); if ($actor instanceof User) { $qb->andWhere('a.userActor = :user'); $qb->setParameter('user', $actor); } elseif ($actor instanceof Magazine) { $qb->andWhere('a.magazineActor = :magazine'); $qb->setParameter('magazine', $actor); } else { throw new \LogicException('Only magazine and user actors supported'); } return $qb->getQuery()->getResult(); } /** * @return Activity[]|null */ public function findAllActivitiesByObject(ActivityPubActivityInterface|ActivityPubActorInterface $object): ?array { $qb = $this->createQueryBuilder('a'); $this->addObjectFilter($qb, $object); return $qb->getQuery()->getResult(); } private function addObjectFilter(QueryBuilder $qb, Activity|ActivityPubActivityInterface|ActivityPubActorInterface|MagazineBan|string $object): void { if ($object instanceof Entry) { $qb->andWhere('a.objectEntry = :entry') ->setParameter('entry', $object); } elseif ($object instanceof EntryComment) { $qb->andWhere('a.objectEntryComment = :entryComment') ->setParameter('entryComment', $object); } elseif ($object instanceof Post) { $qb->andWhere('a.objectPost = :post') ->setParameter('post', $object); } elseif ($object instanceof PostComment) { $qb->andWhere('a.objectPostComment = :postComment') ->setParameter('postComment', $object); } elseif ($object instanceof Message) { $qb->andWhere('a.objectMessage = :message') ->setParameter('message', $object); } elseif ($object instanceof User) { $qb->andWhere('a.objectUser = :user') ->setParameter('user', $object); } elseif ($object instanceof Magazine) { $qb->andWhere('a.objectMagazine = :magazine') ->setParameter('magazine', $object); } elseif ($object instanceof MagazineBan) { $qb->andWhere('a.objectMagazineBan = :magazineBan') ->setParameter('magazineBan', $object); } elseif ($object instanceof Activity) { $qb->andWhere('a.innerActivity = :innerActivity') ->setParameter('innerActivity', $object); } elseif (\is_string($object)) { $qb->andWhere('a.innerActivityUrl = :innerActivityUrl') ->setParameter('innerActivityUrl', $object); } } public function getOutboxActivitiesOfUser(User $user): PagerfantaInterface { if ($user->isDeleted || $user->isBanned || $user->isTrashed() || null !== $user->markedForDeletionAt) { return new Pagerfanta(new ArrayAdapter([])); } $qb = $this->createQueryBuilder('a') ->leftJoin('a.audience', 'm') ->leftJoin('a.objectEntry', 'e') ->leftJoin('a.objectEntryComment', 'ec') ->leftJoin('a.objectPost', 'p') ->leftJoin('a.objectPostComment', 'pc') ->where('a.userActor = :user') ->andWhere('a.type IN (:types)') ->andWhere('a.objectMessage IS NULL') // chat messages are not public ->andWhere('m IS NULL OR m.visibility = :visible') ->andWhere('e IS NULL OR e.visibility = :visible') ->andWhere('ec IS NULL OR ec.visibility = :visible') ->andWhere('p IS NULL OR p.visibility = :visible') ->andWhere('pc IS NULL OR pc.visibility = :visible') ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE) ->setParameter('user', $user) ->setParameter('types', ['Create', 'Announce']) ->orderBy('a.createdAt', 'DESC') ->addOrderBy('a.uuid', 'DESC'); return new Pagerfanta(new QueryAdapter($qb)); } public function createForRemotePayload(array $payload, ActivityPubActivityInterface|Entry|EntryComment|Post|PostComment|ActivityPubActorInterface|User|Magazine|Activity|array|string|null $object = null): Activity { if (isset($payload['@context'])) { unset($payload['@context']); } $activity = new Activity($payload['type']); $activity->activityJson = json_encode($payload['object']); $activity->isRemote = true; if (null !== $object) { $activity->setObject($object); } $this->getEntityManager()->persist($activity); $this->getEntityManager()->flush(); return $activity; } public function createForRemoteActivity(array $payload, ActivityPubActivityInterface|Entry|EntryComment|Post|PostComment|ActivityPubActorInterface|User|Magazine|MagazineBan|Activity|array|string|null $object = null): Activity { if (isset($payload['@context'])) { unset($payload['@context']); } $activity = new Activity($payload['type']); $nestedTypes = ['Announce', 'Accept', 'Reject', 'Add', 'Remove', 'Lock']; if (\in_array($payload['type'], $nestedTypes) && isset($payload['object']) && \is_array($payload['object'])) { $activity->innerActivity = $this->createForRemoteActivity($payload['object'], $object); } else { $activity->activityJson = json_encode($payload); } $activity->isRemote = true; if (null !== $object) { $activity->setObject($object); } $this->getEntityManager()->persist($activity); $this->getEntityManager()->flush(); return $activity; } } ================================================ FILE: src/Repository/ApActivityRepository.php ================================================ 'int', 'type' => 'string', ])] public function findByObjectId(string $apId): ?array { $local = $this->findLocalByApId($apId); if ($local) { return $local; } $conn = $this->getEntityManager()->getConnection(); $tables = [ ['table' => 'entry', 'class' => Entry::class], ['table' => 'entry_comment', 'class' => EntryComment::class], ['table' => 'post', 'class' => Post::class], ['table' => 'post_comment', 'class' => PostComment::class], ['table' => 'message', 'class' => Message::class], ]; foreach ($tables as $table) { $t = $table['table']; $sql = "SELECT id FROM $t WHERE ap_id = :apId"; try { $stmt = $conn->prepare($sql); $stmt->bindValue('apId', $apId); $results = $stmt->executeQuery()->fetchAllAssociative(); if (1 === \sizeof($results) && \array_key_exists('id', $results[0])) { return [ 'id' => $results[0]['id'], 'type' => $table['class'], ]; } } catch (Exception) { } } return null; } #[ArrayShape([ 'id' => 'int', 'type' => 'string', ])] public function findLocalByApId(string $apId): ?array { $parsed = parse_url($apId); if (!isset($parsed['host'])) { // Log the error about missing the host on this apId $this->logger->error('Missing host key on AP ID: {apId}', ['apId' => $apId]); return null; } if ($parsed['host'] === $this->settingsManager->get('KBIN_DOMAIN') && !empty($parsed['path'])) { $exploded = array_filter(explode('/', $parsed['path'])); $id = \intval(end($exploded)); if (\sizeof($exploded) < 3) { return null; } if ('p' === $exploded[3]) { if (4 === \count($exploded)) { return [ 'id' => $id, 'type' => Post::class, ]; } elseif (5 === \count($exploded)) { // post url with slug (non-ap route) return [ 'id' => \intval($exploded[4]), 'type' => Post::class, ]; } else { // since the id is just the intval of the last part in the url it will be 0 if that was not a number if (0 === $id) { return null; } return [ 'id' => $id, 'type' => PostComment::class, ]; } } if ('t' === $exploded[3]) { if (4 === \count($exploded)) { return [ 'id' => $id, 'type' => Entry::class, ]; } elseif (5 === \count($exploded)) { // entry url with slug (non-ap route) return [ 'id' => \intval($exploded[4]), 'type' => Entry::class, ]; } else { // since the id is just the intval of the last part in the url it will be 0 if that was not a number if (0 === $id) { return null; } return [ 'id' => $id, 'type' => EntryComment::class, ]; } } if ('message' === $exploded[3]) { if (4 === \count($exploded)) { return [ 'id' => $id, 'type' => Message::class, ]; } } } return null; } public function getLocalUrlOfActivity(string $type, int $id): ?string { $repo = $this->getEntityManager()->getRepository($type); $entity = $repo->find($id); return $this->getLocalUrlOfEntity($entity); } public function getLocalUrlOfEntity(Entry|EntryComment|Post|PostComment $entity): ?string { if ($entity instanceof Entry) { return $this->urlGenerator->generate('entry_single', ['entry_id' => $entity->getId(), 'magazine_name' => $entity->magazine->name]); } elseif ($entity instanceof EntryComment) { return $this->urlGenerator->generate('entry_comment_view', ['comment_id' => $entity->getId(), 'entry_id' => $entity->entry->getId(), 'magazine_name' => $entity->magazine->name]); } elseif ($entity instanceof Post) { return $this->urlGenerator->generate('post_single', ['post_id' => $entity->getId(), 'magazine_name' => $entity->magazine->name]); } elseif ($entity instanceof PostComment) { return $this->urlGenerator->generate('post_single', ['post_id' => $entity->post->getId(), 'magazine_name' => $entity->magazine->name])."#post-comment-{$entity->getId()}"; } return null; } } ================================================ FILE: src/Repository/BadgeRepository.php ================================================ findBy(['user' => $user]); } public function findOneByUserAndName(User $user, string $name): ?BookmarkList { return $this->findOneBy(['user' => $user, 'name' => $name]); } public function findOneByUserDefault(User $user): BookmarkList { $list = $this->findOneBy(['user' => $user, 'isDefault' => true]); if (null === $list) { $list = new BookmarkList($user, 'Default', true); $this->getEntityManager()->persist($list); $this->getEntityManager()->flush(); } return $list; } public function makeListDefault(User $user, BookmarkList $list): void { $sql = 'UPDATE bookmark_list SET is_default = false WHERE user_id = :user'; $conn = $this->getEntityManager()->getConnection(); $stmt = $conn->prepare($sql); $stmt->bindValue('user', $user->getId()); $stmt->executeStatement(); $sql = 'UPDATE bookmark_list SET is_default = true WHERE user_id = :user AND id = :id'; $stmt = $conn->prepare($sql); $stmt->bindValue('user', $user->getId()); $stmt->bindValue('id', $list->getId()); $stmt->executeStatement(); $this->getEntityManager()->refresh($list); } public function deleteList(BookmarkList $list): void { $sql = 'DELETE FROM bookmark_list WHERE id = :id'; $conn = $this->getEntityManager()->getConnection(); $stmt = $conn->prepare($sql); $stmt->bindValue('id', $list->getId()); $stmt->executeStatement(); } public function editList(User $user, BookmarkList $list, BookmarkListDto $dto): void { $sql = 'UPDATE bookmark_list SET name = :name WHERE id = :id'; $conn = $this->getEntityManager()->getConnection(); $stmt = $conn->prepare($sql); $stmt->bindValue('id', $list->getId()); $stmt->bindValue('name', $dto->name); $rows = $stmt->executeStatement(); if ($dto->isDefault) { $this->makeListDefault($user, $list); } else { // makeListDefault already refreshes the entity, so we do not need to do it $this->getEntityManager()->refresh($list); } } /** * @return BookmarkList[] */ public function findListsBySubject(Entry|EntryDto|EntryComment|EntryCommentDto|Post|PostDto|PostComment|PostCommentDto $content, User $user): array { $qb = $this->createQueryBuilder('bl') ->join('bl.entities', 'b') ->where('bl.user = :user') ->setParameter('user', $user); if ($content instanceof Entry || $content instanceof EntryDto) { $qb->andWhere('b.entry = :content'); } elseif ($content instanceof EntryComment || $content instanceof EntryCommentDto) { $qb->andWhere('b.entryComment = :content'); } elseif ($content instanceof Post || $content instanceof PostDto) { $qb->andWhere('b.post = :content'); } elseif ($content instanceof PostComment || $content instanceof PostCommentDto) { $qb->andWhere('b.postComment = :content'); } $qb->setParameter('content', $content->getId()); return $qb->getQuery()->getResult(); } /** * @return string[]|null */ public function getBookmarksOfContentInterface(ContentInterface $content): ?array { if ($user = $this->security->getUser()) { if ($user instanceof User && ( $content instanceof Entry || $content instanceof EntryDto || $content instanceof EntryComment || $content instanceof EntryCommentDto || $content instanceof Post || $content instanceof PostDto || $content instanceof PostComment || $content instanceof PostCommentDto )) { return array_map(fn ($list) => $list->name, $this->findListsBySubject($content, $user)); } return []; } return null; } } ================================================ FILE: src/Repository/BookmarkRepository.php ================================================ createQueryBuilder('b') ->where('b.user = :user') ->andWhere('b.list = :list') ->setParameter('user', $user) ->setParameter('list', $list) ->getQuery() ->getResult(); } public function removeAllBookmarksForContent(User $user, Entry|EntryComment|Post|PostComment $content): void { if ($content instanceof Entry) { $contentWhere = 'entry_id = :id'; } elseif ($content instanceof EntryComment) { $contentWhere = 'entry_comment_id = :id'; } elseif ($content instanceof Post) { $contentWhere = 'post_id = :id'; } elseif ($content instanceof PostComment) { $contentWhere = 'post_comment_id = :id'; } else { throw new \LogicException(); } $sql = "DELETE FROM bookmark WHERE user_id = :u AND $contentWhere"; $conn = $this->getEntityManager()->getConnection(); $stmt = $conn->prepare($sql); $stmt->bindValue('u', $user->getId()); $stmt->bindValue('id', $content->getId()); $stmt->executeStatement(); } public function removeBookmarkFromList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): void { if ($content instanceof Entry) { $contentWhere = 'entry_id = :id'; } elseif ($content instanceof EntryComment) { $contentWhere = 'entry_comment_id = :id'; } elseif ($content instanceof Post) { $contentWhere = 'post_id = :id'; } elseif ($content instanceof PostComment) { $contentWhere = 'post_comment_id = :id'; } else { throw new \LogicException(); } $sql = "DELETE FROM bookmark WHERE user_id = :u AND list_id = :l AND $contentWhere"; $conn = $this->getEntityManager()->getConnection(); $stmt = $conn->prepare($sql); $stmt->bindValue('u', $user->getId()); $stmt->bindValue('l', $list->getId()); $stmt->bindValue('id', $content->getId()); $stmt->executeStatement(); } public function findPopulatedByList(BookmarkList $list, Criteria $criteria, ?int $perPage = null): PagerfantaInterface { $entryWhereArr = ['b.list_id = :list']; $entryCommentWhereArr = ['b.list_id = :list']; $postWhereArr = ['b.list_id = :list']; $postCommentWhereArr = ['b.list_id = :list']; $parameters = [ 'list' => $list->getId(), ]; $orderBy = match ($criteria->sortOption) { Criteria::SORT_OLD => 'ORDER BY i.created_at ASC', Criteria::SORT_TOP => 'ORDER BY i.score DESC, i.created_at DESC', Criteria::SORT_HOT => 'ORDER BY i.ranking DESC, i.created_at DESC', default => 'ORDER BY created_at DESC', }; if (Criteria::AP_LOCAL === $criteria->federation) { $entryWhereArr[] = 'e.ap_id IS NULL'; $entryCommentWhereArr[] = 'ec.ap_id IS NULL'; $postWhereArr[] = 'p.ap_id IS NULL'; $postCommentWhereArr[] = 'pc.ap_id IS NULL'; } if ('all' !== $criteria->type) { $entryWhereArr[] = 'e.type = :type'; $entryCommentWhereArr[] = 'false'; $postWhereArr[] = 'false'; $postCommentWhereArr[] = 'false'; $parameters['type'] = $criteria->type; } if (Criteria::TIME_ALL !== $criteria->time) { $entryWhereArr[] = 'b.created_at > :time'; $entryCommentWhereArr[] = 'b.created_at > :time'; $postWhereArr[] = 'b.created_at > :time'; $postCommentWhereArr[] = 'b.created_at > :time'; $parameters['time'] = $criteria->getSince(); } $entryWhere = SqlHelpers::makeWhereString($entryWhereArr); $entryCommentWhere = SqlHelpers::makeWhereString($entryCommentWhereArr); $postWhere = SqlHelpers::makeWhereString($postWhereArr); $postCommentWhere = SqlHelpers::makeWhereString($postCommentWhereArr); $sql = " SELECT * FROM ( SELECT e.id AS id, e.ap_id AS ap_id, e.score AS score, e.ranking AS ranking, b.created_at AS created_at, 'entry' AS type FROM bookmark b INNER JOIN entry e ON b.entry_id = e.id $entryWhere UNION SELECT ec.id AS id, ec.ap_id AS ap_id, (ec.up_votes + ec.favourite_count - ec.down_votes) AS score, ec.up_votes AS ranking, b.created_at AS created_at, 'entry_comment' AS type FROM bookmark b INNER JOIN entry_comment ec ON b.entry_comment_id = ec.id $entryCommentWhere UNION SELECT p.id AS id, p.ap_id AS ap_id, p.score AS score, p.ranking AS ranking, b.created_at AS created_at, 'post' AS type FROM bookmark b INNER JOIN post p ON b.post_id = p.id $postWhere UNION SELECT pc.id AS id, pc.ap_id AS ap_id, (pc.up_votes + pc.favourite_count - pc.down_votes) AS score, pc.up_votes AS ranking, b.created_at AS created_at, 'post_comment' AS type FROM bookmark b INNER JOIN post_comment pc ON b.post_comment_id = pc.id $postCommentWhere ) i $orderBy "; $this->logger->info('bookmark list sql: {sql}', ['sql' => $sql]); $conn = $this->getEntityManager()->getConnection(); $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer); return Pagerfanta::createForCurrentPageWithMaxPerPage($adapter, $criteria->page, $perPage ?? EntryRepository::PER_PAGE); } } ================================================ FILE: src/Repository/ContentRepository.php ================================================ getQueryAndParameters($criteria, false); $conn = $this->entityManager->getConnection(); $numResults = null; if ('test' !== $this->kernel->getEnvironment() && !$criteria->magazine && !$criteria->moderated && !$criteria->favourite && Criteria::TIME_ALL === $criteria->time && Criteria::AP_ALL === $criteria->federation && 'all' === $criteria->type) { // pre-set the results to 1000 pages for queries not very limited by the parameters so the count query is not being executed $numResults = 1000 * ($criteria->perPage ?? self::PER_PAGE); } $fanta = new Pagerfanta(new NativeQueryAdapter($conn, $query['sql'], $query['parameters'], numOfResults: $numResults, transformer: $this->contentPopulationTransformer, cache: $this->cache)); $fanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE); $fanta->setCurrentPage($criteria->page); return $fanta; } /** * @template-covariant TCursor * * @param TCursor|null $currentCursor * * @return CursorPaginationInterface * * @throws Exception */ public function findByCriteriaCursored(Criteria $criteria, mixed $currentCursor, mixed $currentCursor2 = null): CursorPaginationInterface { $query = $this->getQueryAndParameters($criteria, true); $conn = $this->entityManager->getConnection(); $orderings = $this->getOrderings($criteria); $start = new \DateTimeImmutable(); $start = $start->setTimestamp(0); $fanta = new CursorPagination( new NativeQueryCursorAdapter( $conn, $query['sql'], $this->getCursorWhereFromCriteria($criteria), $this->getCursorWhereInvertedFromCriteria($criteria), join(',', $orderings), join(',', SqlHelpers::invertOrderings($orderings)), $query['parameters'], $this->getSecondaryCursorWhereFromCriteria($criteria), $this->getSecondaryCursorWhereFromCriteriaInverted($criteria), 'c.created_at DESC', 'c.created_at', transformer: $this->contentPopulationTransformer, ), $this->getCursorFieldFromCriteria($criteria), $criteria->perPage ?? self::PER_PAGE, 'createdAt', $start, ); $fanta->setCurrentPage($currentCursor ?? $this->guessInitialCursor($criteria->sortOption), $currentCursor2 ?? new \DateTimeImmutable('now + 1 minute')); return $fanta; } /** * @return array{sql: string, parameters: array}> */ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): array { $includeEntries = Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_THREADS === $criteria->content; $includeEntryComments = Criteria::CONTENT_COMBINED === $criteria->content && $criteria->includeBoosts; $includePostComments = (Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_MICROBLOG === $criteria->content) && $criteria->includeBoosts; $parameters = [ 'visible' => VisibilityInterface::VISIBILITY_VISIBLE, 'private' => VisibilityInterface::VISIBILITY_PRIVATE, ]; /** @var ?User $user */ $user = $this->security->getUser(); $currenFilterLists = $user?->getCurrentFilterLists() ?? []; $parameters['loggedInUser'] = $user?->getId(); $timeClause = ''; if ($criteria->time && Criteria::TIME_ALL !== $criteria->time) { $timeClause = 'c.created_at >= :time'; $parameters['time'] = $criteria->getSince(); } $magazineClause = ''; if ($criteria->magazine) { $magazineClause = 'c.magazine_id = :magazine'; $parameters['magazine'] = $criteria->magazine->getId(); } $userClause = ''; if ($criteria->user) { $userClause = 'c.user_id = :user'; $parameters['user'] = $criteria->user->getId(); } $hashtagClauseEntry = ''; $hashtagClausePost = ''; $hashtagClauseEntryComment = ''; $hashtagClausePostComment = ''; if ($criteria->tag) { $hashtagClauseEntry = 'EXISTS (SELECT * FROM hashtag_link hl INNER JOIN hashtag h ON hl.hashtag_id = h.id WHERE hl.entry_id = c.id AND h.tag = :hashtag)'; $hashtagClausePost = 'EXISTS (SELECT * FROM hashtag_link hl INNER JOIN hashtag h ON hl.hashtag_id = h.id WHERE hl.post_id = c.id AND h.tag = :hashtag)'; $hashtagClauseEntryComment = 'EXISTS (SELECT * FROM hashtag_link hl INNER JOIN hashtag h ON hl.hashtag_id = h.id WHERE hl.entry_comment_id = c.id AND h.tag = :hashtag)'; $hashtagClausePostComment = 'EXISTS (SELECT * FROM hashtag_link hl INNER JOIN hashtag h ON hl.hashtag_id = h.id WHERE hl.post_comment_id = c.id AND h.tag = :hashtag)'; $parameters['hashtag'] = $criteria->tag; } $federationClause = ''; if (Criteria::AP_LOCAL === $criteria->federation) { $federationClause = 'c.ap_id IS NULL'; } elseif (Criteria::AP_FEDERATED === $criteria->federation) { $federationClause = 'c.ap_id IS NOT NULL'; } $domainClausePost = ''; $domainClauseEntry = ''; if ($criteria->domain) { $domainClauseEntry = 'd.name = :domain'; $parameters['domain'] = $criteria->domain; $domainClausePost = 'false'; } $languagesClause = ''; if ($criteria->languages) { $languagesClause = 'c.lang IN (:languages)'; $parameters['languages'] = $criteria->languages; } $contentTypeClauseEntry = ''; $contentTypeClausePost = ''; if ($criteria->type && 'all' !== $criteria->type) { $contentTypeClauseEntry = 'c.type = :type'; $contentTypeClausePost = 'false'; $parameters['type'] = $criteria->type; } $contentClauseEntry = ''; $contentClausePost = ''; if (Criteria::CONTENT_COMBINED !== $criteria->content) { if (Criteria::CONTENT_THREADS === $criteria->content) { $contentClausePost = 'false'; } elseif (Criteria::CONTENT_MICROBLOG === $criteria->content) { $contentClauseEntry = 'false'; } else { throw new \LogicException("cannot handle content of type $criteria->content"); } } if (null !== $criteria->cachedUserFollows) { $parameters['cachedUserFollows'] = $criteria->cachedUserFollows; } $subClausePost = ''; $subClauseEntry = ''; $subClauseEntryComment = ''; $subClausePostComment = ''; if ($user && $criteria->subscribed) { $subClausePost = 'c.user_id = :loggedInUser' .(null === $criteria->cachedUserSubscribedMagazines ? ' OR EXISTS (SELECT 1 FROM magazine_subscription ms WHERE ms.user_id = :loggedInUser AND ms.magazine_id = m.id)' : ' OR m.id IN (:cachedUserSubscribedMagazines)') .(null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf WHERE uf.follower_id = :loggedInUser AND uf.following_id = c.user_id)' : ' OR c.user_id IN (:cachedUserFollows)'); $subClauseEntry = $subClausePost .(null === $criteria->cachedUserSubscribedDomains ? ' OR EXISTS (SELECT 1 FROM domain_subscription ds WHERE ds.domain_id = c.domain_id AND ds.user_id = :loggedInUser)' : ' OR c.domain_id IN (:cachedUserSubscribedDomains)'); if ($criteria->includeBoosts) { $repliesCommonWhere = 'c.user_id = :loggedInUser' .(null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf WHERE uf.follower_id = :loggedInUser AND uf.following_id = c.user_id)' : ' OR c.user_id IN (:cachedUserFollows)'); $subClauseEntryComment = $repliesCommonWhere. (null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN entry_comment_vote v ON uf.following_id = v.user_id WHERE c.id = v.comment_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : ' OR EXISTS (SELECT 1 FROM entry_comment_vote v WHERE c.id = v.comment_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)'); $subClausePostComment = $repliesCommonWhere. (null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN post_comment_vote v ON uf.following_id = v.user_id WHERE c.id = v.comment_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : ' OR EXISTS (SELECT 1 FROM post_comment_vote v WHERE c.id = v.comment_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)'); $subClausePost = $subClausePost .(null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN post_vote v ON uf.following_id = v.user_id WHERE c.id = v.post_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : ' OR EXISTS (SELECT 1 FROM post_vote v WHERE c.id = v.post_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)'); $subClauseEntry = $subClauseEntry .(null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN entry_vote v ON uf.following_id = v.user_id WHERE c.id = v.entry_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : ' OR EXISTS (SELECT 1 FROM entry_vote v WHERE c.id = v.entry_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)'); } if (null !== $criteria->cachedUserSubscribedMagazines) { $parameters['cachedUserSubscribedMagazines'] = $criteria->cachedUserSubscribedMagazines; } if (null !== $criteria->cachedUserSubscribedDomains && $includeEntries) { $parameters['cachedUserSubscribedDomains'] = $criteria->cachedUserSubscribedDomains; } } $modClause = ''; if ($user && $criteria->moderated) { if (null === $criteria->cachedUserModeratedMagazines) { $modClause = 'EXISTS (SELECT * FROM moderator mod WHERE mod.magazine_id = m.id AND mod.user_id = :loggedInUser)'; } else { $modClause = 'm.id IN (:cachedUserModeratedMagazines)'; $parameters['cachedUserModeratedMagazines'] = $criteria->cachedUserModeratedMagazines; } } $allClause = ''; $allClauseU = ''; if (!$criteria->moderated && !$criteria->subscribed && !$criteria->magazine && !$criteria->user && !$criteria->domain && !$criteria->tag) { // hide all posts from non-discoverable users and magazines from /all (and only from there) $allClause = 'm.ap_discoverable = true OR m.ap_discoverable IS NULL'; $allClauseU = 'u.ap_discoverable = true OR u.ap_discoverable IS NULL'; } $favClauseEntry = ''; $favClausePost = ''; $favClauseEntryComment = ''; $favClausePostComment = ''; if ($user && $criteria->favourite) { $favClauseEntry = 'EXISTS (SELECT * FROM favourite f WHERE f.entry_id = c.id AND f.user_id = :loggedInUser)'; $favClausePost = 'EXISTS (SELECT * FROM favourite f WHERE f.post_id = c.id AND f.user_id = :loggedInUser)'; $favClauseEntryComment = 'EXISTS (SELECT * FROM favourite f WHERE f.entry_comment_id = c.id AND f.user_id = :loggedInUser)'; $favClausePostComment = 'EXISTS (SELECT * FROM favourite f WHERE f.post_comment_id = c.id AND f.user_id = :loggedInUser)'; } $blockingClausePost = ''; $blockingClauseEntry = ''; if ($user && (!$criteria->magazine || !$criteria->magazine->userIsModerator($user)) && !$criteria->moderated) { if (null === $criteria->cachedUserBlocks) { $blockingClausePost = 'NOT EXISTS (SELECT * FROM user_block ub WHERE ub.blocker_id = :loggedInUser AND ub.blocked_id = c.user_id)'; } else { $blockingClausePost = 'c.user_id NOT IN (:cachedUserBlocks)'; $parameters['cachedUserBlocks'] = $criteria->cachedUserBlocks; } if (!$criteria->domain) { if (null === $criteria->cachedUserBlockedMagazines) { $blockingClausePost .= ' AND NOT EXISTS (SELECT * FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :loggedInUser)'; } else { $blockingClausePost .= ' AND (m IS NULL OR m.id NOT IN (:cachedUserBlockedMagazines))'; $parameters['cachedUserBlockedMagazines'] = $criteria->cachedUserBlockedMagazines; } } if (null === $criteria->cachedUserBlockedDomains) { $blockingClauseEntry = $blockingClausePost.' AND NOT EXISTS (SELECT * FROM domain_block db WHERE db.user_id = :loggedInUser AND db.domain_id = c.domain_id)'; } else { $blockingClauseEntry = $blockingClausePost.' AND (c.domain_id IS NULL OR c.domain_id NOT IN (:cachedUserBlockedDomains))'; if ($includeEntries) { $parameters['cachedUserBlockedDomains'] = $criteria->cachedUserBlockedDomains; } } } $hideAdultClause = ''; if ($user && $user->hideAdult) { $hideAdultClause = 'c.is_adult = FALSE AND m.is_adult = FALSE'; } $visibilityClauseM = 'm.visibility = :visible'; if (null === $criteria->cachedUserFollows) { $visibilityClauseC = 'c.visibility = :visible OR (c.visibility = :private AND EXISTS (SELECT * FROM user_follow uf WHERE uf.follower_id = :loggedInUser AND uf.following_id = c.user_id))'; } else { $visibilityClauseC = 'c.visibility = :visible OR (c.visibility = :private AND c.user_id IN (:cachedUserFollows))'; } $filterClauseEntry = ''; $filterClausePost = ''; $filterClauseComments = ''; if (\sizeof($currenFilterLists) > 0) { $listOrsEntry = []; $listOrsPost = []; $listOrsComments = []; $i = 0; foreach ($currenFilterLists as $filterList) { foreach ($filterList->words as $filterListWord) { $word = $filterListWord['word']; $exact = $filterListWord['exactMatch']; if ($exact) { if ($filterList->feeds) { $listOrsEntry[] = "(c.title LIKE :word$i)"; $listOrsEntry[] = "(c.body LIKE :word$i)"; $listOrsPost[] = "(c.body LIKE :word$i)"; } if ($filterList->comments) { $listOrsComments[] = "(c.body LIKE :word$i)"; } } else { if ($filterList->feeds) { $listOrsEntry[] = "(c.title ILIKE :word$i)"; $listOrsEntry[] = "(c.body ILIKE :word$i)"; $listOrsPost[] = "(c.body ILIKE :word$i)"; } if ($filterList->comments) { $listOrsComments[] = "(c.body ILIKE :word$i)"; } } if ($filterList->feeds || ($filterList->comments && ($includeEntryComments || $includePostComments))) { $parameters["word$i"] = '%'.$word.'%'; } ++$i; } } if (\sizeof($listOrsEntry) > 0) { $filterClauseEntry = 'NOT ('.implode(' OR ', $listOrsEntry).') OR c.user_id = :loggedInUser'; } if (\sizeof($listOrsPost) > 0) { $filterClausePost = 'NOT ('.implode(' OR ', $listOrsPost).') OR c.user_id = :loggedInUser'; } if (\sizeof($listOrsComments) > 0) { $filterClauseComments = 'NOT ('.implode(' OR ', $listOrsComments).') OR c.user_id = :loggedInUser'; } } $deletedClause = 'u.is_deleted = false'; $visibilityClauseU = 'u.visibility = :visible'; $entryWhere = SqlHelpers::makeWhereString([ $contentClauseEntry, $timeClause, $magazineClause, $userClause, $hashtagClauseEntry, $federationClause, $domainClauseEntry, $languagesClause, $contentTypeClauseEntry, $subClauseEntry, $modClause, $favClauseEntry, $blockingClauseEntry, $hideAdultClause, $visibilityClauseM, $visibilityClauseC, $allClause, $addCursor ? '%cursor% OR (%cursor2%)' : '', $filterClauseEntry, ]); $postWhere = SqlHelpers::makeWhereString([ $contentClausePost, $timeClause, $magazineClause, $userClause, $hashtagClausePost, $federationClause, $domainClausePost, $languagesClause, $contentTypeClausePost, $subClausePost, $modClause, $favClausePost, $blockingClausePost, $hideAdultClause, $visibilityClauseM, $visibilityClauseC, $allClause, $addCursor ? '%cursor% OR (%cursor2%)' : '', $filterClausePost, ]); $entryCommentWhere = SqlHelpers::makeWhereString([ $contentClauseEntry, $timeClause, $magazineClause, $userClause, $hashtagClauseEntryComment, $federationClause, $domainClausePost, $languagesClause, $subClauseEntryComment, $modClause, $favClauseEntryComment, $blockingClausePost, $hideAdultClause, $visibilityClauseM, $visibilityClauseC, $allClause, $filterClauseComments, ]); $postCommentWhere = SqlHelpers::makeWhereString([ $contentClausePost, $timeClause, $magazineClause, $userClause, $hashtagClausePostComment, $federationClause, $domainClausePost, $languagesClause, $contentTypeClausePost, $subClausePostComment, $modClause, $favClausePostComment, $blockingClausePost, $hideAdultClause, $visibilityClauseM, $visibilityClauseC, $allClause, $filterClauseComments, ]); $outerWhere = SqlHelpers::makeWhereString([ $visibilityClauseU, $deletedClause, $allClauseU, $addCursor ? '%cursor% OR (%cursor2%)' : '', ]); $orderings = $addCursor ? ['%cursorSort%', '%cursorSort2%'] : $this->getOrderings($criteria); $orderBy = 'ORDER BY '.join(', ', $orderings); // only join domain if we are explicitly looking at one $domainJoin = $criteria->domain ? 'LEFT JOIN domain d ON d.id = c.domain_id' : ''; $entrySql = "SELECT c.id, 'entry' as type, c.type as content_type, c.created_at, c.ranking, c.score, c.comment_count, c.sticky, c.last_active, c.user_id FROM entry c LEFT JOIN magazine m ON c.magazine_id = m.id $domainJoin $entryWhere"; $postSql = "SELECT c.id, 'post' as type, 'microblog' as content_type, c.created_at, c.ranking, c.score, c.comment_count, c.sticky, c.last_active, c.user_id FROM post c LEFT JOIN magazine m ON c.magazine_id = m.id $postWhere"; $entryCommentSql = "SELECT c.id, 'entry_comment' as type, 'microblog' as content_type, c.created_at, 0 as ranking, 0 as score, 0 as comment_count, false as sticky, c.last_active, c.user_id FROM entry_comment c LEFT JOIN magazine m ON c.magazine_id = m.id $entryCommentWhere"; $postCommentSql = "SELECT c.id, 'post_comment' as type, 'microblog' as content_type, c.created_at, 0 as ranking, 0 as score, 0 as comment_count, false as sticky, c.last_active, c.user_id FROM post_comment c LEFT JOIN magazine m ON c.magazine_id = m.id $postCommentWhere"; $innerLimit = $addCursor ? 'LIMIT :limit' : ''; $innerSql = ''; if (Criteria::CONTENT_THREADS === $criteria->content) { if ($includeEntryComments) { $innerSql = "($entrySql $orderBy $innerLimit) UNION ALL ($entryCommentSql $orderBy $innerLimit)"; } else { $innerSql = "$entrySql $orderBy $innerLimit"; } } elseif (Criteria::CONTENT_MICROBLOG === $criteria->content) { if ($includePostComments) { $innerSql = "($postSql $orderBy $innerLimit) UNION ALL ($postCommentSql $orderBy $innerLimit)"; } else { $innerSql = "$postSql $orderBy $innerLimit"; } } else { $innerSql = "($entrySql $orderBy $innerLimit) UNION ALL ($postSql $orderBy $innerLimit)"; if ($includeEntryComments) { $innerSql .= " UNION ALL ($entryCommentSql $orderBy $innerLimit)"; } if ($includePostComments) { $innerSql .= " UNION ALL ($postCommentSql $orderBy $innerLimit)"; } } $sql = "SELECT c.* FROM ($innerSql) c INNER JOIN \"user\" u ON c.user_id = u.id $outerWhere $orderBy"; if (!str_contains($sql, ':loggedInUser')) { $parameters = array_filter($parameters, fn ($key) => 'loggedInUser' !== $key, mode: ARRAY_FILTER_USE_KEY); } $rewritten = SqlHelpers::rewriteArrayParameters($parameters, $sql); $this->logger->debug('{s} | {p}', ['s' => $sql, 'p' => $parameters]); $this->logger->debug('Rewritten to: {s} | {p}', ['p' => $rewritten['parameters'], 's' => $rewritten['sql']]); return $rewritten; } private function getCursorFieldFromCriteria(Criteria $criteria): string { return match ($criteria->sortOption) { Criteria::SORT_TOP => 'score', Criteria::SORT_HOT => 'ranking', Criteria::SORT_COMMENTED => 'commentCount', Criteria::SORT_ACTIVE => 'lastActive', default => 'createdAt', }; } private function getCursorWhereFromCriteria(Criteria $criteria): string { return match ($criteria->sortOption) { Criteria::SORT_TOP => 'c.score < :cursor', Criteria::SORT_HOT => 'c.ranking < :cursor', Criteria::SORT_COMMENTED => 'c.comment_count < :cursor', Criteria::SORT_ACTIVE => 'c.last_active < :cursor', Criteria::SORT_OLD => 'c.created_at > :cursor', default => 'c.created_at < :cursor', }; } private function getCursorWhereInvertedFromCriteria(Criteria $criteria): string { return match ($criteria->sortOption) { Criteria::SORT_TOP => 'c.score > :cursor', Criteria::SORT_HOT => 'c.ranking > :cursor', Criteria::SORT_COMMENTED => 'c.comment_count > :cursor', Criteria::SORT_ACTIVE => 'c.last_active > :cursor', Criteria::SORT_OLD => 'c.created_at < :cursor', default => 'c.created_at >= :cursor', }; } private function getSecondaryCursorWhereFromCriteria(Criteria $criteria): string { return match ($criteria->sortOption) { Criteria::SORT_TOP => 'c.score = :cursor AND c.created_at < :cursor2', Criteria::SORT_HOT => 'c.ranking = :cursor AND c.created_at < :cursor2', Criteria::SORT_COMMENTED => 'c.comment_count = :cursor AND c.created_at < :cursor2', Criteria::SORT_ACTIVE => 'c.last_active = :cursor AND c.created_at < :cursor2', default => 'FALSE', }; } private function getSecondaryCursorWhereFromCriteriaInverted(Criteria $criteria): string { return match ($criteria->sortOption) { Criteria::SORT_TOP => 'c.score = :cursor AND c.created_at >= :cursor2', Criteria::SORT_HOT => 'c.ranking = :cursor AND c.created_at >= :cursor2', Criteria::SORT_COMMENTED => 'c.comment_count = :cursor AND c.created_at >= :cursor2', Criteria::SORT_ACTIVE => 'c.last_active = :cursor AND c.created_at >= :cursor2', default => 'FALSE', }; } public function guessInitialCursor(string $sortOption): mixed { return match ($sortOption) { Criteria::SORT_TOP, Criteria::SORT_HOT, Criteria::SORT_COMMENTED => 2147483647, // postgresql max int Criteria::SORT_OLD => (new \DateTimeImmutable())->setTimestamp(0), default => new \DateTimeImmutable('now + 1 minute'), }; } private function getOrderings(Criteria $criteria): array { $orderings = []; if ($criteria->stickiesFirst) { $orderings[] = 'sticky DESC'; } switch ($criteria->sortOption) { case Criteria::SORT_TOP: $orderings[] = 'score DESC'; break; case Criteria::SORT_HOT: $orderings[] = 'ranking DESC'; break; case Criteria::SORT_COMMENTED: $orderings[] = 'comment_count DESC'; break; case Criteria::SORT_ACTIVE: $orderings[] = 'last_active DESC'; break; default: } switch ($criteria->sortOption) { case Criteria::SORT_OLD: $orderings[] = 'created_at ASC'; break; case Criteria::SORT_NEW: default: $orderings[] = 'created_at DESC'; } return $orderings; } } ================================================ FILE: src/Repository/Criteria.php ================================================ SELF::THEME_MBIN, // TODO uncomment when theme is ready '/kbin' => self::THEME_KBIN, 'default_theme_auto' => self::THEME_AUTO, 'light' => self::THEME_LIGHT, 'dark' => self::THEME_DARK, 'solarized_auto' => self::THEME_SOLARIZED_AUTO, 'solarized_light' => self::THEME_SOLARIZED_LIGHT, 'solarized_dark' => self::THEME_SOLARIZED_DARK, 'tokyo_night' => self::THEME_TOKYO_NIGHT, ]; public function __construct(int $page) { $this->page = $page; } public function setFederation($feed): self { $this->federation = $feed; return $this; } public function setType(?string $type): self { if ($type) { $this->type = $type; } return $this; } public function setContent(string $content): self { $this->content = $content; return $this; } public function setTag(string $name): self { $this->tag = $name; return $this; } public function setDomain(string $name): self { $this->domain = $name; return $this; } public function addLanguage(string $lang): self { if (null === $this->languages) { $this->languages = []; } array_push($this->languages, $lang); return $this; } public function showSortOption(?string $sortOption): self { if ($sortOption) { $this->sortOption = $sortOption; } return $this; } protected function routes(): array { // @todo getRoute EntryManager return [ 'top' => Criteria::SORT_TOP, 'hot' => Criteria::SORT_HOT, 'active' => Criteria::SORT_ACTIVE, 'newest' => Criteria::SORT_NEW, 'oldest' => Criteria::SORT_OLD, 'commented' => Criteria::SORT_COMMENTED, ]; } public function resolveSort(?string $value): string { $routes = $this->routes(); return $routes[$value] ?? $routes['hot']; } // resolveTime() converts our internal values into ones for human presenation // $reverse = true indicates converting back, from human values to internal ones // This whole approach is a mess; this translation layer is temporary until // we have time to take a pass through the whole codebase and convert so there's // no such thing as multiple alternate value strings and translation layers // between them. This is just a temporary measure to produce desired output // until the whole layer goes away. public function resolveTime(?string $value, bool $reverse = false): ?string { // @todo $routes = [ '3h' => Criteria::TIME_3_HOURS, '6h' => Criteria::TIME_6_HOURS, '12h' => Criteria::TIME_12_HOURS, '1d' => Criteria::TIME_DAY, '1w' => Criteria::TIME_WEEK, '1m' => Criteria::TIME_MONTH, '1y' => Criteria::TIME_YEAR, '∞' => Criteria::TIME_ALL, 'all' => Criteria::TIME_ALL, ]; if ($reverse) { if ('all' === $value || '∞' === $value || null === $value) { return '∞'; } $reversedRoutes = array_flip($routes); return $reversedRoutes[$value] ?? '∞'; } else { return $routes[$value] ?? null; } } public function resolveType(?string $value): ?string { return match ($value) { 'article', 'articles' => Entry::ENTRY_TYPE_ARTICLE, 'link', 'links' => Entry::ENTRY_TYPE_LINK, 'video', 'videos' => Entry::ENTRY_TYPE_VIDEO, 'photo', 'photos', 'image', 'images' => Entry::ENTRY_TYPE_IMAGE, default => 'all', }; } public function translateType(): string { return match ($this->resolveType($this->type)) { Entry::ENTRY_TYPE_ARTICLE => 'threads', Entry::ENTRY_TYPE_LINK => 'links', Entry::ENTRY_TYPE_VIDEO => 'videos', Entry::ENTRY_TYPE_IMAGE => 'photos', default => 'all', }; } public function resolveSubscriptionFilter(): ?string { if ($this->subscribed) { return 'subscribed'; } elseif ($this->moderated) { return 'moderated'; } elseif ($this->favourite) { return 'favourites'; } else { return 'all'; } } public function setVisibility(string $visibility): self { $this->visibility = $visibility; return $this; } public function setTime(?string $time): self { if ($time) { $this->time = $time; } else { $this->time = EntryRepository::TIME_DEFAULT; } return $this; } public function getSince(): \DateTimeImmutable { $since = new \DateTimeImmutable('@'.time()); return match ($this->time) { Criteria::TIME_YEAR => $since->modify('-1 year'), Criteria::TIME_MONTH => $since->modify('-1 month'), Criteria::TIME_WEEK => $since->modify('-1 week'), Criteria::TIME_DAY => $since->modify('-1 day'), Criteria::TIME_12_HOURS => $since->modify('-12 hours'), Criteria::TIME_6_HOURS => $since->modify('-6 hours'), Criteria::TIME_3_HOURS => $since->modify('-3 hours'), default => throw new \LogicException(), }; } public function getOption(string $key): string { return match ($key) { 'sort' => $this->resolveSort($this->sortOption), 'time' => '∞' === $this->resolveTime($this->time, true) ? 'all' : $this->resolveTime($this->time, true), 'type' => $this->translateType(), 'visibility' => $this->visibility, 'federation' => $this->federation, 'content' => $this->content, 'tag' => $this->tag, 'domain' => $this->domain, 'subscription' => $this->resolveSubscriptionFilter(), default => throw new \LogicException('Unknown option: '.$key), }; } public function fetchCachedItems(SqlHelpers $sqlHelpers, User $loggedInUser): void { $this->cachedUserFollows = $sqlHelpers->getCachedUserFollows($loggedInUser); if ($this->subscribed) { $this->cachedUserSubscribedDomains = $sqlHelpers->getCachedUserSubscribedDomains($loggedInUser); $this->cachedUserSubscribedMagazines = $sqlHelpers->getCachedUserSubscribedMagazines($loggedInUser); } if ($this->moderated) { $this->cachedUserModeratedMagazines = $sqlHelpers->getCachedUserModeratedMagazines($loggedInUser); } $this->cachedUserBlocks = $sqlHelpers->getCachedUserBlocks($loggedInUser); $this->cachedUserBlockedDomains = $sqlHelpers->getCachedUserDomainBlocks($loggedInUser); $this->cachedUserBlockedMagazines = $sqlHelpers->getCachedUserMagazineBlocks($loggedInUser); } } ================================================ FILE: src/Repository/DomainRepository.php ================================================ createQueryBuilder('d'); $pagerfanta = new Pagerfanta( new QueryAdapter( $qb ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findSubscribedDomains(int $page, User $user, int $perPage = self::PER_PAGE): Pagerfanta { $pagerfanta = new Pagerfanta( new CollectionAdapter( $user->subscribedDomains ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findBlockedDomains(int $page, User $user, int $perPage = self::PER_PAGE): Pagerfanta { $pagerfanta = new Pagerfanta( new CollectionAdapter( $user->blockedDomains ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function search(string $domain, int $page, int $perPage = self::PER_PAGE): Pagerfanta { $qb = $this->createQueryBuilder('d') ->where( 'LOWER(d.name) LIKE LOWER(:q)' ) ->orderBy('d.entryCount', 'DESC') ->setParameter('q', '%'.$domain.'%'); $pagerfanta = new Pagerfanta( new QueryAdapter( $qb ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } } ================================================ FILE: src/Repository/DomainSubscriptionRepository.php ================================================ findOneByUrl($entity->url)) { // Do not exceed URL length limit defined by db schema try { $this->getEntityManager()->persist($entity); if ($flush) { $this->getEntityManager()->flush(); } } catch (\Exception $e) { $this->logger->warning('Embed URL exceeds allowed length: {url, length}', ['url' => $entity->url, \strlen($entity->url)]); } } } public function remove(Embed $entity, bool $flush = true): void { $this->getEntityManager()->remove($entity); if ($flush) { $this->getEntityManager()->flush(); } } } ================================================ FILE: src/Repository/EntryCommentRepository.php ================================================ // // SPDX-License-Identifier: Zlib declare(strict_types=1); namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; use App\Entity\DomainBlock; use App\Entity\DomainSubscription; use App\Entity\Entry; use App\Entity\EntryComment; use App\Entity\EntryCommentFavourite; use App\Entity\HashtagLink; use App\Entity\Image; use App\Entity\MagazineBlock; use App\Entity\MagazineSubscription; use App\Entity\Moderator; use App\Entity\User; use App\Entity\UserBlock; use App\Entity\UserFollow; use App\Service\SettingsManager; use App\Utils\DownvotesMode; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Pagerfanta\Doctrine\ORM\QueryAdapter; use Pagerfanta\Exception\NotValidCurrentPageException; use Pagerfanta\Pagerfanta; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * @method EntryComment|null find($id, $lockMode = null, $lockVersion = null) * @method EntryComment|null findOneBy(array $criteria, array $orderBy = null) * @method EntryComment[] findAll() * @method EntryComment[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class EntryCommentRepository extends ServiceEntityRepository { public const SORT_DEFAULT = 'active'; public const PER_PAGE = 15; public function __construct( ManagerRegistry $registry, private readonly Security $security, private readonly SettingsManager $settingsManager, ) { parent::__construct($registry, EntryComment::class); } public function findByCriteria(Criteria $criteria): Pagerfanta { $pagerfanta = new Pagerfanta( new QueryAdapter( $this->getEntryQueryBuilder($criteria), false ) ); try { $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE); $pagerfanta->setCurrentPage($criteria->page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } private function getEntryQueryBuilder(Criteria $criteria): QueryBuilder { $user = $this->security->getUser(); $qb = $this->createQueryBuilder('c') ->select('c', 'u') ->join('c.user', 'u') ->andWhere('c.visibility IN (:visibility)') ->andWhere('u.visibility IN (:visible)'); if ($user && VisibilityInterface::VISIBILITY_VISIBLE === $criteria->visibility) { $qb->orWhere( 'c.user IN (SELECT IDENTITY(cuf.following) FROM '.UserFollow::class.' cuf WHERE cuf.follower = :cUser AND c.visibility = :cVisibility)' ) ->setParameter('cUser', $user) ->setParameter('cVisibility', VisibilityInterface::VISIBILITY_PRIVATE); } $qb->setParameter( 'visibility', [ VisibilityInterface::VISIBILITY_SOFT_DELETED, VisibilityInterface::VISIBILITY_VISIBLE, VisibilityInterface::VISIBILITY_TRASHED, ] ) ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE); $this->addTimeClause($qb, $criteria); $this->filter($qb, $criteria); $this->addBannedHashtagClause($qb); if ($user instanceof User) { $this->filterWords($qb, $user); } return $qb; } private function addTimeClause(QueryBuilder $qb, Criteria $criteria): void { if (Criteria::TIME_ALL !== $criteria->time) { $since = $criteria->getSince(); $qb->andWhere('c.createdAt > :time') ->setParameter('time', $since, Types::DATETIMETZ_IMMUTABLE); } } private function addBannedHashtagClause(QueryBuilder $qb): void { $dql = $this->getEntityManager()->createQueryBuilder() ->select('hl2') ->from(HashtagLink::class, 'hl2') ->join('hl2.hashtag', 'h2') ->where('h2.banned = true') ->andWhere('hl2.entryComment = c') ->getDQL(); $qb->andWhere($qb->expr()->not($qb->expr()->exists($dql))); } private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder { $user = $this->security->getUser(); if (Criteria::AP_LOCAL === $criteria->federation) { $qb->andWhere('c.apId IS NULL'); } if ($criteria->entry) { $qb->andWhere('c.entry = :entry') ->setParameter('entry', $criteria->entry); } if ($criteria->magazine) { $qb->join('c.entry', 'e', Join::WITH, 'e.magazine = :magazine') ->setParameter('magazine', $criteria->magazine) ->andWhere('e.visibility = :visible') ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE); } else { $qb->join('c.entry', 'e') ->andWhere('e.visibility = :visible') ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE); } if ($criteria->user) { $qb->andWhere('c.user = :user') ->setParameter('user', $criteria->user); } $qb->join('c.entry', 'ce'); if ($criteria->domain) { $qb->andWhere('ced.name = :domain') ->join('ce.domain', 'ced') ->setParameter('domain', $criteria->domain); } if ($criteria->languages) { $qb->andWhere('c.lang IN (:languages)') ->setParameter('languages', $criteria->languages, ArrayParameterType::STRING); } if ($criteria->tag) { $qb->andWhere('t.tag = :tag') ->join('c.hashtags', 'h') ->join('h.hashtag', 't') ->setParameter('tag', $criteria->tag); } if ($criteria->subscribed) { $qb->andWhere( 'c.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :follower) OR c.user IN (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :follower) OR c.user = :follower OR ce.domain IN (SELECT IDENTITY(ds.domain) FROM '.DomainSubscription::class.' ds WHERE ds.user = :follower)' ); $qb->setParameter('follower', $user); } if ($criteria->moderated) { $qb->andWhere( 'c.magazine IN (SELECT IDENTITY(cm.magazine) FROM '.Moderator::class.' cm WHERE cm.user = :user)' ); $qb->setParameter('user', $this->security->getUser()); } if ($criteria->favourite) { $qb->andWhere( 'c.id IN (SELECT IDENTITY(cf.entryComment) FROM '.EntryCommentFavourite::class.' cf WHERE cf.user = :user)' ); $qb->setParameter('user', $this->security->getUser()); } if ($user && (!$criteria->magazine || !$criteria->magazine->userIsModerator($user)) && !$criteria->moderated) { $qb->andWhere( 'c.user NOT IN (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker)' ); $qb->andWhere( 'ce.user NOT IN (SELECT IDENTITY(ubc.blocked) FROM '.UserBlock::class.' ubc WHERE ubc.blocker = :blocker)' ); $qb->andWhere( 'c.magazine NOT IN (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :blocker)' ); if (!$criteria->domain) { $qb->andWhere( 'ce.domain IS null OR ce.domain NOT IN (SELECT IDENTITY(db.domain) FROM '.DomainBlock::class.' db WHERE db.user = :blocker)' ); } $qb->setParameter('blocker', $user); } if ($criteria->onlyParents) { $qb->andWhere('c.parent IS NULL'); } if (!$user || $user->hideAdult) { $qb->join('e.magazine', 'm') ->andWhere('m.isAdult = :isAdult') ->andWhere('e.isAdult = :isAdult') ->setParameter('isAdult', false); } switch ($criteria->sortOption) { case Criteria::SORT_HOT: $qb->orderBy('c.upVotes', 'DESC'); break; case Criteria::SORT_TOP: if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) { $qb->orderBy('c.upVotes + c.favouriteCount', 'DESC'); } else { $qb->orderBy('c.upVotes + c.favouriteCount - c.downVotes', 'DESC'); } break; case Criteria::SORT_ACTIVE: $qb->orderBy('c.lastActive', 'DESC'); break; case Criteria::SORT_NEW: $qb->orderBy('c.createdAt', 'DESC'); break; case Criteria::SORT_OLD: $qb->orderBy('c.createdAt', 'ASC'); break; default: $qb->addOrderBy('c.lastActive', 'DESC'); } $qb->addOrderBy('c.createdAt', 'DESC'); $qb->addOrderBy('c.id', 'DESC'); return $qb; } private function filterWords(QueryBuilder $qb, User $user): QueryBuilder { $i = 0; foreach ($user->getCurrentFilterLists() as $list) { if (!$list->comments) { continue; } foreach ($list->words as $word) { if ($word['exactMatch']) { $qb->andWhere("NOT (c.body LIKE :word$i) OR c.user = :filterUser") ->setParameter("word$i", '%'.$word['word'].'%'); } else { $qb->andWhere("NOT (lower(c.body) LIKE lower(:word$i)) OR c.user = :filterUser") ->setParameter("word$i", '%'.$word['word'].'%'); } ++$i; } } if ($i > 0) { $qb->setParameter('filterUser', $user); } return $qb; } /** * @return Image[] */ public function findImagesByEntry(Entry $entry): array { $results = $this->createQueryBuilder('c') ->addSelect('i') ->innerJoin('c.image', 'i') ->andWhere('c.entry = :entry') ->setParameter('entry', $entry) ->getQuery() ->getResult(); return array_map(fn (EntryComment $comment) => $comment->image, $results); } public function hydrateChildren(EntryComment ...$comments): void { $children = $this->createQueryBuilder('c') ->andWhere('c.root IN (:ids)') ->setParameter('ids', $comments) ->getQuery()->getResult(); $this->hydrate(...$children); } public function hydrate(EntryComment ...$comments): void { $this->createQueryBuilder('c') ->select('PARTIAL c.{id}') ->addSelect('u') ->addSelect('e') ->addSelect('em') ->join('c.user', 'u') ->join('c.entry', 'e') ->join('e.magazine', 'em') ->where('c IN (?1)') ->setParameter(1, $comments) ->getQuery() ->execute(); $this->createQueryBuilder('c') ->select('PARTIAL c.{id}') ->addSelect('cc') ->addSelect('ccu') ->addSelect('ccua') ->leftJoin('c.children', 'cc') ->join('cc.user', 'ccu') ->leftJoin('ccu.avatar', 'ccua') ->where('c IN (?1)') ->setParameter(1, $comments) ->getQuery() ->execute(); } } ================================================ FILE: src/Repository/EntryRepository.php ================================================ // // SPDX-License-Identifier: Zlib declare(strict_types=1); namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; use App\Entity\DomainBlock; use App\Entity\DomainSubscription; use App\Entity\Entry; use App\Entity\EntryFavourite; use App\Entity\HashtagLink; use App\Entity\Magazine; use App\Entity\MagazineBlock; use App\Entity\MagazineSubscription; use App\Entity\Moderator; use App\Entity\User; use App\Entity\UserBlock; use App\Entity\UserFollow; use App\PageView\EntryPageView; use App\Pagination\AdapterFactory; use App\Service\SettingsManager; use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\NoResultException; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Pagerfanta\Exception\NotValidCurrentPageException; use Pagerfanta\Pagerfanta; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; /** * @extends ServiceEntityRepository * * @method Entry|null find($id, $lockMode = null, $lockVersion = null) * @method Entry|null findOneBy(array $criteria, array $orderBy = null) * @method Entry|null findOneByUrl(string $url) * @method Entry[] findAll() * @method Entry[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class EntryRepository extends ServiceEntityRepository { public const SORT_DEFAULT = 'hot'; public const TIME_DEFAULT = Criteria::TIME_ALL; public const PER_PAGE = 25; public function __construct( ManagerRegistry $registry, private readonly Security $security, private readonly CacheInterface $cache, private readonly AdapterFactory $adapterFactory, private readonly SettingsManager $settingsManager, private readonly SqlHelpers $sqlHelpers, ) { parent::__construct($registry, Entry::class); } public function findByCriteria(EntryPageView|Criteria $criteria): Pagerfanta { $pagerfanta = new Pagerfanta($this->adapterFactory->create($this->getEntryQueryBuilder($criteria))); try { $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE); $pagerfanta->setCurrentPage($criteria->page); if (!$criteria->magazine) { $pagerfanta->setMaxNbPages(1000); } } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } private function getEntryQueryBuilder(EntryPageView $criteria): QueryBuilder { $user = $this->security->getUser(); $qb = $this->createQueryBuilder('e') ->addSelect('e', 'm', 'u', 'd') ->where('e.visibility = :visibility') ->andWhere('m.visibility = :visible') ->andWhere('u.visibility = :visible') ->andWhere('u.isDeleted = false') ->join('e.magazine', 'm') ->join('e.user', 'u') ->leftJoin('e.domain', 'd'); if ($user && VisibilityInterface::VISIBILITY_VISIBLE === $criteria->visibility) { $qb->orWhere( 'e.user IN (SELECT IDENTITY(euf.following) FROM '.UserFollow::class.' euf WHERE euf.follower = :euf_user AND e.visibility = :euf_visibility)' ) ->setParameter('euf_user', $user) ->setParameter('euf_visibility', VisibilityInterface::VISIBILITY_PRIVATE); } else { $qb->orWhere('e.user IS NULL'); } $qb->setParameter('visibility', $criteria->visibility) ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE); $this->addTimeClause($qb, $criteria); $this->addStickyClause($qb, $criteria); $this->filter($qb, $criteria); $this->addBannedHashtagClause($qb); return $qb; } private function addTimeClause(QueryBuilder $qb, EntryPageView $criteria): void { if (Criteria::TIME_ALL !== $criteria->time) { $since = $criteria->getSince(); $qb->andWhere('e.createdAt > :time') ->setParameter('time', $since, Types::DATETIMETZ_IMMUTABLE); } } private function addStickyClause(QueryBuilder $qb, EntryPageView $criteria): void { if ($criteria->stickiesFirst) { if (1 === $criteria->page) { $qb->addOrderBy('e.sticky', 'DESC'); } else { $qb->andWhere($qb->expr()->eq('e.sticky', 'false')); } } } private function addBannedHashtagClause(QueryBuilder $qb): void { $dql = $this->getEntityManager()->createQueryBuilder() ->select('hl2') ->from(HashtagLink::class, 'hl2') ->join('hl2.hashtag', 'h2') ->where('h2.banned = true') ->andWhere('hl2.entry = e') ->getDQL(); $qb->andWhere( $qb->expr()->not( $qb->expr()->exists($dql) ) ); } private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder { /** @var User $user */ $user = $this->security->getUser(); if (Criteria::AP_LOCAL === $criteria->federation) { $qb->andWhere('e.apId IS NULL'); } elseif (Criteria::AP_FEDERATED === $criteria->federation) { $qb->andWhere('e.apId IS NOT NULL'); } if ($criteria->magazine) { $qb->andWhere('e.magazine = :magazine') ->setParameter('magazine', $criteria->magazine); } if ($criteria->user) { $qb->andWhere('e.user = :user') ->setParameter('user', $criteria->user); } if ($criteria->type and 'all' !== $criteria->type) { $qb->andWhere('e.type = :type') ->setParameter('type', $criteria->type); } if ($criteria->tag) { $qb->andWhere('t.tag = :tag') ->join('e.hashtags', 'h') ->join('h.hashtag', 't') ->setParameter('tag', $criteria->tag); } if ($criteria->domain) { $qb->andWhere('d.name = :domain') ->setParameter('domain', $criteria->domain); } if ($criteria->languages) { $qb->andWhere('e.lang IN (:languages)') ->setParameter('languages', $criteria->languages, ArrayParameterType::STRING); } if ($criteria->subscribed) { $qb->andWhere( 'e.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user) OR e.user IN (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user) OR e.domain IN (SELECT IDENTITY(ds.domain) FROM '.DomainSubscription::class.' ds WHERE ds.user = :user) OR e.user = :user' ) ->setParameter('user', $this->security->getUser()); } if ($criteria->moderated) { $qb->andWhere( 'e.magazine IN (SELECT IDENTITY(mm.magazine) FROM '.Moderator::class.' mm WHERE mm.user = :user)' ); $qb->setParameter('user', $this->security->getUser()); } if ($criteria->favourite) { $qb->andWhere( 'e.id IN (SELECT IDENTITY(mf.entry) FROM '.EntryFavourite::class.' mf WHERE mf.user = :user)' ); $qb->setParameter('user', $this->security->getUser()); } if ($user && (!$criteria->magazine || !$criteria->magazine->userIsModerator($user)) && !$criteria->moderated) { $qb->andWhere( 'e.user NOT IN (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker)' ); $qb->andWhere( 'e.magazine NOT IN (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :blocker)' ); if (!$criteria->domain) { $qb->andWhere( 'e.domain IS null OR e.domain NOT IN (SELECT IDENTITY(db.domain) FROM '.DomainBlock::class.' db WHERE db.user = :blocker)' ); } $qb->setParameter('blocker', $user); } if (!$user || $user->hideAdult) { $qb->andWhere('m.isAdult = :isAdult') ->andWhere('e.isAdult = :isAdult') ->setParameter('isAdult', false); } switch ($criteria->sortOption) { case Criteria::SORT_TOP: $qb->addOrderBy('e.score', 'DESC'); break; case Criteria::SORT_HOT: $qb->addOrderBy('e.ranking', 'DESC'); break; case Criteria::SORT_COMMENTED: $qb->addOrderBy('e.commentCount', 'DESC'); break; case Criteria::SORT_ACTIVE: $qb->addOrderBy('e.lastActive', 'DESC'); break; default: } $qb->addOrderBy('e.createdAt', Criteria::SORT_OLD === $criteria->sortOption ? 'ASC' : 'DESC'); $qb->addOrderBy('e.id', 'DESC'); return $qb; } public function hydrate(Entry ...$entries): void { $this->getEntityManager()->createQueryBuilder() ->select('PARTIAL e.{id}') ->addSelect('u') ->addSelect('ua') ->addSelect('m') ->addSelect('mi') ->addSelect('d') ->addSelect('i') ->addSelect('b') ->from(Entry::class, 'e') ->join('e.user', 'u') ->join('e.magazine', 'm') ->join('e.domain', 'd') ->leftJoin('u.avatar', 'ua') ->leftJoin('m.icon', 'mi') ->leftJoin('e.image', 'i') ->leftJoin('e.badges', 'b') ->where('e IN (?1)') ->setParameter(1, $entries) ->getQuery() ->getResult(); /* we don't need to hydrate all the votes and favourites. We only use the count saved in the entry entity if ($this->security->getUser()) { $this->_em->createQueryBuilder() ->select('PARTIAL e.{id}') ->addSelect('ev') ->addSelect('ef') ->from(Entry::class, 'e') ->leftJoin('e.favourites', 'ef') ->leftJoin('e.votes', 'ev') ->where('e IN (?1)') ->setParameter(1, $entries) ->getQuery() ->getResult(); } */ } public function countEntriesByMagazine(Magazine $magazine): int { return \intval( $this->createQueryBuilder('e') ->select('count(e.id)') ->where('e.magazine = :magazine') ->andWhere('e.visibility = :visibility') ->setParameter('magazine', $magazine) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->getQuery() ->getSingleScalarResult() ); } public function countEntryCommentsByMagazine(Magazine $magazine): int { return \intval( $this->createQueryBuilder('e') ->select('sum(e.commentCount)') ->where('e.magazine = :magazine') ->setParameter('magazine', $magazine) ->getQuery() ->getSingleScalarResult() ); } public function findToDelete(User $user, int $limit): array { return $this->createQueryBuilder('e') ->where('e.visibility != :visibility') ->andWhere('e.user = :user') ->setParameter('visibility', VisibilityInterface::VISIBILITY_SOFT_DELETED) ->setParameter('user', $user) ->orderBy('e.id', 'DESC') ->setMaxResults($limit) ->getQuery() ->getResult(); } public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); $qb->andWhere('e.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') ->andWhere('u.isDeleted = false') ->andWhere('u.apDiscoverable = true') ->andWhere('m.isAdult = false') ->andWhere('e.isAdult = false') ->andWhere('h.tag = :tag') ->join('e.magazine', 'm') ->join('e.user', 'u') ->join('e.hashtags', 'hl') ->join('hl.hashtag', 'h') ->orderBy('e.createdAt', 'DESC') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setParameter('tag', $tag) ->setMaxResults($limit); if (null !== $user) { $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); $qb->setParameter('user', $user); } return $qb->getQuery() ->getResult(); } public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); $qb->where('m.name LIKE :name OR m.title LIKE :title') ->andWhere('e.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') ->andWhere('u.isDeleted = false') ->andWhere('u.apDiscoverable = true') ->andWhere('m.isAdult = false') ->andWhere('e.isAdult = false') ->join('e.magazine', 'm') ->join('e.user', 'u') ->orderBy('e.createdAt', 'DESC') ->setParameter('name', "%{$name}%") ->setParameter('title', "%{$name}%") ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setMaxResults($limit); if (null !== $user) { $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); $qb->setParameter('user', $user); } return $qb->getQuery() ->getResult(); } public function findLast(int $limit, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); $qb = $qb->where('e.isAdult = false') ->andWhere('e.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('m.apDiscoverable = true') ->andWhere('u.visibility = :visibility') ->andWhere('u.apDiscoverable = true') ->andWhere('u.isDeleted = false') ->andWhere('m.isAdult = false'); if ($this->settingsManager->get('MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY')) { $qb = $qb->andWhere('m.apId IS NULL'); } if (null !== $user) { $magazineBlocks = $this->sqlHelpers->getCachedUserMagazineBlocks($user); if (\sizeof($magazineBlocks) > 0) { $qb->andWhere($qb->expr()->not($qb->expr()->in('m.id', $magazineBlocks))); } $userBlocks = $this->sqlHelpers->getCachedUserBlocks($user); if (\sizeof($userBlocks) > 0) { $qb->andWhere($qb->expr()->not($qb->expr()->in('u.id', $userBlocks))); } } return $qb->join('e.magazine', 'm') ->join('e.user', 'u') ->orderBy('e.createdAt', 'DESC') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setMaxResults($limit) ->getQuery() ->getResult(); } /** * @return Entry[] */ public function findPinned(Magazine $magazine): array { return $this->createQueryBuilder('e') ->where('e.magazine = :m') ->andWhere('e.sticky = true') ->andWhere('e.visibility = :visibility') ->setParameter('m', $magazine) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->getQuery() ->getResult() ; } private function countAll(EntryPageView|Criteria $criteria): int { return $this->cache->get( 'entries_count_'.$criteria->magazine?->name, function (ItemInterface $item) use ($criteria): int { $item->expiresAfter(60); if (!$criteria->magazine) { $query = $this->getEntityManager()->createQuery( 'SELECT COUNT(p.id) FROM App\Entity\Entry p WHERE p.visibility = :visibility' ) ->setParameter('visibility', 'visible'); } else { $query = $this->getEntityManager()->createQuery( 'SELECT COUNT(p.id) FROM App\Entity\Entry p WHERE p.visibility = :visibility AND p.magazine = :magazine' ) ->setParameter('visibility', 'visible') ->setParameter('magazine', $criteria->magazine); } try { return $query->getSingleScalarResult(); } catch (NoResultException $e) { return 0; } } ); } public function findCross(Entry $entry): array { if (\strlen($entry->title) <= 10 && !$entry->url) { return []; } $qb = $this->createQueryBuilder('e'); if ($entry->url) { $qb->where('e.url = :url') ->setParameter('url', $entry->url); } else { $qb->where('e.title = :title') ->setParameter('title', $entry->title); } if ($entry->image) { $qb->leftJoin('e.image', 'i') ->andWhere('i = :img') ->setParameter('img', $entry->image); } $qb->andWhere('e.id != :id') ->andWhere('m.visibility = :visibility') ->andWhere('e.visibility = :visibility') ->andWhere('u.isDeleted = false') ->innerJoin('e.user', 'u') ->innerJoin('e.magazine', 'm') ->setParameter('id', $entry->getId()) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->orderBy('e.createdAt', 'DESC') ->setMaxResults(5); return $qb->getQuery()->getResult(); } } ================================================ FILE: src/Repository/FavouriteRepository.php ================================================ $this->findByEntry($user, $subject), $subject instanceof EntryComment => $this->findByEntryComment($user, $subject), $subject instanceof Post => $this->findByPost($user, $subject), $subject instanceof PostComment => $this->findByPostComment($user, $subject), default => throw new \LogicException(), }; } private function findByEntry(User $user, Entry $entry): ?EntryFavourite { $dql = 'SELECT f FROM '.EntryFavourite::class.' f WHERE f.entry = :entry AND f.user = :user'; return $this->getEntityManager()->createQuery($dql) ->setParameter('entry', $entry) ->setParameter('user', $user) ->getOneOrNullResult(); } private function findByEntryComment(User $user, EntryComment $comment): ?EntryCommentFavourite { $dql = 'SELECT f FROM '.EntryCommentFavourite::class.' f WHERE f.entryComment = :comment AND f.user = :user'; return $this->getEntityManager()->createQuery($dql) ->setParameter('comment', $comment) ->setParameter('user', $user) ->getOneOrNullResult(); } private function findByPost(User $user, Post $post): ?PostFavourite { $dql = 'SELECT f FROM '.PostFavourite::class.' f WHERE f.post = :post AND f.user = :user'; return $this->getEntityManager()->createQuery($dql) ->setParameter('post', $post) ->setParameter('user', $user) ->getOneOrNullResult(); } private function findByPostComment(User $user, PostComment $comment): ?PostCommentFavourite { $dql = 'SELECT f FROM '.PostCommentFavourite::class.' f WHERE f.postComment = :comment AND f.user = :user'; return $this->getEntityManager()->createQuery($dql) ->setParameter('comment', $comment) ->setParameter('user', $user) ->getOneOrNullResult(); } } ================================================ FILE: src/Repository/ImageRepository.php ================================================ findOrCreateFromSource($upload->getPathname(), ImageOrigin::Uploaded); } /** * Process and store an image from source path. * * @param $source string file path of the image * * @throws \RuntimeException if image type can't be identified * @throws ImageDownloadTooLargeException */ public function findOrCreateFromPath(string $source): ?Image { return $this->findOrCreateFromSource($source, ImageOrigin::External); } /** * Process and store an image from source file given path. * * @param string $source file path of the image * @param ImageOrigin $origin where the image comes from * * @throws ImageDownloadTooLargeException */ private function findOrCreateFromSource(string $source, ImageOrigin $origin): ?Image { [$filePath, $fileName] = $this->imageManager->getFilePathAndName($source); $sha256 = hash_file('sha256', $source, true); if ($image = $this->findOneBySha256($sha256)) { if (file_exists($source)) { unlink($source); } $this->logger->debug('found image by Sha256, imageId: {id}', ['id' => $image->getId()]); return $image; } [$width, $height] = @getimagesize($source); $blurhash = $this->blurhash($source); $image = new Image($fileName, $filePath, $sha256, $width, $height, $blurhash); if (!$image->width || !$image->height) { // why get size again? [$width, $height] = @getimagesize($source); $image->setDimensions($width, $height); } $previousFileSize = filesize($source); $image->originalSize = $previousFileSize; $this->dispatcher->dispatch(new ImagePostProcessEvent($source, $filePath, $origin)); $afterProcessFileSize = filesize($source); if ($afterProcessFileSize < $previousFileSize) { $image->isCompressed = true; } try { $this->imageManager->store($source, $filePath); $image->localSize = $afterProcessFileSize; return $image; } catch (ImageDownloadTooLargeException $e) { if (ImageOrigin::External === $origin) { $this->logger->warning( 'findOrCreateFromSource: failed to store image file, because it is too big. Storing only a reference', ['origin' => $origin, 'type' => \gettype($e)], ); $image->filePath = null; $image->localSize = 0; $image->sourceTooBig = true; return $image; } else { $this->logger->error( 'findOrCreateFromSource: failed to store image file, because it is too big - {msg}', ['origin' => $origin, 'type' => \gettype($e), 'msg' => $e->getMessage()], ); throw $e; } } catch (\Exception $e) { $this->logger->error( 'findOrCreateFromSource: failed to store image file: '.$e->getMessage(), ['origin' => $origin, 'type' => \gettype($e)], ); } finally { if (file_exists($source)) { unlink($source); } } return null; } public function blurhash(string $filePath): ?string { $maxWidth = 20; $componentsX = 4; $componentsY = 3; try { $image = imagecreatefromstring(file_get_contents($filePath)); $width = imagesx($image); $height = imagesy($image); if ($width > $maxWidth) { // resizing image with ratio exceeds max width would yield image with height < 1 and fail $ratio = $width / $height; $image = imagescale($image, $maxWidth, $componentsY * $ratio < $maxWidth ? -1 : $componentsY); if (!$image) { throw new \Exception('Could not scale image'); } $width = imagesx($image); $height = imagesy($image); } $pixels = []; for ($y = 0; $y < $height; ++$y) { $row = []; for ($x = 0; $x < $width; ++$x) { $index = imagecolorat($image, $x, $y); $colors = imagecolorsforindex($image, $index); $row[] = [$colors['red'], $colors['green'], $colors['blue']]; } $pixels[] = $row; } return Blurhash::encode($pixels, $componentsX, $componentsY); } catch (\Exception $e) { $this->logger->info('Failed to calculate blurhash: '.$e->getMessage()); return null; } } /** * @param int $limit use a high limit, as this query takes a few seconds and the limit does not affect that, so we are using as high a number as we can -> we're limited by memory * * @return Pagerfanta * * @throws Exception */ public function findOldRemoteMediaPaginated(int $olderThanDays, int $limit = 10000): Pagerfanta { // this complicated looking query makes sure to not include avatars, covers, icons or banners $sql = 'SELECT id, MAX(last_active) as last_active, MAX(downloaded_at) as downloaded_at, \'image\' as type FROM ( SELECT i.id, i.downloaded_at, e.last_active FROM image i INNER JOIN entry e ON i.id = e.image_id LEFT JOIN "user" u ON i.id = u.avatar_id LEFT JOIN "user" u2 ON i.id = u2.cover_id LEFT JOIN magazine m ON i.id = m.icon_id LEFT JOIN magazine m2 ON i.id = m2.banner_id WHERE u IS NULL AND u2 IS NULL AND m IS NULL AND m2 IS NULL AND i.file_path IS NOT NULL AND i.source_url IS NOT NULL UNION ALL SELECT i.id, i.downloaded_at, ec.last_active FROM image i INNER JOIN entry_comment ec ON i.id = ec.image_id LEFT JOIN "user" u ON i.id = u.avatar_id LEFT JOIN "user" u2 ON i.id = u2.cover_id LEFT JOIN magazine m ON i.id = m.icon_id LEFT JOIN magazine m2 ON i.id = m2.banner_id WHERE u IS NULL AND u2 IS NULL AND m IS NULL AND m2 IS NULL AND i.file_path IS NOT NULL AND i.source_url IS NOT NULL UNION ALL SELECT i.id, i.downloaded_at, p.last_active FROM image i INNER JOIN post p ON i.id = p.image_id LEFT JOIN "user" u ON i.id = u.avatar_id LEFT JOIN "user" u2 ON i.id = u2.cover_id LEFT JOIN magazine m ON i.id = m.icon_id LEFT JOIN magazine m2 ON i.id = m2.banner_id WHERE u IS NULL AND u2 IS NULL AND m IS NULL AND m2 IS NULL AND i.file_path IS NOT NULL AND i.source_url IS NOT NULL UNION ALL SELECT i.id, i.downloaded_at, pc.last_active FROM image i INNER JOIN post_comment pc ON i.id = pc.image_id LEFT JOIN "user" u ON i.id = u.avatar_id LEFT JOIN "user" u2 ON i.id = u2.cover_id LEFT JOIN magazine m ON i.id = m.icon_id LEFT JOIN magazine m2 ON i.id = m2.banner_id WHERE u IS NULL AND u2 IS NULL AND m IS NULL AND m2 IS NULL AND i.file_path IS NOT NULL AND i.source_url IS NOT NULL ) images WHERE last_active < :date AND (downloaded_at < :date OR downloaded_at IS NULL) GROUP BY id'; $adapter = new NativeQueryAdapter($this->getEntityManager()->getConnection(), $sql, ['date' => new \DateTimeImmutable("now - $olderThanDays days")], transformer: $this->contentPopulationTransformer); $fanta = new Pagerfanta($adapter); $fanta->setCurrentPage(1); $fanta->setMaxPerPage($limit); return $fanta; } /** * @return Pagerfanta */ public function findSavedImagesPaginated(int $pageSize): Pagerfanta { $query = $this->createQueryBuilder('i') ->andWhere('i.filePath IS NOT NULL') ->orderBy('i.filePath'); $adapter = new QueryAdapter($query); $fanta = new Pagerfanta($adapter); $fanta->setMaxPerPage($pageSize); $fanta->setCurrentPage(1); return $fanta; } public function redownloadImage(Image $image): void { if ($image->filePath || !$image->sourceUrl || $image->sourceTooBig) { return; } $tempFilePath = $this->imageManager->download($image->sourceUrl); if (null === $tempFilePath) { return; } [$filePath, $fileName] = $this->imageManager->getFilePathAndName($tempFilePath); $previousFileSize = filesize($tempFilePath); $image->originalSize = $previousFileSize; $this->dispatcher->dispatch(new ImagePostProcessEvent($tempFilePath, $filePath, ImageOrigin::External)); $afterProcessFileSize = filesize($tempFilePath); if ($afterProcessFileSize < $previousFileSize) { $image->isCompressed = true; } try { if ($this->imageManager->store($tempFilePath, $filePath)) { $image->filePath = $filePath; $image->localSize = $afterProcessFileSize; $image->downloadedAt = new \DateTimeImmutable('now'); } } catch (ImageDownloadTooLargeException) { $image->localSize = 0; $image->sourceTooBig = true; } catch (\Exception) { } } /** * @param Image[] $images */ public function redownloadImagesIfNecessary(array $images): void { foreach ($images as $image) { $this->logger->debug('Maybe redownloading images {i}', ['i' => implode(', ', array_map(fn (Image $image) => $image->getId(), $images))]); if ($image && null === $image->filePath && !$image->sourceTooBig && $image->sourceUrl) { // there is an image, but not locally, and it was not too big, and we have the source URL -> try redownloading it $this->redownloadImage($image); } } $this->getEntityManager()->flush(); } } ================================================ FILE: src/Repository/InstanceRepository.php ================================================ findOneBy(['domain' => $user->apDomain]); } public function getInstanceOfMagazine(Magazine $magazine): ?Instance { return $this->findOneBy(['domain' => $magazine->apDomain]); } /** @return Instance[] */ public function getAllowedInstances(bool $useAllowlist): array { $qb = $this->createQueryBuilder('i'); if ($useAllowlist) { $qb->Where('i.isExplicitlyAllowed = true'); } else { $qb->where('i.isBanned = false'); } return $qb ->orderBy('i.domain') ->getQuery() ->getResult(); } /** @return Instance[] */ public function getBannedInstances(): array { return $this->createQueryBuilder('i') ->where('i.isBanned = true') ->andWhere('i.isExplicitlyAllowed = false') ->orderBy('i.domain') ->getQuery() ->getResult(); } /** @return Instance[] */ public function getDeadInstances(): array { return $this->createQueryBuilder('i') ->where('i.failedDelivers >= :numToDead') ->andWhere('i.lastSuccessfulDeliver < :dateBeforeDead OR i.lastSuccessfulDeliver IS NULL') ->andWhere('i.lastSuccessfulReceive < :dateBeforeDead OR i.lastSuccessfulReceive IS NULL') ->setParameter('numToDead', Instance::NUMBER_OF_FAILED_DELIVERS_UNTIL_DEAD) ->setParameter('dateBeforeDead', Instance::getDateBeforeDead()) ->orderBy('i.domain') ->getQuery() ->getResult(); } public function getOrCreateInstance(string $domain): Instance { $instance = $this->findOneBy(['domain' => $domain]); if (null !== $instance) { return $instance; } $instance = new Instance($domain); $this->getEntityManager()->persist($instance); $this->getEntityManager()->flush(); return $instance; } /** @return string[] */ public function getBannedInstanceUrls(): array { return array_map(fn (Instance $i) => $i->domain, $this->getBannedInstances()); } /** * @return array{magazines: int, users: int, theirUserFollows: int, ourUserFollows: int, theirSubscriptions: int, ourSubscriptions: int} * * @throws \Doctrine\DBAL\Exception */ public function getInstanceCounts(Instance $instance): array { $sql = "SELECT 'users' as type, COUNT(u.id) as c FROM instance i INNER JOIN \"user\" u ON u.ap_domain = i.domain WHERE u.is_banned = false AND u.is_deleted = false AND u.visibility = :visible AND u.marked_for_deletion_at IS NULL AND i.domain = :domain UNION SELECT 'magazines' as type, COUNT(m.id) as c FROM instance i INNER JOIN magazine m ON m.ap_domain = i.domain WHERE m.visibility = :visible AND m.ap_deleted_at IS NULL AND m.marked_for_deletion_at IS NULL AND i.domain = :domain UNION SELECT 'theirUserFollows' as type, COUNT(uf.id) as c FROM instance i INNER JOIN \"user\" u ON u.ap_domain = i.domain INNER JOIN user_follow uf ON uf.follower_id = u.id INNER JOIN \"user\" u2 ON uf.following_id = u2.id AND u2.ap_id IS NULL WHERE u.is_banned = false AND u.is_deleted = false AND u.visibility = :visible AND u.marked_for_deletion_at IS NULL AND i.domain = :domain AND u2.is_banned = false AND u2.is_deleted = false AND u.visibility = :visible AND u2.marked_for_deletion_at IS NULL UNION SELECT 'ourUserFollows' as type, COUNT(uf.id) as c FROM instance i INNER JOIN \"user\" u ON u.ap_domain = i.domain INNER JOIN user_follow uf ON uf.following_id = u.id INNER JOIN \"user\" u2 ON uf.follower_id = u2.id AND u2.ap_id IS NULL WHERE u.is_banned = false AND u.is_deleted = false AND u.visibility = :visible AND u.marked_for_deletion_at IS NULL AND i.domain = :domain AND u2.is_banned = false AND u2.is_deleted = false AND u.visibility = :visible AND u2.marked_for_deletion_at IS NULL UNION SELECT 'theirSubscriptions' as type, COUNT(ms.id) as c FROM instance i INNER JOIN \"user\" u ON u.ap_domain = i.domain INNER JOIN magazine_subscription ms ON ms.user_id = u.id INNER JOIN magazine m ON m.id = ms.magazine_id AND m.ap_id IS NULL WHERE u.is_banned = false AND u.is_deleted = false AND u.visibility = :visible AND u.marked_for_deletion_at IS NULL AND i.domain = :domain AND m.visibility = :visible AND m.ap_deleted_at IS NULL AND m.marked_for_deletion_at IS NULL UNION SELECT 'ourSubscriptions' as type, COUNT(ms.id) as c FROM instance i INNER JOIN magazine m ON m.ap_domain = i.domain INNER JOIN magazine_subscription ms ON ms.magazine_id = m.id INNER JOIN \"user\" u ON u.id = ms.user_id AND u.ap_id IS NULL WHERE u.is_banned = false AND u.is_deleted = false AND u.visibility = :visible AND u.marked_for_deletion_at IS NULL AND i.domain = :domain AND m.visibility = :visible AND m.ap_deleted_at IS NULL AND m.marked_for_deletion_at IS NULL "; $stmt = $this->getEntityManager()->getConnection()->prepare($sql); $stmt->bindValue('visible', VisibilityInterface::VISIBILITY_VISIBLE); $stmt->bindValue('domain', $instance->domain); $result = $stmt->executeQuery() ->fetchAllAssociative(); $mappedResult = []; foreach ($result as $row) { $mappedResult[$row['type']] = $row['c']; } return [ 'magazines' => $mappedResult['magazines'], 'users' => $mappedResult['users'], 'ourUserFollows' => $mappedResult['ourUserFollows'], 'theirUserFollows' => $mappedResult['theirUserFollows'], 'ourSubscriptions' => $mappedResult['ourSubscriptions'], 'theirSubscriptions' => $mappedResult['theirSubscriptions'], ]; } /** * @return Instance[] */ public function findAllOrdered(): array { $qb = $this->createQueryBuilder('i') ->orderBy('i.domain', 'ASC'); return $qb->getQuery()->getResult(); } } ================================================ FILE: src/Repository/MagazineBanRepository.php ================================================ * * @method MagazineBlock|null find($id, $lockMode = null, $lockVersion = null) * @method MagazineBlock|null findOneBy(array $criteria, array $orderBy = null) * @method MagazineBlock[] findAll() * @method MagazineBlock[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class MagazineBlockRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, MagazineBlock::class); } public function findMagazineBlocksIds(User $user): array { return array_column( $this->createQueryBuilder('mb') ->select('mbm.id') ->join('mb.magazine', 'mbm') ->where('mb.user = :user') ->setParameter('user', $user) ->getQuery() ->getResult(), 'id' ); } } ================================================ FILE: src/Repository/MagazineLogRepository.php ================================================ createQueryBuilder('ml'); if (null !== $types && \sizeof($types) > 0) { $wheres = array_map(fn ($type) => 'ml INSTANCE OF '.MagazineLog::DISCRIMINATOR_MAP[$type], $types); $qb = $qb->where(implode(' OR ', $wheres)); if (null !== $magazine) { $qb = $qb->andWhere('ml.magazine = :magazine') ->setParameter('magazine', $magazine); } } elseif (null !== $magazine) { $qb = $qb->where('ml.magazine = :magazine') ->setParameter('magazine', $magazine); } $qb->orderBy('ml.createdAt', 'DESC'); $pager = new Pagerfanta(new QueryAdapter($qb)); try { $pager->setMaxPerPage($perPage); $pager->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pager; } public function removeEntryLogs(Entry $entry): void { $conn = $this->getEntityManager()->getConnection(); $sql = 'DELETE FROM magazine_log AS m WHERE m.entry_id = :entryId'; $stmt = $conn->prepare($sql); $stmt->bindValue('entryId', $entry->getId()); $stmt->executeQuery(); } public function removeEntryCommentLogs(EntryComment $comment): void { $conn = $this->getEntityManager()->getConnection(); $sql = 'DELETE FROM magazine_log AS m WHERE m.entry_comment_id = :commentId'; $stmt = $conn->prepare($sql); $stmt->bindValue('commentId', $comment->getId()); $stmt->executeQuery(); } public function removePostLogs(Post $post): void { $conn = $this->getEntityManager()->getConnection(); $sql = 'DELETE FROM magazine_log AS m WHERE m.post_id = :postId'; $stmt = $conn->prepare($sql); $stmt->bindValue('postId', $post->getId()); $stmt->executeQuery(); } public function removePostCommentLogs(PostComment $comment): void { $conn = $this->getEntityManager()->getConnection(); $sql = 'DELETE FROM magazine_log AS m WHERE m.post_comment_id = :commentId'; $stmt = $conn->prepare($sql); $stmt->bindValue('commentId', $comment->getId()); $stmt->executeQuery(); } } ================================================ FILE: src/Repository/MagazineOwnershipRequestRepository.php ================================================ createQueryBuilder('r') ->orderBy('r.createdAt', 'ASC'); $pagerfanta = new Pagerfanta( new QueryAdapter( $qb ) ); try { $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } } ================================================ FILE: src/Repository/MagazineRepository.php ================================================ getEntityManager()->persist($entity); if ($flush) { $this->getEntityManager()->flush(); } } public function findOneByName(?string $name): ?Magazine { return $this->createQueryBuilder('m') ->andWhere('LOWER(m.name) = LOWER(:name)') ->setParameter('name', $name) ->getQuery() ->getOneOrNullResult(); } public function findPaginated(MagazinePageView $criteria): PagerfantaInterface { $qb = $this->createQueryBuilder('m') ->andWhere('m.visibility = :visibility') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE); if ($criteria->query) { $restrictions = 'LOWER(m.name) LIKE LOWER(:q) OR LOWER(m.title) LIKE LOWER(:q)'; if ($criteria->fields === $criteria::FIELDS_NAMES_DESCRIPTIONS) { $restrictions .= ' OR LOWER(m.description) LIKE LOWER(:q)'; } $qb->andWhere($restrictions) ->setParameter('q', '%'.trim($criteria->query).'%'); } if ($criteria->showOnlyLocalMagazines()) { $qb->andWhere('m.apId IS NULL'); } if ($criteria->abandoned) { if (!$criteria->showOnlyLocalMagazines()) { throw new \InvalidArgumentException('filtering for abandoned magazines only works for local'); } $qb->andWhere('mod.magazine IS NOT NULL') ->andWhere('mod.isOwner = true') ->andWhere('modUser.lastActive < :abandonedThreshold') ->join('m.moderators', 'mod') ->join('mod.user', 'modUser') ->setParameter('abandonedThreshold', new \DateTime('-1 month')); } match ($criteria->adult) { $criteria::ADULT_HIDE => $qb->andWhere('m.isAdult = false'), $criteria::ADULT_ONLY => $qb->andWhere('m.isAdult = true'), $criteria::ADULT_SHOW => true, }; match ($criteria->sortOption) { default => $qb->addOrderBy('m.subscriptionsCount', 'DESC'), $criteria::SORT_ACTIVE => $qb->addOrderBy('m.lastActive', 'DESC'), $criteria::SORT_NEW => $qb->addOrderBy('m.createdAt', 'DESC'), $criteria::SORT_THREADS => $qb->addOrderBy('m.entryCount', 'DESC'), $criteria::SORT_COMMENTS => $qb->addOrderBy('m.entryCommentCount', 'DESC'), $criteria::SORT_POSTS => $qb->addOrderBy('m.postCount + m.postCommentCount', 'DESC'), $criteria::SORT_OWNER_LAST_ACTIVE => $criteria->abandoned ? $qb->orderBy('modUser.lastActive', 'ASC') : throw new \InvalidArgumentException($criteria::SORT_OWNER_LAST_ACTIVE.' requires abandoned filter'), }; $pagerfanta = new Pagerfanta(new QueryAdapter($qb)); try { $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE); $pagerfanta->setCurrentPage($criteria->page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findSubscribedMagazines(int $page, User $user, int $perPage = self::PER_PAGE): PagerfantaInterface { $pagerfanta = new Pagerfanta( new CollectionAdapter( $user->subscriptions ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } /** * @return Magazine[] */ public function findMagazineSubscriptionsOfUser(User $user, SubscriptionSort $sort, int $max): array { $query = $this->createQueryBuilder('m') ->join('m.subscriptions', 'ms') ->join('ms.user', 'u') ->andWhere('u.id = :userId') ->setParameter('userId', $user->getId()); if (SubscriptionSort::LastActive === $sort) { $query = $query ->orderBy('m.lastActive', 'DESC') ->andWhere('m.lastActive IS NOT NULL'); } elseif (SubscriptionSort::Alphabetically === $sort) { $query = $query->orderBy('m.name'); } $query = $query->getQuery(); $query->setMaxResults($max); $goodResults = $query->getResult(); $remaining = $max - \sizeof($goodResults); if ($remaining > 0) { $query = $this->createQueryBuilder('m') ->join('m.subscriptions', 'ms') ->join('ms.user', 'u') ->andWhere('u.id = :userId') ->andWhere('m.lastActive IS NULL') ->setParameter('userId', $user->getId()) ->setMaxResults($remaining); $additionalResults = $query->getQuery()->getResult(); $goodResults = array_merge($goodResults, $additionalResults); } return $goodResults; } public function findBlockedMagazines(int $page, User $user, int $perPage = self::PER_PAGE): PagerfantaInterface { $pagerfanta = new Pagerfanta( new CollectionAdapter( $user->blockedMagazines ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findModerators( Magazine $magazine, ?int $page = 1, int $perPage = self::PER_PAGE, ): PagerfantaInterface { $criteria = Criteria::create() ->orderBy(['isOwner' => 'DESC']) ->orderBy(['createdAt' => 'ASC']); $moderators = new Pagerfanta(new SelectableAdapter($magazine->moderators, $criteria)); try { $moderators->setMaxPerPage($perPage); $moderators->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $moderators; } public function findBans(Magazine $magazine, ?int $page = 1, int $perPage = self::PER_PAGE): PagerfantaInterface { $criteria = Criteria::create() ->andWhere(Criteria::expr()->gt('expiredAt', new \DateTimeImmutable())) ->orWhere(Criteria::expr()->isNull('expiredAt')) ->orderBy(['createdAt' => 'DESC']); $bans = new Pagerfanta(new SelectableAdapter($magazine->bans, $criteria)); try { $bans->setMaxPerPage($perPage); $bans->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $bans; } public function findReports( Magazine $magazine, ?int $page = 1, int $perPage = self::PER_PAGE, string $status = Report::STATUS_PENDING, ): PagerfantaInterface { $dql = 'SELECT r FROM '.Report::class.' r WHERE r.magazine = :magazine'; if (Report::STATUS_ANY !== $status) { $dql .= ' AND r.status = :status'; } $dql .= " ORDER BY CASE WHEN r.status = 'pending' THEN 1 ELSE 2 END, r.weight DESC, r.createdAt DESC"; $query = $this->getEntityManager()->createQuery($dql); $query->setParameter('magazine', $magazine); if (Report::STATUS_ANY !== $status) { $query->setParameter('status', $status); } $pagerfanta = new Pagerfanta( new QueryAdapter($query) ); try { $pagerfanta->setMaxPerPage(self::PER_PAGE); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findBadges(Magazine $magazine): Collection { return $magazine->badges; } public function findModeratedMagazines( User $user, ?int $page = 1, int $perPage = self::PER_PAGE, ): PagerfantaInterface { $dql = 'SELECT m FROM '.Magazine::class.' m WHERE m IN ('. 'SELECT IDENTITY(md.magazine) FROM '.Moderator::class.' md WHERE md.user = :user) ORDER BY m.apId DESC, m.lastActive DESC'; $query = $this->getEntityManager()->createQuery($dql) ->setParameter('user', $user); $pagerfanta = new Pagerfanta( new QueryAdapter( $query ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findTrashed(Magazine $magazine, int $page = 1, int $perPage = self::PER_PAGE): PagerfantaInterface { $magazineId = $magazine->getId(); $sql = ' (SELECT id, last_active, magazine_id, \'entry\' AS type FROM entry WHERE magazine_id = :magazineId AND visibility = \'trashed\') UNION ALL (SELECT id, last_active, magazine_id, \'entry_comment\' AS type FROM entry_comment WHERE magazine_id = :magazineId AND visibility = \'trashed\') UNION ALL (SELECT id, last_active, magazine_id, \'post\' AS type FROM post WHERE magazine_id = :magazineId AND visibility = \'trashed\') UNION ALL (SELECT id, last_active, magazine_id, \'post_comment\' AS type FROM post_comment WHERE magazine_id = :magazineId AND visibility = \'trashed\') ORDER BY last_active DESC'; $parameters = [ 'magazineId' => $magazineId, ]; $adapter = new NativeQueryAdapter($this->getEntityManager()->getConnection(), $sql, $parameters, transformer: $this->contentPopulationTransformer, cache: $this->cache); $pagerfanta = new Pagerfanta($adapter); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findAudience(Magazine $magazine): array { if (null !== $magazine->apId) { return [$magazine->apInboxUrl]; } $dql = 'SELECT COUNT(u.id), u.apInboxUrl FROM '.User::class.' u WHERE u IN ('. 'SELECT IDENTITY(ms.user) FROM '.MagazineSubscription::class.' ms WHERE ms.magazine = :magazine)'. 'AND u.apId IS NOT NULL AND u.isBanned = false AND u.apTimeoutAt IS NULL '. 'GROUP BY u.apInboxUrl'; $res = $this->getEntityManager()->createQuery($dql) ->setParameter('magazine', $magazine) ->getResult(); return array_map(fn ($item) => $item['apInboxUrl'], $res); } public function findWithoutKeys(): array { return $this->createQueryBuilder('m') ->where('m.privateKey IS NULL') ->andWhere('m.apId IS NULL') ->getQuery() ->getResult(); } public function findByTag($tag): ?Magazine { return $this->createQueryBuilder('m') ->andWhere('m.tags IS NOT NULL AND JSONB_CONTAINS(m.tags, :tag) = true') ->orderBy('m.lastActive', 'DESC') ->setMaxResults(1) ->setParameter('tag', "\"$tag\"") ->getQuery() ->getOneOrNullResult(); } public function findByActivity() { return $this->createQueryBuilder('m') ->andWhere('m.postCount > 0') ->orWhere('m.entryCount > 0') ->andWhere('m.lastActive >= :date') ->andWhere('m.isAdult = false') ->andWhere('m.visibility = :visibility') ->setMaxResults(50) ->setParameter('date', new \DateTime('-5 months')) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->orderBy('m.entryCount', 'DESC') ->getQuery() ->getResult(); } public function findByApGroupProfileId(array $apIds): ?Magazine { return $this->createQueryBuilder('m') ->where('m.apProfileId IN (?1)') ->setParameter(1, $apIds) ->setMaxResults(1) ->getQuery() ->getOneOrNullResult(); } public function search(string $magazine, int $page, int $perPage = self::PER_PAGE): Pagerfanta { $qb = $this->createQueryBuilder('m') ->andWhere('m.visibility = :visibility') ->andWhere( 'LOWER(m.name) LIKE LOWER(:q) OR LOWER(m.title) LIKE LOWER(:q) OR LOWER(m.description) LIKE LOWER(:q)' ) ->orderBy('m.apId', 'DESC') ->orderBy('m.subscriptionsCount', 'DESC') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setParameter('q', '%'.$magazine.'%'); $pagerfanta = new Pagerfanta( new QueryAdapter( $qb ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findRandom(?User $user = null): array { $conn = $this->getEntityManager()->getConnection(); $whereClauses = [ 'm.is_adult = false', 'm.visibility = :visibility', ]; $parameters = [ 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, ]; if ($this->settingsManager->get('MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY')) { $whereClauses[] = 'm.ap_id IS NULL'; } if (null !== $user) { $whereClauses[] = 'm.id NOT IN(:blockedMagazines)'; $parameters['blockedMagazines'] = $this->sqlHelpers->getCachedUserMagazineBlocks($user); } $whereString = SqlHelpers::makeWhereString($whereClauses); $sql = SqlHelpers::rewriteArrayParameters($parameters, "SELECT m.id FROM magazine m $whereString ORDER BY random() LIMIT 5"); $stmt = $conn->prepare($sql['sql']); foreach ($sql['parameters'] as $param => $value) { $stmt->bindValue($param, $value, SqlHelpers::getSqlType($value)); } $stmt = $stmt->executeQuery(); $ids = $stmt->fetchAllAssociative(); return $this->createQueryBuilder('m') ->where('m.id IN (:ids)') ->setParameter('ids', $ids) ->getQuery() ->getResult(); } public function findRelated(string $magazine, ?User $user = null): array { $qb = $this->createQueryBuilder('m') ->where('m.entryCount > 0 OR m.postCount > 0') ->andWhere('m.title LIKE :magazine OR m.description LIKE :magazine OR m.name LIKE :magazine') ->andWhere('m.isAdult = false') ->andWhere('m.visibility = :visibility') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setParameter('magazine', "%{$magazine}%") ->setMaxResults(5); if (null !== $user) { $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))); $qb->setParameter('user', $user); } return $qb->getQuery() ->getResult(); } public function findRemoteForUpdate(): array { return $this->createQueryBuilder('m') ->where('m.apId IS NOT NULL') ->andWhere('m.apDomain IS NULL') ->andWhere('m.apDeletedAt IS NULL') ->andWhere('m.apTimeoutAt IS NULL') ->addOrderBy('m.apFetchedAt', 'ASC') ->setMaxResults(1000) ->getQuery() ->getResult(); } public function findForDeletionPaginated(int $page): PagerfantaInterface { $query = $this->createQueryBuilder('m') ->where('m.apId IS NULL') ->andWhere('m.visibility = :visibility') ->orderBy('m.markedForDeletionAt', 'ASC') ->setParameter('visibility', VisibilityInterface::VISIBILITY_SOFT_DELETED) ->getQuery(); $pagerfanta = new Pagerfanta( new QueryAdapter( $query ) ); try { $pagerfanta->setMaxPerPage(self::PER_PAGE); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findAbandoned(int $page = 1): PagerfantaInterface { $query = $this->createQueryBuilder('m') ->where('mod.magazine IS NOT NULL') ->andWhere('mod.isOwner = true') ->andWhere('u.lastActive < :date') ->andWhere('m.apId IS NULL') ->join('m.moderators', 'mod') ->join('mod.user', 'u') ->setParameter('date', new \DateTime('-1 month')) ->orderBy('u.lastActive', 'ASC') ->getQuery(); $pagerfanta = new Pagerfanta( new QueryAdapter( $query ) ); try { $pagerfanta->setMaxPerPage(self::PER_PAGE); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function getMagazineFromModeratorsUrl($target): ?Magazine { if ($this->settingsManager->isLocalUrl($target)) { $matches = []; if (preg_match_all("/\/m\/([a-zA-Z0-9\-_:]+)\/moderators/", $target, $matches)) { $magName = $matches[1][0]; return $this->findOneByName($magName); } } else { return $this->findOneBy(['apAttributedToUrl' => $target]); } return null; } public function getMagazineFromPinnedUrl($target): ?Magazine { if ($this->settingsManager->isLocalUrl($target)) { $matches = []; if (preg_match_all("/\/m\/([a-zA-Z0-9\-_:]+)\/pinned/", $target, $matches)) { $magName = $matches[1][0]; return $this->findOneByName($magName); } } else { return $this->findOneBy(['apFeaturedUrl' => $target]); } return null; } } ================================================ FILE: src/Repository/MagazineSubscriptionRepository.php ================================================ createQueryBuilder('ms') ->addSelect('u') ->join('ms.user', 'u') ->where('u.notifyOnNewEntry = true') ->andWhere('ms.magazine = :magazine') ->andWhere('u != :user') ->andWhere('u.apId IS NULL') ->setParameter('magazine', $entry->magazine) ->setParameter('user', $entry->user) ->getQuery() ->getResult(); } /** * @return MagazineSubscription[] */ public function findNewPostSubscribers(Post $post): array { return $this->createQueryBuilder('ms') ->addSelect('u') ->join('ms.user', 'u') ->where('u.notifyOnNewPost = true') ->andWhere('ms.magazine = :magazine') ->andWhere('u != :user') ->andWhere('u.apId IS NULL') ->setParameter('magazine', $post->magazine) ->setParameter('user', $post->user) ->getQuery() ->getResult(); } public function findMagazineSubscribers(int $page, Magazine $magazine): PagerfantaInterface { $query = $this->createQueryBuilder('ms') ->addSelect('u') ->join('ms.user', 'u') ->andWhere('ms.magazine = :magazine') ->andWhere('u.apId IS NULL') ->setParameter('magazine', $magazine) ->getQuery(); $pagerfanta = new Pagerfanta( new QueryAdapter( $query ) ); try { $pagerfanta->setMaxPerPage(self::PER_PAGE); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } } ================================================ FILE: src/Repository/MagazineSubscriptionRequestRepository.php ================================================ * * @method MagazineSubscriptionRequest|null find($id, $lockMode = null, $lockVersion = null) * @method MagazineSubscriptionRequest|null findOneBy(array $criteria, array $orderBy = null) * @method MagazineSubscriptionRequest[] findAll() * @method MagazineSubscriptionRequest[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class MagazineSubscriptionRequestRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, MagazineSubscriptionRequest::class); } } ================================================ FILE: src/Repository/MessageRepository.php ================================================ createQueryBuilder('m') ->where('m.thread = :m_thread_id') ->setParameter('m_thread_id', $criteria->thread->getId()); switch ($criteria->sortOption) { case Criteria::SORT_OLD: $qb->orderBy('m.createdAt', 'ASC'); break; default: $qb->orderBy('m.createdAt', 'DESC'); } $messages = new Pagerfanta( new QueryAdapter( $qb, false ) ); try { $messages->setMaxPerPage($criteria->perPage ?? self::PER_PAGE); $messages->setCurrentPage($criteria->page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $messages; } public function findLastMessageBefore(Message $message): ?Message { $results = $this->createQueryBuilder('m') ->where('m.createdAt < :previous_message') ->andWhere('m.thread = :thread') ->orderBy('m.createdAt', 'DESC') ->setMaxResults(1) ->setParameter('previous_message', $message->createdAt) ->setParameter('thread', $message->thread) ->getQuery() ->getResult(); if (1 === \sizeof($results)) { return $results[0]; } return null; } public function findByApId(string $apId): ?Message { if ($this->settingsManager->isLocalUrl($apId)) { $path = parse_url($apId, PHP_URL_PATH); preg_match('/\/messages\/([\w\-]+)/', $path, $matches); if (2 === \sizeof($matches)) { $uuid = $matches[1]; return $this->findOneBy(['uuid' => $uuid]); } } else { return $this->findOneBy(['apId' => $apId]); } return null; } } ================================================ FILE: src/Repository/MessageThreadRepository.php ================================================ createQueryBuilder('mt'); $qb->where(':user MEMBER OF mt.participants') ->andWhere($qb->expr()->exists('SELECT m FROM '.Message::class.' m WHERE m.thread = mt')) ->orderBy('mt.updatedAt', 'DESC') ->setParameter(':user', $user); $pager = new Pagerfanta(new QueryAdapter($qb)); try { $pager->setMaxPerPage($perPage); $pager->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pager; } /** * @param User[] $participants * * @return MessageThread[] the message threads that contain the participants and no one else, order by their updated date (last message) * * @throws Exception */ public function findByParticipants(array $participants): array { $this->logger->debug('looking for thread with participants: {p}', ['p' => array_map(fn (User $u) => $u->username, $participants)]); $whereString = ''; $parameters = ['ctn' => [\sizeof($participants), ParameterType::INTEGER]]; $i = 0; foreach ($participants as $participant) { $whereString .= "AND EXISTS(SELECT * FROM message_thread_participants mtp WHERE mtp.message_thread_id = mt.id AND mtp.user_id = :p$i)"; $parameters["p$i"] = [$participant->getId(), ParameterType::INTEGER]; ++$i; } $sql = "SELECT mt.id FROM message_thread mt WHERE (SELECT COUNT(*) FROM message_thread_participants mtp WHERE mtp.message_thread_id = mt.id) = :ctn $whereString ORDER BY mt.updated_at DESC"; $em = $this->getEntityManager(); $stmt = $em->getConnection()->prepare($sql); foreach ($parameters as $param => $value) { $stmt->bindValue($param, $value[0], $value[1]); } $results = $stmt->executeQuery()->fetchAllAssociative(); $this->logger->debug('got results for query {q}: {r}', ['q' => $sql, 'r' => $results]); if (\sizeof($results) > 0) { $ids = []; foreach ($results as $result) { $ids[] = $result['id']; } return $this->findBy(['id' => $ids], ['updatedAt' => 'DESC']); } return []; } } ================================================ FILE: src/Repository/ModeratorRequestRepository.php ================================================ createQueryBuilder('r') ->where('r.magazine = :magazine') ->orderBy('r.createdAt', 'ASC') ->setParameter('magazine', $magazine); $pagerfanta = new Pagerfanta( new QueryAdapter( $qb ) ); try { $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } } ================================================ FILE: src/Repository/MonitoringRepository.php ================================================ createQueryBuilder('m') ->addCriteria($criteria); return new Pagerfanta(new QueryAdapter($qb)); } /** * @return array{path: string, total_duration: float, query_duration: float, twig_render_duration: float, curl_request_duration: float, response_duration: float} * * @throws \Doctrine\DBAL\Exception */ public function getOverviewRouteCalls(MonitoringExecutionContextFilterDto $dto, int $limit = 10): array { $criteria = $dto->toSqlWheres(); if (null === $dto->createdFrom) { $criteria['whereConditions'][] = 'created_at > now() - \'30 days\'::interval'; } $whereString = implode(' AND ', $criteria['whereConditions']); if ('mean' === $dto->chartOrdering) { $sql = "SELECT path, (SUM(duration_milliseconds) / COUNT(uuid)) as total_duration, (SUM(query_duration_milliseconds) / COUNT(uuid)) as query_duration, (SUM(twig_render_duration_milliseconds) / COUNT(uuid)) as twig_render_duration, (SUM(curl_request_duration_milliseconds) / COUNT(uuid)) as curl_request_duration, (SUM(response_sending_duration_milliseconds) / COUNT(uuid)) as response_duration FROM monitoring_execution_context WHERE $whereString GROUP BY path ORDER BY total_duration DESC LIMIT :limit"; } else { $sql = "SELECT path, SUM(duration_milliseconds) as total_duration, SUM(query_duration_milliseconds) as query_duration, SUM(twig_render_duration_milliseconds) as twig_render_duration, SUM(curl_request_duration_milliseconds) as curl_request_duration, SUM(response_sending_duration_milliseconds) as response_duration FROM monitoring_execution_context WHERE $whereString GROUP BY path ORDER BY total_duration DESC LIMIT :limit"; } $conn = $this->getEntityManager()->getConnection(); $stmt = $conn->prepare($sql); $stmt->bindValue('limit', $limit, ParameterType::INTEGER); foreach ($criteria['parameters'] as $key => $value) { if (\is_array($value)) { $stmt->bindValue($key, $value['value'], $value['type']); } elseif (\is_int($value)) { $stmt->bindValue($key, $value, ParameterType::INTEGER); } else { $stmt->bindValue($key, $value); } } return $stmt->executeQuery()->fetchAllAssociative(); } public function getFilteredContextsPaginated(MonitoringExecutionContextFilterDto $dto): Pagerfanta { $criteria = $dto->toCriteria(); $criteria->orderBy(orderings: ['createdAt' => 'DESC']); return $this->findByPaginated($criteria); } /** * @return array{ * monitoringEnabled: bool, * monitoringQueryParametersEnabled: bool, * monitoringQueriesEnabled: bool, * monitoringQueriesPersistingEnabled: bool, * monitoringTwigRendersEnabled: bool, * monitoringTwigRendersPersistingEnabled: bool, * monitoringCurlRequestsEnabled: bool, * monitoringCurlRequestPersistingEnabled: bool * } */ public function getConfiguration(): array { return [ 'monitoringEnabled' => $this->monitoringEnabled, 'monitoringQueryParametersEnabled' => $this->monitoringQueryParametersEnabled, 'monitoringQueriesEnabled' => $this->monitoringQueriesEnabled, 'monitoringQueriesPersistingEnabled' => $this->monitoringQueriesPersistingEnabled, 'monitoringTwigRendersEnabled' => $this->monitoringTwigRendersEnabled, 'monitoringTwigRendersPersistingEnabled' => $this->monitoringTwigRendersPersistingEnabled, 'monitoringCurlRequestsEnabled' => $this->monitoringCurlRequestsEnabled, 'monitoringCurlRequestPersistingEnabled' => $this->monitoringCurlRequestPersistingEnabled, ]; } } ================================================ FILE: src/Repository/NotificationRepository.php ================================================ createQueryBuilder('n') ->where('n.user = :user') ->setParameter('user', $user) ->orderBy('n.id', 'DESC'); if (self::STATUS_ALL !== $status) { $qb->andWhere('n.status = :status') ->setParameter('status', $status); } $pagerfanta = new Pagerfanta( new QueryAdapter( $qb ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findUnreadEntryNotifications(User $user, Entry $entry): iterable { $result = $this->findUnreadNotifications($user); return array_filter( $result, fn ($notification) => (isset($notification->entry) && $notification->entry === $entry) || (isset($notification->entryComment) && $notification->entryComment->entry === $entry) ); } public function findUnreadNotifications(User $user): array { $dql = 'SELECT n FROM '.Notification::class.' n WHERE n.user = :user AND n.status = :status'; return $this->getEntityManager()->createQuery($dql) ->setParameter('user', $user) ->setParameter('status', Notification::STATUS_NEW) ->getResult(); } public function countUnreadNotifications(User $user): int { $dql = 'SELECT count(n.id) FROM '.Notification::class.' n WHERE n.user = :user AND n.status = :status'; return $this->getEntityManager()->createQuery($dql) ->setParameter('user', $user) ->setParameter('status', Notification::STATUS_NEW) ->getSingleScalarResult(); } public function findUnreadPostNotifications(User $user, Post $post): iterable { $result = $this->findUnreadNotifications($user); return array_filter( $result, fn ($notification) => (isset($notification->post) && $notification->post === $post) || (isset($notification->postComment) && $notification->postComment->post === $post) ); } public function removeEntryNotifications(Entry $entry): void { $conn = $this->getEntityManager()->getConnection(); $sql = 'DELETE FROM notification AS n WHERE n.entry_id = :entryId'; $stmt = $conn->prepare($sql); $stmt->bindValue('entryId', $entry->getId()); $stmt->executeQuery(); } public function removeEntryCommentNotifications(EntryComment $comment): void { $conn = $this->getEntityManager()->getConnection(); $sql = 'DELETE FROM notification AS n WHERE n.entry_comment_id = :commentId'; $stmt = $conn->prepare($sql); $stmt->bindValue('commentId', $comment->getId()); $stmt->executeQuery(); } public function removePostNotifications(Post $post): void { $conn = $this->getEntityManager()->getConnection(); $sql = 'DELETE FROM notification AS n WHERE n.post_id = :postId'; $stmt = $conn->prepare($sql); $stmt->bindValue('postId', $post->getId()); $stmt->executeQuery(); } public function removePostCommentNotifications(PostComment $comment): void { $conn = $this->getEntityManager()->getConnection(); $sql = 'DELETE FROM notification AS n WHERE n.post_comment_id = :commentId'; $stmt = $conn->prepare($sql); $stmt->bindValue('commentId', $comment->getId()); $stmt->executeQuery(); } public function markReportNotificationsAsRead(User $user): void { $conn = $this->getEntityManager()->getConnection(); $sql = 'UPDATE notification n SET status = :s WHERE n.user_id = :uId AND n.report_id IS NOT NULL'; $stmt = $conn->prepare($sql); $stmt->bindValue('s', Notification::STATUS_READ); $stmt->bindValue('uId', $user->getId()); $stmt->executeQuery(); } public function markReportNotificationsInMagazineAsRead(User $user, Magazine $magazine): void { $conn = $this->getEntityManager()->getConnection(); $sql = 'UPDATE notification n SET status = :s WHERE n.user_id = :uId AND n.report_id IS NOT NULL AND EXISTS (SELECT id FROM report r WHERE r.id = n.report_id AND r.magazine_id = :mId)'; $stmt = $conn->prepare($sql); $stmt->bindValue('s', Notification::STATUS_READ); $stmt->bindValue('uId', $user->getId()); $stmt->bindValue('mId', $magazine->getId()); $stmt->executeQuery(); } public function markOwnReportNotificationsAsRead(User $user): void { $conn = $this->getEntityManager()->getConnection(); $sql = 'UPDATE notification n SET status = :s WHERE n.user_id = :uId AND n.report_id IS NOT NULL AND EXISTS (SELECT id FROM report r WHERE r.id = n.report_id AND r.reporting_id = :uId)'; $stmt = $conn->prepare($sql); $stmt->bindValue('s', Notification::STATUS_READ); $stmt->bindValue('uId', $user->getId()); $stmt->executeQuery(); } public function markUserSignupNotificationsAsRead(User $user, User $signedUpUser): void { $conn = $this->getEntityManager()->getConnection(); $sql = 'UPDATE notification n SET status = :s WHERE n.user_id = :uId AND n.new_user_id = :newUserId'; $stmt = $conn->prepare($sql); $stmt->bindValue('s', Notification::STATUS_READ); $stmt->bindValue('uId', $user->getId()); $stmt->bindValue('newUserId', $signedUpUser->getId()); $stmt->executeQuery(); } } ================================================ FILE: src/Repository/NotificationSettingsRepository.php ================================================ createQueryBuilder('ns') ->where('ns.user = :user'); if ($target instanceof User || $target instanceof UserDto) { $qb->andWhere('ns.targetUser = :target'); } elseif ($target instanceof Magazine || $target instanceof MagazineDto) { $qb->andWhere('ns.magazine = :target'); } elseif ($target instanceof Entry || $target instanceof EntryDto) { $qb->andWhere('ns.entry = :target'); } elseif ($target instanceof Post || $target instanceof PostDto) { $qb->andWhere('ns.post = :target'); } $qb->setParameter('target', $target->getId()); $qb->setParameter('user', $user); return $qb->getQuery() ->getOneOrNullResult(); } public function setStatusByTarget(User $user, Entry|Post|User|Magazine $target, ENotificationStatus $status): void { $setting = $this->findOneByTarget($user, $target); if (null === $setting) { $setting = new NotificationSettings($user, $target, $status); } else { $setting->setStatus($status); } $this->getEntityManager()->persist($setting); $this->getEntityManager()->flush(); } /** * gets the users that should be notified about the created of $target. This respects user and magazine blocks * as well as custom notification settings and the users default notification settings. * * @return int[] * * @throws Exception */ public function findNotificationSubscribersByTarget(Entry|EntryComment|Post|PostComment $target): array { $nestedCommentPostAuthor = 'false'; if ($target instanceof Entry || $target instanceof EntryComment) { $targetCol = 'entry_id'; if ($target instanceof Entry) { $targetId = $target->getId(); $notifyCol = 'notify_on_new_entry'; $isMagazineLevel = true; $dontNeedSubscription = false; $dontNeedToBeAuthor = true; $targetParentUserId = null; } else { $targetId = $target->entry->getId(); if (null === $target->parent) { $notifyCol = 'notify_on_new_entry_reply'; $targetParentUserId = $target->entry->user->getId(); } else { $notifyCol = 'notify_on_new_entry_comment_reply'; $targetParentUserId = $target->parent->user->getId(); $nestedCommentPostAuthor = 'u.notify_on_new_entry_reply = true AND u.id = :targetParent2UserId'; $targetParent2UserId = $target->entry->user->getId(); } $isMagazineLevel = false; $dontNeedSubscription = true; $dontNeedToBeAuthor = false; } } else { $targetCol = 'post_id'; if ($target instanceof Post) { $targetId = $target->getId(); $notifyCol = 'notify_on_new_post'; $isMagazineLevel = true; $dontNeedSubscription = false; $dontNeedToBeAuthor = true; $targetParentUserId = null; } else { $targetId = $target->post->getId(); if (null === $target->parent) { $notifyCol = 'notify_on_new_post_reply'; $targetParentUserId = $target->post->user->getId(); } else { $notifyCol = 'notify_on_new_post_comment_reply'; $targetParentUserId = $target->parent->user->getId(); $nestedCommentPostAuthor = 'u.notify_on_new_post_reply = true AND u.id = :targetParent2UserId'; $targetParent2UserId = $target->post->user->getId(); } $isMagazineLevel = false; $dontNeedSubscription = true; $dontNeedToBeAuthor = false; } } $isMagazineLevelString = $isMagazineLevel ? 'true' : 'false'; $isNotMagazineLevelString = !$isMagazineLevel ? 'true' : 'false'; $dontNeedSubscriptionString = $dontNeedSubscription ? 'true' : 'false'; $dontNeedToBeAuthorString = $dontNeedToBeAuthor ? 'true' : 'false'; $sql = "SELECT u.id FROM \"user\" u LEFT JOIN notification_settings ns_user ON ns_user.user_id = u.id AND ns_user.target_user_id = :targetUserId LEFT JOIN notification_settings ns_post ON ns_post.user_id = u.id AND ns_post.$targetCol = :targetId LEFT JOIN notification_settings ns_mag ON ns_mag.user_id = u.id AND ns_mag.magazine_id = :magId WHERE u.ap_id IS NULL AND u.id <> :targetUserId AND ( COALESCE(ns_user.notification_status, :normal) = :loud OR ( COALESCE(ns_user.notification_status, :normal) = :normal AND COALESCE(ns_post.notification_status, :normal) = :loud ) OR ( COALESCE(ns_user.notification_status, :normal) = :normal AND COALESCE(ns_post.notification_status, :normal) = :normal AND COALESCE(ns_mag.notification_status, :normal) = :loud -- deactivate loud magazine notifications for comments AND $isMagazineLevelString ) OR ( COALESCE(ns_user.notification_status, :normal) = :normal AND COALESCE(ns_post.notification_status, :normal) = :normal AND ( -- ignore the magazine level settings for comments COALESCE(ns_mag.notification_status, :normal) = :normal OR $isNotMagazineLevelString ) AND ( ( u.$notifyCol = true AND ( -- deactivate magazine subscription need for comments $dontNeedSubscriptionString OR EXISTS (SELECT * FROM magazine_subscription ms WHERE ms.user_id = u.id AND ms.magazine_id = :magId) ) AND ( -- deactivate the need to be the author of the parent to receive notifications $dontNeedToBeAuthorString OR u.id = :targetParentUserId ) ) OR ( $nestedCommentPostAuthor ) ) ) ) AND NOT EXISTS (SELECT * FROM user_block ub WHERE ub.blocker_id = u.id AND ub.blocked_id = :targetUserId) "; $conn = $this->getEntityManager()->getConnection(); $stmt = $conn->prepare($sql); $stmt->bindValue('normal', ENotificationStatus::Default->value); $stmt->bindValue('loud', ENotificationStatus::Loud->value); $stmt->bindValue('targetUserId', $target->user->getId()); $stmt->bindValue('targetId', $targetId); $stmt->bindValue('magId', $target->magazine->getId()); $stmt->bindValue('targetParentUserId', $targetParentUserId); if (isset($targetParent2UserId)) { $stmt->bindValue('targetParent2UserId', $targetParent2UserId); } $result = $stmt->executeQuery(); $rows = $result->fetchAllAssociative(); $this->logger->debug('got subscribers for target {c} id {id}: {subs}, (magLevel: {ml}, notMagLevel: {nml}, targetCol: {tc}, notifyCol: {nc}, dontNeedSubs: {dns}, doneNeedAuthor: {dna}, nestedComment extra condition: {nested})', [ 'c' => \get_class($target), 'id' => $target->getId(), 'subs' => $rows, 'ml' => $isMagazineLevelString, 'nml' => $isNotMagazineLevelString, 'tc' => $targetCol, 'nc' => $notifyCol, 'dns' => $dontNeedSubscriptionString, 'dna' => $dontNeedToBeAuthorString, 'nested' => $nestedCommentPostAuthor, ]); return array_map(fn (array $row) => $row['id'], $rows); } } ================================================ FILE: src/Repository/OAuth2ClientAccessRepository.php ================================================ * * @method OAuth2ClientAccess|null find($id, $lockMode = null, $lockVersion = null) * @method OAuth2ClientAccess|null findOneBy(array $criteria, array $orderBy = null) * @method OAuth2ClientAccess[] findAll() * @method OAuth2ClientAccess[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class OAuth2ClientAccessRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, OAuth2ClientAccess::class); } public function save(OAuth2ClientAccess $entity, bool $flush = false): void { $this->getEntityManager()->persist($entity); if ($flush) { $this->getEntityManager()->flush(); } } public function remove(OAuth2ClientAccess $entity, bool $flush = false): void { $this->getEntityManager()->remove($entity); if ($flush) { $this->getEntityManager()->flush(); } } public function getStats( string $intervalStr, ?\DateTime $start, ?\DateTime $end, ): array { $interval = $intervalStr ?? 'hour'; switch ($interval) { case 'all': return $this->aggregateTotalStats(); case 'year': case 'month': case 'day': case 'hour': case 'minute': case 'second': case 'milliseconds': break; default: throw new \LogicException('Invalid interval provided'); } return $this->aggregateStats($interval, $start, $end); } // Todo - stats need improvement for sure but that's out of the scope of making the starting API private function aggregateStats(string $interval, ?\DateTime $start, ?\DateTime $end): array { if (null === $end) { $end = new \DateTime(); } if (null === $start) { $start = new \DateTime('-1 '.$interval); } if ($end < $start) { throw new \LogicException('End date must be after start date!'); } $conn = $this->getEntityManager()->getConnection(); $sql = 'SELECT c.name as client, date_trunc(?, e.created_at) as datetime, COUNT(e) as count FROM oauth2_client_access e JOIN oauth2_client c on c.identifier = e.client_id WHERE e.created_at BETWEEN ? AND ? GROUP BY 1, 2 ORDER BY 3 DESC'; $stmt = $conn->prepare($sql); $stmt->bindValue(1, $interval); $stmt->bindValue(2, $start, 'datetime'); $stmt->bindValue(3, $end, 'datetime'); return $stmt->executeQuery()->fetchAllAssociative(); } private function aggregateTotalStats(): array { $conn = $this->getEntityManager()->getConnection(); $sql = 'SELECT e.client_id as client, COUNT(e) as count FROM oauth2_client_access e GROUP BY 1 ORDER BY 2 DESC'; $stmt = $conn->prepare($sql); return $stmt->executeQuery()->fetchAllAssociative(); } } ================================================ FILE: src/Repository/OAuth2UserConsentRepository.php ================================================ * * @method OAuth2UserConsent|null find($id, $lockMode = null, $lockVersion = null) * @method OAuth2UserConsent|null findOneBy(array $criteria, array $orderBy = null) * @method OAuth2UserConsent[] findAll() * @method OAuth2UserConsent[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class OAuth2UserConsentRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, OAuth2UserConsent::class); } public function save(OAuth2UserConsent $entity, bool $flush = false): void { $this->getEntityManager()->persist($entity); if ($flush) { $this->getEntityManager()->flush(); } } public function remove(OAuth2UserConsent $entity, bool $flush = false): void { $this->getEntityManager()->remove($entity); if ($flush) { $this->getEntityManager()->flush(); } } // /** // * @return OAuth2UserConsent[] Returns an array of OAuth2UserConsent objects // */ // public function findByExampleField($value): array // { // return $this->createQueryBuilder('o') // ->andWhere('o.exampleField = :val') // ->setParameter('val', $value) // ->orderBy('o.id', 'ASC') // ->setMaxResults(10) // ->getQuery() // ->getResult() // ; // } // public function findOneBySomeField($value): ?OAuth2UserConsent // { // return $this->createQueryBuilder('o') // ->andWhere('o.exampleField = :val') // ->setParameter('val', $value) // ->getQuery() // ->getOneOrNullResult() // ; // } } ================================================ FILE: src/Repository/PostCommentRepository.php ================================================ // // SPDX-License-Identifier: Zlib declare(strict_types=1); namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; use App\Entity\HashtagLink; use App\Entity\Image; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\User; use App\Entity\UserBlock; use App\Entity\UserFollow; use App\PageView\PostCommentPageView; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Pagerfanta\Doctrine\ORM\QueryAdapter; use Pagerfanta\Exception\NotValidCurrentPageException; use Pagerfanta\Pagerfanta; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * @method PostComment|null find($id, $lockMode = null, $lockVersion = null) * @method PostComment|null findOneBy(array $criteria, array $orderBy = null) * @method PostComment[] findAll() * @method PostComment[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class PostCommentRepository extends ServiceEntityRepository { public const PER_PAGE = 15; public function __construct( ManagerRegistry $registry, private readonly Security $security, ) { parent::__construct($registry, PostComment::class); } public function findByCriteria(PostCommentPageView $criteria) { // return $this->createQueryBuilder('pc') // ->orderBy('pc.createdAt', 'DESC') // ->setMaxResults(10) // ->getQuery() // ->getResult(); $pagerfanta = new Pagerfanta( new QueryAdapter( $this->getCommentQueryBuilder($criteria), false ) ); try { $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE); $pagerfanta->setCurrentPage($criteria->page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } private function getCommentQueryBuilder(Criteria $criteria): QueryBuilder { $user = $this->security->getUser(); $qb = $this->createQueryBuilder('c') ->select('c', 'u') ->join('c.user', 'u') ->andWhere('c.visibility IN (:visibility)') ->andWhere('u.visibility = :visible'); if ($user && VisibilityInterface::VISIBILITY_VISIBLE === $criteria->visibility) { $qb->orWhere( 'c.user IN (SELECT IDENTITY(cuf.following) FROM '.UserFollow::class.' cuf WHERE cuf.follower = :cUser AND c.visibility = :cVisibility)' ) ->setParameter('cUser', $user) ->setParameter('cVisibility', VisibilityInterface::VISIBILITY_PRIVATE); } $qb->setParameter( 'visibility', [ VisibilityInterface::VISIBILITY_SOFT_DELETED, VisibilityInterface::VISIBILITY_VISIBLE, VisibilityInterface::VISIBILITY_TRASHED, ] ) ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE); $this->addTimeClause($qb, $criteria); $this->filter($qb, $criteria); $this->addBannedHashtagClause($qb); if ($user instanceof User) { $this->filterWords($qb, $user); } return $qb; } private function addTimeClause(QueryBuilder $qb, Criteria $criteria): void { if (Criteria::TIME_ALL !== $criteria->time) { $since = $criteria->getSince(); $qb->andWhere('c.createdAt > :time') ->setParameter('time', $since, Types::DATETIMETZ_IMMUTABLE); } } private function addBannedHashtagClause(QueryBuilder $qb): void { $dql = $this->getEntityManager()->createQueryBuilder() ->select('hl2') ->from(HashtagLink::class, 'hl2') ->join('hl2.hashtag', 'h2') ->where('h2.banned = true') ->andWhere('hl2.postComment = c') ->getDQL(); $qb->andWhere($qb->expr()->not($qb->expr()->exists($dql))); } private function filter(QueryBuilder $qb, Criteria $criteria) { if ($criteria->post) { $qb->andWhere('c.post = :post') ->setParameter('post', $criteria->post); } if ($criteria->magazine) { $qb->join('c.post', 'p', Join::WITH, 'p.magazine = :magazine'); $qb->setParameter('magazine', $criteria->magazine); } if ($criteria->languages) { $qb->andWhere('c.lang IN (:languages)') ->setParameter('languages', $criteria->languages, ArrayParameterType::STRING); } if ($criteria->user) { $qb->andWhere('c.user = :user') ->setParameter('user', $criteria->user); } if ($criteria->tag) { $qb->andWhere('t.tag = :tag') ->join('p.hashtags', 'h') ->join('h.hashtag', 't') ->setParameter('tag', $criteria->tag); } $user = $this->security->getUser(); if ($user && !$criteria->moderated) { $qb->andWhere( 'c.user NOT IN (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker)' ); $qb->setParameter('blocker', $user); } if ($criteria->onlyParents) { $qb->andWhere('c.parent IS NULL'); } switch ($criteria->sortOption) { case Criteria::SORT_HOT: case Criteria::SORT_TOP: $qb->orderBy('c.upVotes + c.favouriteCount', 'DESC'); break; case Criteria::SORT_ACTIVE: $qb->orderBy('c.lastActive', 'DESC'); break; case Criteria::SORT_NEW: $qb->orderBy('c.createdAt', 'DESC'); break; case Criteria::SORT_OLD: $qb->orderBy('c.createdAt', 'ASC'); break; default: $qb->addOrderBy('c.lastActive', 'DESC'); } $qb->addOrderBy('c.createdAt', 'DESC'); $qb->addOrderBy('c.id', 'DESC'); } private function filterWords(QueryBuilder $qb, User $user): QueryBuilder { $i = 0; foreach ($user->getCurrentFilterLists() as $list) { if (!$list->comments) { continue; } foreach ($list->words as $word) { if ($word['exactMatch']) { $qb->andWhere("NOT (c.body LIKE :word$i) or c.user = :filterUser") ->setParameter("word$i", '%'.$word['word'].'%'); } else { $qb->andWhere("NOT (lower(c.body) LIKE lower(:word$i)) or c.user = :filterUser") ->setParameter("word$i", '%'.$word['word'].'%'); } ++$i; } } if ($i > 0) { $qb->setParameter('filterUser', $user); } return $qb; } /** * @return Image[] */ public function findImagesByPost(Post $post): array { $results = $this->createQueryBuilder('c') ->addSelect('i') ->innerJoin('c.image', 'i') ->andWhere('c.post = :post') ->setParameter('post', $post) ->getQuery() ->getResult(); return array_map(fn (PostComment $comment) => $comment->image, $results); } public function hydrateChildren(PostComment ...$comments): void { $children = $this->createQueryBuilder('c') ->andWhere('c.root IN (:ids)') ->setParameter('ids', $comments) ->getQuery()->getResult(); $this->hydrate(...$children); } public function hydrate(PostComment ...$comment): void { $this->getEntityManager()->createQueryBuilder() ->select('PARTIAL c.{id}') ->addSelect('u') ->addSelect('m') ->addSelect('i') ->from(PostComment::class, 'c') ->join('c.user', 'u') ->join('c.magazine', 'm') ->leftJoin('c.image', 'i') ->where('c IN (?1)') ->setParameter(1, $comment) ->getQuery() ->getResult(); /* we don't need to hydrate all the votes and favourites. We only use the count saved in the PostComment entity if ($this->security->getUser()) { $this->_em->createQueryBuilder() ->select('PARTIAL c.{id}') ->from(PostComment::class, 'c') ->leftJoin('c.votes', 'cv') ->leftJoin('c.favourites', 'cf') ->where('c IN (?1)') ->setParameter(1, $comment) ->getQuery() ->getResult(); } */ } } ================================================ FILE: src/Repository/PostRepository.php ================================================ // // SPDX-License-Identifier: Zlib declare(strict_types=1); namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; use App\Entity\HashtagLink; use App\Entity\Magazine; use App\Entity\MagazineBlock; use App\Entity\MagazineSubscription; use App\Entity\Moderator; use App\Entity\Post; use App\Entity\PostFavourite; use App\Entity\User; use App\Entity\UserBlock; use App\Entity\UserFollow; use App\PageView\EntryPageView; use App\PageView\PostPageView; use App\Pagination\AdapterFactory; use App\Service\SettingsManager; use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\NoResultException; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Pagerfanta\Exception\NotValidCurrentPageException; use Pagerfanta\Pagerfanta; use Pagerfanta\PagerfantaInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; /** * @method Post|null find($id, $lockMode = null, $lockVersion = null) * @method Post|null findOneBy(array $criteria, array $orderBy = null) * @method Post[] findAll() * @method Post[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class PostRepository extends ServiceEntityRepository { public const PER_PAGE = 15; public const SORT_DEFAULT = 'hot'; public function __construct( ManagerRegistry $registry, private readonly Security $security, private readonly CacheInterface $cache, private readonly AdapterFactory $adapterFactory, private readonly SettingsManager $settingsManager, private readonly SqlHelpers $sqlHelpers, ) { parent::__construct($registry, Post::class); } public function findByCriteria(PostPageView $criteria): PagerfantaInterface { $pagerfanta = new Pagerfanta($this->adapterFactory->create($this->getEntryQueryBuilder($criteria))); try { $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE); $pagerfanta->setCurrentPage($criteria->page); if (!$criteria->magazine) { $pagerfanta->setMaxNbPages(1000); } } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } private function getEntryQueryBuilder(PostPageView $criteria): QueryBuilder { $user = $this->security->getUser(); $qb = $this->createQueryBuilder('p') ->select('p', 'm', 'u') ->where('p.visibility = :visibility') ->join('p.magazine', 'm') ->join('p.user', 'u') ->andWhere('m.visibility = :visible') ->andWhere('u.visibility = :visible'); if ($user && VisibilityInterface::VISIBILITY_VISIBLE === $criteria->visibility) { $qb->orWhere( 'EXISTS (SELECT IDENTITY(puf.following) FROM '.UserFollow::class.' puf WHERE puf.follower = :puf_user AND p.visibility = :puf_visibility AND puf.following = p.user)' ) ->setParameter('puf_user', $user) ->setParameter('puf_visibility', VisibilityInterface::VISIBILITY_PRIVATE); } else { $qb->orWhere('p.user IS NULL'); } $qb->setParameter('visibility', $criteria->visibility) ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE); $this->addTimeClause($qb, $criteria); $this->addStickyClause($qb, $criteria); $this->filter($qb, $criteria); $this->addBannedHashtagClause($qb); return $qb; } private function addTimeClause(QueryBuilder $qb, Criteria $criteria): void { if (Criteria::TIME_ALL !== $criteria->time) { $since = $criteria->getSince(); $qb->andWhere('p.createdAt > :time') ->setParameter('time', $since, Types::DATETIMETZ_IMMUTABLE); } } private function addStickyClause(QueryBuilder $qb, PostPageView $criteria): void { if ($criteria->stickiesFirst) { if (1 === $criteria->page) { $qb->addOrderBy('p.sticky', 'DESC'); } else { $qb->andWhere($qb->expr()->eq('p.sticky', 'false')); } } } private function addBannedHashtagClause(QueryBuilder $qb): void { $dql = $this->getEntityManager()->createQueryBuilder() ->select('hl2') ->from(HashtagLink::class, 'hl2') ->join('hl2.hashtag', 'h2') ->where('h2.banned = true') ->andWhere('hl2.post = p') ->getDQL(); $qb->andWhere($qb->expr()->not($qb->expr()->exists($dql))); } private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder { /** @var User|null $user */ $user = $this->security->getUser(); if (Criteria::AP_LOCAL === $criteria->federation) { $qb->andWhere('p.apId IS NULL'); } elseif (Criteria::AP_FEDERATED === $criteria->federation) { $qb->andWhere('p.apId IS NOT NULL'); } if ($criteria->magazine) { $qb->andWhere('p.magazine = :magazine') ->setParameter('magazine', $criteria->magazine); } if ($criteria->user) { $qb->andWhere('p.user = :user') ->setParameter('user', $criteria->user); } if ($criteria->tag) { $qb->andWhere('t.tag = :tag') ->join('p.hashtags', 'h') ->join('h.hashtag', 't') ->setParameter('tag', $criteria->tag); } if ($criteria->subscribed) { $qb->andWhere( 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine) OR EXISTS (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user AND uf.following = p.user) OR p.user = :user' ); $qb->setParameter('user', $this->security->getUser()); } if ($criteria->moderated) { $qb->andWhere( 'EXISTS (SELECT IDENTITY(mm.magazine) FROM '.Moderator::class.' mm WHERE mm.user = :user AND mm.magazine = p.magazine)' ); $qb->setParameter('user', $this->security->getUser()); } if ($criteria->favourite) { $qb->andWhere( 'EXISTS (SELECT IDENTITY(pf.post) FROM '.PostFavourite::class.' pf WHERE pf.user = :user AND pf.post = p)' ); $qb->setParameter('user', $this->security->getUser()); } if ($criteria->languages) { $qb->andWhere('p.lang IN (:languages)') ->setParameter('languages', $criteria->languages, ArrayParameterType::STRING); } if ($user && (!$criteria->magazine || !$criteria->magazine->userIsModerator($user)) && !$criteria->moderated) { $qb->andWhere( 'NOT EXISTS (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker AND ub.blocked = p.user)' ); $qb->setParameter('blocker', $user); $qb->andWhere( 'NOT EXISTS (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :magazineBlocker AND mb.magazine = p.magazine)' ); $qb->setParameter('magazineBlocker', $user); } if (!$user || $user->hideAdult) { $qb->andWhere('m.isAdult = :isAdult') ->andWhere('p.isAdult = :isAdult') ->setParameter('isAdult', false); } switch ($criteria->sortOption) { case Criteria::SORT_HOT: $qb->addOrderBy('p.ranking', 'DESC'); break; case Criteria::SORT_TOP: $qb->addOrderBy('p.score', 'DESC'); break; case Criteria::SORT_COMMENTED: $qb->addOrderBy('p.commentCount', 'DESC'); break; case Criteria::SORT_ACTIVE: $qb->addOrderBy('p.lastActive', 'DESC'); break; default: } $qb->addOrderBy('p.createdAt', Criteria::SORT_OLD === $criteria->sortOption ? 'ASC' : 'DESC'); $qb->addOrderBy('p.id', 'DESC'); return $qb; } public function hydrate(Post ...$posts): void { $this->getEntityManager()->createQueryBuilder() ->select('PARTIAL p.{id}') ->addSelect('u') ->addSelect('ua') ->addSelect('m') ->addSelect('i') ->from(Post::class, 'p') ->join('p.user', 'u') ->join('p.magazine', 'm') ->leftJoin('u.avatar', 'ua') ->leftJoin('p.image', 'i') ->where('p IN (?1)') ->setParameter(1, $posts) ->getQuery() ->getResult(); /* we don't need to hydrate all the votes and favourites. We only use the count saved in the post entity if ($this->security->getUser()) { $this->_em->createQueryBuilder() ->select('PARTIAL p.{id}') ->addSelect('pv') ->addSelect('pf') ->from(Post::class, 'p') ->leftJoin('p.votes', 'pv') ->leftJoin('p.favourites', 'pf') ->where('p IN (?1)') ->setParameter(1, $posts) ->getQuery() ->getResult(); } */ } public function countPostsByMagazine(Magazine $magazine) { return \intval( $this->createQueryBuilder('p') ->select('count(p.id)') ->where('p.magazine = :magazine') ->andWhere('p.visibility = :visibility') ->setParameter('magazine', $magazine) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->getQuery() ->getSingleScalarResult() ); } public function countPostCommentsByMagazine(Magazine $magazine): int { return \intval( $this->createQueryBuilder('p') ->select('sum(p.commentCount)') ->where('p.magazine = :magazine') ->setParameter('magazine', $magazine) ->getQuery() ->getSingleScalarResult() ); } public function findToDelete(User $user, int $limit): array { return $this->createQueryBuilder('p') ->where('p.visibility != :visibility') ->andWhere('p.user = :user') ->setParameter('visibility', VisibilityInterface::VISIBILITY_SOFT_DELETED) ->setParameter('user', $user) ->orderBy('p.id', 'DESC') ->setMaxResults($limit) ->getQuery() ->getResult(); } public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); $qb = $qb ->andWhere('p.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') ->andWhere('u.apDiscoverable = true') ->andWhere('m.name != :name') ->andWhere('p.isAdult = false') ->andWhere('m.isAdult = false') ->andWhere('h.tag = :name') ->join('p.magazine', 'm') ->join('p.user', 'u') ->join('p.hashtags', 'hl') ->join('hl.hashtag', 'h') ->orderBy('p.createdAt', 'DESC') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setParameter('name', $tag) ->setMaxResults($limit); if (null !== $user) { $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); $qb->setParameter('user', $user); } return $qb->getQuery() ->getResult(); } public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); $qb = $qb->where('m.name LIKE :name OR m.title LIKE :title') ->andWhere('p.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') ->andWhere('u.apDiscoverable = true') ->andWhere('p.isAdult = false') ->andWhere('m.isAdult = false') ->join('p.magazine', 'm') ->join('p.user', 'u') ->orderBy('p.createdAt', 'DESC') ->setParameter('name', "%{$name}%") ->setParameter('title', "%{$name}%") ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setMaxResults($limit); if (null !== $user) { $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); $qb->setParameter('user', $user); } return $qb->getQuery() ->getResult(); } public function findLast(int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); $qb = $qb->where('p.isAdult = false') ->andWhere('p.visibility = :visibility') ->andWhere('u.apDiscoverable = true') ->andWhere('m.isAdult = false') ->andWhere('m.apDiscoverable = true'); if ($this->settingsManager->get('MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY')) { $qb = $qb->andWhere('m.apId IS NULL'); } if (null !== $user) { $magazineBlocks = $this->sqlHelpers->getCachedUserMagazineBlocks($user); if (\sizeof($magazineBlocks) > 0) { $qb->andWhere($qb->expr()->not($qb->expr()->in('m.id', $magazineBlocks))); } $userBlocks = $this->sqlHelpers->getCachedUserBlocks($user); if (\sizeof($userBlocks) > 0) { $qb->andWhere($qb->expr()->not($qb->expr()->in('u.id', $userBlocks))); } } return $qb->join('p.magazine', 'm') ->join('p.user', 'u') ->orderBy('p.createdAt', 'DESC') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setMaxResults($limit) ->getQuery() ->getResult(); } public function findFederated() { return $this->createQueryBuilder('p') ->andWhere('p.apId IS NOT NULL') ->andWhere('p.visibility = :visibility') ->orderBy('p.createdAt', 'DESC') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->getQuery() ->getResult(); } public function findTaggedFederatedInRandomMagazine() { return $this->createQueryBuilder('p') ->join('p.magazine', 'm') ->andWhere('m.name = :magazine') ->andWhere('p.apId IS NOT NULL') ->andWhere('p.visibility = :visibility') ->orderBy('p.createdAt', 'DESC') ->setParameter('magazine', 'random') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->getQuery() ->getResult(); } public function findUsers(Magazine $magazine, ?bool $federated = false): array { $qb = $this->createQueryBuilder('p') ->select('u.id, COUNT(p.id) as count') ->groupBy('u.id') ->join('p.user', 'u') ->join('p.magazine', 'm') ->andWhere('p.magazine = :magazine') ->andWhere('p.visibility = :visibility') ->andWhere('u.about != :emptyString') ->andWhere('u.isBanned = false'); if ($federated) { $qb->andWhere('u.apId IS NOT NULL') ->andWhere('u.apDiscoverable = true'); } else { $qb->andWhere('u.apId IS NULL'); } return $qb->orderBy('count', 'DESC') ->setParameter('magazine', $magazine) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setParameter('emptyString', '') ->setMaxResults(100) ->getQuery() ->getResult(); } private function countAll(EntryPageView|Criteria $criteria): int { return $this->cache->get( 'posts_count_'.$criteria->magazine?->name, function (ItemInterface $item) use ($criteria): int { $item->expiresAfter(60); if (!$criteria->magazine) { $query = $this->getEntityManager()->createQuery( 'SELECT COUNT(p.id) FROM App\Entity\Post p WHERE p.visibility = :visibility' ) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE); } else { $query = $this->getEntityManager()->createQuery( 'SELECT COUNT(p.id) FROM App\Entity\Post p WHERE p.visibility = :visibility AND p.magazine = :magazine' ) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setParameter('magazine', $criteria->magazine); } try { return $query->getSingleScalarResult(); } catch (NoResultException $e) { return 0; } } ); } } ================================================ FILE: src/Repository/ReportRepository.php ================================================ $this->findByEntry($subject), $subject instanceof EntryComment => $this->findByEntryComment($subject), $subject instanceof Post => $this->findByPost($subject), $subject instanceof PostComment => $this->findByPostComment($subject), default => throw new \LogicException(), }; } private function findByEntry(Entry $entry): ?EntryReport { $dql = 'SELECT r FROM '.EntryReport::class.' r WHERE r.entry = :entry'; return $this->getEntityManager()->createQuery($dql) ->setParameter('entry', $entry) ->getOneOrNullResult(); } private function findByEntryComment(EntryComment $comment): ?EntryCommentReport { $dql = 'SELECT r FROM '.EntryCommentReport::class.' r WHERE r.entryComment = :comment'; return $this->getEntityManager()->createQuery($dql) ->setParameter('comment', $comment) ->getOneOrNullResult(); } private function findByPost(Post $post): ?PostReport { $dql = 'SELECT r FROM '.PostReport::class.' r WHERE r.post = :post'; return $this->getEntityManager()->createQuery($dql) ->setParameter('post', $post) ->getOneOrNullResult(); } private function findByPostComment(PostComment $comment): ?PostCommentReport { $dql = 'SELECT r FROM '.PostCommentReport::class.' r WHERE r.postComment = :comment'; return $this->getEntityManager()->createQuery($dql) ->setParameter('comment', $comment) ->getOneOrNullResult(); } public function findPendingBySubject(ReportInterface $subject): ?Report { return match (true) { $subject instanceof Entry => $this->findPendingByEntry($subject), $subject instanceof EntryComment => $this->findPendingByEntryComment($subject), $subject instanceof Post => $this->findPendingByPost($subject), $subject instanceof PostComment => $this->findPendingByPostComment($subject), default => throw new \LogicException(), }; } private function findPendingByEntry(Entry $entry): ?EntryReport { $dql = 'SELECT r FROM '.EntryReport::class.' r WHERE r.entry = :entry AND r.status = :status'; return $this->getEntityManager()->createQuery($dql) ->setParameter('entry', $entry) ->setParameter('status', Report::STATUS_PENDING) ->getOneOrNullResult(); } private function findPendingByEntryComment(EntryComment $comment): ?EntryCommentReport { $dql = 'SELECT r FROM '.EntryCommentReport::class.' r WHERE r.entryComment = :comment AND r.status = :status'; return $this->getEntityManager()->createQuery($dql) ->setParameter('comment', $comment) ->setParameter('status', Report::STATUS_PENDING) ->getOneOrNullResult(); } private function findPendingByPost(Post $post): ?PostReport { $dql = 'SELECT r FROM '.PostReport::class.' r WHERE r.post = :post AND r.status = :status'; return $this->getEntityManager()->createQuery($dql) ->setParameter('post', $post) ->setParameter('status', Report::STATUS_PENDING) ->getOneOrNullResult(); } private function findPendingByPostComment(PostComment $comment): ?PostCommentReport { $dql = 'SELECT r FROM '.PostCommentReport::class.' r WHERE r.postComment = :comment AND r.status = :status'; return $this->getEntityManager()->createQuery($dql) ->setParameter('comment', $comment) ->setParameter('status', Report::STATUS_PENDING) ->getOneOrNullResult(); } public function findAllPaginated(int $page = 1, string $status = Report::STATUS_PENDING): PagerfantaInterface { $dql = 'SELECT r FROM '.Report::class.' r'; if (Report::STATUS_ANY !== $status) { $dql .= ' WHERE r.status = :status'; } $dql .= " ORDER BY CASE WHEN r.status = 'pending' THEN 1 ELSE 2 END, r.weight DESC, r.createdAt DESC"; $query = $this->getEntityManager()->createQuery($dql); if (Report::STATUS_ANY !== $status) { $query->setParameter('status', $status); } $pagerfanta = new Pagerfanta( new QueryAdapter($query) ); try { $pagerfanta->setMaxPerPage(self::PER_PAGE); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findByUserPaginated(User $user, int $page = 1, string $status = Report::STATUS_PENDING): PagerfantaInterface { $qb = $this->createQueryBuilder('r') ->where('r.reporting = :u') ->setParameter('u', $user); if (Report::STATUS_ANY !== $status) { $qb->andWhere('r.status = :s') ->setParameter('s', $status); } $qb->orderBy('r.createdAt'); $pagerfanta = new Pagerfanta( new QueryAdapter($qb) ); try { $pagerfanta->setMaxPerPage(self::PER_PAGE); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } } ================================================ FILE: src/Repository/ReputationRepository.php ================================================ getEntityManager()->getClassMetadata($className)->getTableName(); $voteTable = $table.'_vote'; $idColumn = $table.'_id'; $sql = "SELECT date_trunc('day', created_at) as day, sum(choice) as points FROM ( SELECT v.created_at, v.choice FROM $voteTable v WHERE v.author_id = :userId AND v.choice = -1 --downvotes UNION ALL SELECT v.created_at, 2 as choice FROM $voteTable v WHERE v.author_id = :userId AND v.choice = 1 --boosts -> 2x UNION ALL SELECT f.created_at, 1 as choice FROM favourite f INNER JOIN $table s ON f.$idColumn = s.id WHERE s.user_id = :userId --upvotes -> 1x ) as interactions GROUP BY day ORDER BY day DESC"; $adapter = new NativeQueryAdapter($this->getEntityManager()->getConnection(), $sql, ['userId' => $user->getId()], cache: $this->cache); $pagerfanta = new Pagerfanta($adapter); try { $pagerfanta->setMaxPerPage(self::PER_PAGE); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException) { throw new NotFoundHttpException(); } return $pagerfanta; } public function getUserReputationTotal(User $user): int { $conn = $this->getEntityManager() ->getConnection(); if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) { $sql = 'SELECT COALESCE((SELECT SUM((up_votes * 2) + favourite_count) FROM entry WHERE user_id = :user), 0) + COALESCE((SELECT SUM((up_votes * 2) + favourite_count) FROM entry_comment WHERE user_id = :user), 0) + COALESCE((SELECT SUM((up_votes * 2) + favourite_count) FROM post WHERE user_id = :user), 0) + COALESCE((SELECT SUM((up_votes * 2) + favourite_count) FROM post_comment WHERE user_id = :user), 0) as total'; } else { $sql = 'SELECT COALESCE((SELECT SUM((up_votes * 2) - down_votes + favourite_count) FROM entry WHERE user_id = :user), 0) + COALESCE((SELECT SUM((up_votes * 2) - down_votes + favourite_count) FROM entry_comment WHERE user_id = :user), 0) + COALESCE((SELECT SUM((up_votes * 2) - down_votes + favourite_count) FROM post WHERE user_id = :user), 0) + COALESCE((SELECT SUM((up_votes * 2) - down_votes + favourite_count) FROM post_comment WHERE user_id = :user), 0) as total'; } $stmt = $conn->prepare($sql); $stmt->bindValue('user', $user->getId()); $stmt = $stmt->executeQuery(); return $stmt->fetchAllAssociative()[0]['total'] ?? 0; } /** * @return float[] the percentage of upvotes a user gives (0-100) indexed by the userId * * @throws Exception */ public function getUserAttitudes(int ...$userIds): array { if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) { return array_map(fn () => 0, $userIds); } $conn = $this->getEntityManager()->getConnection(); $sql = 'SELECT sum(up_votes) as up_votes, sum(down_votes)as down_votes, user_id FROM ( (SELECT COUNT(*) as up_votes, 0 as down_votes, f.user_id as user_id FROM favourite f WHERE f.user_id IN (?) GROUP BY user_id) UNION ALL (SELECT COUNT(*) as up_votes, 0 as down_votes, v.user_id as user_id FROM entry_vote v WHERE v.user_id IN (?) AND v.choice = 1 GROUP BY user_id) UNION ALL (SELECT COUNT(*) as up_votes, 0 as down_votes, v.user_id as user_id FROM entry_comment_vote v WHERE v.user_id IN (?) AND v.choice = 1 GROUP BY user_id) UNION ALL (SELECT COUNT(*) as up_votes, 0 as down_votes, v.user_id as user_id FROM post_vote v WHERE v.user_id IN (?) AND v.choice = 1 GROUP BY user_id) UNION ALL (SELECT COUNT(*) as up_votes, 0 as down_votes, v.user_id as user_id FROM post_comment_vote v WHERE v.user_id IN (?) AND v.choice = 1 GROUP BY user_id) UNION ALL (SELECT 0 as up_votes, COUNT(*) as down_votes, v.user_id as user_id FROM entry_vote v WHERE v.user_id IN (?) AND v.choice = -1 GROUP BY user_id) UNION ALL (SELECT 0 as up_votes, COUNT(*) as down_votes, v.user_id as user_id FROM entry_comment_vote v WHERE v.user_id IN (?) AND v.choice = -1 GROUP BY user_id) UNION ALL (SELECT 0 as up_votes, COUNT(*) as down_votes, v.user_id as user_id FROM post_vote v WHERE v.user_id IN (?) AND v.choice = -1 GROUP BY user_id) UNION ALL (SELECT 0 as up_votes, COUNT(*) as down_votes, v.user_id as user_id FROM post_comment_vote v WHERE v.user_id IN (?) AND v.choice = -1 GROUP BY user_id) ) as votes GROUP BY user_id '; // array parameter types are ass in SQL, so this is the nicest way to bind the userIds to this query $rows = $conn->executeQuery( $sql, [$userIds, $userIds, $userIds, $userIds, $userIds, $userIds, $userIds, $userIds, $userIds], [ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER] ) ->fetchAllAssociative(); $result = []; foreach ($rows as $row) { $upVotes = $row['up_votes'] ?? 0; $downVotes = $row['down_votes'] ?? 0; $votes = $upVotes + $downVotes; if (0 === $votes) { $result[$row['user_id']] = -1; continue; } $result[$row['user_id']] = 100 / $votes * $upVotes; } return $result; } } ================================================ FILE: src/Repository/ResetPasswordRequestRepository.php ================================================ getEntityManager()->persist($entity); if ($flush) { $this->getEntityManager()->flush(); } } public function remove(ResetPasswordRequest $entity, bool $flush = true): void { $this->getEntityManager()->remove($entity); if ($flush) { $this->getEntityManager()->flush(); } } public function createResetPasswordRequest( object $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken, ): ResetPasswordRequestInterface { return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken); } } ================================================ FILE: src/Repository/SearchRepository.php ================================================ entityManager->createQuery($dql) ->setParameter('user', $user) ->getResult() ); } public function countBoosts(User $user): int { $conn = $this->entityManager->getConnection(); $sql = "SELECT COUNT(*) as cnt FROM ( SELECT entry_id as id, created_at, 'entry' AS type FROM entry_vote WHERE user_id = :userId AND choice = 1 UNION ALL SELECT comment_id as id, created_at, 'entry_comment' AS type FROM entry_comment_vote WHERE user_id = :userId AND choice = 1 UNION ALL SELECT post_id as id, created_at, 'post' AS type FROM post_vote WHERE user_id = :userId AND choice = 1 UNION ALL SELECT comment_id as id, created_at, 'post_comment' AS type FROM post_comment_vote WHERE user_id = :userId AND choice = 1 ) sub"; $stmt = $conn->prepare($sql); $stmt->bindValue('userId', $user->getId()); $stmt = $stmt->executeQuery(); return $stmt->fetchAllAssociative()[0]['cnt']; } public function findBoosts(int $page, User $user): PagerfantaInterface { $conn = $this->entityManager->getConnection(); $sql = " SELECT entry_id as id, created_at, 'entry' AS type FROM entry_vote WHERE user_id = :userId AND choice = 1 UNION ALL SELECT comment_id as id, created_at, 'entry_comment' AS type FROM entry_comment_vote WHERE user_id = :userId AND choice = 1 UNION ALL SELECT post_id as id, created_at, 'post' AS type FROM post_vote WHERE user_id = :userId AND choice = 1 UNION ALL SELECT comment_id as id, created_at, 'post_comment' AS type FROM post_comment_vote WHERE user_id = :userId AND choice = 1 ORDER BY created_at DESC"; $pagerfanta = new Pagerfanta(new NativeQueryAdapter($conn, $sql, [ 'userId' => $user->getId(), ], transformer: $this->transformer)); $pagerfanta->setCurrentPage($page); return $pagerfanta; } /** * @throws Exception */ private function getUserPublicActivityQueryAdapter(User $user, bool $hideAdult): AdapterInterface { $parameters = [ 'userId' => $user->getId(), 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, ]; $loggedInUser = $this->security->getUser(); $bodyWordsCond = ''; $titleWordsCond = ''; if ($loggedInUser instanceof User) { $bodyWordsFilter = []; $titleWordsFilter = []; $i = 0; foreach ($loggedInUser->getCurrentFilterLists() as $filterList) { if (!$filterList->profile) { continue; } foreach ($filterList->words as $word) { if ($word['exactMatch']) { $titleWordsFilter[] = "(title LIKE :word$i)"; $bodyWordsFilter[] = "(body LIKE :word$i)"; } else { $titleWordsFilter[] = "(title ILIKE :word$i)"; $bodyWordsFilter[] = "(body ILIKE :word$i)"; } $parameters["word$i"] = '%'.$word['word'].'%'; ++$i; } } if ($i > 0) { $bodyWordsCond = 'AND (NOT ('.implode(' OR ', $bodyWordsFilter).') OR user_id = :loggedInUser)'; $titleWordsCond = 'AND (NOT ('.implode(' OR ', $titleWordsFilter).') OR user_id = :loggedInUser)'; $parameters['loggedInUser'] = $loggedInUser->getId(); } } $falseCond = $user->isDeleted ? ' AND FALSE ' : ''; $hideAdultCond = $hideAdult ? ' AND is_adult = false ' : ''; $sql = "SELECT id, created_at, 'entry' AS type FROM entry WHERE user_id = :userId AND visibility = :visibility $falseCond $hideAdultCond $titleWordsCond $bodyWordsCond UNION ALL SELECT id, created_at, 'entry_comment' AS type FROM entry_comment WHERE user_id = :userId AND visibility = :visibility $falseCond $hideAdultCond $bodyWordsCond UNION ALL SELECT id, created_at, 'post' AS type FROM post WHERE user_id = :userId AND visibility = :visibility $falseCond $hideAdultCond $bodyWordsCond UNION ALL SELECT id, created_at, 'post_comment' AS type FROM post_comment WHERE user_id = :userId AND visibility = :visibility $falseCond $hideAdultCond $bodyWordsCond ORDER BY created_at DESC"; return new NativeQueryAdapter( $this->entityManager->getConnection(), $sql, $parameters, transformer: $this->transformer, cache: $this->cache ); } /** * @throws Exception */ public function findUserPublicActivity(int $page, User $user, bool $hideAdult): PagerfantaInterface { $pagerfanta = new Pagerfanta($this->getUserPublicActivityQueryAdapter($user, $hideAdult)); $pagerfanta->setMaxPerPage(self::PER_PAGE); $pagerfanta->setCurrentPage($page); return $pagerfanta; } /** * @param 'entry'|'post'|'magazine'|'user'|'users+magazines'|'entry+post'|null $specificType */ public function search( ?User $searchingUser, string $query, int $page = 1, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null, ?\DateTimeImmutable $sinceDate = null, int $perPage = SearchRepository::PER_PAGE, ): PagerfantaInterface { $authorWhere = null !== $authorId ? 'AND e.user_id = :authorId' : ''; $magazineWhere = null !== $magazineId ? 'AND e.magazine_id = :magazineId' : ''; $createdWhere = null !== $sinceDate ? 'AND e.created_at >= :since' : ''; $createdWhereMagazine = null !== $sinceDate ? 'AND m.created_at >= :since' : ''; $createdWhereUser = null !== $sinceDate ? 'AND u.created_at >= :since' : ''; $blockMagazineAndUserResult = null !== $authorId || null !== $magazineId ? 'AND false' : ''; $conn = $this->entityManager->getConnection(); $sqlEntry = "SELECT e.id, e.created_at, e.visibility, 2 * ts_rank_cd(e.title_ts, plainto_tsquery(:query)) + ts_rank_cd(e.body_ts, plainto_tsquery(:query)) as rank, 'entry' AS type FROM entry e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE (e.body_ts @@ plainto_tsquery( :query ) = true OR e.title_ts @@ plainto_tsquery( :query ) = true OR e.title LIKE :likeQuery) AND e.visibility = :visibility AND u.is_deleted = false AND (u.ap_discoverable = true OR u.ap_discoverable IS NULL) AND (m.ap_discoverable = true OR m.ap_discoverable IS NULL) AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_id = e.id) $authorWhere $magazineWhere $createdWhere UNION ALL SELECT e.id, e.created_at, e.visibility, 3 * ts_rank_cd(e.body_ts, plainto_tsquery(:query)) as rank, 'entry_comment' AS type FROM entry_comment e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE (e.body_ts @@ plainto_tsquery( :query ) = true) AND e.visibility = :visibility AND u.is_deleted = false AND (u.ap_discoverable = true OR u.ap_discoverable IS NULL) AND (m.ap_discoverable = true OR m.ap_discoverable IS NULL) AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_comment_id = e.id) $authorWhere $magazineWhere $createdWhere "; $sqlPost = "SELECT e.id, e.created_at, e.visibility, 3 * ts_rank_cd(e.body_ts, plainto_tsquery(:query)) as rank, 'post' AS type FROM post e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE (e.body_ts @@ plainto_tsquery( :query ) = true) AND e.visibility = :visibility AND u.is_deleted = false AND (u.ap_discoverable = true OR u.ap_discoverable IS NULL) AND (m.ap_discoverable = true OR m.ap_discoverable IS NULL) AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_id = e.id) $authorWhere $magazineWhere $createdWhere UNION ALL SELECT e.id, e.created_at, e.visibility, 3 * ts_rank_cd(e.body_ts, plainto_tsquery(:query)) as rank, 'post_comment' AS type FROM post_comment e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE (e.body_ts @@ plainto_tsquery( :query ) = true) AND e.visibility = :visibility AND u.is_deleted = false AND (u.ap_discoverable = true OR u.ap_discoverable IS NULL) AND (m.ap_discoverable = true OR m.ap_discoverable IS NULL) AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_comment_id = e.id) $authorWhere $magazineWhere $createdWhere "; $sqlMagazine = "SELECT m.Id, m.created_at, m.visibility, ts_rank_cd(m.name_ts, plainto_tsquery(:query)) + ts_rank_cd(m.title_ts, plainto_tsquery(:query)) + ts_rank_cd(m.description_ts, plainto_tsquery(:query)) as rank, 'magazine' AS type FROM magazine m WHERE (m.name_ts @@ plainto_tsquery( :query ) = true OR m.title_ts @@ plainto_tsquery( :query ) = true OR m.description_ts @@ plainto_tsquery( :query ) = true OR m.title LIKE :likeQuery) AND m.visibility = :visibility AND m.ap_deleted_at IS NULL AND m.marked_for_deletion_at IS NULL AND (m.ap_discoverable = true OR m.ap_discoverable IS NULL) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) $createdWhereMagazine $blockMagazineAndUserResult "; $sqlUser = "SELECT u.Id, u.created_at, u.visibility, ts_rank_cd(u.username_ts, plainto_tsquery(:query)) + ts_rank_cd(u.title_ts, plainto_tsquery(:query)) + ts_rank_cd(u.about_ts, plainto_tsquery(:query)) as rank, 'user' AS type FROM \"user\" u WHERE (u.username_ts @@ plainto_tsquery( :query ) = true OR u.title_ts @@ plainto_tsquery( :query ) = true OR u.about_ts @@ plainto_tsquery( :query ) = true OR u.username LIKE :likeQuery) AND u.visibility = :visibility AND u.is_deleted = false AND u.marked_for_deletion_at IS NULL AND u.ap_deleted_at IS NULL AND (u.ap_discoverable = true OR u.ap_discoverable IS NULL) AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) $createdWhereUser $blockMagazineAndUserResult "; if (null === $specificType) { $sql = "$sqlEntry UNION ALL $sqlPost UNION ALL $sqlMagazine UNION ALL $sqlUser ORDER BY rank DESC, created_at DESC"; } else { $sql = match ($specificType) { 'entry' => "$sqlEntry ORDER BY rank DESC, created_at DESC", 'post' => "$sqlPost ORDER BY rank DESC, created_at DESC", 'magazine' => "$sqlMagazine ORDER BY rank DESC, created_at DESC", 'user' => "$sqlUser ORDER BY rank DESC, created_at DESC", 'users+magazines' => "$sqlMagazine UNION ALL $sqlUser ORDER BY rank DESC, created_at DESC", 'entry+post' => "$sqlEntry UNION ALL $sqlPost ORDER BY rank DESC, created_at DESC", default => throw new \LogicException($specificType.' is not supported'), }; } $parameters = [ 'query' => $query, 'likeQuery' => "%$query%", 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'queryingUser' => $searchingUser?->getId() ?? -1, ]; $this->logger->debug('Search query: {sql}', ['sql' => $sql]); if (null !== $authorId) { $parameters['authorId'] = $authorId; } if (null !== $magazineId) { $parameters['magazineId'] = $magazineId; } if (null !== $sinceDate) { $parameters['since'] = $sinceDate; } $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer); $pagerfanta = new Pagerfanta($adapter); $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); return $pagerfanta; } public function findByApId($url): array { $conn = $this->entityManager->getConnection(); $sql = " SELECT id, created_at, 'entry' AS type FROM entry WHERE ap_id = :url UNION ALL SELECT id, created_at, 'entry_comment' AS type FROM entry_comment WHERE ap_id = :url UNION ALL SELECT id, created_at, 'post' AS type FROM post WHERE ap_id = :url UNION ALL SELECT id, created_at, 'post_comment' AS type FROM post_comment WHERE ap_id = :url UNION ALL SELECT id, created_at, 'user' AS type FROM \"user\" WHERE ap_profile_id = :url OR ap_public_url = :url UNION ALL SELECT id, created_at, 'magazine' AS type FROM magazine WHERE ap_profile_id = :url OR ap_public_url = :url ORDER BY created_at DESC "; $pagerfanta = new Pagerfanta(new NativeQueryAdapter($conn, $sql, [ 'url' => "$url", ], transformer: $this->transformer)); return $pagerfanta->getCurrentPageResults(); } } ================================================ FILE: src/Repository/SettingsRepository.php ================================================ * * @method Settings|null find($id, $lockMode = null, $lockVersion = null) * @method Settings|null findOneBy(array $criteria, array $orderBy = null) * @method Settings[] findAll() * @method Settings[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class SettingsRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Settings::class); } } ================================================ FILE: src/Repository/SiteRepository.php ================================================ 'array', 'comments' => 'array', 'posts' => 'array', 'replies' => 'array'])] public function getOverallStats( ?User $user = null, ?Magazine $magazine = null, ?bool $onlyLocal = null, ): array { $this->user = $user; $this->magazine = $magazine; $this->onlyLocal = $onlyLocal; $entries = $this->getMonthlyStats('entry'); $comments = $this->getMonthlyStats('entry_comment'); $posts = $this->getMonthlyStats('post'); $replies = $this->getMonthlyStats('post_comment'); return $this->prepareContentReturn($entries, $comments, $posts, $replies); } private function getMonthlyStats(string $table): array { $conn = $this->getEntityManager() ->getConnection(); $onlyLocalWhere = $this->onlyLocal ? ' AND e.ap_id IS NULL' : ''; $userWhere = $this->user ? ' AND e.user_id = :userId ' : ''; $magazineWhere = $this->magazine ? ' AND e.magazine_id = :magazineId ' : ''; $sql = "SELECT to_char(e.created_at,'Mon') as month, extract(year from e.created_at) as year, COUNT(e.id) as count FROM $table e INNER JOIN public.user u ON u.id = user_id WHERE u.is_deleted = false $onlyLocalWhere $userWhere $magazineWhere GROUP BY 1,2"; $stmt = $conn->prepare($sql); if ($this->user) { $stmt->bindValue('userId', $this->user->getId()); } elseif ($this->magazine) { $stmt->bindValue('magazineId', $this->magazine->getId()); } $stmt = $stmt->executeQuery(); return array_map(fn ($val) => [ 'month' => date_parse($val['month'])['month'], 'year' => (int) $val['year'], 'count' => (int) $val['count'], ], $stmt->fetchAllAssociative()); } #[ArrayShape(['entries' => 'array', 'comments' => 'array', 'posts' => 'array', 'replies' => 'array'])] public function getStatsByTime(\DateTime $start, ?User $user = null, ?Magazine $magazine = null, ?bool $onlyLocal = null): array { $this->start = $start; $this->user = $user; $this->magazine = $magazine; $this->onlyLocal = $onlyLocal; return [ 'entries' => $this->prepareContentDaily($this->getDailyStats('entry')), 'comments' => $this->prepareContentDaily($this->getDailyStats('entry_comment')), 'posts' => $this->prepareContentDaily($this->getDailyStats('post')), 'replies' => $this->prepareContentDaily($this->getDailyStats('post_comment')), ]; } private function getDailyStats(string $table): array { $conn = $this->getEntityManager() ->getConnection(); $onlyLocalWhere = $this->onlyLocal ? ' AND e.ap_id IS NULL' : ''; $userWhere = $this->user ? ' AND e.user_id = :userId ' : ''; $magazineWhere = $this->magazine ? ' AND e.magazine_id = :magazineId ' : ''; $sql = "SELECT date_trunc('day', e.created_at) as day, COUNT(e.id) as count FROM $table e INNER JOIN public.user u ON e.user_id = u.id WHERE u.is_deleted = false AND e.created_at >= :startDate $userWhere $magazineWhere $onlyLocalWhere GROUP BY 1"; $stmt = $conn->prepare($sql); if ($this->user) { $stmt->bindValue('userId', $this->user->getId()); } elseif ($this->magazine) { $stmt->bindValue('magazineId', $this->magazine->getId()); } $stmt->bindValue('startDate', $this->start->format('Y-m-d H:i:s')); $stmt = $stmt->executeQuery(); $results = $stmt->fetchAllAssociative(); usort($results, fn ($a, $b): int => $a['day'] <=> $b['day']); return $results; } public function getStats(?Magazine $magazine, string $interval, ?\DateTimeImmutable $start, ?\DateTimeImmutable $end, ?bool $onlyLocal): array { switch ($interval) { case 'all': case 'year': case 'month': case 'day': case 'hour': break; default: throw new \LogicException('Invalid interval provided'); } if (null !== $start && null === $end) { $end = $start->modify('+1 '.$interval); } elseif (null === $start && null !== $end) { $start = $end->modify('-1 '.$interval); } return [ 'entry' => $this->aggregateStats('entry', $start, $end, true !== $onlyLocal, $magazine), 'entry_comment' => $this->aggregateStats('entry_comment', $start, $end, true !== $onlyLocal, $magazine), 'post' => $this->aggregateStats('post', $start, $end, true !== $onlyLocal, $magazine), 'post_comment' => $this->aggregateStats('post_comment', $start, $end, true !== $onlyLocal, $magazine), ]; } public function aggregateStats(string $tableName, ?\DateTimeImmutable $sinceDate, ?\DateTimeImmutable $tilDate, bool $federated, ?Magazine $magazine): int { $tableName = match ($tableName) { 'entry' => 'entry', 'entry_comment' => 'entry_comment', 'post' => 'post', 'post_comment' => 'post_comment', default => throw new \InvalidArgumentException("$tableName is not a valid countable"), }; $federatedCond = false === $federated ? ' AND e.ap_id IS NULL ' : ''; $magazineCond = $magazine ? 'AND e.magazine_id = :magId' : ''; $sinceDateCond = $sinceDate ? 'AND e.created_at > :date' : ''; $tilDateCond = $tilDate ? 'AND e.created_at < :untilDate' : ''; $sql = "SELECT COUNT(e.id) as count FROM $tableName e INNER JOIN public.user u ON e.user_id = u.id WHERE u.is_deleted = false $sinceDateCond $tilDateCond $federatedCond $magazineCond"; $rsm = new ResultSetMapping(); $rsm->addScalarResult('count', 0); $query = $this->getEntityManager()->createNativeQuery($sql, $rsm); if (null !== $sinceDate) { $query->setParameter(':date', $sinceDate); } if (null !== $tilDate) { $query->setParameter(':untilDate', $tilDate); } if (null !== $magazine) { $query->setParameter(':magId', $magazine->getId()); } $res = $query->getScalarResult(); if (0 === \sizeof($res) || 0 === \sizeof($res[0])) { return 0; } return $res[0][0]; } public function countLocalPosts(): int { $entries = $this->aggregateStats('entry', null, null, false, null); $posts = $this->aggregateStats('post', null, null, false, null); return $entries + $posts; } public function countLocalComments(): int { $entryComments = $this->aggregateStats('entry_comment', null, null, false, null); $postComments = $this->aggregateStats('post_comment', null, null, false, null); return $entryComments + $postComments; } public function countUsers(?\DateTime $startDate = null): int { $users = $this->getEntityManager()->createQueryBuilder() ->select('COUNT(u.id)') ->from(User::class, 'u') ->where('u.apId IS NULL') ->andWhere('u.isDeleted = false') ; if ($startDate) { $users->andWhere('u.lastActive >= :startDate') ->setParameter('startDate', $startDate); } return $users->getQuery() ->getSingleScalarResult(); } } ================================================ FILE: src/Repository/StatsRepository.php ================================================ [$a['year'], $a['month']] <=> [$b['year'], $b['month']] ); return $results; } protected function prepareContentDaily(array $entries): array { $to = new \DateTime(); $interval = \DateInterval::createFromDateString('1 day'); $period = new \DatePeriod($this->start, $interval, $to); $results = []; foreach ($period as $d) { $existed = array_filter( $entries, fn ($entry) => (new \DateTime($entry['day']))->format('Y-m-d') === $d->format('Y-m-d') ); if (!empty($existed)) { $existed = current($existed); $existed['day'] = new \DateTime($existed['day']); $results[] = $existed; continue; } $results[] = [ 'day' => $d, 'count' => 0, ]; } return $results; } protected function prepareContentOverall(array $entries, int $startYear, int $startMonth): array { $currentMonth = (int) (new \DateTime('now'))->format('n'); $currentYear = (int) (new \DateTime('now'))->format('Y'); $results = []; for ($y = $startYear; $y <= $currentYear; ++$y) { for ($m = 1; $m <= 12; ++$m) { if ($y === $currentYear && $m > $currentMonth) { break; } if ($y === $startYear && $m < $startMonth) { continue; } $existed = array_filter($entries, fn ($entry) => $entry['month'] === $m && (int) $entry['year'] === $y); if (!empty($existed)) { $results[] = current($existed); continue; } $results[] = [ 'month' => $m, 'year' => $y, 'count' => 0, ]; } } return $results; } protected function getStartDate(array $values): array { return array_map(fn ($val) => ['year' => $val['year'], 'month' => $val['month']], $values); } protected function prepareContentReturn(array $entries, array $comments, array $posts, array $replies): array { $startDate = $this->sort( array_merge( $this->getStartDate($entries), $this->getStartDate($comments), $this->getStartDate($posts), $this->getStartDate($replies) ) ); if (empty($startDate) || !\array_key_exists('year', $startDate[0]) || !\array_key_exists('month', $startDate[0])) { return [ 'entries' => [], 'comments' => [], 'posts' => [], 'replies' => [], ]; } return [ 'entries' => $this->prepareContentOverall( $this->sort($entries), $startDate[0]['year'], $startDate[0]['month'] ), 'comments' => $this->prepareContentOverall( $this->sort($comments), $startDate[0]['year'], $startDate[0]['month'] ), 'posts' => $this->prepareContentOverall($this->sort($posts), $startDate[0]['year'], $startDate[0]['month']), 'replies' => $this->prepareContentOverall( $this->sort($replies), $startDate[0]['year'], $startDate[0]['month'] ), ]; } } ================================================ FILE: src/Repository/StatsVotesRepository.php ================================================ 'array', 'comments' => 'array', 'posts' => 'array', 'replies' => 'array'])] public function getOverallStats( ?User $user = null, ?Magazine $magazine = null, ?bool $onlyLocal = null, ): array { $this->user = $user; $this->magazine = $magazine; $this->onlyLocal = $onlyLocal; $entries = $this->getMonthlyStats('entry_vote', 'entry_id'); $comments = $this->getMonthlyStats('entry_comment_vote', 'comment_id'); $posts = $this->getMonthlyStats('post_vote', 'post_id'); $replies = $this->getMonthlyStats('post_comment_vote', 'comment_id'); return $this->prepareContentReturn($entries, $comments, $posts, $replies); } #[ArrayShape([[ 'month' => 'string', 'year' => 'string', 'up' => 'int', 'down' => 'int', 'boost' => 'int', ]])] private function getMonthlyStats(string $table, ?string $relation = null): array { $votes = $this->getMonthlyVoteStats($table, $relation); $favourites = $this->getMonthlyFavouriteStats($table); $dateMap = []; for ($i = 0; $i < \count($votes); ++$i) { $key = $votes[$i]['year'].'-'.$votes[$i]['month']; $dateMap[$key] = $i; $votes[$i]['up'] = 0; } foreach ($favourites as $favourite) { $key = $favourite['year'].'-'.$favourite['month']; if (\array_key_exists($key, $dateMap)) { $i = $dateMap[$key]; $votes[$i]['up'] = $favourite['up']; } else { $votes[] = [ 'year' => $favourite['year'], 'month' => $favourite['month'], 'up' => $favourite['up'], 'boost' => 0, 'down' => 0, ]; } } return array_map(fn ($val) => [ 'month' => date_parse($val['month'])['month'], 'year' => (int) $val['year'], 'up' => (int) $val['up'], 'down' => (int) $val['down'], 'boost' => (int) $val['boost'], ], $votes); } #[ArrayShape([[ 'month' => 'string', 'year' => 'string', 'boost' => 'int', 'down' => 'int', ]])] private function getMonthlyVoteStats(string $table, string $relation): array { $conn = $this->getEntityManager()->getConnection(); $onlyLocalWhere = $this->onlyLocal ? 'AND u.ap_id IS NULL' : ''; $userWhere = $this->user ? ' AND e.user_id = :userId ' : ''; $magazineJoin = $this->magazine ? 'INNER JOIN '.str_replace('_vote', '', $table).' AS parent ON '.$relation.' = parent.id AND parent.magazine_id = :magazineId' : ''; $sql = "SELECT to_char(e.created_at,'Mon') as month, extract(year from e.created_at) as year, COUNT(case e.choice when 1 then 1 else null end) as boost, COUNT(case e.choice when -1 then 1 else null end) as down FROM $table e INNER JOIN public.user u ON u.id = e.user_id $magazineJoin WHERE u.is_deleted = false $onlyLocalWhere $userWhere GROUP BY 1,2"; $stmt = $conn->prepare($sql); if ($this->user) { $stmt->bindValue('userId', $this->user->getId()); } elseif ($this->magazine) { $stmt->bindValue('magazineId', $this->magazine->getId()); } $results = $stmt->executeQuery()->fetchAllAssociative(); if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) { for ($i = 0; $i < \count($results); ++$i) { $results[$i]['down'] = 0; } } return $results; } #[ArrayShape([[ 'month' => 'string', 'year' => 'string', 'up' => 'int', ]])] private function getMonthlyFavouriteStats(string $table): array { $conn = $this->getEntityManager()->getConnection(); $onlyLocalWhere = $this->onlyLocal ? 'AND u.ap_id IS NULL' : ''; $userWhere = $this->user ? ' AND f.user_id = :userId ' : ''; $magazineWhere = $this->magazine ? 'AND f.magazine_id = :magazineId ' : ''; $idCol = str_replace('_vote', '', $table).'_id'; $sql = "SELECT to_char(f.created_at,'Mon') as month, extract(year from f.created_at) as year, COUNT(f.id) as up FROM favourite f INNER JOIN public.user u ON u.id = f.user_id WHERE u.is_deleted = false AND f.$idCol IS NOT NULL $magazineWhere $onlyLocalWhere $userWhere GROUP BY 1,2"; $stmt = $conn->prepare($sql); if ($this->user) { $stmt->bindValue('userId', $this->user->getId()); } elseif ($this->magazine) { $stmt->bindValue('magazineId', $this->magazine->getId()); } $results = $stmt->executeQuery()->fetchAllAssociative(); if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) { for ($i = 0; $i < \count($results); ++$i) { $results[$i]['down'] = 0; } } return $results; } protected function prepareContentOverall(array $entries, int $startYear, int $startMonth): array { $currentMonth = (int) (new \DateTime('now'))->format('n'); $currentYear = (int) (new \DateTime('now'))->format('Y'); $results = []; for ($y = $startYear; $y <= $currentYear; ++$y) { for ($m = 1; $m <= 12; ++$m) { if ($y === $currentYear && $m > $currentMonth) { break; } if ($y === $startYear && $m < $startMonth) { continue; } $existed = array_filter($entries, fn ($entry) => $entry['month'] === $m && (int) $entry['year'] === $y); if (!empty($existed)) { $results[] = current($existed); if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) { $results[0]['down'] = 0; } continue; } $results[] = [ 'month' => $m, 'year' => $y, 'up' => 0, 'down' => 0, 'boost' => 0, ]; } } return $results; } #[ArrayShape(['entries' => 'array', 'comments' => 'array', 'posts' => 'array', 'replies' => 'array'])] public function getStatsByTime(\DateTime $start, ?User $user = null, ?Magazine $magazine = null, ?bool $onlyLocal = null): array { $this->start = $start; $this->user = $user; $this->magazine = $magazine; $this->onlyLocal = $onlyLocal; return [ 'entries' => $this->prepareContentDaily($this->getDailyStats('entry_vote', 'entry_id')), 'comments' => $this->prepareContentDaily($this->getDailyStats('entry_comment_vote', 'comment_id')), 'posts' => $this->prepareContentDaily($this->getDailyStats('post_vote', 'post_id')), 'replies' => $this->prepareContentDaily($this->getDailyStats('post_comment_vote', 'comment_id')), ]; } #[ArrayShape([[ 'day' => 'string', 'up' => 'int', 'down' => 'int', 'boost' => 'int', ]])] protected function prepareContentDaily(array $entries): array { $to = new \DateTime(); $interval = \DateInterval::createFromDateString('1 day'); $period = new \DatePeriod($this->start, $interval, $to); $results = []; foreach ($period as $d) { $existed = array_filter( $entries, fn ($entry) => (new \DateTime($entry['day']))->format('Y-m-d') === $d->format('Y-m-d') ); if (!empty($existed)) { $existed = current($existed); $existed['day'] = new \DateTime($existed['day']); if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) { $existed['down'] = 0; } $results[] = $existed; continue; } $results[] = [ 'day' => $d, 'up' => 0, 'down' => 0, 'boost' => 0, ]; } return $results; } #[ArrayShape([[ 'day' => 'string', 'up' => 'int', 'down' => 'int', 'boost' => 'int', ]])] private function getDailyStats(string $table, string $relation): array { $results = $this->getDailyVoteStats($table, $relation); $dateMap = []; for ($i = 0; $i < \count($results); ++$i) { $dateMap[$results[$i]['day']] = $i; $results[$i]['up'] = 0; } $favourites = $this->getDailyFavouriteStats($table); foreach ($favourites as $favourite) { if (\array_key_exists($favourite['day'], $dateMap)) { $results[$dateMap[$favourite['day']]]['up'] = $favourite['up']; } else { $results[] = [ 'day' => $favourite['day'], 'boost' => 0, 'down' => 0, 'up' => $favourite['up'], ]; } } usort($results, fn ($a, $b): int => $a['day'] <=> $b['day']); return $results; } #[ArrayShape([[ 'day' => 'string', 'down' => 'int', 'boost' => 'int', ]])] private function getDailyVoteStats(string $table, string $relation): array { $conn = $this->getEntityManager()->getConnection(); $onlyLocalWhere = $this->onlyLocal ? ' AND u.ap_id IS NULL ' : ''; $userWhere = $this->user ? ' AND e.user_id = :userId ' : ''; $magazineJoin = $this->magazine ? 'INNER JOIN '.str_replace('_vote', '', $table).' AS parent ON '.$relation.' = parent.id AND parent.magazine_id = :magazineId' : ''; $sql = "SELECT date_trunc('day', e.created_at) as day, COUNT(case e.choice when 1 then 1 else null end) as boost, COUNT(case e.choice when -1 then 1 else null end) as down FROM $table e INNER JOIN public.user u ON u.id = e.user_id $magazineJoin WHERE u.is_deleted = false AND e.created_at >= :startDate $userWhere $onlyLocalWhere GROUP BY 1"; $stmt = $conn->prepare($sql); if ($this->user) { $stmt->bindValue('userId', $this->user->getId()); } if ($this->magazine) { $stmt->bindValue('magazineId', $this->magazine->getId()); } $stmt->bindValue('startDate', $this->start, 'datetime'); $stmt = $stmt->executeQuery(); return $stmt->fetchAllAssociative(); } #[ArrayShape([[ 'day' => 'string', 'up' => 'int', ]])] private function getDailyFavouriteStats(string $table): array { $conn = $this->getEntityManager()->getConnection(); $idCol = str_replace('_vote', '', $table).'_id'; $magazineWhere = $this->magazine ? ' AND f.magazine_id = :magazineId' : ''; $userWhere = $this->user ? ' AND f.user_id = :userId ' : ''; $onlyLocalWhere = $this->onlyLocal ? ' AND u.ap_id IS NULL ' : ''; $favSql = "SELECT date_trunc('day', f.created_at) as day, COUNT(f.id) as up FROM favourite f INNER JOIN public.user u ON f.user_id = u.id WHERE u.is_deleted = false AND f.created_at >= :startDate AND f.$idCol IS NOT NULL $onlyLocalWhere $magazineWhere $userWhere GROUP BY 1"; $stmt = $conn->prepare($favSql); if ($this->user) { $stmt->bindValue('userId', $this->user->getId()); } if ($this->magazine) { $stmt->bindValue('magazineId', $this->magazine->getId()); } $stmt->bindValue('startDate', $this->start, 'datetime'); $stmt = $stmt->executeQuery(); return $stmt->fetchAllAssociative(); } public function getStats(?Magazine $magazine, string $intervalStr, ?\DateTime $start, ?\DateTime $end, ?bool $onlyLocal): array { $this->onlyLocal = $onlyLocal; $interval = $intervalStr ?? 'month'; switch ($interval) { case 'all': return $this->aggregateTotalStats($magazine); case 'year': case 'month': case 'day': case 'hour': break; default: throw new \LogicException('Invalid interval provided'); } $this->start = $start ?? new \DateTime('-1 '.$interval); return $this->aggregateStats($magazine, $interval, $end); } private function aggregateStats(?Magazine $magazine, string $interval, ?\DateTime $end): array { if (null === $end) { $end = new \DateTime(); } if ($end < $this->start) { throw new \LogicException('End date must be after start date!'); } $results = []; foreach (['entry', 'entry_comment', 'post', 'post_comment'] as $table) { $results[$table] = $this->aggregateVoteStats($table, $magazine, $interval, $end); $datemap = []; for ($i = 0; $i < \count($results[$table]); ++$i) { $datemap[$results[$table][$i]['datetime']] = $i; $results[$table][$i]['up'] = 0; if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) { $results[$table][$i]['down'] = 0; } } $favourites = $this->aggregateFavouriteStats($table, $magazine, $interval, $end); foreach ($favourites as $favourite) { if (\array_key_exists($favourite['datetime'], $datemap)) { $results[$table][$datemap[$favourite['datetime']]]['up'] = $favourite['up']; } else { $results[$table][] = [ 'datetime' => $favourite['datetime'], 'boost' => 0, 'down' => 0, 'up' => $favourite['up'], ]; } } usort($results[$table], fn ($a, $b): int => $a['datetime'] <=> $b['datetime']); } return $results; } private function aggregateVoteStats(string $table, ?Magazine $magazine, string $interval, \DateTime $end): array { $conn = $this->getEntityManager()->getConnection(); $relation = false === strstr($table, '_comment') ? $table.'_id' : 'comment_id'; $voteTable = $table.'_vote'; $magazineJoinCond = $magazine ? ' AND parent.magazine_id = ? ' : ''; $onlyLocalWhere = $this->onlyLocal ? 'u.ap_id IS NULL ' : ''; $sql = "SELECT date_trunc(?, e.created_at) as datetime, COUNT(case e.choice when 1 then 1 else null end) as boost, COUNT(case e.choice when -1 then 1 else null end) as down FROM $voteTable e INNER JOIN $table AS parent ON $relation = parent.id INNER JOIN public.user u ON e.user_id = u.id $magazineJoinCond WHERE u.is_deleted = false AND e.created_at BETWEEN ? AND ? $onlyLocalWhere GROUP BY 1 ORDER BY 1"; $stmt = $conn->prepare($sql); $index = 1; $stmt->bindValue($index++, $interval); if ($magazine) { $stmt->bindValue($index++, $magazine->getId(), ParameterType::INTEGER); } $stmt->bindValue($index++, $this->start, 'datetime'); $stmt->bindValue($index++, $end, 'datetime'); return $stmt->executeQuery()->fetchAllAssociative(); } private function aggregateFavouriteStats(string $table, ?Magazine $magazine, string $interval, \DateTime $end): array { $conn = $this->getEntityManager()->getConnection(); $magazineWhere = $magazine ? ' AND e.magazine_id = ? ' : ''; $onlyLocalWhere = $this->onlyLocal ? 'u.ap_id IS NULL ' : ''; $idCol = $table.'_id'; $sql = "SELECT date_trunc(?, e.created_at) as datetime, COUNT(e.id) as up FROM favourite e INNER JOIN public.user u on e.user_id = u.id WHERE u.is_deleted = false AND e.created_at BETWEEN ? AND ? AND e.$idCol IS NOT NULL $magazineWhere $onlyLocalWhere GROUP BY 1 ORDER BY 1"; $stmt = $conn->prepare($sql); $stmt->bindValue(1, $interval); $stmt->bindValue(2, $this->start, 'datetime'); $stmt->bindValue(3, $end, 'datetime'); if ($magazine) { $stmt->bindValue(4, $magazine->getId(), ParameterType::INTEGER); } return $stmt->executeQuery()->fetchAllAssociative(); } private function aggregateTotalStats(?Magazine $magazine): array { $conn = $this->getEntityManager()->getConnection(); $results = []; foreach (['entry', 'entry_comment', 'post', 'post_comment'] as $table) { $relation = false === strstr($table, '_comment') ? $table.'_id' : 'comment_id'; $voteTable = $table.'_vote'; $magazineJoinCond = $magazine ? ' AND parent.magazine_id = ?' : ''; $onlyLocalWhere = $this->onlyLocal ? ' u.ap_id IS NULL ' : ''; $sql = "SELECT COUNT(case e.choice when 1 then 1 else null end) as boost, COUNT(case e.choice when -1 then 1 else null end) as down FROM $voteTable e INNER JOIN public.user u ON e.user_id = u.id INNER JOIN $table AS parent ON $relation = parent.id $magazineJoinCond WHERE u.is_deleted = false $onlyLocalWhere"; $stmt = $conn->prepare($sql); if ($magazine) { $stmt->bindValue(1, $magazine->getId(), ParameterType::INTEGER); } $results[$table] = $stmt->executeQuery()->fetchAllAssociative(); $magazineWhere = $magazine ? ' AND e.magazine_id = ?' : ''; $idCol = $table.'_id'; $sql = "SELECT COUNT(e.id) as up FROM favourite e INNER JOIN public.user u on u.id = e.user_id WHERE u.is_deleted = false $magazineWhere $onlyLocalWhere AND e.$idCol IS NOT NULL"; $stmt = $conn->prepare($sql); if ($magazine) { $stmt->bindValue(1, $magazine->getId(), ParameterType::INTEGER); } $favourites = $stmt->executeQuery()->fetchAllAssociative(); if (0 < \count($results[$table]) && 0 < \count($favourites)) { $results[$table][0]['up'] = $favourites[0]['up']; } elseif (0 < \count($favourites)) { $results[$table][] = [ 'boost' => 0, 'down' => 0, 'up' => $favourites[0]['up'], ]; } else { $results[$table][] = [ 'boost' => 0, 'down' => 0, 'up' => 0, ]; } if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) { $results[$table][0]['down'] = 0; } usort($results[$table], fn ($a, $b): int => $a['datetime'] <=> $b['datetime']); } return $results; } } ================================================ FILE: src/Repository/TagLinkRepository.php ================================================ getTagsOfEntry($content); } elseif ($content instanceof EntryComment) { return $this->getTagsOfEntryComment($content); } elseif ($content instanceof Post) { return $this->getTagsOfPost($content); } elseif ($content instanceof PostComment) { return $this->getTagsOfPostComment($content); } else { // this is unreachable because of the strict types throw new \LogicException('Cannot handle content of type '.\get_class($content)); } } /** * @return string[] */ private function getTagsOfEntry(Entry $entry): array { $result = $this->findBy(['entry' => $entry]); return array_map(fn ($row) => $row->hashtag->tag, $result); } public function removeTagOfEntry(Entry $entry, Hashtag $tag): void { $link = $this->findOneBy(['entry' => $entry, 'hashtag' => $tag]); $this->getEntityManager()->remove($link); $this->getEntityManager()->flush(); } public function addTagToEntry(Entry $entry, Hashtag $tag): void { $link = new HashtagLink(); $link->entry = $entry; $link->hashtag = $tag; $this->getEntityManager()->persist($link); $this->getEntityManager()->flush(); } public function entryHasTag(Entry $entry, Hashtag $tag): bool { return null !== $this->findOneBy(['entry' => $entry, 'hashtag' => $tag]); } /** * @return string[] */ private function getTagsOfEntryComment(EntryComment $entryComment): array { $result = $this->findBy(['entryComment' => $entryComment]); return array_map(fn ($row) => $row->hashtag->tag, $result); } public function removeTagOfEntryComment(EntryComment $entryComment, Hashtag $tag): void { $link = $this->findOneBy(['entryComment' => $entryComment, 'hashtag' => $tag]); $this->getEntityManager()->remove($link); $this->getEntityManager()->flush(); } public function addTagToEntryComment(EntryComment $entryComment, Hashtag $tag): void { $link = new HashtagLink(); $link->entryComment = $entryComment; $link->hashtag = $tag; $this->getEntityManager()->persist($link); $this->getEntityManager()->flush(); } /** * @return string[] */ private function getTagsOfPost(Post $post): array { $result = $this->findBy(['post' => $post]); return array_map(fn ($row) => $row->hashtag->tag, $result); } public function removeTagOfPost(Post $post, Hashtag $tag): void { $link = $this->findOneBy(['post' => $post, 'hashtag' => $tag]); $this->getEntityManager()->remove($link); $this->getEntityManager()->flush(); } public function addTagToPost(Post $post, Hashtag $tag): void { $link = new HashtagLink(); $link->post = $post; $link->hashtag = $tag; $this->getEntityManager()->persist($link); $this->getEntityManager()->flush(); } /** * @return string[] */ private function getTagsOfPostComment(PostComment $postComment): array { $result = $this->findBy(['postComment' => $postComment]); return array_map(fn ($row) => $row->hashtag->tag, $result); } public function removeTagOfPostComment(PostComment $postComment, Hashtag $tag): void { $link = $this->findOneBy(['postComment' => $postComment, 'hashtag' => $tag]); $this->getEntityManager()->remove($link); $this->getEntityManager()->flush(); } public function addTagToPostComment(PostComment $postComment, Hashtag $tag): void { $link = new HashtagLink(); $link->postComment = $postComment; $link->hashtag = $tag; $this->getEntityManager()->persist($link); $this->getEntityManager()->flush(); } } ================================================ FILE: src/Repository/TagRepository.php ================================================ findBy(['tag' => $tag]); $countAll = $this->tagLinkRepository->createQueryBuilder('link') ->select('count(link.id)') ->where('link.hashtag = :tag') ->setParameter(':tag', $hashtag) ->getQuery() ->getSingleScalarResult(); $conn = $this->getEntityManager()->getConnection(); $sql = "SELECT e.id, e.created_at, 'entry' AS type FROM entry e INNER JOIN hashtag_link l ON e.id = l.entry_id INNER JOIN hashtag h ON l.hashtag_id = h.id AND h.tag = :tag WHERE visibility = :visibility UNION ALL SELECT ec.id, ec.created_at, 'entry_comment' AS type FROM entry_comment ec INNER JOIN hashtag_link l ON ec.id = l.entry_comment_id INNER JOIN hashtag h ON l.hashtag_id = h.id AND h.tag = :tag WHERE visibility = :visibility UNION ALL SELECT p.id, p.created_at, 'post' AS type FROM post p INNER JOIN hashtag_link l ON p.id = l.post_id INNER JOIN hashtag h ON l.hashtag_id = h.id AND h.tag = :tag WHERE visibility = :visibility UNION ALL SELECT pc.id, created_at, 'post_comment' AS type FROM post_comment pc INNER JOIN hashtag_link l ON pc.id = l.post_comment_id INNER JOIN hashtag h ON l.hashtag_id = h.id AND h.tag = :tag WHERE visibility = :visibility ORDER BY created_at DESC"; $adapter = new NativeQueryAdapter($conn, $sql, [ 'tag' => $tag, 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, ], $countAll, $this->populationTransformer); $pagerfanta = new Pagerfanta($adapter); try { $pagerfanta->setMaxPerPage(self::PER_PAGE); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function create(string $tag): Hashtag { $entity = new Hashtag(); $entity->tag = $tag; $this->getEntityManager()->persist($entity); $this->getEntityManager()->flush(); return $entity; } #[ArrayShape([ 'entry' => 'int', 'entry_comment' => 'int', 'post' => 'int', 'post_comment' => 'int', ])] public function getCounts(string $tag): ?array { $conn = $this->getEntityManager()->getConnection(); $stmt = $conn->prepare('SELECT COUNT(entry_id) as entry, COUNT(entry_comment_id) as entry_comment, COUNT(post_id) as post, COUNT(post_comment_id) as post_comment FROM hashtag_link INNER JOIN public.hashtag h ON h.id = hashtag_link.hashtag_id AND h.tag = :tag GROUP BY h.tag'); $stmt->bindValue('tag', $tag); $result = $stmt->executeQuery()->fetchAllAssociative(); if (1 === \sizeof($result)) { return $result[0]; } return [ 'entry' => 0, 'entry_comment' => 0, 'post' => 0, 'post_comment' => 0, ]; } } ================================================ FILE: src/Repository/UserBlockRepository.php ================================================ * * @method UserBlock|null find($id, $lockMode = null, $lockVersion = null) * @method UserBlock|null findOneBy(array $criteria, array $orderBy = null) * @method UserBlock[] findAll() * @method UserBlock[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class UserBlockRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, UserBlock::class); } public function findUserBlocksIds(User $user): array { return array_column( $this->createQueryBuilder('ub') ->select('ubu.id') ->join('ub.blocked', 'ubu') ->where('ub.blocker = :user') ->setParameter('user', $user) ->getQuery() ->getResult(), 'id' ); } } ================================================ FILE: src/Repository/UserFollowRepository.php ================================================ * * @method UserFollow|null find($id, $lockMode = null, $lockVersion = null) * @method UserFollow|null findOneBy(array $criteria, array $orderBy = null) * @method UserFollow[] findAll() * @method UserFollow[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class UserFollowRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, UserFollow::class); } public function findUserFollowIds(User $user): array { return array_column( $this->createQueryBuilder('uf') ->select('ufu.id') ->join('uf.following', 'ufu') ->where('uf.follower = :user') ->setParameter('user', $user) ->getQuery() ->getResult(), 'id' ); } } ================================================ FILE: src/Repository/UserFollowRequestRepository.php ================================================ * * @method UserFollowRequest|null find($id, $lockMode = null, $lockVersion = null) * @method UserFollowRequest|null findOneBy(array $criteria, array $orderBy = null) * @method UserFollowRequest[] findAll() * @method UserFollowRequest[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class UserFollowRequestRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, UserFollowRequest::class); } } ================================================ FILE: src/Repository/UserNoteRepository.php ================================================ getEntityManager()->persist($entity); if ($flush) { $this->getEntityManager()->flush(); } } public function loadUserByUsername(string $username): ?User { return $this->loadUserByIdentifier($username); } public function loadUserByIdentifier($val): ?User { return $this->createQueryBuilder('u') ->where('LOWER(u.username) = :email') ->orWhere('LOWER(u.email) = :email') ->setParameter('email', mb_strtolower($val)) ->getQuery() ->getOneOrNullResult(); } public function findFollowing(int $page, User $user, int $perPage = self::PER_PAGE): PagerfantaInterface { $pagerfanta = new Pagerfanta( new CollectionAdapter( $user->follows ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findFollowers(int $page, User $user, int $perPage = self::PER_PAGE): PagerfantaInterface { $pagerfanta = new Pagerfanta( new CollectionAdapter( $user->followers ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findAudience(User $user): array { $dql = 'SELECT COUNT(u.id), u.apInboxUrl FROM '.User::class.' u WHERE u IN ('. 'SELECT IDENTITY(us.follower) FROM '.UserFollow::class.' us WHERE us.following = :user)'. 'AND u.apId IS NOT NULL AND u.isBanned = false AND u.isDeleted = false AND u.apTimeoutAt IS NULL '. 'GROUP BY u.apInboxUrl'; $res = $this->getEntityManager()->createQuery($dql) ->setParameter('user', $user) ->getResult(); return array_map(fn ($item) => $item['apInboxUrl'], $res); } public function findBlockedUsers(int $page, User $user, int $perPage = self::PER_PAGE): PagerfantaInterface { $pagerfanta = new Pagerfanta( new CollectionAdapter( $user->blocks ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findAllActivePaginated(int $page, bool $onlyLocal, ?string $searchTerm = null, ?OrderBy $orderBy = null): PagerfantaInterface { $builder = $this->createBasicQueryBuilder($onlyLocal, $searchTerm); $builder ->andWhere('u.visibility = :visibility') ->andWhere('u.isDeleted = false') ->andWhere('u.isBanned = false') ->andWhere('u.applicationStatus = :status') ->setParameter('status', EApplicationStatus::Approved->value) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE); return $this->executeBasicQueryBuilder($builder, $page, $orderBy); } public function findAllInactivePaginated(int $page, bool $onlyLocal = true, ?string $searchTerm = null, ?OrderBy $orderBy = null): PagerfantaInterface { $builder = $this->createBasicQueryBuilder($onlyLocal, $searchTerm, needToBeVerified: false); $builder->andWhere('u.visibility = :visibility') ->andWhere('u.isVerified = false') ->andWhere('u.isDeleted = false') ->andWhere('u.isBanned = false') ->andWhere('u.applicationStatus = :status') ->setParameter('status', EApplicationStatus::Approved->value) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE); return $this->executeBasicQueryBuilder($builder, $page, $orderBy); } public function findAllBannedPaginated(int $page, bool $onlyLocal = false, ?string $searchTerm = null, ?OrderBy $orderBy = null): PagerfantaInterface { $builder = $this->createBasicQueryBuilder($onlyLocal, $searchTerm); $builder ->andWhere('u.isBanned = true') ->andWhere('u.isDeleted = false'); return $this->executeBasicQueryBuilder($builder, $page, $orderBy); } public function findAllSuspendedPaginated(int $page, bool $onlyLocal = false, ?string $searchTerm = null, ?OrderBy $orderBy = null): PagerfantaInterface { $builder = $this->createBasicQueryBuilder($onlyLocal, $searchTerm); $builder ->andWhere('u.visibility = :visibility') ->andWhere('u.isDeleted = false') ->setParameter('visibility', VisibilityInterface::VISIBILITY_TRASHED); return $this->executeBasicQueryBuilder($builder, $page, $orderBy); } public function findForDeletionPaginated(int $page): PagerfantaInterface { $builder = $this->createBasicQueryBuilder(onlyLocal: true, searchTerm: null) ->andWhere('u.visibility = :visibility') ->setParameter('visibility', VisibilityInterface::VISIBILITY_SOFT_DELETED); return $this->executeBasicQueryBuilder($builder, $page, new OrderBy('u.markedForDeletionAt', 'ASC')); } /** * @param bool|null $needToBeVerified this is only relevant if $onlyLocal is true, requires the user to be verified */ private function createBasicQueryBuilder(bool $onlyLocal, ?string $searchTerm, ?bool $needToBeVerified = true): QueryBuilder { $builder = $this->createQueryBuilder('u'); if ($onlyLocal) { $builder->where('u.apId IS NULL'); if ($needToBeVerified) { $builder->andWhere('u.isVerified = true'); } } else { $builder->where('u.apId IS NOT NULL'); } if ($searchTerm) { $builder ->andWhere('lower(u.username) LIKE lower(:searchTerm) OR lower(u.email) LIKE lower(:searchTerm)') ->setParameter('searchTerm', '%'.$searchTerm.'%'); } return $builder; } private function executeBasicQueryBuilder(QueryBuilder $builder, int $page, ?OrderBy $orderBy = null): Pagerfanta { if (null === $orderBy) { $orderBy = new OrderBy('u.createdAt', 'ASC'); } $query = $builder ->orderBy($orderBy) ->getQuery(); $pagerfanta = new Pagerfanta(new QueryAdapter($query)); try { $pagerfanta->setMaxPerPage(self::PER_PAGE); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void { if (!$user instanceof User) { throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', $user::class)); } $user->setPassword($newHashedPassword); $this->getEntityManager()->persist($user); $this->getEntityManager()->flush(); } public function findOneByUsername(string $username): ?User { return $this->createQueryBuilder('u') ->Where('LOWER(u.username) = LOWER(:username)') ->andWhere('u.applicationStatus = :status') ->setParameter('status', EApplicationStatus::Approved->value) ->setParameter('username', $username) ->getQuery() ->getOneOrNullResult(); } public function findByUsernames(array $users): array { return $this->createQueryBuilder('u') ->where('u.username IN (?1)') ->andWhere('u.applicationStatus = :status') ->setParameter('status', EApplicationStatus::Approved->value) ->setParameter(1, $users) ->getQuery() ->getResult(); } public function findWithoutKeys(): array { return $this->createQueryBuilder('u') ->where('u.privateKey IS NULL') ->andWhere('u.apId IS NULL') ->getQuery() ->getResult(); } /** * @return User[] */ public function findAllRemote(): array { return $this->createQueryBuilder('u') ->where('u.apId IS NOT NULL') ->getQuery() ->getResult(); } public function findRemoteForUpdate(): array { return $this->createQueryBuilder('u') ->where('u.apId IS NOT NULL') ->andWhere('u.apDomain IS NULL') ->andWhere('u.apDeletedAt IS NULL') ->andWhere('u.apTimeoutAt IS NULL') ->addOrderBy('u.apFetchedAt', 'ASC') ->setMaxResults(1000) ->getQuery() ->getResult(); } private function findUsersQueryBuilder(string $group, ?bool $recentlyActive = true): QueryBuilder { $qb = $this->createQueryBuilder('u'); $qb->where('u.visibility = :visibility') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE); if ($recentlyActive) { $qb->andWhere('u.lastActive >= :lastActive') ->setParameter('lastActive', (new \DateTime())->modify('-7 days')); } switch ($group) { case self::USERS_LOCAL: $qb->andWhere('u.apId IS NULL'); break; case self::USERS_REMOTE: $qb->andWhere('u.apId IS NOT NULL') ->andWhere('u.apDiscoverable = true'); break; } return $qb ->andWhere('u.isDeleted = false') ->andWhere('u.apDiscoverable = true') ->andWhere('u.applicationStatus = :status') ->setParameter('status', EApplicationStatus::Approved->value) ->orderBy('u.lastActive', 'DESC'); } public function findPaginated(int $page, bool $needsAbout, string $group = self::USERS_ALL, int $perPage = self::PER_PAGE, ?string $query = null): PagerfantaInterface { $query = $this->findQueryBuilder($group, $query, $needsAbout)->getQuery(); $pagerfanta = new Pagerfanta( new QueryAdapter( $query ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } private function findQueryBuilder(string $group, ?string $query, bool $needsAbout): QueryBuilder { $qb = $this->createQueryBuilder('u'); if ($needsAbout) { $qb->andWhere('u.about != \'\'') ->andWhere('u.about IS NOT NULL'); } if (null !== $query) { $qb->andWhere('u.username LIKE :query') ->setParameter('query', '%'.$query.'%'); } switch ($group) { case self::USERS_LOCAL: $qb->andWhere('u.apId IS NULL'); break; case self::USERS_REMOTE: $qb->andWhere('u.apId IS NOT NULL') ->andWhere('u.apDiscoverable = true'); break; } return $qb ->andWhere('u.applicationStatus = :status') ->setParameter('status', EApplicationStatus::Approved->value) ->orderBy('u.lastActive', 'DESC'); } public function findUsersForGroup(string $group = self::USERS_ALL, ?bool $recentlyActive = true): array { return $this->findUsersQueryBuilder($group, $recentlyActive)->setMaxResults(28)->getQuery()->getResult(); } private function findBannedQueryBuilder(string $group): QueryBuilder { $qb = $this->createQueryBuilder('u') ->andWhere('u.isBanned = true'); switch ($group) { case self::USERS_LOCAL: $qb->andWhere('u.apId IS NULL'); break; case self::USERS_REMOTE: $qb->andWhere('u.apId IS NOT NULL') ->andWhere('u.apDiscoverable = true'); break; } return $qb->orderBy('u.lastActive', 'DESC'); } public function findBannedPaginated( int $page, string $group = self::USERS_ALL, int $perPage = self::PER_PAGE, ): PagerfantaInterface { $query = $this->findBannedQueryBuilder($group)->getQuery(); $pagerfanta = new Pagerfanta( new QueryAdapter( $query ) ); try { $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } public function findAdmin(): User { // @todo orderBy lastActivity $result = $this->createQueryBuilder('u') ->andWhere("JSONB_CONTAINS(u.roles, '\"".'ROLE_ADMIN'."\"') = true") ->andWhere('u.isDeleted = false') ->andWhere('u.applicationStatus = :status') ->setParameter('status', EApplicationStatus::Approved->value) ->getQuery() ->getResult(); if (0 === \sizeof($result)) { throw new \Exception('the server must always have an active admin account'); } return $result[0]; } /** * @return User[] */ public function findAllAdmins(): array { return $this->createQueryBuilder('u') ->andWhere("JSONB_CONTAINS(u.roles, '\"".'ROLE_ADMIN'."\"') = true") ->andWhere('u.isDeleted = false') ->andWhere('u.applicationStatus = :status') ->setParameter('status', EApplicationStatus::Approved->value) ->getQuery() ->getResult(); } /** * @return User[] */ public function findUsersSuggestions(string $query): array { $qb = $this->createQueryBuilder('u'); return $qb ->andWhere($qb->expr()->like('u.username', ':query')) ->orWhere($qb->expr()->like('u.email', ':query')) ->andWhere('u.isBanned = false') ->andWhere('u.isDeleted = false') ->andWhere('u.applicationStatus = :status') ->setParameter('query', "{$query}%") ->setParameter('status', EApplicationStatus::Approved->value) ->setMaxResults(5) ->getQuery() ->getResult(); } public function findUsersForMagazine(Magazine $magazine, ?bool $federated = false, int $limit = 200, bool $limitTime = false, bool $requireAvatar = false): array { $output = $this->findForMagazineUsersByContentCount($magazine, $federated, $limit, $limitTime, $requireAvatar); $userIds = array_map(fn ($item) => $item['user_id'], $output); $qb = $this->createQueryBuilder('u', 'u.id'); $qb->andWhere($qb->expr()->in('u.id', $userIds)); $qb->setMaxResults($limit); try { $users = $qb->getQuery()->getResult(); // @todo } catch (\Exception $e) { return []; } $res = []; $i = 0; foreach ($output as $item) { if (isset($users[$item['user_id']])) { $res[] = $users[$item['user_id']]; ++$i; } if ($i >= $limit) { break; } } return $res; } /** * @return array * * @throws Exception * @throws \DateMalformedStringException */ public function findActiveUsers(?Magazine $magazine = null): array { if ($magazine) { $results = $this->findUsersForMagazine($magazine, null, 35, $magazine->getContentCount() > 1000, true); } else { $qb = $this->createQueryBuilder('u') ->andWhere('u.applicationStatus = :status') ->andWhere('u.lastActive >= :lastActive') ->andWhere('u.isBanned = false') ->andWhere('u.isDeleted = false') ->andWhere('u.visibility = :visibility') ->andWhere('u.apDiscoverable = true') ->andWhere('u.apDeletedAt IS NULL') ->andWhere('u.apTimeoutAt IS NULL') ->andWhere('u.avatar IS NOT NULL'); if ($this->settingsManager->get('MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY')) { $qb->andWhere('u.apId IS NULL'); } $queryResult = $qb->join('u.avatar', 'a') ->orderBy('u.lastActive', 'DESC') ->setParameter('lastActive', (new \DateTime())->modify('-7 days')) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setParameter('status', EApplicationStatus::Approved->value) ->setMaxResults(35) ->getQuery() ->getResult(); $results = array_map(fn (User $user) => ['user_id' => $user->getId(), 'sum' => null], $queryResult); } shuffle($results); return \array_slice($results, 0, 12); } public function findByProfileIds(array $arr): array { return $this->createQueryBuilder('u') ->andWhere('u.apProfileId IN (:arr)') ->setParameter('arr', $arr) ->getQuery() ->getResult(); } public function findModerators(int $page = 1): PagerfantaInterface { $query = $this->createQueryBuilder('u') ->where("JSONB_CONTAINS(u.roles, '\"".'ROLE_MODERATOR'."\"') = true") ->andWhere('u.visibility = :visibility') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE); $pagerfanta = new Pagerfanta( new QueryAdapter( $query ) ); try { $pagerfanta->setMaxPerPage(self::PER_PAGE); $pagerfanta->setCurrentPage($page); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } return $pagerfanta; } /** * @return User[] */ public function findAllModerators(): array { return $this->createQueryBuilder('u') ->where("JSONB_CONTAINS(u.roles, '\"".'ROLE_MODERATOR'."\"') = true") ->andWhere('u.visibility = :visibility') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->getQuery() ->getResult() ; } public function findAllSignupRequestsPaginated(int $page = 1): PagerfantaInterface { $query = $this->createQueryBuilder('u') ->where('u.applicationStatus = :status') ->andWhere('u.apId IS NULL') ->andWhere('u.isDeleted = false') ->andWhere('u.markedForDeletionAt IS NULL') ->setParameter('status', EApplicationStatus::Pending->value) ->getQuery(); $fanta = new Pagerfanta(new QueryAdapter($query)); $fanta->setCurrentPage($page); $fanta->setMaxPerPage(self::PER_PAGE); return $fanta; } public function findSignupRequest(string $username): ?User { return $this->createQueryBuilder('u') ->where('u.applicationStatus = :status') ->andWhere('u.apId IS NULL') ->andWhere('u.isDeleted = false') ->andWhere('u.markedForDeletionAt IS NULL') ->andWhere('u.username = :username') ->setParameter('status', EApplicationStatus::Pending->value) ->setParameter('username', $username) ->getQuery() ->getOneOrNullResult(); } /** * @return array * * @throws Exception */ public function findForMagazineUsersByContentCount(Magazine $magazine, ?bool $federated, int $limit, bool $limitTime, bool $requireAvatar): array { $conn = $this->getEntityManager()->getConnection(); $userWhere = [ 'u.is_banned = false', 'u.is_deleted = false', 'u.application_status = :status', 'u.visibility = :visibility', 'u.ap_discoverable = true', 'u.ap_deleted_at IS NULL', 'u.ap_timeout_at IS NULL', ]; if ($requireAvatar) { $userWhere[] = 'u.avatar_id IS NOT NULL'; } if (true === $federated) { $userWhere[] = 'u.ap_id IS NOT NULL'; } elseif (false === $federated) { $userWhere[] = 'u.ap_id IS NULL'; } $userWhereString = SqlHelpers::makeWhereString($userWhere); $timeWhere = $limitTime ? "AND created_at > now() - '30 days'::interval" : ''; $sql = " SELECT user_id, SUM(count) FROM ( (SELECT count(id), user_id FROM entry WHERE magazine_id = :magazineId $timeWhere GROUP BY user_id ORDER BY count DESC LIMIT :limit) UNION ALL (SELECT count(id), user_id FROM entry_comment WHERE magazine_id = :magazineId $timeWhere GROUP BY user_id ORDER BY count DESC LIMIT :limit) UNION ALL (SELECT count(id), user_id FROM post WHERE magazine_id = :magazineId $timeWhere GROUP BY user_id ORDER BY count DESC LIMIT :limit) UNION ALL (SELECT count(id), user_id FROM post_comment WHERE magazine_id = :magazineId $timeWhere GROUP BY user_id ORDER BY count DESC LIMIT :limit) ) grouping INNER JOIN \"user\" u ON u.id = user_id $userWhereString GROUP BY user_id ORDER BY sum DESC LIMIT :limit "; $stmt = $conn->prepare($sql); $stmt->bindValue('magazineId', $magazine->getId()); $stmt->bindValue('limit', $limit); $stmt->bindValue('visibility', VisibilityInterface::VISIBILITY_VISIBLE); $stmt->bindValue('status', EApplicationStatus::Approved->value); return $stmt->executeQuery()->fetchAllAssociative(); } public function findOldestUser(): ?User { $qb = $this->createQueryBuilder('u') ->where('u.apId IS NULL') ->orderBy('u.createdAt', Order::Ascending->value); $result = $qb->setMaxResults(1) ->getQuery() ->getResult(); if (0 === \count($result)) { return null; } return $result[0]; } } ================================================ FILE: src/Repository/VoteRepository.php ================================================ entityManager->getConnection(); $sql = "SELECT COUNT(e.id) as cnt FROM $table e INNER JOIN public.user u ON user_id=u.id {$this->where($date, $withFederated)}"; $stmt = $conn->prepare($sql); if (null !== $date) { $stmt->bindValue(':date', $date, 'datetime'); } $stmt = $stmt->executeQuery(); $count += $stmt->fetchAllAssociative()[0]['cnt']; } return $count; } private function where(?\DateTimeImmutable $date = null, ?bool $withFederated = null): string { $where = 'WHERE u.is_deleted = false'; $dateWhere = $date ? ' AND e.created_at > :date' : ''; $federationWhere = $withFederated ? '' : ' AND u.ap_id IS NULL'; return $where.$dateWhere.$federationWhere; } } ================================================ FILE: src/Scheduler/MbinTaskProvider.php ================================================ schedule) { $this->schedule = (new Schedule()) ->add( RecurringMessage::every('1 day', new ClearDeletedUserMessage()), RecurringMessage::every('1 day', new ClearDeadMessagesMessage()), ) ->stateful($this->cache); } return $this->schedule; } } ================================================ FILE: src/Schema/ContentSchema.php ================================================ getCurrentCursor(); $this->currentCursor = $this->cursorToString($current[0]); $this->currentCursor2 = $this->cursorToString($current[1]); $next = $pagerfanta->hasNextPage() ? $pagerfanta->getNextPage() : null; $this->nextCursor = $next ? $this->cursorToString($next[0]) : null; $this->nextCursor2 = $next ? $this->cursorToString($next[1]) : null; $previous = $pagerfanta->hasPreviousPage() ? $pagerfanta->getPreviousPage() : null; $this->previousCursor = $previous ? $this->cursorToString($previous[0]) : null; $this->previousCursor2 = $previous ? $this->cursorToString($previous[1]) : null; $this->perPage = $pagerfanta->getMaxPerPage(); } private function cursorToString(mixed $cursor): string { if ($cursor instanceof \DateTime || $cursor instanceof \DateTimeImmutable) { return $cursor->format(DATE_ATOM); } elseif (\is_int($cursor)) { return ''.$cursor; } return $cursor->__toString(); } public function jsonSerialize(): array { return [ 'currentCursor' => $this->currentCursor, 'currentCursor2' => $this->currentCursor2, 'nextCursor' => $this->nextCursor, 'nextCursor2' => $this->nextCursor2, 'previousCursor' => $this->previousCursor, 'previousCursor2' => $this->previousCursor2, 'perPage' => $this->perPage, ]; } } ================================================ FILE: src/Schema/Errors/BadRequestErrorSchema.php ================================================ count = $pagerfanta->count(); $this->currentPage = $pagerfanta->getCurrentPage(); $this->maxPage = $pagerfanta->getNbPages(); $this->perPage = $pagerfanta->getMaxPerPage(); } public function jsonSerialize(): mixed { return [ 'count' => $this->count, 'currentPage' => $this->currentPage, 'maxPage' => $this->maxPage, 'perPage' => $this->perPage, ]; } } ================================================ FILE: src/Schema/SearchActorSchema.php ================================================ attributes->get('_route'); } public function authenticate(Request $request): Passport { $client = $this->clientRegistry->getClient('authentik'); $slugger = $this->slugger; $provider = $client->getOAuth2Provider(); $accessToken = $provider->getAccessToken('authorization_code', [ 'code' => $request->query->get('code'), ]); $rememberBadge = new RememberMeBadge(); $rememberBadge = $rememberBadge->enable(); return new SelfValidatingPassport( new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) { /** @var AuthentikResourceOwner $authentikUser */ $authentikUser = $client->fetchUserFromToken($accessToken); $existingUser = $this->entityManager->getRepository(User::class)->findOneBy( ['oauthAuthentikId' => $authentikUser->getId()] ); if ($existingUser) { return $existingUser; } $user = $this->userRepository->findOneBy(['email' => $authentikUser->getEmail()]); if ($user) { $user->oauthAuthentikId = $authentikUser->getId(); $this->entityManager->persist($user); $this->entityManager->flush(); return $user; } if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) { throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED'); } $username = $slugger->slug($authentikUser->toArray()['preferred_username']); if ($this->userRepository->count(['username' => $username]) > 0) { $username .= rand(1, 999); $request->getSession()->set('is_newly_created', true); } $dto = (new UserDto())->create( $username, $authentikUser->getEmail() ); $avatar = $this->getAvatar($authentikUser->getPictureUrl()); if ($avatar) { $dto->avatar = $this->imageFactory->createDto($avatar); } $dto->plainPassword = bin2hex(random_bytes(20)); $dto->ip = $this->ipResolver->resolve(); $user = $this->userManager->create($dto, false); $user->oauthAuthentikId = $authentikUser->getId(); $user->avatar = $this->getAvatar($authentikUser->getPictureUrl()); $user->isVerified = true; $this->entityManager->persist($user); $this->entityManager->flush(); return $user; }), [ $rememberBadge, ] ); } private function getAvatar(?string $pictureUrl): ?Image { if (!$pictureUrl) { return null; } try { $tempFile = $this->imageManager->download($pictureUrl); } catch (\Exception $e) { $tempFile = null; } if ($tempFile) { $image = $this->imageRepository->findOrCreateFromPath($tempFile); if ($image) { $this->entityManager->persist($image); $this->entityManager->flush(); } } return $image ?? null; } } ================================================ FILE: src/Security/AzureAuthenticator.php ================================================ attributes->get('_route'); } public function authenticate(Request $request): Passport { $client = $this->clientRegistry->getClient('azure'); $slugger = $this->slugger; $provider = $client->getOAuth2Provider(); $accessToken = $provider->getAccessToken('authorization_code', [ 'code' => $request->query->get('code'), ]); $rememberBadge = new RememberMeBadge(); $rememberBadge = $rememberBadge->enable(); return new SelfValidatingPassport( new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) { /** @var AzureResourceOwner $azureUser */ $azureUser = $client->fetchUserFromToken($accessToken); $existingUser = $this->entityManager->getRepository(User::class)->findOneBy( ['oauthAzureId' => $azureUser->getUpn()] ); if ($existingUser) { return $existingUser; } $user = $this->userRepository->findOneBy(['email' => $azureUser->getUpn()]); if ($user) { $user->oauthAzureId = $azureUser->getUpn(); $this->entityManager->persist($user); $this->entityManager->flush(); return $user; } if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) { throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED'); } $username = $slugger->slug($azureUser->toArray()['name']); if ($this->userRepository->count(['username' => $username]) > 0) { $username .= rand(1, 999); $request->getSession()->set('is_newly_created', true); } $dto = (new UserDto())->create( $username, $azureUser->getUpn() ); $dto->plainPassword = bin2hex(random_bytes(20)); $dto->ip = $this->ipResolver->resolve(); $user = $this->userManager->create($dto, false); $user->oauthAzureId = $azureUser->getUpn(); $user->isVerified = true; $this->entityManager->persist($user); $this->entityManager->flush(); return $user; }), [ $rememberBadge, ] ); } } ================================================ FILE: src/Security/DiscordAuthenticator.php ================================================ attributes->get('_route'); } public function authenticate(Request $request): Passport { $client = $this->clientRegistry->getClient('discord'); $slugger = $this->slugger; $session = $this->requestStack->getSession(); $accessToken = $this->fetchAccessToken($client, ['prompt' => 'consent', 'accessType' => 'offline']); $session->set('access_token', $accessToken); $accessToken = $session->get('access_token'); if ($accessToken->hasExpired()) { $accessToken = $client->refreshAccessToken($accessToken->getRefreshToken()); $session->set('access_token', $accessToken); } $rememberBadge = new RememberMeBadge(); $rememberBadge = $rememberBadge->enable(); return new SelfValidatingPassport( new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) { /** @var DiscordResourceOwner $discordUser */ $discordUser = $client->fetchUserFromToken($accessToken); $existingUser = $this->entityManager->getRepository(User::class)->findOneBy( ['oauthDiscordId' => $discordUser->getId()] ); if ($existingUser) { return $existingUser; } $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $discordUser->getEmail()] ); if ($user) { $user->oauthDiscordId = $discordUser->getId(); $this->entityManager->persist($user); $this->entityManager->flush(); return $user; } if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) { throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED'); } $dto = (new UserDto())->create( $slugger->slug($discordUser->getUsername()).rand(1, 999), $discordUser->getEmail() ); $dto->plainPassword = bin2hex(random_bytes(20)); $dto->ip = $this->ipResolver->resolve(); $user = $this->userManager->create($dto, false); $user->oauthDiscordId = $discordUser->getId(); $user->isVerified = true; $this->entityManager->persist($user); $this->entityManager->flush(); $request->getSession()->set('is_newly_created', true); return $user; }), [ $rememberBadge, ] ); } } ================================================ FILE: src/Security/EmailVerifier.php ================================================ verifyEmailHelper->generateSignature( $verifyEmailRouteName, (string) $user->getId(), $user->email, ['id' => $user->getId()] ); $context = $email->getContext(); $context['signedUrl'] = $signatureComponents->getSignedUrl(); $context['expiresAtMessageKey'] = $signatureComponents->getExpirationMessageKey(); $context['expiresAtMessageData'] = $signatureComponents->getExpirationMessageData(); $email->context($context); $this->mailer->send($email); } /** * @throws VerifyEmailExceptionInterface */ public function handleEmailConfirmation(Request $request, UserInterface $user): void { $this->verifyEmailHelper->validateEmailConfirmation($request->getUri(), (string) $user->getId(), $user->email); $user->isVerified = true; $this->entityManager->persist($user); $this->entityManager->flush(); } } ================================================ FILE: src/Security/FacebookAuthenticator.php ================================================ attributes->get('_route'); } public function authenticate(Request $request): Passport { $client = $this->clientRegistry->getClient('facebook'); $slugger = $this->slugger; $accessToken = $this->fetchAccessToken($client); try { $provider = $client->getOAuth2Provider(); $accessToken = $provider->getLongLivedAccessToken($accessToken->getToken()); } catch (\Exception $e) { } $rememberBadge = new RememberMeBadge(); $rememberBadge = $rememberBadge->enable(); return new SelfValidatingPassport( new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) { /** @var FacebookUser $facebookUser */ $facebookUser = $client->fetchUserFromToken($accessToken); $existingUser = $this->entityManager->getRepository(User::class)->findOneBy( ['oauthFacebookId' => $facebookUser->getId()] ); if ($existingUser) { return $existingUser; } $user = $this->entityManager->getRepository(User::class)->findOneBy( ['email' => $facebookUser->getEmail()] ); if ($user) { $user->oauthFacebookId = $facebookUser->getId(); $this->entityManager->persist($user); $this->entityManager->flush(); return $user; } if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) { throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED'); } $dto = (new UserDto())->create( $slugger->slug($facebookUser->getName()).rand(1, 999), $facebookUser->getEmail() ); $avatar = $this->getAvatar($facebookUser->getPictureUrl()); if ($avatar) { $dto->avatar = $this->imageFactory->createDto($avatar); } $dto->plainPassword = bin2hex(random_bytes(20)); $dto->ip = $this->ipResolver->resolve(); $user = $this->userManager->create($dto, false); $user->oauthFacebookId = $facebookUser->getId(); $user->avatar = $this->getAvatar($facebookUser->getPictureUrl()); $user->isVerified = true; $this->entityManager->persist($user); $this->entityManager->flush(); $request->getSession()->set('is_newly_created', true); return $user; }), [ $rememberBadge, ] ); } private function getAvatar(?string $pictureUrl): ?Image { if (!$pictureUrl) { return null; } try { $tempFile = $this->imageManager->download($pictureUrl); } catch (\Exception $e) { $tempFile = null; } if ($tempFile) { $image = $this->imageRepository->findOrCreateFromPath($tempFile); if ($image) { $this->entityManager->persist($image); $this->entityManager->flush(); } } return $image ?? null; } } ================================================ FILE: src/Security/GithubAuthenticator.php ================================================ attributes->get('_route'); } public function authenticate(Request $request): Passport { $client = $this->clientRegistry->getClient('github'); $accessToken = $this->fetchAccessToken($client); $slugger = $this->slugger; return new SelfValidatingPassport( new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) { /** @var GithubResourceOwner $githubUser */ $githubUser = $client->fetchUserFromToken($accessToken); $existingUser = $this->entityManager->getRepository(User::class)->findOneBy( ['oauthGithubId' => \strval($githubUser->getId())] ); if ($existingUser) { return $existingUser; } $user = $this->entityManager->getRepository(User::class)->findOneBy( ['email' => $githubUser->getEmail()] ); if ($user) { $user->oauthGithubId = \strval($githubUser->getId()); $this->entityManager->persist($user); $this->entityManager->flush(); return $user; } if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) { throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED'); } $dto = (new UserDto())->create( $slugger->slug($githubUser->getNickname()).rand(1, 999), $githubUser->getEmail(), null ); $dto->plainPassword = bin2hex(random_bytes(20)); $user = $this->userManager->create($dto, false); $user->oauthGithubId = \strval($githubUser->getId()); $user->isVerified = true; $this->entityManager->persist($user); $this->entityManager->flush(); $request->getSession()->set('is_newly_created', true); return $user; }) ); } } ================================================ FILE: src/Security/GoogleAuthenticator.php ================================================ attributes->get('_route'); } public function authenticate(Request $request): Passport { $client = $this->clientRegistry->getClient('google'); $slugger = $this->slugger; $session = $this->requestStack->getSession(); $accessToken = $this->fetchAccessToken($client, ['prompt' => 'consent', 'accessType' => 'offline']); $session->set('access_token', $accessToken); $accessToken = $session->get('access_token'); if ($accessToken->hasExpired()) { $accessToken = $client->refreshAccessToken($accessToken->getRefreshToken()); $session->set('access_token', $accessToken); } $rememberBadge = new RememberMeBadge(); $rememberBadge = $rememberBadge->enable(); return new SelfValidatingPassport( new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) { /** @var GoogleUser $googleUser */ $googleUser = $client->fetchUserFromToken($accessToken); $existingUser = $this->entityManager->getRepository(User::class)->findOneBy( ['oauthGoogleId' => $googleUser->getId()] ); if ($existingUser) { return $existingUser; } $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $googleUser->getEmail()] ); if ($user) { $user->oauthGoogleId = $googleUser->getId(); $this->entityManager->persist($user); $this->entityManager->flush(); return $user; } if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) { throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED'); } $dto = (new UserDto())->create( $slugger->slug($googleUser->getName()).rand(1, 999), $googleUser->getEmail() ); $avatar = $this->getAvatar($googleUser->getAvatar()); if ($avatar) { $dto->avatar = $this->imageFactory->createDto($avatar); } $dto->plainPassword = bin2hex(random_bytes(20)); $dto->ip = $this->ipResolver->resolve(); $user = $this->userManager->create($dto, false); $user->oauthGoogleId = $googleUser->getId(); $user->isVerified = true; $this->entityManager->persist($user); $this->entityManager->flush(); $request->getSession()->set('is_newly_created', true); return $user; }), [ $rememberBadge, ] ); } private function getAvatar(?string $pictureUrl): ?Image { if (!$pictureUrl) { return null; } try { $tempFile = $this->imageManager->download($pictureUrl); } catch (\Exception $e) { $tempFile = null; } if ($tempFile) { $image = $this->imageRepository->findOrCreateFromPath($tempFile); if ($image) { $this->entityManager->persist($image); $this->entityManager->flush(); } } return $image ?? null; } } ================================================ FILE: src/Security/KbinAuthenticator.php ================================================ urlGenerator = $urlGenerator; } public function authenticate(Request $request): Passport { $email = trim($request->request->get('email', '')); $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $email); return new Passport( new UserBadge($email), new PasswordCredentials($request->request->get('password', '')), [ new RememberMeBadge(), new CsrfTokenBadge('authenticate', $request->get('_csrf_token')), ] ); } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { return new RedirectResponse($targetPath); } else { return new RedirectResponse($this->urlGenerator->generate('front')); } } protected function getLoginUrl(Request $request): string { return $this->urlGenerator->generate(self::LOGIN_ROUTE); } } ================================================ FILE: src/Security/KeycloakAuthenticator.php ================================================ attributes->get('_route'); } public function authenticate(Request $request): Passport { $client = $this->clientRegistry->getClient('keycloak'); $slugger = $this->slugger; $provider = $client->getOAuth2Provider(); $accessToken = $provider->getAccessToken('authorization_code', [ 'code' => $request->query->get('code'), ]); $rememberBadge = new RememberMeBadge(); $rememberBadge = $rememberBadge->enable(); return new SelfValidatingPassport( new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) { /** @var KeycloakResourceOwner $keycloakUser */ $keycloakUser = $client->fetchUserFromToken($accessToken); $existingUser = $this->entityManager->getRepository(User::class)->findOneBy( ['oauthKeycloakId' => $keycloakUser->getId()] ); if ($existingUser) { return $existingUser; } $user = $this->userRepository->findOneBy(['email' => $keycloakUser->getEmail()]); if ($user) { $user->oauthKeycloakId = $keycloakUser->getId(); $this->entityManager->persist($user); $this->entityManager->flush(); return $user; } if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) { throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED'); } $username = $slugger->slug($keycloakUser->toArray()['preferred_username']); if ($this->userRepository->count(['username' => $username]) > 0) { $username .= rand(1, 999); $request->getSession()->set('is_newly_created', true); } $dto = (new UserDto())->create( $username, $keycloakUser->getEmail() ); $dto->plainPassword = bin2hex(random_bytes(20)); $dto->ip = $this->ipResolver->resolve(); $user = $this->userManager->create($dto, false); $user->oauthKeycloakId = $keycloakUser->getId(); $user->isVerified = true; $this->entityManager->persist($user); $this->entityManager->flush(); return $user; }), [ $rememberBadge, ] ); } } ================================================ FILE: src/Security/MbinOAuthAuthenticatorBase.php ================================================ getSession(); if ($url = $session->get('_security.main.target_path')) { $targetUrl = $url; } elseif ($session->get('is_newly_created')) { $targetUrl = $this->router->generate('user_settings_profile'); $session->remove('is_newly_created'); } else { $targetUrl = $this->router->generate('front'); } return new RedirectResponse($targetUrl); } public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { $message = strtr($exception->getMessageKey(), $exception->getMessageData()); if ('MBIN_SSO_REGISTRATIONS_ENABLED' === $message) { $session = $request->getSession(); $session->getFlashBag()->add('error', 'sso_registrations_enabled.error'); return new RedirectResponse($this->router->generate('app_login')); } return new Response($message, Response::HTTP_FORBIDDEN); } } ================================================ FILE: src/Security/OAuth/ClientCredentialsGrant.php ================================================ * @copyright Copyright (c) Alex Bilbie * @license http://mit-license.org/ * * @see https://github.com/thephpleague/oauth2-server */ namespace App\Security\OAuth; use App\Entity\Client; use Doctrine\ORM\EntityManagerInterface; use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Grant\AbstractGrant; use League\OAuth2\Server\Grant\ClientCredentialsGrant as LeagueClientCredentialsGrant; use League\OAuth2\Server\RequestAccessTokenEvent; use League\OAuth2\Server\RequestEvent; use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; use Psr\Http\Message\ServerRequestInterface; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; use Symfony\Contracts\Service\Attribute\Required; /** * Client credentials grant class. Modified to provide a bot user agent for the client. */ #[AsDecorator(LeagueClientCredentialsGrant::class)] class ClientCredentialsGrant extends AbstractGrant { protected EntityManagerInterface $entityManager; #[Required] public function setEntityManager(EntityManagerInterface $entityManager) { $this->entityManager = $entityManager; } private function getKbinClientEntityOrFail(string $clientId, ServerRequestInterface $request): Client { $clientEntityInterface = $this->getClientEntityOrFail($clientId, $request); $repository = $this->entityManager->getRepository(Client::class); /** @var ?Client $client */ $client = $repository->findOneBy(['identifier' => $clientEntityInterface->getIdentifier()]); if (false === $client instanceof Client) { $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); throw OAuthServerException::invalidClient($request); } if (null === $client->getUser()) { $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); throw OAuthServerException::invalidClient($request); } return $client; } public function respondToAccessTokenRequest( ServerRequestInterface $request, ResponseTypeInterface $responseType, \DateInterval $accessTokenTTL, ): ResponseTypeInterface { list($clientId) = $this->getClientCredentials($request); $client = $this->getKbinClientEntityOrFail($clientId, $request); if (!$client->isConfidential()) { $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); throw OAuthServerException::invalidClient($request); } // Validate request $this->validateClient($request); $scopes = $this->validateScopes($this->getRequestParameter('scope', $request, $this->defaultScope)); // Finalize the requested scopes $finalizedScopes = $this->scopeRepository->finalizeScopes($scopes, $this->getIdentifier(), $client); // Issue and persist access token $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $client->getUser()->getUserIdentifier(), $finalizedScopes); // Send event to emitter $this->getEmitter()->emit(new RequestAccessTokenEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request, $accessToken)); // Inject access token into response type $responseType->setAccessToken($accessToken); return $responseType; } public function getIdentifier(): string { return 'client_credentials'; } } ================================================ FILE: src/Security/PrivacyPortalAuthenticator.php ================================================ attributes->get('_route'); } public function authenticate(Request $request): Passport { $client = $this->clientRegistry->getClient('privacyportal'); $accessToken = $this->fetchAccessToken($client); $slugger = $this->slugger; return new SelfValidatingPassport( new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) { /** @var PrivacyPortalResourceOwner $privacyPortalUser */ $privacyPortalUser = $client->fetchUserFromToken($accessToken); $existingUser = $this->entityManager->getRepository(User::class)->findOneBy( ['oauthPrivacyPortalId' => (string) $privacyPortalUser->getId()] ); if ($existingUser) { return $existingUser; } $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $privacyPortalUser->getEmail()] ); if ($user) { $user->oauthPrivacyPortalId = (string) $privacyPortalUser->getId(); $this->entityManager->persist($user); $this->entityManager->flush(); return $user; } if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) { throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED'); } $username = $slugger->slug($privacyPortalUser->getName()); if ($this->userRepository->count(['username' => $username]) > 0) { $username .= rand(1, 9999); $request->getSession()->set('is_newly_created', true); } $dto = (new UserDto())->create( $username, $privacyPortalUser->getEmail() ); $dto->plainPassword = bin2hex(random_bytes(20)); $dto->ip = $this->ipResolver->resolve(); $user = $this->userManager->create($dto, false); $user->oauthPrivacyPortalId = (string) $privacyPortalUser->getId(); $user->isVerified = true; $this->entityManager->persist($user); $this->entityManager->flush(); return $user; }) ); } } ================================================ FILE: src/Security/SimpleLoginAuthenticator.php ================================================ attributes->get('_route'); } public function authenticate(Request $request): Passport { $client = $this->clientRegistry->getClient('simplelogin'); $slugger = $this->slugger; $provider = $client->getOAuth2Provider(); $accessToken = $provider->getAccessToken('authorization_code', [ 'code' => $request->query->get('code'), ]); $rememberBadge = new RememberMeBadge(); $rememberBadge = $rememberBadge->enable(); return new SelfValidatingPassport( new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) { /** @var SimpleLoginResourceOwner $simpleloginUser */ $simpleloginUser = $client->fetchUserFromToken($accessToken); $existingUser = $this->entityManager->getRepository(User::class)->findOneBy( ['oauthSimpleLoginId' => $simpleloginUser->getId()] ); if ($existingUser) { return $existingUser; } $user = $this->userRepository->findOneBy(['email' => $simpleloginUser->getEmail()]); if ($user) { $user->oauthSimpleLoginId = $simpleloginUser->getId(); $this->entityManager->persist($user); $this->entityManager->flush(); return $user; } if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) { throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED'); } $name = $simpleloginUser->getName(); $name = preg_replace('/\s+/', '', $name); // remove all whitespace $name = preg_replace('#[[:punct:]]#', '', $name); // remove all punctuation $username = $slugger->slug($name); $usernameTaken = $this->entityManager->getRepository(User::class)->findOneBy( ['username' => $username] ); if ($usernameTaken) { $username = $username.rand(1, 999); $request->getSession()->set('is_newly_created', true); } $dto = (new UserDto())->create( $username, $simpleloginUser->getEmail() ); $avatar = $this->getAvatar($simpleloginUser->getPictureUrl()); if ($avatar) { $dto->avatar = $this->imageFactory->createDto($avatar); } $dto->plainPassword = bin2hex(random_bytes(20)); $dto->ip = $this->ipResolver->resolve(); $user = $this->userManager->create($dto, false); $user->oauthSimpleLoginId = $simpleloginUser->getId(); $user->avatar = $this->getAvatar($simpleloginUser->getPictureUrl()); $user->isVerified = true; $this->entityManager->persist($user); $this->entityManager->flush(); return $user; }), [ $rememberBadge, ] ); } private function getAvatar(?string $pictureUrl): ?Image { if (!$pictureUrl) { return null; } try { $tempFile = $this->imageManager->download($pictureUrl); } catch (\Exception $e) { $tempFile = null; } if ($tempFile) { $image = $this->imageRepository->findOrCreateFromPath($tempFile); if ($image) { $this->entityManager->persist($image); $this->entityManager->flush(); } } return $image ?? null; } } ================================================ FILE: src/Security/UserChecker.php ================================================ apId) { throw new BadCredentialsException(); } if ($user->isDeleted) { if ($user->markedForDeletionAt > (new \DateTime('now'))) { $this->userManager->removeDeleteRequest($user); } else { throw new BadCredentialsException(); } } $applicationStatus = $user->getApplicationStatus(); if (EApplicationStatus::Approved !== $applicationStatus) { if (EApplicationStatus::Pending === $applicationStatus) { throw new CustomUserMessageAccountStatusException($this->translator->trans('your_account_is_not_yet_approved')); } elseif (EApplicationStatus::Rejected === $applicationStatus) { throw new BadCredentialsException(); } else { throw new \LogicException("Unrecognized application status $applicationStatus->value"); } } if (!$user->isVerified) { $resendEmailActivationUrl = $this->urlGenerator->generate('app_resend_email_activation'); throw new CustomUserMessageAccountStatusException($this->translator->trans('your_account_is_not_active', ['%link_target%' => $resendEmailActivationUrl])); } if ($user->isBanned) { throw new CustomUserMessageAccountStatusException($this->translator->trans('your_account_has_been_banned')); } } public function checkPostAuth(UserInterface $user): void { if (!$user instanceof AppUser) { return; } } } ================================================ FILE: src/Security/Voter/EntryCommentVoter.php ================================================ getUser(); if (!$user instanceof User) { return false; } return match ($attribute) { self::EDIT => $this->canEdit($subject, $user), self::PURGE => $this->canPurge($subject, $user), self::DELETE => $this->canDelete($subject, $user), self::VOTE => $this->canVote($subject, $user), self::MODERATE => $this->canModerate($subject, $user), default => throw new \LogicException(), }; } private function canEdit(EntryComment $comment, User $user): bool { if ($comment->user === $user) { return true; } return false; } private function canPurge(EntryComment $comment, User $user): bool { return $user->isAdmin(); } private function canDelete(EntryComment $comment, User $user): bool { if ($user->isAdmin() || $user->isModerator()) { return true; } if ($comment->user === $user) { return true; } if ($comment->entry->magazine->userIsModerator($user)) { return true; } return false; } private function canVote(EntryComment $comment, User $user): bool { // if ($comment->user === $user) { // return false; // } if ($comment->entry->magazine->isBanned($user) || $user->isBanned()) { return false; } return true; } private function canModerate(EntryComment $comment, User $user): bool { return $comment->magazine->userIsModerator($user) || $user->isAdmin() || $user->isModerator(); } } ================================================ FILE: src/Security/Voter/EntryVoter.php ================================================ getUser(); if (!$user instanceof User) { return false; } return match ($attribute) { self::EDIT => $this->canEdit($subject, $user), self::DELETE => $this->canDelete($subject, $user), self::PURGE => $this->canPurge($subject, $user), self::COMMENT => $this->canComment($subject, $user), self::VOTE => $this->canVote($subject, $user), self::MODERATE => $this->canModerate($subject, $user), self::LOCK => $subject->user === $user || $this->canModerate($subject, $user), default => throw new \LogicException(), }; } private function canEdit(Entry $entry, User $user): bool { if ($user->isAdmin() || $user->isModerator()) { return true; } if ($entry->user === $user) { return true; } return false; } private function canDelete(Entry $entry, User $user): bool { if ($user->isAdmin() || $user->isModerator()) { return true; } if ($entry->user === $user) { return true; } if ($entry->magazine->userIsModerator($user)) { return true; } return false; } private function canPurge(Entry $entry, User $user): bool { return $user->isAdmin(); } private function canComment(Entry $entry, User $user): bool { return !$entry->magazine->isBanned($user) && !$user->isBanned; } private function canVote(Entry $entry, User $user): bool { // if ($entry->user === $user) { // return false; // } if ($entry->magazine->isBanned($user) || $user->isBanned()) { return false; } return true; } private function canModerate(Entry $entry, User $user): bool { return $entry->magazine->userIsModerator($user) || $user->isAdmin() || $user->isModerator(); } } ================================================ FILE: src/Security/Voter/FilterListVoter.php ================================================ getUser(); if (!$user instanceof User) { return false; } return match ($attribute) { self::EDIT, self::DELETE => $this->isOwner($subject, $user), default => throw new \LogicException(), }; } private function isOwner(UserFilterList $list, User $loggedInUser): bool { if ($list->user === $loggedInUser) { return true; } return false; } } ================================================ FILE: src/Security/Voter/MagazineVoter.php ================================================ getUser(); if (!$user instanceof User) { return false; } return match ($attribute) { self::CREATE_CONTENT => $this->canCreateContent($subject, $user), self::EDIT => $this->canEdit($subject, $user), self::DELETE => $this->canDelete($subject, $user), self::PURGE => $this->canPurge($subject, $user), self::MODERATE => $this->canModerate($subject, $user), self::SUBSCRIBE => $this->canSubscribe($subject, $user), self::BLOCK => $this->canBlock($subject, $user), default => throw new \LogicException(), }; } private function canCreateContent(Magazine $magazine, User $user): bool { return !$magazine->isBanned($user) && !$user->isBanned(); } private function canEdit(Magazine $magazine, User $user): bool { return $magazine->userIsOwner($user) || $user->isAdmin() || $user->isModerator(); } private function canDelete(Magazine $magazine, User $user): bool { return $magazine->userIsOwner($user) || $user->isAdmin() || $user->isModerator(); } private function canPurge(Magazine $magazine, User $user): bool { return $user->isAdmin(); } private function canModerate(Magazine $magazine, User $user): bool { return $magazine->userIsModerator($user) || $user->isAdmin() || $user->isModerator(); } public function canSubscribe(Magazine $magazine, User $user): bool { return !$magazine->isBanned($user) && !$user->isBanned(); } public function canBlock(Magazine $magazine, User $user): bool { if ($magazine->userIsOwner($user)) { return false; } return !$magazine->isBanned($user) && !$user->isBanned(); } } ================================================ FILE: src/Security/Voter/MessageThreadVoter.php ================================================ getUser(); if (!$user instanceof User) { return false; } return match ($attribute) { self::SHOW => $this->canShow($subject, $user), self::REPLY => $this->canReply($subject, $user), default => throw new \LogicException(), }; } private function canShow(MessageThread $thread, User $user): bool { if (!$user instanceof User) { return false; } if (!$thread->userIsParticipant($user)) { return false; } return true; } private function canReply(MessageThread $thread, User $user): bool { if (!$user instanceof User) { return false; } if (!$thread->userIsParticipant($user)) { return false; } return true; } } ================================================ FILE: src/Security/Voter/MessageVoter.php ================================================ getUser(); if (!$user instanceof User) { return false; } return match ($attribute) { self::DELETE => $this->canDelete($subject, $user), default => throw new \LogicException(), }; } private function canDelete(Message $message, User $user): bool { return false; } } ================================================ FILE: src/Security/Voter/NotificationVoter.php ================================================ getUser(); if (!$user instanceof User) { return false; } return match ($attribute) { self::VIEW => $this->canView($subject, $user), self::DELETE => $this->canDelete($subject, $user), default => throw new \LogicException(), }; } private function canView(Notification $notification, User $user): bool { return $notification->user->getId() === $user->getId(); } private function canDelete(Notification $notification, User $user): bool { return $notification->user->getId() === $user->getId(); } } ================================================ FILE: src/Security/Voter/OAuth2UserConsentVoter.php ================================================ getUser(); if (!$user instanceof User) { return false; } return match ($attribute) { self::VIEW => $this->canView($subject, $user), self::EDIT => $this->canEdit($subject, $user), default => throw new \LogicException(), }; } private function canView(OAuth2UserConsent $consent, User $user): bool { if ($consent->getUser() !== $user) { return false; } return true; } private function canEdit(OAuth2UserConsent $consent, User $user): bool { if ($consent->getUser() !== $user) { return false; } return true; } } ================================================ FILE: src/Security/Voter/PostCommentVoter.php ================================================ getUser(); if (!$user instanceof User) { return false; } return match ($attribute) { self::EDIT => $this->canEdit($subject, $user), self::PURGE => $this->canPurge($subject, $user), self::DELETE => $this->canDelete($subject, $user), self::VOTE => $this->canVote($subject, $user), self::MODERATE => $this->canModerate($subject, $user), default => throw new \LogicException(), }; } private function canEdit(PostComment $comment, User $user): bool { if ($comment->user === $user) { return true; } return false; } private function canPurge(PostComment $comment, User $user): bool { return $user->isAdmin(); } private function canDelete(PostComment $comment, User $user): bool { if ($user->isAdmin() || $user->isModerator()) { return true; } if ($comment->user === $user) { return true; } if ($comment->post->magazine->userIsModerator($user)) { return true; } return false; } private function canVote(PostComment $comment, User $user): bool { // if ($comment->user === $user) { // return false; // } if ($comment->post->magazine->isBanned($user) || $user->isBanned()) { return false; } return true; } private function canModerate(PostComment $comment, User $user): bool { return $comment->magazine->userIsModerator($user) || $user->isAdmin() || $user->isModerator(); } } ================================================ FILE: src/Security/Voter/PostVoter.php ================================================ getUser(); if (!$user instanceof User) { return false; } return match ($attribute) { self::EDIT => $this->canEdit($subject, $user), self::DELETE => $this->canDelete($subject, $user), self::PURGE => $this->canPurge($subject, $user), self::COMMENT => $this->canComment($subject, $user), self::VOTE => $this->canVote($subject, $user), self::MODERATE => $this->canModerate($subject, $user), self::LOCK => $subject->user === $user || $this->canModerate($subject, $user), default => throw new \LogicException(), }; } private function canEdit(Post $post, User $user): bool { if ($post->user === $user) { return true; } return false; } private function canDelete(Post $post, User $user): bool { if ($user->isAdmin() || $user->isModerator()) { return true; } if ($post->user === $user) { return true; } if ($post->magazine->userIsModerator($user)) { return true; } return false; } private function canPurge(Post $post, User $user): bool { return $user->isAdmin(); } private function canComment(Post $post, User $user): bool { return !$post->magazine->isBanned($user) && !$user->isBanned(); } private function canVote(Post $post, User $user): bool { // if ($post->user === $user) { // return false; // } if ($post->magazine->isBanned($user) || $user->isBanned()) { return false; } return true; } private function canModerate(Post $post, User $user): bool { return $post->magazine->userIsModerator($user) || $user->isAdmin() || $user->isModerator(); } } ================================================ FILE: src/Security/Voter/PrivateInstanceVoter.php ================================================ settingsManager->get('MBIN_PRIVATE_INSTANCE')) { $user = $token->getUser(); if (!$user instanceof User) { return false; } } return true; } } ================================================ FILE: src/Security/Voter/UserVoter.php ================================================ getUser(); if (!$user instanceof User) { return false; } return match ($attribute) { self::FOLLOW => $this->canFollow($subject, $user), self::BLOCK => $this->canBlock($subject, $user), self::MESSAGE => $this->canMessage($subject, $user), self::EDIT_PROFILE => $this->canEditProfile($subject, $user), self::EDIT_USERNAME => $this->canEditUsername($subject, $user), default => throw new \LogicException(), }; } private function canFollow(User $following, User $follower): bool { if ($following === $follower) { return false; } return true; } private function canBlock(User $blocked, User $blocker): bool { if ($blocked === $blocker) { return false; } return true; } private function canMessage(User $receiver, User $sender): bool { if (!$sender instanceof User) { return false; } if ($receiver->isBlocked($sender) || $sender->isBlocked($receiver)) { return false; } return true; } private function canEditProfile(User $subject, User $user): bool { return $subject === $user; } private function canEditUsername(User $subject, User $user): bool { return $this->canEditProfile($subject, $user) && !$user->entries->count() && !$user->entryComments->count() && !$user->posts->count() && !$user->postComments->count() && !$user->subscriptions->count() && !$user->follows->count() && !$user->followers->count() && !$user->entryVotes->count() && !$user->entryVotes->count() && !$user->entryCommentVotes->count() && !$user->postVotes->count() && !$user->postCommentVotes->count() && !$user->blocks->count() && !$user->favourites->count(); } } ================================================ FILE: src/Security/ZitadelAuthenticator.php ================================================ attributes->get('_route'); } public function authenticate(Request $request): Passport { $client = $this->clientRegistry->getClient('zitadel'); $slugger = $this->slugger; $provider = $client->getOAuth2Provider(); $accessToken = $provider->getAccessToken('authorization_code', [ 'code' => $request->query->get('code'), ]); $rememberBadge = new RememberMeBadge(); $rememberBadge = $rememberBadge->enable(); return new SelfValidatingPassport( new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) { /** @var ZitadelResourceOwner $zitadelUser */ $zitadelUser = $client->fetchUserFromToken($accessToken); $existingUser = $this->entityManager->getRepository(User::class)->findOneBy( ['oauthZitadelId' => $zitadelUser->getId()] ); if ($existingUser) { return $existingUser; } $user = $this->userRepository->findOneBy(['email' => $zitadelUser->getEmail()]); if ($user) { $user->oauthZitadelId = $zitadelUser->getId(); $this->entityManager->persist($user); $this->entityManager->flush(); return $user; } if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) { throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED'); } $email = $zitadelUser->toArray()['preferred_username']; $username = $slugger->slug(substr($email, 0, strrpos($email, '@'))); if ($this->userRepository->count(['username' => $username]) > 0) { $username .= rand(1, 999); $request->getSession()->set('is_newly_created', true); } $dto = (new UserDto())->create( $username, $zitadelUser->getEmail() ); $avatar = $this->getAvatar($zitadelUser->getPictureUrl()); if ($avatar) { $dto->avatar = $this->imageFactory->createDto($avatar); } $dto->plainPassword = bin2hex(random_bytes(20)); $dto->ip = $this->ipResolver->resolve(); $user = $this->userManager->create($dto, false); $user->oauthZitadelId = $zitadelUser->getId(); $user->avatar = $this->getAvatar($zitadelUser->getPictureUrl()); $user->isVerified = true; $this->entityManager->persist($user); $this->entityManager->flush(); return $user; }), [ $rememberBadge, ] ); } private function getAvatar(?string $pictureUrl): ?Image { if (!$pictureUrl) { return null; } try { $tempFile = $this->imageManager->download($pictureUrl); } catch (\Exception $e) { $tempFile = null; } if ($tempFile) { $image = $this->imageRepository->findOrCreateFromPath($tempFile); if ($image) { $this->entityManager->persist($image); $this->entityManager->flush(); } } return $image ?? null; } } ================================================ FILE: src/Service/ActivityPub/ActivityJsonBuilder.php ================================================ logger->debug('activity json: build for {id}', ['id' => $activity->uuid->toString()]); if (null !== $activity->activityJson) { $json = json_decode($activity->activityJson, true); $this->logger->debug('activity json: {json}', ['json' => json_encode($json, JSON_PRETTY_PRINT)]); return $json; } $json = match ($activity->type) { 'Create' => $this->buildCreateFromActivity($activity), 'Like' => $this->buildLikeFromActivity($activity), 'Undo' => $this->buildUndoFromActivity($activity), 'Announce' => $this->buildAnnounceFromActivity($activity), 'Delete' => $this->buildDeleteFromActivity($activity), 'Add', 'Remove' => $this->buildAddRemoveFromActivity($activity), 'Flag' => $this->buildFlagFromActivity($activity), 'Follow' => $this->buildFollowFromActivity($activity), 'Accept', 'Reject' => $this->buildAcceptRejectFromActivity($activity), 'Update' => $this->buildUpdateFromActivity($activity), 'Block' => $this->buildBlockFromActivity($activity), 'Lock' => $this->buildLockFromActivity($activity), default => new \LogicException(), }; $this->logger->debug('activity json: {json}', ['json' => json_encode($json, JSON_PRETTY_PRINT)]); if (!$includeContext) { unset($json['@context']); } return $json; } public function buildCreateFromActivity(Activity $activity): array { $o = $activity->objectEntry ?? $activity->objectEntryComment ?? $activity->objectPost ?? $activity->objectPostComment ?? $activity->objectMessage; $item = $this->activityFactory->create($o, true); unset($item['@context']); $activityJson = [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => 'Create', 'actor' => $item['attributedTo'], 'published' => $item['published'], 'to' => $item['to'], 'cc' => $item['cc'], 'object' => $item, ]; if (isset($item['audience'])) { $activityJson['audience'] = $item['audience']; } return $activityJson; } public function buildLikeFromActivity(Activity $activity): array { $actor = $this->personFactory->getActivityPubId($activity->userActor); if (null !== $activity->userActor->apId) { if ('test' === $this->kernel->getEnvironment()) { // ignore this in testing } else { throw new \LogicException('activities cannot be build for remote users'); } } $object = $activity->getObject(); if (!\is_string($object)) { throw new \LogicException('object must be a string'); } $activityJson = [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => 'Like', 'actor' => $actor, 'to' => [ActivityPubActivityInterface::PUBLIC_URL], 'cc' => [ $this->urlGenerator->generate('ap_user_followers', ['username' => $activity->userActor->username], UrlGeneratorInterface::ABSOLUTE_URL), ], 'object' => $object, ]; if (null !== $activity->audience) { $magazineId = $this->groupFactory->getActivityPubId($activity->audience); $activityJson['cc'][] = $magazineId; $activityJson['audience'] = $magazineId; } return $activityJson; } public function buildUndoFromActivity(Activity $activity): array { if (null !== $activity->innerActivity) { $object = $this->buildActivityJson($activity->innerActivity); } elseif (null !== $activity->innerActivityUrl) { $object = $this->apHttpClient->getActivityObject($activity->innerActivityUrl); if (!\is_array($object)) { throw new \LogicException('object must be another activity'); } } else { throw new \LogicException('undo activity must have an inner activity / -url'); } unset($object['@context']); $activityJson = [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => 'Undo', 'actor' => $object['actor'], 'object' => $object, 'to' => $object['to'], 'cc' => $object['cc'] ?? [], ]; if (isset($object['audience'])) { $activityJson['audience'] = $object['audience']; } return $activityJson; } public function buildAnnounceFromActivity(Activity $activity): array { $actor = $activity->getActor(); $to = [ActivityPubActivityInterface::PUBLIC_URL]; $cc = []; if ($actor instanceof User) { $cc[] = $this->personFactory->getActivityPubFollowersId($actor); } elseif ($actor instanceof Magazine) { $cc[] = $this->groupFactory->getActivityPubFollowersId($actor); } $object = $activity->getObject(); if (null !== $activity->innerActivity) { $object = $this->buildActivityJson($activity->innerActivity); } elseif (null !== $activity->innerActivityUrl) { $object = $this->apHttpClient->getActivityObject($activity->innerActivityUrl); } elseif ($object instanceof ActivityPubActivityInterface) { $object = $this->activityFactory->create($object); if (isset($object['attributedTo'])) { $to[] = $object['attributedTo']; } elseif (isset($object['actor'])) { $to[] = $object['actor']; } } if (isset($object['@context'])) { unset($object['@context']); } $actorUrl = $actor instanceof User ? $this->personFactory->getActivityPubId($actor) : $this->groupFactory->getActivityPubId($actor); if (isset($object['cc'])) { $cc = array_merge($cc, array_filter($object['cc'], fn (string $url) => $url !== $actorUrl)); } $activityJson = [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => 'Announce', 'actor' => $actorUrl, 'object' => $object, 'to' => $to, 'cc' => $cc, 'published' => (new \DateTime())->format(DATE_ATOM), ]; if ($actor instanceof Magazine) { $activityJson['audience'] = $this->groupFactory->getActivityPubId($actor); } return $activityJson; } public function buildDeleteFromActivity(Activity $activity): array { $item = $activity->getObject(); if (!\is_array($item)) { throw new \LogicException(); } $activityActor = $activity->getActor(); if ($activityActor instanceof User) { $userUrl = $this->personFactory->getActivityPubId($activityActor); } elseif ($activityActor instanceof Magazine) { $userUrl = $this->groupFactory->getActivityPubId($activityActor); } else { throw new \LogicException(); } if (isset($item->magazine)) { $audience = $this->groupFactory->getActivityPubId($item->magazine); } $activityJson = [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => 'Delete', 'actor' => $userUrl, 'object' => [ 'id' => $item['id'], 'type' => 'Tombstone', ], 'to' => $item['to'], 'cc' => $item['cc'], ]; if (isset($audience)) { $activityJson['audience'] = $audience; } return $activityJson; } public function buildAddRemoveFromActivity(Activity $activity): array { if (null !== $activity->objectUser) { $object = $this->personFactory->getActivityPubId($activity->objectUser); } elseif (null !== $activity->objectEntry) { $object = $this->entryPageFactory->getActivityPubId($activity->objectEntry); } else { throw new \LogicException('There is no object set for the add/remove activity'); } return [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'actor' => $this->personFactory->getActivityPubId($activity->userActor), 'to' => [ActivityPubActivityInterface::PUBLIC_URL], 'object' => $object, 'cc' => [$this->groupFactory->getActivityPubId($activity->audience)], 'type' => $activity->type, 'target' => $activity->targetString, 'audience' => $this->groupFactory->getActivityPubId($activity->audience), ]; } public function buildFlagFromActivity(Activity $activity): array { // mastodon does not accept a report that does not have an array as object. // I created an issue for it: https://github.com/mastodon/mastodon/issues/28159 $mastodonObject = [ $this->getPublicUrl($activity->getObject()), $this->personFactory->getActivityPubId($activity->objectUser), ]; // lemmy does not accept a report that does have an array as object. // I created an issue for it: https://github.com/LemmyNet/lemmy/issues/4217 $lemmyObject = $this->getPublicUrl($activity->getObject()); if ('random' !== $activity->audience || $activity->audience->apId) { // apAttributedToUrl is not a standardized field, // so it is not implemented by every software that supports groups. // Some don't have moderation at all, so it will probably remain optional in the future. $audience = $this->groupFactory->getActivityPubId($activity->audience); $object = $lemmyObject; } else { $audience = $this->personFactory->getActivityPubId($activity->objectUser); $object = $mastodonObject; } $result = [ '@context' => ActivityPubActivityInterface::CONTEXT_URL, 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => 'Flag', 'actor' => $this->personFactory->getActivityPubId($activity->userActor), 'object' => $object, 'audience' => $audience, 'summary' => $activity->contentString, 'content' => $activity->contentString, ]; if ('random' !== $activity->audience->name || $activity->audience->apId) { $result['to'] = [$this->groupFactory->getActivityPubId($activity->audience)]; } return $result; } public function buildFollowFromActivity(Activity $activity): array { $object = $activity->getObject(); if ($object instanceof User) { $activityObject = $this->personFactory->getActivityPubId($object); } else { $activityObject = $this->groupFactory->getActivityPubId($object); } return [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => 'Follow', 'actor' => $this->personFactory->getActivityPubId($activity->userActor), 'object' => $activityObject, 'to' => [ $activityObject, ], ]; } public function buildAcceptRejectFromActivity(Activity $activity): array { $activityActor = $activity->getActor(); if ($activityActor instanceof User) { $actor = $this->personFactory->getActivityPubId($activityActor); } elseif ($activityActor instanceof Magazine) { $actor = $this->groupFactory->getActivityPubId($activityActor); } else { throw new \LogicException(); } if (null !== ($activityObject = $activity->getObject())) { $object = $activityObject; } elseif (null !== $activity->innerActivity) { $object = $this->buildActivityJson($activity->innerActivity); if (isset($object['@context'])) { unset($object['@context']); } } else { throw new \LogicException('There is no object set for the accept/reject activity'); } return [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => $activity->type, 'actor' => $actor, 'object' => $object, 'to' => [ $object['actor'], ], ]; } public function buildUpdateFromActivity(Activity $activity): array { $object = $activity->getObject(); if ($object instanceof ActivityPubActivityInterface) { return $this->buildUpdateForContentFromActivity($activity, $object); } elseif ($object instanceof ActivityPubActorInterface) { return $this->buildUpdateForActorFromActivity($activity, $object); } else { throw new \LogicException(); } } public function buildUpdateForContentFromActivity(Activity $activity, ActivityPubActivityInterface $content): array { $entity = $this->activityFactory->create($content); $entity['object']['updated'] = $content->editedAt ? $content->editedAt->format(DATE_ATOM) : (new \DateTime())->format(DATE_ATOM); $activityJson = [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => 'Update', 'actor' => $this->personFactory->getActivityPubId($activity->userActor), 'published' => $entity['published'], 'to' => $entity['to'], 'cc' => $entity['cc'], 'object' => $entity, ]; if (null !== $activity->audience) { $activityJson['audience'] = $this->groupFactory->getActivityPubId($activity->audience); } return $activityJson; } public function buildUpdateForActorFromActivity(Activity $activity, ActivityPubActorInterface $object): array { if ($object instanceof User) { $activityObject = $this->personFactory->create($object, false); if (null === $object->apId) { $cc = [$this->urlGenerator->generate('ap_user_followers', ['username' => $object->username], UrlGeneratorInterface::ABSOLUTE_URL)]; } else { $cc = [$object->apFollowersUrl]; } } elseif ($object instanceof Magazine) { $activityObject = $this->groupFactory->create($object, false); if (null === $object->apId) { $cc = [$this->urlGenerator->generate('ap_magazine_followers', ['name' => $object->name], UrlGeneratorInterface::ABSOLUTE_URL)]; } else { $cc = [$object->apFollowersUrl]; } } else { throw new \LogicException('Unknown actor type: '.\get_class($object)); } $actorUrl = $this->personFactory->getActivityPubId($activity->userActor); return [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => 'Update', 'actor' => $actorUrl, 'published' => $activityObject['published'], 'to' => [ActivityPubActivityInterface::PUBLIC_URL], 'cc' => $cc, 'object' => $activityObject, ]; } private function buildBlockFromActivity(Activity $activity): array { $object = $activity->getObject(); $expires = null; $cc = []; if ($object instanceof MagazineBan) { $reason = $object->reason; $jsonObject = $this->personFactory->getActivityPubId($object->user); $target = $this->groupFactory->getActivityPubId($object->magazine); $expires = $object->expiredAt?->format(DATE_ATOM); $cc = [$this->groupFactory->getActivityPubId($activity->audience)]; } elseif ($object instanceof User) { $reason = $object->banReason; $jsonObject = $this->personFactory->getActivityPubId($object); $target = $this->instanceFactory->getTargetUrl(); } else { throw new \LogicException('Object of a block activity has to be of type MagazineBan'); } return [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => 'Block', 'actor' => $this->personFactory->getActivityPubId($activity->userActor), 'object' => $jsonObject, 'target' => $target, 'summary' => $reason, 'audience' => $activity->audience ? $this->groupFactory->getActivityPubId($activity->audience) : null, 'expires' => $expires, 'to' => [ActivityPubActivityInterface::PUBLIC_URL], 'cc' => $cc, ]; } private function buildLockFromActivity(Activity $activity): array { $object = $activity->getObject(); if ($object instanceof Entry) { $objectUrl = $this->entryPageFactory->getActivityPubId($object); } elseif ($object instanceof Post) { $objectUrl = $this->postNoteFactory->getActivityPubId($object); } else { throw new \LogicException('Lock activity is only supported for entries and posts, not for '.\get_class($object)); } return [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => 'Lock', 'actor' => $this->personFactory->getActivityPubId($activity->userActor), 'to' => [ActivityPubActivityInterface::PUBLIC_URL], 'cc' => [ $this->groupFactory->getActivityPubId($object->magazine), $this->urlGenerator->generate('ap_user_followers', ['username' => $activity->userActor->username], UrlGeneratorInterface::ABSOLUTE_URL), ], 'object' => $objectUrl, ]; } public function getPublicUrl(ReportInterface|ActivityPubActivityInterface $subject): string { if ($subject instanceof Entry) { return $this->entryPageFactory->getActivityPubId($subject); } elseif ($subject instanceof EntryComment) { return $this->entryCommentNoteFactory->getActivityPubId($subject); } elseif ($subject instanceof Post) { return $this->postNoteFactory->getActivityPubId($subject); } elseif ($subject instanceof PostComment) { return $this->postCommentNoteFactory->getActivityPubId($subject); } elseif ($subject instanceof Message) { return $this->urlGenerator->generate('ap_message', ['uuid' => $subject->uuid], UrlGeneratorInterface::ABSOLUTE_URL); } throw new \LogicException("can't handle ".\get_class($subject)); } } ================================================ FILE: src/Service/ActivityPub/ActivityPubContent.php ================================================ apFollowersUrl, $toAndCC)) { throw new \LogicException('PM: not implemented.'); } return VisibilityInterface::VISIBILITY_PRIVATE; } return VisibilityInterface::VISIBILITY_VISIBLE; } protected function handleDate(PostDto|PostCommentDto|EntryCommentDto|EntryDto $dto, string $date): void { $dto->createdAt = new \DateTimeImmutable($date); $dto->lastActive = new \DateTime($date); } protected function handleSensitiveMedia(PostDto|PostCommentDto|EntryCommentDto|EntryDto $dto, string|bool $sensitive): void { if (true === filter_var($sensitive, FILTER_VALIDATE_BOOLEAN)) { $dto->isAdult = true; } } } ================================================ FILE: src/Service/ActivityPub/ApHttpClient.php ================================================ getActivityObjectCacheKey($url); if ($this->cache->hasItem($key)) { /** @var CacheItem $item */ $item = $this->cache->getItem($key); $resp = $item->get(); return $decoded ? json_decode($resp, true) : $resp; } $resp = $this->getActivityObjectImpl($url); if (!$resp) { return null; } /** @var CacheItem $item */ $item = $this->cache->getItem($key); $item->expiresAt(new \DateTime('+1 hour')); $item->set($resp); $this->cache->save($item); return $decoded ? json_decode($resp, true) : $resp; } /** * Do a GET request for an ActivityPub object and return the response content. * * @return string|null returns the response content or null if the request failed * * @throws InvalidApPostException */ private function getActivityObjectImpl(string $url): ?string { $this->logger->debug("[ApHttpClient::getActivityObjectImpl] URL: $url"); $content = null; try { $this->dispatcher->dispatch(new CurlRequestBeginningEvent($url)); } catch (\Throwable) { } try { $client = new CurlHttpClient(); $response = $client->request('GET', $url, [ 'max_duration' => self::MAX_DURATION, 'timeout' => self::TIMEOUT, 'headers' => $this->getInstanceHeaders($url), ]); $statusCode = $response->getStatusCode(); // Accepted status code are 2xx or 410 (used Tombstone types) if (!str_starts_with((string) $statusCode, '2') && 410 !== $statusCode) { // Do NOT include the response content in the error message, this will be often a full HTML page throw new InvalidApPostException('Invalid status code while getting', $url, $statusCode); } // Read also non-OK responses (like 410) by passing 'false' $content = $response->getContent(false); $this->logger->debug('[ApHttpClient::getActivityObjectImpl] URL: {url} - content: {content}', ['url' => $url, 'content' => $content]); try { $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, true, $content)); } catch (\Throwable) { } } catch (\Exception $e) { $this->logRequestException($response ?? null, $url, 'ApHttpClient:getActivityObject', $e); } return $content; } public function getActivityObjectCacheKey(string $url): string { return 'ap_object_'.hash('sha256', $url); } /** * Retrieve AP actor object (could be a user or magazine). * * @return string return the inbox URL of the actor * * @throws \LogicException|InvalidApPostException if the AP actor object cannot be found */ public function getInboxUrl(string $apProfileId): string { $actor = $this->getActorObject($apProfileId); if (!empty($actor)) { return $actor['endpoints']['sharedInbox'] ?? $actor['inbox']; } else { throw new \LogicException("Unable to find AP actor (user or magazine) with URL: $apProfileId"); } } /** * Execute a webfinger request according to RFC 7033 (https://tools.ietf.org/html/rfc7033). * * @param string $url the URL of the user/magazine to get the webfinger object for * * @return array|null The webfinger object (as PHP Object) * * @throws InvalidWebfingerException|InvalidArgumentException */ public function getWebfingerObject(string $url): ?array { $key = 'wf_'.hash('sha256', $url); if ($this->cache->hasItem($key)) { /** @var CacheItem $item */ $item = $this->cache->getItem($key); $resp = $item->get(); return $resp ? json_decode($resp, true) : null; } $resp = $this->getWebfingerObjectImpl($url); /** @var CacheItem $item */ $item = $this->cache->getItem($key); $item->expiresAt(new \DateTime('+1 hour')); $item->set($resp); $this->cache->save($item); return $resp ? json_decode($resp, true) : null; } private function getWebfingerObjectImpl(string $url): ?string { $this->logger->debug("[ApHttpClient::getWebfingerObjectImpl] URL: $url"); $response = null; try { $this->dispatcher->dispatch(new CurlRequestBeginningEvent($url)); } catch (\Throwable) { } try { $client = new CurlHttpClient(); $response = $client->request('GET', $url, [ 'max_duration' => self::MAX_DURATION, 'timeout' => self::TIMEOUT, 'headers' => $this->getInstanceHeaders($url, null, 'get', ApRequestType::WebFinger), ]); $content = $response->getContent(); $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, true, $content)); } catch (\Exception $e) { $this->logRequestException($response, $url, 'ApHttpClient:getWebfingerObject', $e); } return $content; } private function getActorCacheKey(string $apProfileId): string { return 'ap_'.hash('sha256', $apProfileId); } private function getCollectionCacheKey(string $apAddress): string { return 'ap_collection'.hash('sha256', $apAddress); } /** * Retrieve AP actor object (could be a user or magazine). * * @return array|null key/value array of actor response body (as PHP Object) * * @throws InvalidApPostException|InvalidArgumentException */ public function getActorObject(string $apProfileId): ?array { $key = $this->getActorCacheKey($apProfileId); if ($this->cache->hasItem($key)) { /** @var CacheItem $item */ $item = $this->cache->getItem($key); $resp = $item->get(); return $resp ? json_decode($resp, true) : null; } $resp = $this->getActorObjectImpl($apProfileId); /** @var CacheItem $item */ $item = $this->cache->getItem($key); $item->expiresAt(new \DateTime('+1 hour')); $item->set($resp); $this->cache->save($item); return $resp ? json_decode($resp, true) : null; } private function getActorObjectImpl(string $apProfileId): ?string { $this->logger->debug("[ApHttpClient::getActorObjectImpl] URL: $apProfileId"); $response = null; try { $this->dispatcher->dispatch(new CurlRequestBeginningEvent($apProfileId)); } catch (\Throwable) { } try { // Set-up request $client = new CurlHttpClient(); $response = $client->request('GET', $apProfileId, [ 'max_duration' => self::MAX_DURATION, 'timeout' => self::TIMEOUT, 'headers' => $this->getInstanceHeaders($apProfileId, null, 'get', ApRequestType::ActivityPub), ]); // If 4xx error response, try to find the actor locally if (str_starts_with((string) $response->getStatusCode(), '4')) { if ($user = $this->userRepository->findOneByApProfileId($apProfileId)) { $user->apDeletedAt = new \DateTime(); $this->userRepository->save($user, true); } if ($magazine = $this->magazineRepository->findOneByApProfileId($apProfileId)) { $magazine->apDeletedAt = new \DateTime(); $this->magazineRepository->save($magazine, true); } } // Pass the 'false' option to getContent so it doesn't throw errors on "non-OK" respones (eg. 410 status codes). $content = $response->getContent(false); try { $this->dispatcher->dispatch(new CurlRequestFinishedEvent($apProfileId, true, $content)); } catch (\Throwable) { } } catch (\Exception|TransportExceptionInterface $e) { // If an exception occurred, try to find the actor locally if ($user = $this->userRepository->findOneByApProfileId($apProfileId)) { $user->apTimeoutAt = new \DateTime(); $this->userRepository->save($user, true); } if ($magazine = $this->magazineRepository->findOneByApProfileId($apProfileId)) { $magazine->apTimeoutAt = new \DateTime(); $this->magazineRepository->save($magazine, true); } $this->logRequestException($response, $apProfileId, 'ApHttpClient:getActorObject', $e); } if (404 === $response->getStatusCode()) { // treat a 404 error the same as a tombstone, since we think there was an actor, but it isn't there anymore return json_encode($this->tombstoneFactory->create($apProfileId)); } return $content; } /** * Remove actor object from cache. * * @param string $apProfileId AP profile ID to remove from cache */ public function invalidateActorObjectCache(string $apProfileId): void { $this->cache->delete($this->getActorCacheKey($apProfileId)); } /** * Remove collection object from cache. * * @param string $apAddress AP address to remove from cache */ public function invalidateCollectionObjectCache(string $apAddress): void { $this->cache->delete($this->getCollectionCacheKey($apAddress)); } /** * Retrieve AP collection object. First look in cache, then try to retrieve from AP server. * And finally, save the response to cache. * * @return array|null JSON Response body (as PHP Object) * * @throws InvalidArgumentException */ public function getCollectionObject(string $apAddress): ?array { $key = $this->getCollectionCacheKey($apAddress); if ($this->cache->hasItem($key)) { /** @var CacheItem $item */ $item = $this->cache->getItem($key); $resp = $item->get(); return $resp ? json_decode($resp, true) : null; } $resp = $this->getCollectionObjectImpl($apAddress); /** @var CacheItem $item */ $item = $this->cache->getItem($key); $item->expiresAt(new \DateTime('+24 hour')); $item->set($resp); $this->cache->save($item); return $resp ? json_decode($resp, true) : null; } private function getCollectionObjectImpl(string $apAddress): ?string { $this->logger->debug("[ApHttpClient::getCollectionObjectImpl] URL: $apAddress"); $response = null; try { $this->dispatcher->dispatch(new CurlRequestBeginningEvent($apAddress)); } catch (\Throwable) { } try { // Set-up request $client = new CurlHttpClient(); $response = $client->request('GET', $apAddress, [ 'max_duration' => self::MAX_DURATION, 'timeout' => self::TIMEOUT, 'headers' => $this->getInstanceHeaders($apAddress, null, 'get', ApRequestType::ActivityPub), ]); $statusCode = $response->getStatusCode(); // Accepted status code are 2xx or 410 (used Tombstone types) if (!str_starts_with((string) $statusCode, '2') && 410 !== $statusCode) { // Do NOT include the response content in the error message, this will be often a full HTML page throw new InvalidApPostException('Invalid status code while getting', $apAddress, $statusCode); } $content = $response->getContent(); try { $this->dispatcher->dispatch(new CurlRequestFinishedEvent($apAddress, true, $content)); } catch (\Throwable) { } } catch (\Exception $e) { $this->logRequestException($response, $apAddress, 'ApHttpClient:getCollectionObject', $e); } // When everything goes OK, return the data return $content; } /** * Helper function for logging get/post/.. requests to the error & debug log with additional info. * * @param ResponseInterface|null $response Optional response object * @param string $requestUrl Full URL of the request * @param string $requestType an additional string where the error happened in the code * @param \Exception $e Error object * * @throws InvalidApPostException rethrows the error */ private function logRequestException(?ResponseInterface $response, string $requestUrl, string $requestType, \Exception $e, ?string $requestBody = null): void { if (null !== $response) { try { $content = $response->getContent(false); } catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $e) { $class = \get_class($e); $content = "there was an exception while getting the content, $class: {$e->getMessage()}"; } } // Often 400, 404 errors just return the full HTML page, so we don't want to log the full content of them // We truncate the content to 200 characters max. $this->logger->error('[ApHttpClient::logRequestException] {type} failed: {address}, ex: {e}: {msg}. Truncated content: {content}. Truncated request body: {body}', [ 'type' => $requestType, 'address' => $requestUrl, 'e' => \get_class($e), 'msg' => $e->getMessage(), 'content' => substr($content ?? 'No content provided', 0, 200), 'body' => substr($requestBody ?? 'No body provided', 0, 200), ]); // And only log the full content in debug log mode if (isset($content)) { $this->logger->debug('[ApHttpClient::logRequestException] Full response body content: {content}', [ 'content' => $content, ]); } try { $this->dispatcher->dispatch(new CurlRequestFinishedEvent($requestUrl, false, $content ?? null, $e)); } catch (\Throwable $e) { } throw $e; // re-throw the exception } /** * Sends a POST request to the specified URL with optional request body and caching mechanism. * * @param string $url the URL to which the POST request will be sent * @param User|Magazine $actor The actor initiating the request, either a User or Magazine object * @param array|null $body (Optional) The body of the POST request. Defaults to null. * @param bool $useOldPrivateKey (Optional) Whether to use the old private key for signing (e.g. to send an update activity rotating the private key) * * @throws InvalidApPostException if the POST request fails with a non-2xx response status code * @throws TransportExceptionInterface */ public function post(string $url, User|Magazine $actor, ?array $body = null, bool $useOldPrivateKey = false): void { $cacheKey = 'ap_'.hash('sha256', $url.':'.$body['id']); if ($this->cache->hasItem($cacheKey)) { $this->logger->warning('[ApHttpClient::post] Not posting activity with id {id} to {inbox} again, as we already did that sometime in the last 45 minutes', [ 'id' => $body['id'], 'inbox' => $url, ]); return; } $jsonBody = json_encode($body ?? []); $this->logger->debug("[ApHttpClient::post] URL: $url"); $this->logger->debug("[ApHttpClient::post] Body: $jsonBody"); try { $this->dispatcher->dispatch(new CurlRequestBeginningEvent($url, 'POST', $jsonBody)); } catch (\Throwable) { } // Set-up request try { $client = new CurlHttpClient(); $response = $client->request('POST', $url, [ 'max_duration' => self::MAX_DURATION, 'timeout' => self::TIMEOUT, 'body' => $jsonBody, 'headers' => $this->getHeaders($url, $actor, $body, $useOldPrivateKey), ]); $statusCode = $response->getStatusCode(); if (!str_starts_with((string) $statusCode, '2')) { // Do NOT include the response content in the error message, this will be often a full HTML page throw new InvalidApPostException('Post failed', $url, $statusCode, $body); } try { $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, true)); } catch (\Throwable) { } } catch (\Exception $e) { $this->logRequestException($response ?? null, $url, 'ApHttpClient:post', $e, $jsonBody); } // build cache $item = $this->cache->getItem($cacheKey); $item->set(true); $item->expiresAt(new \DateTime('+45 minutes')); $this->cache->save($item); } public function fetchInstanceNodeInfoEndpoints(string $domain, bool $decoded = true): array|string|null { $url = "https://$domain/.well-known/nodeinfo"; $resp = $this->generalFetchCached('nodeinfo_endpoints_', 'nodeinfo endpoints', $url, ApRequestType::NodeInfo); if (!$resp) { return null; } return $decoded ? json_decode($resp, true) : $resp; } public function fetchInstanceNodeInfo(string $url, bool $decoded = true): array|string|null { $resp = $this->generalFetchCached('nodeinfo_', 'nodeinfo', $url, ApRequestType::NodeInfo); if (!$resp) { return null; } return $decoded ? json_decode($resp, true) : $resp; } /** * @throws TransportExceptionInterface * @throws ServerExceptionInterface * @throws RedirectionExceptionInterface * @throws ClientExceptionInterface */ private function generalFetch(string $url, ApRequestType $requestType = ApRequestType::ActivityPub): string { $client = new CurlHttpClient(); $this->logger->debug("[ApHttpClient::generalFetch] URL: $url"); $r = $client->request('GET', $url, [ 'max_duration' => self::MAX_DURATION, 'timeout' => self::TIMEOUT, 'headers' => $this->getInstanceHeaders($url, requestType: $requestType), ]); return $r->getContent(); } private function generalFetchCached(string $cachePrefix, string $fetchType, string $url, ApRequestType $requestType = ApRequestType::ActivityPub): ?string { $key = $cachePrefix.hash('sha256', $url); if ($this->cache->hasItem($key)) { /** @var CacheItem $item */ $item = $this->cache->getItem($key); return $item->get(); } try { try { $this->dispatcher->dispatch(new CurlRequestBeginningEvent($url)); } catch (\Throwable) { } $resp = $this->generalFetch($url, $requestType); try { $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, true, $resp)); } catch (\Throwable) { } } catch (\Exception $e) { $this->logger->warning('[ApHttpClient::generalFetchCached] There was an exception fetching {type} from {url}: {e} - {msg}', [ 'type' => $fetchType, 'url' => $url, 'e' => \get_class($e), 'msg' => $e->getMessage(), ]); $resp = null; $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, false)); } if (!$resp) { return null; } $item = $this->cache->getItem($key); $item->set($resp); $item->expiresAt(new \DateTime('+1 day')); $this->cache->save($item); return $resp; } private function getFetchAcceptHeaders(ApRequestType $requestType): array { return match ($requestType) { ApRequestType::WebFinger => [ 'Accept' => 'application/jrd+json', 'Content-Type' => 'application/jrd+json', ], ApRequestType::ActivityPub => [ 'Accept' => 'application/activity+json', 'Content-Type' => 'application/activity+json', ], ApRequestType::NodeInfo => [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', ], }; } private static function headersToCurlArray($headers): array { return array_map(function ($k, $v) { return "$k: $v"; }, array_keys($headers), $headers); } private function getHeaders(string $url, User|Magazine $actor, ?array $body = null, bool $useOldPrivateKey = false): array { if ($useOldPrivateKey) { $this->logger->debug('[ApHttpClient::getHeaders] Signing headers using the old private key'); } $headers = self::headersToSign($url, $body ? self::digest($body) : null); $stringToSign = self::headersToSigningString($headers); $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers))); $key = openssl_pkey_get_private($useOldPrivateKey ? $actor->oldPrivateKey : $actor->privateKey); if (false !== $key) { $success_sign = openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256); } else { $success_sign = false; } $signatureHeader = null; if ($success_sign) { $signature = base64_encode($signature); $keyId = $actor instanceof User ? $this->personFactory->getActivityPubId($actor).'#main-key' : $this->groupFactory->getActivityPubId($actor).'#main-key'; $signatureHeader = 'keyId="'.$keyId.'",headers="'.$signedHeaders.'",algorithm="rsa-sha256",signature="'.$signature.'"'; } else { $this->logger->error('[ApHttpClient::getHeaders] Failed to sign headers for {url} with private key of {actor}: {headers}', [ 'url' => $url, 'headers' => $headers, 'actor' => $actor->apId ?? ( $actor instanceof User ? $this->personFactory->getActivityPubId($actor).'#main-key' : $this->groupFactory->getActivityPubId($actor).'#main-key' ), ]); throw new \Exception('Failed to sign headers'); } unset($headers['(request-target)']); if ($signatureHeader) { $headers['Signature'] = $signatureHeader; } $headers['User-Agent'] = $this->projectInfo->getUserAgent(); $headers['Accept'] = 'application/activity+json'; $headers['Content-Type'] = 'application/activity+json'; return $headers; } private function getInstanceHeaders(string $url, ?array $body = null, string $method = 'get', ApRequestType $requestType = ApRequestType::ActivityPub): array { $keyId = 'https://'.$this->kbinDomain.'/i/actor#main-key'; $privateKey = $this->getInstancePrivateKey(); $headers = self::headersToSign($url, $body ? self::digest($body) : null, $method); $stringToSign = self::headersToSigningString($headers); $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers))); $key = openssl_pkey_get_private($privateKey); if (false !== $key) { $success_sign = openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256); } else { $success_sign = false; } $signatureHeader = null; if ($success_sign) { $signature = base64_encode($signature); $signatureHeader = 'keyId="'.$keyId.'",headers="'.$signedHeaders.'",algorithm="rsa-sha256",signature="'.$signature.'"'; } else { $this->logger->error('[ApHttpClient::getInstanceHeaders] Failed to sign headers for {url}: {headers}', [ 'url' => $url, 'headers' => $headers, ]); throw new \Exception('Failed to sign headers'); } unset($headers['(request-target)']); if ($signatureHeader) { $headers['Signature'] = $signatureHeader; } $headers['User-Agent'] = $this->projectInfo->getUserAgent(); $headers = array_merge($headers, $this->getFetchAcceptHeaders($requestType)); return $headers; } #[ArrayShape([ '(request-target)' => 'string', 'Date' => 'string', 'Host' => 'mixed', 'Accept' => 'string', 'Digest' => 'string', ])] protected static function headersToSign(string $url, ?string $digest = null, string $method = 'post'): array { $date = new \DateTime('UTC'); if (!\in_array($method, ['post', 'get'])) { throw new InvalidApPostException('Invalid method used to sign headers in ApHttpClient'); } $headers = [ '(request-target)' => $method.' '.parse_url($url, PHP_URL_PATH), 'Date' => $date->format('D, d M Y H:i:s \G\M\T'), 'Host' => parse_url($url, PHP_URL_HOST), ]; if (!empty($digest)) { $headers['Digest'] = 'SHA-256='.$digest; } return $headers; } private static function digest(array $body): string { return base64_encode(hash('sha256', json_encode($body), true)); } private static function headersToSigningString(array $headers): string { return implode( "\n", array_map(function ($k, $v) { return strtolower($k).': '.$v; }, array_keys($headers), $headers) ); } private function getInstancePrivateKey(): string { return $this->cache->get('instance_private_key', function (ItemInterface $item) { $item->expiresAt(new \DateTime('+1 day')); return $this->siteRepository->findAll()[0]->privateKey; }); } public function getInstancePublicKey(): string { return $this->cache->get('instance_public_key', function (ItemInterface $item) { $item->expiresAt(new \DateTime('+1 day')); return $this->siteRepository->findAll()[0]->publicKey; }); } } ================================================ FILE: src/Service/ActivityPub/ApHttpClientInterface.php ================================================ markdown conversion of content return $this->markdownConverter->convert($content, $object['tag'] ?? []); } return ''; } public function getExternalMediaBody(array $object): ?string { $body = null; if (isset($object['attachment'])) { $attachments = $object['attachment']; if ($images = $this->activityPubManager->handleExternalImages($attachments)) { $body .= "\n\n".implode( " \n", array_map( fn ($image) => \sprintf( '![%s](%s)', preg_replace('/\r\n|\r|\n/', ' ', $image->name), $image->url ), $images ) ); } if ($videos = $this->activityPubManager->handleExternalVideos($attachments)) { $body .= "\n\n".implode( " \n", array_map( fn ($video) => \sprintf( '![%s](%s)', preg_replace('/\r\n|\r|\n/', ' ', $video->name), $video->url ), $videos ) ); } } return $body; } } ================================================ FILE: src/Service/ActivityPub/ContextsProvider.php ================================================ urlGenerator->generate('ap_contexts', [], UrlGeneratorInterface::ABSOLUTE_URL), ]; } } ================================================ FILE: src/Service/ActivityPub/DeleteService.php ================================================ apId || !$content->magazine->apId || !$deletingUser->apId) && ($content->magazine->userIsModerator($deletingUser) || $content->magazine->hasSameHostAsUser($deletingUser) || $content->isAuthor($deletingUser))) { if ($deletingUser->apId) { $deleteActivity = $this->activityRepository->findFirstActivitiesByTypeAndObject('Delete', $content); if (!$deleteActivity) { throw new \Exception('Cannot announce an activity that is not in the DB'); } if (!$content->apId) { // local content, but remote actor -> // this activity should be just forwarded to the inbox of the accounts following the author, // but we do not have anything for that, yet, so instead we just announce it as the user $activity = $this->announceWrapper->build($content->user, $deleteActivity); } elseif (!$content->magazine->apId) { // local magazine, but remote actor -> announce $activity = $this->announceWrapper->build($content->magazine, $deleteActivity); } } else { $activity = $this->deleteWrapper->build($content, $deletingUser); } $payload = $this->activityJsonBuilder->buildActivityJson($activity); $this->bus->dispatch(new DeleteMessage($payload, $content->user->getId(), $content->magazine->getId())); } } } ================================================ FILE: src/Service/ActivityPub/HttpSignature.php ================================================ 'string', 'algorithm' => 'string', 'headers' => 'string', 'signature' => 'string', ])] public static function parseSignatureHeader(string $signature): array { $parts = explode(',', $signature); $signatureData = []; foreach ($parts as $part) { if (preg_match('/(.+)="(.+)"/', $part, $match)) { $signatureData[$match[1]] = $match[2]; } } if (!isset($signatureData['keyId'])) { throw new InvalidApSignatureException('No keyId was found in the signature header. Found: '.implode(', ', array_keys($signatureData))); } if (!filter_var($signatureData['keyId'], FILTER_VALIDATE_URL)) { throw new InvalidApSignatureException('keyId is not a URL: '.$signatureData['keyId']); } if (!isset($signatureData['headers']) || !isset($signatureData['signature'])) { throw new InvalidApSignatureException('Signature is missing headers or signature parts.'); } return $signatureData; } } ================================================ FILE: src/Service/ActivityPub/KeysGenerator.php ================================================ publicKey = (string) $privateKey->getPublicKey(); $actor->privateKey = (string) $privateKey; return $actor; } } ================================================ FILE: src/Service/ActivityPub/MarkdownConverter.php ================================================ true]); $converter->getEnvironment()->addConverter(new TableConverter()); $converter->getEnvironment()->addConverter(new StrikethroughConverter()); $value = stripslashes($converter->convert($value)); // an example value: [@user](https://some.instance.tld/u/user) preg_match_all('/\[([^]]*)\] *\(([^)]*)\)/i', $value, $matches, PREG_SET_ORDER); foreach ($matches as $match) { if ($this->mentionManager->extract($match[1])) { $mentionFromTag = $this->findMentionFromTag($match, $apTags); if (\count($mentionFromTag)) { $mentionFromTagObj = $mentionFromTag[array_key_first($mentionFromTag)]; $mentioned = null; try { $mentioned = $this->activityPubManager->findActorOrCreate($mentionFromTagObj['href']); } catch (\Throwable) { } if ($mentioned instanceof User) { $replace = $this->mentionManager->getUsername($mentioned->username, true); } elseif ($mentioned instanceof Magazine) { $replace = $this->mentionManager->getUsername('@'.$mentioned->name, true); } else { $replace = $mentionFromTagObj['name'] ?? $match[1]; } } else { try { $actor = $this->activityPubManager->findActorOrCreate($match[2]); $username = $actor instanceof User ? $actor->username : $actor->name; $replace = $this->mentionManager->getUsername($username, true); } catch (\Throwable) { $replace = $match[1]; } } $value = str_replace($match[0], $replace, $value); } if ($this->tagExtractor->extract($match[1])) { $value = str_replace($match[0], $match[1], $value); } } return $value; } private function findMentionFromTag(array $match, array $apTags): array { $res = []; foreach ($apTags as $tag) { if ('Mention' === $tag['type']) { if ($match[2] === $tag['href']) { // the href in the tag array might be the same as the link from the text $res[] = $tag; } elseif ($match[1] === $tag['name']) { // or it might not be, but the linktext from the text might be the same as the name in the tag array $res[] = $tag; } elseif (($host = parse_url($tag['href'], PHP_URL_HOST)) && "$match[1]@$host" === $tag['name']) { // or the tag array might contain the full handle, but the linktext might only be the name part of the handle $res[] = $tag; } } } return $res; } } ================================================ FILE: src/Service/ActivityPub/Note.php ================================================ repository->findByObjectId($object['id']); if ($current) { return $this->entityManager->getRepository($current['type'])->find((int) $current['id']); } if ($this->settingsManager->isBannedInstance($object['id'])) { throw new InstanceBannedException(); } if (\is_string($object['to'])) { $object['to'] = [$object['to']]; } if (!isset($object['cc'])) { $object['cc'] = []; } elseif (\is_string($object['cc'])) { $object['cc'] = [$object['cc']]; } if (isset($object['inReplyTo']) && $replyTo = $object['inReplyTo']) { // Create post or entry comment $parentObjectId = $this->repository->findByObjectId($replyTo); $parent = $this->entityManager->getRepository($parentObjectId['type'])->find((int) $parentObjectId['id']); if ($parent instanceof Entry) { $root = $parent; return $this->createEntryComment($object, $parent, $root); } elseif ($parent instanceof EntryComment) { $root = $parent->entry; return $this->createEntryComment($object, $parent, $root); } elseif ($parent instanceof Post) { $root = $parent; return $this->createPostComment($object, $parent, $root); } elseif ($parent instanceof PostComment) { $root = $parent->post; return $this->createPostComment($object, $parent, $root); } else { throw new \LogicException(\get_class($parent).' is not a valid parent'); } } return $this->createPost($object, $stickyIt); } /** * @throws TagBannedException * @throws UserBannedException * @throws UserDeletedException * @throws EntryLockedException * @throws \Exception */ private function createEntryComment(array $object, ActivityPubActivityInterface $parent, ?ActivityPubActivityInterface $root = null): EntryComment { $dto = new EntryCommentDto(); if ($parent instanceof EntryComment) { $dto->parent = $parent; $dto->root = $parent->root ?? $parent; } $dto->entry = $root; $dto->apId = $object['id']; if ( isset($object['attachment']) && $image = $this->activityPubManager->handleImages($object['attachment']) ) { $dto->image = $this->imageFactory->createDto($image); } $actor = $this->activityPubManager->findActorOrCreate($object['attributedTo']); if ($actor instanceof User) { if ($actor->isBanned) { throw new UserBannedException(); } if ($actor->isDeleted || $actor->isSoftDeleted() || $actor->isTrashed()) { throw new UserDeletedException(); } $dto->body = $this->objectExtractor->getMarkdownBody($object); if ($media = $this->objectExtractor->getExternalMediaBody($object)) { $dto->body .= $media; } $dto->visibility = $this->getVisibility($object, $actor); $this->handleDate($dto, $object['published']); if (isset($object['sensitive'])) { $this->handleSensitiveMedia($dto, $object['sensitive']); } if (!empty($object['language'])) { $dto->lang = $object['language']['identifier']; } elseif (!empty($object['contentMap'])) { $dto->lang = array_keys($object['contentMap'])[0]; } else { $dto->lang = $this->settingsManager->get('KBIN_DEFAULT_LANG'); } $dto->apLikeCount = $this->activityPubManager->extractRemoteLikeCount($object); $dto->apDislikeCount = $this->activityPubManager->extractRemoteDislikeCount($object); $dto->apShareCount = $this->activityPubManager->extractRemoteShareCount($object); return $this->entryCommentManager->create($dto, $actor, false); } elseif ($actor instanceof Magazine) { throw new UnrecoverableMessageHandlingException('Actor "'.$object['attributedTo'].'" is not a user, but a magazine for post "'.$dto->apId.'".'); } else { throw new UnrecoverableMessageHandlingException('Actor "'.$object['attributedTo'].'"could not be found for post "'.$dto->apId.'".'); } } /** * @throws UserDeletedException * @throws TagBannedException * @throws UserBannedException */ private function createPost(array $object, bool $stickyIt = false): Post { $dto = new PostDto(); $dto->magazine = $this->activityPubManager->findOrCreateMagazineByToCCAndAudience($object); $dto->apId = $object['id']; $actor = $this->activityPubManager->findActorOrCreate($object['attributedTo']); if ($actor instanceof User) { if ($actor->isBanned) { throw new UserBannedException(); } if ($actor->isDeleted || $actor->isSoftDeleted() || $actor->isTrashed()) { throw new UserDeletedException(); } if (isset($object['attachment']) && $image = $this->activityPubManager->handleImages($object['attachment'])) { $dto->image = $this->imageFactory->createDto($image); $this->logger->debug("adding image to post '{title}', {image}", ['title' => $dto->slug, 'image' => $image->getId()]); } $dto->body = $this->objectExtractor->getMarkdownBody($object); if ($media = $this->objectExtractor->getExternalMediaBody($object)) { $dto->body .= $media; } $dto->visibility = $this->getVisibility($object, $actor); $this->handleDate($dto, $object['published']); if (isset($object['sensitive'])) { $this->handleSensitiveMedia($dto, $object['sensitive']); } if (!empty($object['language'])) { $dto->lang = $object['language']['identifier']; } elseif (!empty($object['contentMap'])) { $dto->lang = array_keys($object['contentMap'])[0]; } else { $dto->lang = $this->settingsManager->get('KBIN_DEFAULT_LANG'); } $dto->apLikeCount = $this->activityPubManager->extractRemoteLikeCount($object); $dto->apDislikeCount = $this->activityPubManager->extractRemoteDislikeCount($object); $dto->apShareCount = $this->activityPubManager->extractRemoteShareCount($object); if (isset($object['commentsEnabled']) && \is_bool($object['commentsEnabled'])) { $dto->isLocked = !$object['commentsEnabled']; } return $this->postManager->create($dto, $actor, false, $stickyIt); } elseif ($actor instanceof Magazine) { throw new UnrecoverableMessageHandlingException('Actor "'.$object['attributedTo'].'" is not a user, but a magazine for post "'.$dto->apId.'".'); } else { throw new UnrecoverableMessageHandlingException('Actor "'.$object['attributedTo'].'"could not be found for post "'.$dto->apId.'".'); } } /** * @throws UserDeletedException * @throws TagBannedException * @throws UserBannedException * @throws PostLockedException */ private function createPostComment(array $object, ActivityPubActivityInterface $parent, ?ActivityPubActivityInterface $root = null): PostComment { $dto = new PostCommentDto(); if ($parent instanceof PostComment) { $dto->parent = $parent; } $dto->post = $root; $dto->apId = $object['id']; if ( isset($object['attachment']) && $image = $this->activityPubManager->handleImages($object['attachment']) ) { $dto->image = $this->imageFactory->createDto($image); } $actor = $this->activityPubManager->findActorOrCreate($object['attributedTo']); if ($actor instanceof User) { if ($actor->isBanned) { throw new UserBannedException(); } if ($actor->isDeleted || $actor->isSoftDeleted() || $actor->isTrashed()) { throw new UserDeletedException(); } $dto->body = $this->objectExtractor->getMarkdownBody($object); if ($media = $this->objectExtractor->getExternalMediaBody($object)) { $dto->body .= $media; } $dto->visibility = $this->getVisibility($object, $actor); $this->handleDate($dto, $object['published']); if (isset($object['sensitive'])) { $this->handleSensitiveMedia($dto, $object['sensitive']); } if (!empty($object['language'])) { $dto->lang = $object['language']['identifier']; } elseif (!empty($object['contentMap'])) { $dto->lang = array_keys($object['contentMap'])[0]; } else { $dto->lang = $this->settingsManager->get('KBIN_DEFAULT_LANG'); } $dto->apLikeCount = $this->activityPubManager->extractRemoteLikeCount($object); $dto->apDislikeCount = $this->activityPubManager->extractRemoteDislikeCount($object); $dto->apShareCount = $this->activityPubManager->extractRemoteShareCount($object); return $this->postCommentManager->create($dto, $actor, false); } elseif ($actor instanceof Magazine) { throw new UnrecoverableMessageHandlingException('Actor "'.$object['attributedTo'].'" is not a user, but a magazine for post "'.$dto->apId.'".'); } else { throw new UnrecoverableMessageHandlingException('Actor "'.$object['attributedTo'].'"could not be found for post "'.$dto->apId.'".'); } } } ================================================ FILE: src/Service/ActivityPub/Page.php ================================================ repository->findByObjectId($object['id']); if ($current) { return $this->entityManager->getRepository($current['type'])->find((int) $current['id']); } $actorUrl = $this->activityPubManager->getSingleActorFromAttributedTo($object['attributedTo']); if ($this->settingsManager->isBannedInstance($actorUrl)) { throw new InstanceBannedException(); } $actor = $this->activityPubManager->findActorOrCreate($actorUrl); if (!empty($actor)) { if ($actor->isBanned) { throw new UserBannedException(); } if ($actor->isDeleted || $actor->isTrashed() || $actor->isSoftDeleted()) { throw new UserDeletedException(); } $current = $this->repository->findByObjectId($object['id']); if ($current) { $this->logger->debug('Page already exists, not creating it'); return $this->entityManager->getRepository($current['type'])->find((int) $current['id']); } if (\is_string($object['to'])) { $object['to'] = [$object['to']]; } if (\is_string($object['cc'])) { $object['cc'] = [$object['cc']]; } $magazine = $this->activityPubManager->findOrCreateMagazineByToCCAndAudience($object); if ($magazine->isActorPostingRestricted($actor)) { throw new PostingRestrictedException($magazine, $actor); } $dto = new EntryDto(); $dto->magazine = $magazine; $dto->title = $object['name']; $dto->apId = $object['id']; if ((isset($object['attachment']) || isset($object['image'])) && $image = $this->activityPubManager->handleImages($object['attachment'])) { $this->logger->debug("adding image to entry '{title}', {image}", ['title' => $dto->title, 'image' => $image->getId()]); $dto->image = $this->imageFactory->createDto($image); } $dto->body = $this->objectExtractor->getMarkdownBody($object); $dto->visibility = $this->getVisibility($object, $actor); $this->extractUrlIntoDto($dto, $object, $actor); $this->handleDate($dto, $object['published']); if (isset($object['sensitive'])) { $this->handleSensitiveMedia($dto, $object['sensitive']); } if (isset($object['sensitive']) && true === $object['sensitive']) { $dto->isAdult = true; } if (!empty($object['language'])) { $dto->lang = $object['language']['identifier']; } elseif (!empty($object['contentMap'])) { $dto->lang = array_keys($object['contentMap'])[0]; } else { $dto->lang = $this->settingsManager->get('KBIN_DEFAULT_LANG'); } $dto->apLikeCount = $this->activityPubManager->extractRemoteLikeCount($object); $dto->apDislikeCount = $this->activityPubManager->extractRemoteDislikeCount($object); $dto->apShareCount = $this->activityPubManager->extractRemoteShareCount($object); if (isset($object['commentsEnabled']) && \is_bool($object['commentsEnabled'])) { $dto->isLocked = !$object['commentsEnabled']; } $this->logger->debug('creating page'); return $this->entryManager->create($dto, $actor, false, $stickyIt); } else { throw new EntityNotFoundException('Actor could not be found for entry.'); } } private function extractUrlIntoDto(EntryDto $dto, ?array $object, User $actor): void { $attachment = \array_key_exists('attachment', $object) ? $object['attachment'] : null; $dto->url = ActivityPubManager::extractUrlFromAttachment($attachment); if (null === $dto->url) { $instance = $this->instanceRepository->findOneBy(['domain' => $actor->apDomain]); if ($instance && 'peertube' === $instance->software) { // we make an exception for PeerTube as we need their embed viewer. // Normally the URL field only links to a user-friendly UI if that differs from the AP id, // which we do not want to have as a URL, but without the embed from PeerTube // a video is only viewable by clicking more -> open original URL // which is not very user-friendly. $url = \array_key_exists('url', $object) ? $object['url'] : null; $dto->url = ActivityPubManager::extractUrlFromAttachment($url); } } } } ================================================ FILE: src/Service/ActivityPub/SignatureValidator.php ================================================ validateUrl($signature['keyId']); if (!isset($payload['id'])) { throw new InvalidApSignatureException('Missing required "id" field in the payload'); } $this->validateUrl($id = \is_array($payload['id']) ? $payload['id'][0] : $payload['id']); $keyDomain = parse_url($signature['keyId'], PHP_URL_HOST); $idDomain = parse_url($id, PHP_URL_HOST); $actorKeyIdMismatch = false; $firstActorHost = null; $erroredActor = null; if (isset($payload['actor'])) { $actors = $payload['actor']; if (\is_string($actors)) { $actors = [$actors]; } foreach ($actors as $actor) { $url = $actor; if (!\is_string($actor) and isset($actor['id'])) { $url = $actor['id']; } $host = parse_url($url, PHP_URL_HOST); if (!$firstActorHost) { $firstActorHost = $host; } if ($host !== $keyDomain) { $actorKeyIdMismatch = true; $erroredActor = $url; break; } } } $forwardedMessage = false; $keyAndIdMismatch = false; if (!$keyDomain || !$idDomain || $keyDomain !== $idDomain) { $keyAndIdMismatch = true; } if ($keyAndIdMismatch or $actorKeyIdMismatch and $firstActorHost === $idDomain) { foreach (ActivityPubManager::getReceivers($payload) as $item) { // if the payload has an inbox of the keyId domain than this is a case of inbox forwarding // and we should dispatch a new message to get the activity from the "real" host $itemDomain = parse_url($item, PHP_URL_HOST); if ($itemDomain === $keyDomain) { $forwardedMessage = true; break; } } } if ($forwardedMessage) { throw new InboxForwardingException($signature['keyId'], $id); } elseif ($actorKeyIdMismatch) { throw new InvalidApSignatureException("Supplied key domain does not match domain of incoming activities 'actor' property. actor: '$erroredActor', keyId : '$keyDomain'"); } elseif ($keyAndIdMismatch) { throw new InvalidApSignatureException("Supplied key domain does not match domain of incoming activity. idDomain: '$idDomain' keyDomain: '$keyDomain'"); } $actorUrl = \is_array($payload['actor']) ? $payload['actor'][0] : $payload['actor']; $user = $this->activityPubManager->findActorOrCreate($actorUrl); if (!empty($user)) { $pem = $user->publicKey ?? $this->client->getActorObject($user->apProfileId)['publicKey']['publicKeyPem'] ?? null; if (null === $pem) { throw new InvalidUserPublicKeyException($user->apProfileId); } $pkey = openssl_pkey_get_public($pem); if (false === $pkey) { throw new InvalidUserPublicKeyException($user->apProfileId); } $this->verifySignature($pkey, $signature, $headers, $request['uri'], $body); } } private function validateUrl(string $url): void { $valid = filter_var($url, FILTER_VALIDATE_URL); if (!$valid) { throw new InvalidApSignatureException('Necessary supplied URL not valid.'); } $parsed = parse_url($url); if ('https' !== $parsed['scheme']) { throw new InvalidApSignatureException('Necessary supplied URL does not use HTTPS.'); } } /** * Verifies the signature of request against the given public key. * * @param array $signature Parsed signature value * * @throws InvalidApSignatureException Signature failed verification */ private function verifySignature( \OpenSSLAsymmetricKey $pkey, array $signature, array $headers, string $inboxUrl, string $payload, ): void { $digest = 'SHA-256='.base64_encode(hash('sha256', $payload, true)); if (isset($headers['digest']) && $digest !== $suppliedDigest = \is_array($headers['digest']) ? $headers['digest'][0] : $headers['digest']) { $this->logger->warning('Supplied digest of incoming request does not match calculated value', ['supplied-digest' => $suppliedDigest]); } $headersToSign = []; foreach (explode(' ', $signature['headers']) as $h) { if ('(request-target)' === $h) { $headersToSign[$h] = 'post '.$inboxUrl; } elseif ('digest' === $h) { $headersToSign[$h] = $digest; } elseif (isset($headers[$h][0])) { $headersToSign[$h] = $headers[$h][0]; } } $signingString = self::headersToSigningString($headersToSign); $verified = openssl_verify($signingString, base64_decode($signature['signature']), $pkey, OPENSSL_ALGO_SHA256); if (!$verified) { throw new InvalidApSignatureException('Signature of request could not be verified.'); } $this->logger->debug('Successfully verified signature of incoming AP request.', ['digest' => $digest]); } private static function headersToSigningString($headers): string { return implode( "\n", array_map(function ($k, $v) { return strtolower($k).': '.$v; }, array_keys($headers), $headers) ); } } ================================================ FILE: src/Service/ActivityPub/StrikethroughConverter.php ================================================ config = $config; } public function convert(ElementInterface $element): string { $value = $element->getValue(); if (!trim($value)) { return $value; } $prefix = ltrim($value) !== $value ? ' ' : ''; $suffix = rtrim($value) !== $value ? ' ' : ''; /* If this node is immediately preceded or followed by one of the same type don't emit * the start or end $style, respectively. This prevents foobar from * being converted to ~~foo~~~~bar~~ which is incorrect. We want ~~foobar~~ instead. */ $preStyle = \in_array($element->getPreviousSibling()?->getTagName(), $this->getSupportedTags()) ? '' : '~~'; $postStyle = \in_array($element->getNextSibling()?->getTagName(), $this->getSupportedTags()) ? '' : '~~'; return $prefix.$preStyle.trim($value).$postStyle.$suffix; } } ================================================ FILE: src/Service/ActivityPub/Webfinger/WebFinger.php ================================================ . */ namespace App\Service\ActivityPub\Webfinger; use App\Exception\InvalidWebfingerException; /** * A simple WebFinger container of data. */ class WebFinger { /** * @var string */ protected $subject; /** * @var string[] */ protected $aliases = []; /** * @var array */ protected $links = []; /** * Construct WebFinger instance. * * @param array $data A WebFinger response */ public function __construct(array $data) { $data['aliases'] = []; foreach (['subject', 'aliases', 'links'] as $key) { if (!isset($data[$key])) { throw new \Exception("WebFinger profile must contain '$key' property"); } $method = 'set'.ucfirst($key); $this->$method($data[$key]); } } /** * Get ActivityPhp profile id URL. * * @return string * * @throws InvalidWebfingerException */ public function getProfileId() { foreach ($this->links as $link) { if ($this->isLinkProfileId($link)) { return $link['href']; } } throw new InvalidWebfingerException('WebFinger data contains no AP profile identifier'); } public function getProfileIds(): array { $urls = []; foreach ($this->links as $link) { if ($this->isLinkProfileId($link)) { $urls[] = $link['href']; } } return $urls; } private function isLinkProfileId(array $link): bool { if (isset($link['rel'], $link['type'], $link['href'])) { if ('self' === $link['rel'] && 'application/activity+json' === $link['type']) { return true; } } return false; } /** * Get WebFinger response as an array. * * @return array */ public function toArray() { return [ 'subject' => $this->subject, 'aliases' => $this->aliases, 'links' => $this->links, ]; } /** * Get aliases. * * @return array */ public function getAliases() { return $this->aliases; } /** * Set aliases property. */ protected function setAliases(array $aliases) { foreach ($aliases as $alias) { if (!\is_string($alias)) { throw new \Exception('WebFinger aliases must be an array of strings'); } $this->aliases[] = $alias; } } /** * Get links. * * @return array */ public function getLinks() { return $this->links; } /** * Set links property. */ protected function setLinks(array $links) { foreach ($links as $link) { if (!\is_array($link)) { throw new \Exception('WebFinger links must be an array of objects'); } if (!isset($link['rel'])) { throw new \Exception("WebFinger links object must contain 'rel' property"); } $tmp = []; $tmp['rel'] = $link['rel']; foreach (['type', 'href', 'template'] as $key) { if (isset($link[$key]) && \is_string($link[$key])) { $tmp[$key] = $link[$key]; } } $this->links[] = $tmp; } } /** * Get subject fetched from profile. * * @return string|null Subject */ public function getSubject() { return $this->subject; } /** * Set subject property. * * @param string $subject */ protected function setSubject($subject) { if (!\is_string($subject)) { throw new \Exception('WebFinger subject must be a string'); } $this->subject = $subject; } /** * Get subject handle fetched from profile. * * @return string|null */ public function getHandle() { return substr($this->subject, 5); } } ================================================ FILE: src/Service/ActivityPub/Webfinger/WebFingerFactory.php ================================================ . */ namespace App\Service\ActivityPub\Webfinger; use App\ActivityPub\ActorHandle; use App\Service\ActivityPub\ApHttpClientInterface; /** * A simple WebFinger discoverer tool. */ class WebFingerFactory { public const WEBFINGER_URL = '%s://%s%s/.well-known/webfinger?resource=acct:%s'; public function __construct(private readonly ApHttpClientInterface $client) { } public function get(string $handle, string $scheme = 'https') { $actorHandle = ActorHandle::parse($handle); if (!$actorHandle) { throw new \Exception("WebFinger handle is malformed '{$handle}'"); } // Build a WebFinger URL $url = \sprintf( self::WEBFINGER_URL, $scheme, $actorHandle->host, $actorHandle->getPortString(), $actorHandle->plainHandle(), ); $content = $this->client->getWebfingerObject($url); if (!\is_array($content) || !\count($content)) { throw new \Exception('WebFinger fetching has failed, no contents returned for '.$handle); } return new WebFinger($content); } } ================================================ FILE: src/Service/ActivityPub/Webfinger/WebFingerParameters.php ================================================ query->get('resource')) { $host = $request->server->get('HTTP_HOST'); // @todo if (!str_contains($resource, '//')) { $resource = str_replace(':', '://', $resource); } if (!str_contains($resource, 'acct:')) { $resource = 'acct://'.$resource; } $url = parse_url($resource); if (!empty($url['scheme']) && !empty($url['user']) && !empty($url['host'])) { $params[static::HOST_KEY_NAME] = $host; if ('acct' === $url['scheme']) { if ($host === $url['host']) { $params[static::ACCOUNT_KEY_NAME] = $url['user']; // @todo } } } } if ($request->query->has('rel')) { $params[static::REL_KEY_NAME] = (array) $request->query->get('rel'); } return $params; } } ================================================ FILE: src/Service/ActivityPub/Wrapper/AnnounceWrapper.php ================================================ setActor($actor); if ($object instanceof Activity) { $activity->innerActivity = $object; } elseif ($object instanceof ActivityPubActivityInterface) { if ($idAsObject) { $arr = $this->activityFactory->create($object); $activity->setObject($arr['id']); } else { $activity->setObject($object); } } else { $url = filter_var($object, FILTER_VALIDATE_URL); if (false === $url) { throw new \LogicException('expecting the object to be an url if it is a string'); } $activity->innerActivityUrl = $url; } $this->entityManager->persist($activity); $this->entityManager->flush(); return $activity; } } ================================================ FILE: src/Service/ActivityPub/Wrapper/CollectionInfoWrapper.php ================================================ 'string', 'type' => 'string', 'id' => 'string', 'first' => 'string', 'totalItems' => 'int', ])] public function build(string $routeName, array $routeParams, int $count, bool $includeContext = true): array { $result = [ '@context' => $this->contextsProvider->referencedContexts(), 'type' => 'OrderedCollection', 'id' => $this->urlGenerator->generate($routeName, $routeParams, UrlGeneratorInterface::ABSOLUTE_URL), 'first' => $this->urlGenerator->generate( $routeName, $routeParams + ['page' => 1], UrlGeneratorInterface::ABSOLUTE_URL ), 'totalItems' => $count, ]; if (!$includeContext) { unset($result['@context']); } return $result; } } ================================================ FILE: src/Service/ActivityPub/Wrapper/CollectionItemsWrapper.php ================================================ 'string', 'type' => 'string', 'partOf' => 'string', 'id' => 'string', 'totalItems' => 'int', 'orderedItems' => "\Pagerfanta\PagerfantaInterface", 'next' => 'string', ])] public function build( string $routeName, array $routeParams, PagerfantaInterface $pagerfanta, array $items, int $page, bool $includeContext = true, ): array { $result = [ '@context' => $this->contextProvider->referencedContexts(), 'type' => 'OrderedCollectionPage', 'partOf' => $this->urlGenerator->generate( $routeName, $routeParams, UrlGeneratorInterface::ABSOLUTE_URL ), 'id' => $this->urlGenerator->generate( $routeName, $routeParams + ['page' => $page], UrlGeneratorInterface::ABSOLUTE_URL ), 'totalItems' => $pagerfanta->getNbResults(), 'orderedItems' => $items, ]; if (!$includeContext) { unset($result['@context']); } if ($pagerfanta->hasNextPage()) { $result['next'] = $this->urlGenerator->generate( $routeName, $routeParams + ['page' => $pagerfanta->getNextPage()], UrlGeneratorInterface::ABSOLUTE_URL ); } return $result; } } ================================================ FILE: src/Service/ActivityPub/Wrapper/CreateWrapper.php ================================================ setObject($item); if ($item instanceof Entry || $item instanceof EntryComment || $item instanceof Post || $item instanceof PostComment) { $activity->userActor = $item->getUser(); } elseif ($item instanceof Message) { $activity->userActor = $item->sender; } $this->entityManager->persist($activity); $this->entityManager->flush(); return $activity; } } ================================================ FILE: src/Service/ActivityPub/Wrapper/DeleteWrapper.php ================================================ factory->create($item); $userUrl = $item['attributedTo']; if (null !== $deletingUser) { // overwrite the actor in the json with the supplied deleting user if (null !== $deletingUser->apId) { $userUrl = $deletingUser->apPublicUrl; } else { $userUrl = $this->urlGenerator->generate('user_overview', ['username' => $deletingUser->username], UrlGeneratorInterface::ABSOLUTE_URL); } } $this->entityManager->persist($activity); $this->entityManager->flush(); $json = [ '@context' => $this->contextsProvider->referencedContexts(), 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => 'Delete', 'actor' => $userUrl, 'object' => [ 'id' => $item['id'], 'type' => 'Tombstone', ], 'to' => $item['to'], 'cc' => $item['cc'], ]; if (!$includeContext) { unset($json['@context']); } $activity->activityJson = json_encode($json); $this->entityManager->persist($activity); $this->entityManager->flush(); return $activity; } public function buildForUser(User $user): Activity { $activity = new Activity('Delete'); $this->entityManager->persist($activity); $this->entityManager->flush(); $userId = $this->urlGenerator->generate('ap_user', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL); $activity->activityJson = json_encode([ 'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL), 'type' => 'Delete', 'actor' => $userId, 'object' => $userId, 'to' => [ActivityPubActivityInterface::PUBLIC_URL], 'cc' => [$this->urlGenerator->generate('ap_user_followers', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL)], // this is a lemmy specific tag, that should cause the deletion of the data of a user (see this issue https://github.com/LemmyNet/lemmy/issues/4544) 'removeData' => true, ]); $this->entityManager->persist($activity); $this->entityManager->flush(); return $activity; } public function adjustDeletePayload(?User $actor, Entry|EntryComment|Post|PostComment $content, bool $includeContext = true): Activity { $payload = $this->build($content, $actor, $includeContext); $json = json_decode($payload->activityJson, true); if (null !== $actor && $content->user->getId() !== $actor->getId()) { // if the user is different, then this is a mod action. Lemmy requires a mod action to have a summary $json['summary'] = ' '; } if (null !== $actor?->apId) { $announceActivity = $this->announceWrapper->build($content->magazine, $payload); $json = $this->activityJsonBuilder->buildActivityJson($announceActivity); } $payload->activityJson = json_encode($json); $this->entityManager->persist($payload); $this->entityManager->flush(); return $payload; } } ================================================ FILE: src/Service/ActivityPub/Wrapper/FollowResponseWrapper.php ================================================ setActor($actor); $activity->setObject($request); $this->entityManager->persist($activity); $this->entityManager->flush(); return $activity; } } ================================================ FILE: src/Service/ActivityPub/Wrapper/FollowWrapper.php ================================================ setActor($follower); $activity->setObject($following); $this->entityManager->persist($activity); $this->entityManager->flush(); return $activity; } } ================================================ FILE: src/Service/ActivityPub/Wrapper/ImageWrapper.php ================================================ 'Image', 'mediaType' => $this->imageManager->getMimetype($image), 'url' => $this->imageManager->getUrl($image), 'name' => $image->altText, 'blurhash' => $image->blurhash, 'focalPoint' => [0, 0], 'width' => $image->width, 'height' => $image->height, ]; $item['image'] = [ // @todo Lemmy 'type' => 'Image', 'url' => $this->imageManager->getUrl($image), ]; return $item; } } ================================================ FILE: src/Service/ActivityPub/Wrapper/LikeWrapper.php ================================================ activityFactory->create($object); $activity = new Activity('Like'); $activity->setObject($activityObject['id']); $activity->userActor = $user; if ($object instanceof Entry || $object instanceof EntryComment || $object instanceof Post || $object instanceof PostComment) { $activity->audience = $object->magazine; } $this->entityManager->persist($activity); $this->entityManager->flush(); return $activity; } } ================================================ FILE: src/Service/ActivityPub/Wrapper/MentionsWrapper.php ================================================ mentionManager->extract($body ?? '') ?? [])); $results = []; foreach ($mentions as $index => $mention) { try { $actor = $this->activityPubManager->findActorOrCreate($mention); if (!$actor) { continue; } if (substr_count($mention, '@') < 2) { $mention = $mention.'@'.$this->settingsManager->get('KBIN_DOMAIN'); } $results[$index] = [ 'type' => 'Mention', 'href' => $actor->apProfileId ?? $this->urlGenerator->generate( 'ap_user', ['username' => $actor->getUserIdentifier()], UrlGeneratorInterface::ABSOLUTE_URL ), 'name' => $mention, ]; } catch (\Exception $e) { continue; } } return $results; } } ================================================ FILE: src/Service/ActivityPub/Wrapper/TagsWrapper.php ================================================ [ 'type' => 'Hashtag', 'href' => $this->urlGenerator->generate( 'tag_overview', ['name' => $tag], UrlGeneratorInterface::ABSOLUTE_URL ), 'name' => '#'.$tag, ], $tags); } } ================================================ FILE: src/Service/ActivityPub/Wrapper/UndoWrapper.php ================================================ innerActivity = $object; $activity->setActor($object->getActor()); } else { if (null === $actor) { throw new \LogicException('actor must not be null if the object is a url'); } $activity->innerActivityUrl = $object; $activity->setActor($actor); } $this->entityManager->persist($activity); $this->entityManager->flush(); return $activity; } } ================================================ FILE: src/Service/ActivityPub/Wrapper/UpdateWrapper.php ================================================ setActor($editedBy ?? $content->getUser()); $activity->setObject($content); if ($content instanceof Entry || $content instanceof EntryComment || $content instanceof Post || $content instanceof PostComment) { $activity->audience = $content->magazine; } $this->entityManager->persist($activity); $this->entityManager->flush(); return $activity; } #[ArrayShape([ '@context' => 'mixed', 'id' => 'mixed', 'type' => 'string', 'actor' => 'mixed', 'published' => 'mixed', 'to' => 'mixed', 'cc' => 'mixed', 'object' => 'array', ])] public function buildForActor(ActivityPubActorInterface $item, ?User $editedBy = null): Activity { $activity = new Activity('Update'); $activity->setActor($editedBy ?? $item); $activity->setObject($item); $this->entityManager->persist($activity); $this->entityManager->flush(); return $activity; } } ================================================ FILE: src/Service/ActivityPubManager.php ================================================ apId) { return $this->personFactory->getActivityPubId($actor); } } // @todo blid webfinger return $actor->apProfileId; } public function findRemoteActor(string $actorUrl): ?User { return $this->userRepository->findOneBy(['apProfileId' => $actorUrl]); } public function createCcFromBody(?string $body): array { $mentions = $this->mentionManager->extract($body) ?? []; $urls = []; foreach ($mentions as $handle) { try { $actor = $this->findActorOrCreate($handle); } catch (\Exception $e) { continue; } if (!$actor) { continue; } $urls[] = $actor->apProfileId ?? $this->urlGenerator->generate( 'ap_user', ['username' => $actor->getUserIdentifier()], UrlGeneratorInterface::ABSOLUTE_URL ); } return $urls; } /** * Find an existing actor or create a new one if the actor doesn't yet exists. * * @param ?string $actorUrlOrHandle actorUrlOrHandle actor URL or actor handle (could even be null) * * @return User|Magazine|null or Magazine or null on error * * @throws InvalidApPostException * @throws InvalidArgumentException * @throws InvalidWebfingerException */ public function findActorOrCreate(?string $actorUrlOrHandle): User|Magazine|null { if (\is_null($actorUrlOrHandle)) { return null; } $this->logger->debug('[ActivityPubManager::findActorOrCreate] Searching for actor at "{handle}"', ['handle' => $actorUrlOrHandle]); if (str_contains($actorUrlOrHandle, $this->settingsManager->get('KBIN_DOMAIN').'/m/')) { $magazine = str_replace('https://'.$this->settingsManager->get('KBIN_DOMAIN').'/m/', '', $actorUrlOrHandle); $this->logger->debug('[ActivityPubManager::findActorOrCreate] Found magazine: "{magName}"', ['magName' => $magazine]); return $this->magazineRepository->findOneByName($magazine); } $actorUrl = $actorUrlOrHandle; if (false === filter_var($actorUrl, FILTER_VALIDATE_URL)) { if (!substr_count(ltrim($actorUrl, '@'), '@')) { // local user. Maybe an @ at the beginning, but not in the middle $user = $this->userRepository->findOneBy(['username' => ltrim($actorUrl, '@')]); } else { // remote user. Maybe @user@domain, maybe only user@domain -> trim left and look in apId $user = $this->userRepository->findOneBy(['apId' => ltrim($actorUrl, '@')]); } if ($user instanceof User) { if ($user->apId && !$user->isDeleted && !$user->isSoftDeleted() && !$user->isTrashed() && (!$user->apFetchedAt || $user->apFetchedAt->modify('+1 hour') < (new \DateTime()))) { $this->dispatchUpdateActor($user->apProfileId); } return $user; } if (!substr_count(ltrim($actorUrl, '@'), '@')) { // local magazine. Maybe an @ at the beginning, but not in the middle $magazine = $this->magazineRepository->findOneBy(['name' => ltrim($actorUrl, '@')]); } else { // remote magazine. Maybe !magazine@domain, maybe only magazine@domain -> trim left and look in apId $magazine = $this->magazineRepository->findOneBy(['apId' => ltrim($actorUrl, '@!')]); } if ($magazine instanceof Magazine) { if ($magazine->apId && !$magazine->isSoftDeleted() && !$magazine->isTrashed() && (!$magazine->apFetchedAt || $magazine->apFetchedAt->modify('+1 hour') < (new \DateTime()))) { $this->dispatchUpdateActor($magazine->apProfileId); } return $magazine; } $actorUrl = $this->webfinger($actorUrl)->getProfileId(); } if (\in_array( parse_url($actorUrl, PHP_URL_HOST), [$this->settingsManager->get('KBIN_DOMAIN'), 'localhost', '127.0.0.1'] )) { $name = explode('/', $actorUrl); $name = end($name); $this->logger->debug('[ActivityPubManager::findActorOrCreate] Found user: "{user}"', ['user' => $name]); return $this->userRepository->findOneBy(['username' => $name]); } // Check if the instance is banned if ($this->settingsManager->isBannedInstance($actorUrl)) { return null; } $user = $this->userRepository->findOneBy(['apProfileId' => $actorUrl]); if (!$user) { // also try the public URL if it was not found by the profile id $user = $this->userRepository->findOneBy(['apPublicUrl' => $actorUrl]); } if ($user instanceof User) { $this->logger->debug('[ActivityPubManager::findActorOrCreate] Found remote user for url: "{url}" in db', ['url' => $actorUrl]); if ($user->apId && !$user->isDeleted && !$user->isSoftDeleted() && !$user->isTrashed() && (!$user->apFetchedAt || $user->apFetchedAt->modify('+1 hour') < (new \DateTime()))) { $this->dispatchUpdateActor($user->apProfileId); } return $user; } $magazine = $this->magazineRepository->findOneBy(['apProfileId' => $actorUrl]); if (!$magazine) { // also try the public URL if it was not found by the profile id $magazine = $this->magazineRepository->findOneBy(['apPublicUrl' => $actorUrl]); } if ($magazine instanceof Magazine) { $this->logger->debug('[ActivityPubManager::findActorOrCreate] Found remote user for url: "{url}" in db', ['url' => $actorUrl]); if (!$magazine->isTrashed() && !$magazine->isSoftDeleted() && (!$magazine->apFetchedAt || $magazine->apFetchedAt->modify('+1 hour') < (new \DateTime()))) { $this->dispatchUpdateActor($magazine->apProfileId); } return $magazine; } $actor = $this->apHttpClient->getActorObject($actorUrl); // Check if actor isn't empty (not set/null/empty array/etc.) and check if actor type is set if (!empty($actor) && isset($actor['type'])) { // User (we don't make a distinction between bots with type Service as Lemmy does) if (\in_array($actor['type'], User::USER_TYPES)) { $this->logger->debug('[ActivityPubManager::findActorOrCreate] Found remote user at "{url}"', ['url' => $actorUrl]); return $this->createUser($actorUrl); } // Magazine (Group) if ('Group' === $actor['type']) { $this->logger->debug('[ActivityPubManager::findActorOrCreate] Found remote magazine at "{url}"', ['url' => $actorUrl]); return $this->createMagazine($actorUrl); } if ('Tombstone' === $actor['type']) { // deleted actor if (null !== ($magazine = $this->magazineRepository->findOneBy(['apProfileId' => $actorUrl])) && null !== $magazine->apId) { $this->magazineManager->purge($magazine); $this->logger->warning('[ActivityPubManager::findActorOrCreate] Got a tombstone for magazine {name} at {url}, deleting it', ['name' => $magazine->name, 'url' => $actorUrl]); } elseif (null !== ($user = $this->userRepository->findOneBy(['apProfileId' => $actorUrl])) && null !== $user->apId) { $this->bus->dispatch(new DeleteUserMessage($user->getId())); $this->logger->warning('[ActivityPubManager::findActorOrCreate] Got a tombstone for user {name} at {url}, deleting it', ['name' => $user->username, 'url' => $actorUrl]); } } } else { $this->logger->debug("[ActivityPubManager::findActorOrCreate] Actor not found, actorUrl: $actorUrl"); } return null; } public function dispatchUpdateActor(string $actorUrl) { if ($this->settingsManager->isBannedInstance($actorUrl)) { return; } $limiter = $this->apUpdateActorLimiter ->create($actorUrl) ->consume(1); if ($limiter->isAccepted()) { $this->bus->dispatch(new UpdateActorMessage($actorUrl)); } else { $this->logger->debug( '[ActivityPubManager::dispatchUpdateActor] Not dispatching updating actor for {actor}: one has been dispatched recently', ['actor' => $actorUrl, 'retry' => $limiter->getRetryAfter()] ); } } /** * Try to find an existing actor or create a new one if the actor doesn't yet exists. * * @param ?string $actorUrlOrHandle actor URL or handle (could even be null) * * @throws \LogicException when the returned actor is not a user or is null */ public function findUserActorOrCreateOrThrow(?string $actorUrlOrHandle): User|Magazine { $object = $this->findActorOrCreate($actorUrlOrHandle); if (!$object) { throw new \LogicException("Could not find actor for 'object' property at: '$actorUrlOrHandle'"); } elseif (!$object instanceof User) { throw new \LogicException("Could not find user actor for 'object' property at: '$actorUrlOrHandle'"); } return $object; } public function webfinger(string $id): WebFinger { $this->logger->debug('[ActivityPubManager::webfinger] Fetching webfinger "{id}"', ['id' => $id]); if (false === filter_var($id, FILTER_VALIDATE_URL)) { $id = ltrim($id, '@'); return $this->webFingerFactory->get($id); } $handle = $this->buildHandle($id); return $this->webFingerFactory->get($handle); } private function buildHandle(string $id): string { $port = !\is_null(parse_url($id, PHP_URL_PORT)) ? ':'.parse_url($id, PHP_URL_PORT) : ''; $apObj = $this->apHttpClient->getActorObject($id); if (!isset($apObj['preferredUsername'])) { throw new \InvalidArgumentException("webfinger from $id does not supply a valid user object"); } return \sprintf( '%s@%s%s', $apObj['preferredUsername'], parse_url($id, PHP_URL_HOST), $port ); } /** * Creates a new user. * * @param string $actorUrl actor URL * * @return ?User or null on error * * @throws InstanceBannedException */ private function createUser(string $actorUrl): ?User { if ($this->settingsManager->isBannedInstance($actorUrl)) { throw new InstanceBannedException(); } $webfinger = $this->webfinger($actorUrl); $dto = $this->userFactory->createDtoFromAp($actorUrl, $webfinger->getHandle()); $this->userManager->create( $dto, false, false, preApprove: true, ); if (method_exists($this->cache, 'invalidateTags')) { // clear markdown renders that are tagged with the handle of the user $tag = UrlUtils::getCacheKeyForMarkdownUserMention($dto->apId); $this->cache->invalidateTags([$tag]); $this->logger->debug('cleared cached items with tag {t}', ['t' => $tag]); } return $this->updateUser($actorUrl); } /** * Update existing user and return user object. * * @param string $actorUrl actor URL * * @return ?User or null on error (e.g. actor not found) */ private function updateUser(string $actorUrl): ?User { $this->logger->info('[ActivityPubManager::updateUser] Updating user {name}', ['name' => $actorUrl]); $user = $this->userRepository->findOneBy(['apProfileId' => $actorUrl]); if ($user->isDeleted || $user->isTrashed() || $user->isSoftDeleted()) { return $user; } $actor = $this->apHttpClient->getActorObject($actorUrl); if (!$actor || !\is_array($actor)) { return null; } if (isset($actor['type']) && 'Tombstone' === $actor['type'] && $user instanceof User) { $this->bus->dispatch(new DeleteUserMessage($user->getId())); return null; } // Check if actor isn't empty (not set/null/empty array/etc.) if (isset($actor['endpoints']['sharedInbox']) || isset($actor['inbox'])) { // Update the following user columns $user->type = $actor['type'] ?? 'Person'; $user->apInboxUrl = $actor['endpoints']['sharedInbox'] ?? $actor['inbox']; $user->apDomain = parse_url($actor['id'], PHP_URL_HOST); if ($actor['preferredUsername']) { $newUsername = '@'.$actor['preferredUsername'].'@'.$user->apDomain; if ($user->username !== $newUsername) { $this->logger->info('The handle of "{u}" ({url}) changed to "{u2}" for id {id}', ['u' => $user->username, 'url' => $user->apProfileId, 'u2' => $newUsername, 'id' => $user->getId()]); $user->username = $newUsername; } } $user->apFollowersUrl = $actor['followers'] ?? null; $user->apAttributedToUrl = $actor['attributedTo'] ?? null; $user->apPreferredUsername = $actor['preferredUsername'] ?? null; $user->title = $actor['name'] ?? null; $user->apDiscoverable = $actor['discoverable'] ?? null; $user->apIndexable = $actor['indexable'] ?? null; $user->apManuallyApprovesFollowers = $actor['manuallyApprovesFollowers'] ?? false; $actorUrlValue = $actor['url'] ?? $actorUrl; if (\is_array($actorUrlValue)) { // Pick the link with the fewest path segments as the most canonical profile URL. // Fall back to $actorUrl if no valid href is found. $best = null; $bestCount = PHP_INT_MAX; foreach ($actorUrlValue as $link) { $href = \is_array($link) ? ($link['href'] ?? null) : (string) $link; if (null === $href) { continue; } $pathSegments = \count(array_filter(explode('/', parse_url($href, PHP_URL_PATH) ?? ''))); if ($pathSegments < $bestCount) { $bestCount = $pathSegments; $best = $href; } } $actorUrlValue = $best ?? $actorUrl; } $user->apPublicUrl = \is_string($actorUrlValue) ? $actorUrlValue : $actorUrl; $user->apDeletedAt = null; $user->apTimeoutAt = null; $user->apFetchedAt = new \DateTime(); if (isset($actor['published'])) { try { $createdAt = new \DateTimeImmutable($actor['published']); $now = new \DateTimeImmutable(); if ($createdAt < $now) { $user->createdAt = $createdAt; } } catch (\Exception) { } } // Only update about when summary is set if (isset($actor['summary'])) { $converter = new HtmlConverter(['strip_tags' => true]); $user->about = stripslashes($converter->convert($actor['summary'])); } // Only update avatar if icon is set if (isset($actor['icon'])) { // we only have to wrap the property in an array if it is not already an array, though that is not that easy to determine // because each json object is an associative array -> each image has to have a 'type' property so use that to check it $icon = !\array_key_exists('type', $actor['icon']) ? $actor['icon'] : [$actor['icon']]; $newImage = $this->handleImages($icon); if ($user->avatar && $newImage !== $user->avatar) { $this->bus->dispatch(new DeleteImageMessage($user->avatar->getId())); } $user->avatar = $newImage; } elseif (null !== $user->avatar) { $this->bus->dispatch(new DeleteImageMessage($user->avatar->getId())); $user->avatar = null; } // Only update cover if image is set if (isset($actor['image'])) { // we only have to wrap the property in an array if it is not already an array, though that is not that easy to determine // because each json object is an associative array -> each image has to have a 'type' property so use that to check it $cover = !\array_key_exists('type', $actor['image']) ? $actor['image'] : [$actor['image']]; $newImage = $this->handleImages($cover); if ($user->cover && $newImage !== $user->cover) { $this->bus->dispatch(new DeleteImageMessage($user->cover->getId())); } $user->cover = $newImage; } elseif (null !== $user->cover) { $this->bus->dispatch(new DeleteImageMessage($user->cover->getId())); $user->cover = null; } if (isset($actor['publicKey']['publicKeyPem']) && $user->publicKey !== $actor['publicKey']['publicKeyPem']) { if (null !== $user->publicKey) { // only log the message if there already was a public key. When initially created the actors do not get one $this->logger->info('The public key of user "{u}" has changed', ['u' => $user->username]); $user->lastKeyRotationDate = new \DateTime(); } $user->oldPublicKey = $user->publicKey; $user->publicKey = $actor['publicKey']['publicKeyPem']; } if (null !== $user->apFollowersUrl) { try { $followersObj = $this->apHttpClient->getCollectionObject($user->apFollowersUrl); if (isset($followersObj['totalItems']) and \is_int($followersObj['totalItems'])) { $user->apFollowersCount = $followersObj['totalItems']; $user->updateFollowCounts(); } } catch (InvalidApPostException|InvalidArgumentException $ignored) { } } if (null !== $user->apId) { $instance = $this->instanceRepository->findOneBy(['domain' => $user->apDomain]); if (null === $instance) { $instance = new Instance($user->apDomain); } $this->remoteInstanceManager->updateInstance($instance); } // Write to DB $this->entityManager->flush(); return $user; } else { $this->logger->debug("[ActivityPubManager::updateUser] Actor not found, actorUrl: $actorUrl"); } return null; } public function handleImages(array $attachment): ?Image { $images = array_filter( $attachment, fn ($val) => $this->isImageAttachment($val) ); // @todo multiple images if (\count($images)) { try { $imageObject = $images[array_key_first($images)]; if (isset($imageObject['height'])) { // determine the highest resolution image foreach ($images as $i) { if (isset($i['height']) && $i['height'] ?? 0 > $imageObject['height'] ?? 0) { $imageObject = $i; } } } if ($tempFile = $this->imageManager->download($imageObject['url'])) { $image = $this->imageRepository->findOrCreateFromPath($tempFile); $image->sourceUrl = $imageObject['url']; if ($image && isset($imageObject['name'])) { $image->altText = $imageObject['name']; } $this->entityManager->persist($image); $this->entityManager->flush(); } } catch (\Exception $e) { return null; } return $image ?? null; } return null; } public static function extractUrlFromAttachment(mixed $attachment): ?string { $url = null; if (\is_array($attachment)) { $link = array_filter( $attachment, fn ($val) => 'Link' === $val['type'] ); $firstArrayKey = array_key_first($link); if (!empty($link[$firstArrayKey]) && isset($link[$firstArrayKey]['href']) && \is_string($link[$firstArrayKey]['href'])) { $url = $link[$firstArrayKey]['href']; } elseif (isset($link['href']) && \is_string($link['href'])) { $url = $link['href']; } } return $url; } /** * Creates a new magazine (Group). * * @param string $actorUrl actor URL * * @return ?Magazine or null on error * * @throws InstanceBannedException */ private function createMagazine(string $actorUrl): ?Magazine { if ($this->settingsManager->isBannedInstance($actorUrl)) { throw new InstanceBannedException(); } $dto = $this->magazineFactory->createDtoFromAp($actorUrl, $this->buildHandle($actorUrl)); $this->magazineManager->create( $dto, null, false ); try { if (method_exists($this->cache, 'invalidateTags')) { // clear markdown renders that are tagged with the handle of the magazine $tag = UrlUtils::getCacheKeyForMarkdownMagazineMention($dto->apId); $this->cache->invalidateTags([$tag]); $this->logger->debug('cleared cached items with tag {t}', ['t' => $tag]); } } catch (CacheException $ex) { $this->logger->error('An error occurred during cache clearing: {e} - {m}', ['e' => \get_class($ex), 'm' => $ex->getMessage()]); } return $this->updateMagazine($actorUrl); } /** * Update an existing magazine. * * @param string $actorUrl actor URL * * @return ?Magazine or null on error */ private function updateMagazine(string $actorUrl): ?Magazine { $this->logger->info('[ActivityPubManager::updateMagazine] Updating magazine "{magName}"', ['magName' => $actorUrl]); $magazine = $this->magazineRepository->findOneBy(['apProfileId' => $actorUrl]); if ($magazine->isTrashed() || $magazine->isSoftDeleted()) { return $magazine; } $actor = $this->apHttpClient->getActorObject($actorUrl); // Check if actor isn't empty (not set/null/empty array/etc.) if ($actor && 'Tombstone' === $actor['type'] && $magazine instanceof Magazine && null !== $magazine->apId) { // tombstone for remote magazine -> delete it $this->magazineManager->purge($magazine); return null; } if (isset($actor['endpoints']['sharedInbox']) || isset($actor['inbox'])) { if (isset($actor['summary'])) { $magazine->description = $this->extractMarkdownSummary($actor); } if (isset($actor['icon'])) { // we only have to wrap the property in an array if it is not already an array, though that is not that easy to determine // because each json object is an associative array -> each image has to have a 'type' property so use that to check it $icon = !\array_key_exists('type', $actor['icon']) ? $actor['icon'] : [$actor['icon']]; $newImage = $this->handleImages($icon); if ($magazine->icon && $newImage !== $magazine->icon) { $this->bus->dispatch(new DeleteImageMessage($magazine->icon->getId())); } $magazine->icon = $newImage; } elseif (null !== $magazine->icon) { $this->bus->dispatch(new DeleteImageMessage($magazine->icon->getId())); $magazine->icon = null; } if (isset($actor['image'])) { $banner = !\array_key_exists('type', $actor['image']) ? $actor['image'] : [$actor['image']]; $newImage = $this->handleImages($banner); if ($magazine->banner && $newImage !== $magazine->banner) { $this->bus->dispatch(new DeleteImageMessage($magazine->banner->getId())); } $magazine->banner = $newImage; } elseif (null !== $magazine->banner) { $this->bus->dispatch(new DeleteImageMessage($magazine->banner->getId())); $magazine->banner = null; } if ($actor['name']) { $magazine->title = $actor['name']; } elseif ($actor['preferredUsername']) { $magazine->title = $actor['preferredUsername']; } if (isset($actor['published'])) { try { $createdAt = new \DateTimeImmutable($actor['published']); $now = new \DateTimeImmutable(); if ($createdAt < $now) { $magazine->createdAt = $createdAt; } } catch (\Exception) { } } $magazine->apInboxUrl = $actor['endpoints']['sharedInbox'] ?? $actor['inbox']; $magazine->apDomain = parse_url($actor['id'], PHP_URL_HOST); $magazine->apFollowersUrl = $actor['followers'] ?? null; $magazine->apAttributedToUrl = isset($actor['attributedTo']) && \is_string($actor['attributedTo']) ? $actor['attributedTo'] : null; $magazine->apFeaturedUrl = $actor['featured'] ?? null; $magazine->apPreferredUsername = $actor['preferredUsername'] ?? null; $magazine->apDiscoverable = $actor['discoverable'] ?? null; $magazine->apPublicUrl = $actor['url'] ?? $actorUrl; $magazine->apDeletedAt = null; $magazine->apTimeoutAt = null; $magazine->apFetchedAt = new \DateTime(); $magazine->isAdult = $actor['sensitive'] ?? false; $magazine->postingRestrictedToMods = filter_var($actor['postingRestrictedToMods'] ?? false, FILTER_VALIDATE_BOOLEAN) ?? false; $magazine->apIndexable = $actor['indexable'] ?? null; if (null !== $magazine->apFollowersUrl) { try { $this->logger->debug('[ActivityPubManager::updateMagazine] Updating remote followers of magazine "{magUrl}"', ['magUrl' => $actorUrl]); $followersObj = $this->apHttpClient->getCollectionObject($magazine->apFollowersUrl); if (isset($followersObj['totalItems']) and \is_int($followersObj['totalItems'])) { $magazine->apFollowersCount = $followersObj['totalItems']; $magazine->updateSubscriptionsCount(); } } catch (InvalidApPostException|InvalidArgumentException $ignored) { } } if (null !== $magazine->apAttributedToUrl) { try { $this->handleModeratorCollection($actorUrl, $magazine); } catch (InvalidArgumentException $ignored) { } } elseif (isset($actor['attributedTo']) && \is_array($actor['attributedTo'])) { $this->handleModeratorArray($magazine, $this->getActorFromAttributedTo($actor['attributedTo'])); } if (null !== $magazine->apFeaturedUrl) { try { $this->handleMagazineFeaturedCollection($actorUrl, $magazine); } catch (InvalidArgumentException $ignored) { } } if (isset($actor['publicKey']['publicKeyPem']) && $magazine->publicKey !== $actor['publicKey']['publicKeyPem']) { if (null !== $magazine->publicKey) { // only log the message if there already was a public key. When initially created the actors do not get one $this->logger->info('The public key of magazine "{m}" has changed', ['m' => $magazine->name]); $magazine->lastKeyRotationDate = new \DateTime(); } $magazine->oldPublicKey = $magazine->publicKey; $magazine->publicKey = $actor['publicKey']['publicKeyPem']; } if (null !== $magazine->apId) { $instance = $this->instanceRepository->findOneBy(['domain' => $magazine->apDomain]); if (null === $instance) { $instance = new Instance($magazine->apDomain); } $this->remoteInstanceManager->updateInstance($instance); } $this->entityManager->flush(); return $magazine; } else { $this->logger->debug("[ActivityPubManager::updateMagazine] Actor not found, actorUrl: $actorUrl"); } return null; } /** * @throws InvalidArgumentException */ private function handleModeratorCollection(string $actorUrl, Magazine $magazine): void { try { $this->logger->debug('[ActivityPubManager::handleModeratorCollection] Fetching moderators of remote magazine: "{magUrl}"', ['magUrl' => $actorUrl]); $attributedObj = $this->apHttpClient->getCollectionObject($magazine->apAttributedToUrl); $items = null; if (isset($attributedObj['items']) and \is_array($attributedObj['items'])) { $items = $attributedObj['items']; } elseif (isset($attributedObj['orderedItems']) and \is_array($attributedObj['orderedItems'])) { $items = $attributedObj['orderedItems']; } $this->logger->debug('[ActivityPubManager::handleModeratorCollection] Got moderator items for magazine: "{magName}": {json}', ['magName' => $magazine->name, 'json' => json_encode($attributedObj)]); if (null !== $items) { $this->handleModeratorArray($magazine, $items); } else { $this->logger->warning('[ActivityPubManager::handleModeratorCollection] Could not update the moderators of "{url}", the response doesn\'t have a "items" or "orderedItems" property or it is not an array', ['url' => $actorUrl]); } } catch (InvalidApPostException $ignored) { } } private function handleModeratorArray(Magazine $magazine, array $items): void { $moderatorsToRemove = []; /** @var Moderator $mod */ foreach ($magazine->moderators as $mod) { $moderatorsToRemove[] = $mod->user; } $indexesNotToRemove = []; foreach ($items as $item) { if (\is_string($item)) { try { $user = $this->findActorOrCreate($item); if ($user instanceof User) { foreach ($moderatorsToRemove as $key => $existMod) { if ($existMod->username === $user->username) { $indexesNotToRemove[] = $key; break; } } if (!$magazine->userIsModerator($user)) { $this->logger->info('[ActivityPubManager::handleModeratorArray] Adding "{user}" as moderator in "{magName}" because they are a mod upstream, but not locally', ['user' => $user->username, 'magName' => $magazine->name]); $this->magazineManager->addModerator(new ModeratorDto($magazine, $user, null)); } } } catch (\Exception) { $this->logger->warning('[ActivityPubManager::handleModeratorArray] Something went wrong while fetching actor "{actor}" as moderator of "{magName}"', ['actor' => $item, 'magName' => $magazine->name]); } } } foreach ($indexesNotToRemove as $i) { $moderatorsToRemove[$i] = null; } foreach ($moderatorsToRemove as $modToRemove) { if (null === $modToRemove) { continue; } $criteria = Criteria::create()->where(Criteria::expr()->eq('magazine', $magazine)); $modObject = $modToRemove->moderatorTokens->matching($criteria)->first(); $this->logger->info('[ActivityPubManager::handleModeratorArray] Removing "{exMod}" from "{magName}" as mod locally because they are no longer mod upstream', ['exMod' => $modToRemove->username, 'magName' => $magazine->name]); $this->magazineManager->removeModerator($modObject, null); } } /** * @throws InvalidArgumentException */ private function handleMagazineFeaturedCollection(string $actorUrl, Magazine $magazine): void { try { $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Fetching featured posts of remote magazine: {url}', ['url' => $actorUrl]); $attributedObj = $this->apHttpClient->getCollectionObject($magazine->apFeaturedUrl); $items = null; if (isset($attributedObj['items']) and \is_array($attributedObj['items'])) { $items = $attributedObj['items']; } elseif (isset($attributedObj['orderedItems']) and \is_array($attributedObj['orderedItems'])) { $items = $attributedObj['orderedItems']; } $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Got featured items for magazine: "{magName}": {json}', ['magName' => $magazine->name, 'json' => json_encode($attributedObj)]); if (null !== $items) { $pinnedToRemove = $this->entryRepository->findPinned($magazine); $indexesNotToRemove = []; $idsToPin = []; foreach ($items as $item) { $apId = null; $isString = false; if (\is_string($item)) { $apId = $item; $isString = true; } elseif (\is_array($item)) { $apId = $item['id']; } else { $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Ignoring {item}, because it is not a string and not an array', ['item' => json_encode($item)]); continue; } $entry = null; $alreadyPinned = false; if ($this->settingsManager->isLocalUrl($apId)) { $pair = $this->activityRepository->findLocalByApId($apId); if (Entry::class === $pair['type']) { foreach ($pinnedToRemove as $i => $entry) { if ($entry->getId() === $pair['id']) { $indexesNotToRemove[] = $i; $alreadyPinned = true; } } } } else { foreach ($pinnedToRemove as $i => $entry) { if ($entry->apId === $apId) { $indexesNotToRemove[] = $i; $alreadyPinned = true; } } if (!$alreadyPinned) { $existingEntry = $this->entryRepository->findOneBy(['apId' => $apId]); if ($existingEntry) { $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Pinning existing entry: {title}', ['title' => $existingEntry->title]); $this->entryManager->pin($existingEntry, null); } else { if (!$this->settingsManager->isBannedInstance($apId)) { $object = $item; if ($isString) { $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Getting {url} because we dont have it', ['url' => $apId]); $object = $this->apHttpClient->getActivityObject($apId); } $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Dispatching create message for entry: {e}', ['e' => json_encode($object)]); $this->bus->dispatch(new CreateMessage($object, true)); } else { $this->logger->info('[ActivityPubManager::handleMagazineFeaturedCollection] The instance is banned, url: {url}', ['url' => $apId]); } } } } } foreach ($indexesNotToRemove as $i) { $pinnedToRemove[$i] = null; } foreach (array_filter($pinnedToRemove) as $pinnedEntry) { // the pin method also unpins if the entry is already pinned $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Unpinning entry: "{title}"', ['title' => $pinnedEntry->title]); $this->entryManager->pin($pinnedEntry, null); } } } catch (InvalidApPostException $ignored) { } } public function createInboxesFromCC(array $activity, User $user): array { $followersUrl = $this->urlGenerator->generate( 'ap_user_followers', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL ); $arr = array_unique( array_filter( array_merge( \App\Utils\JsonldUtils::getArrayValue($activity, 'cc'), \App\Utils\JsonldUtils::getArrayValue($activity, 'to'), ), fn ($val) => !\in_array($val, [ActivityPubActivityInterface::PUBLIC_URL, $followersUrl, []]) ) ); $users = []; foreach ($arr as $url) { if ($user = $this->findActorOrCreate($url)) { $users[] = $user; } } return array_map(fn ($user) => $user->apInboxUrl, $users); } public function handleVideos(array $attachment): ?VideoDto { $videos = array_filter( $attachment, fn ($val) => \in_array($val['type'], ['Document', 'Video']) && VideoManager::isVideoUrl($val['url']) ); if (\count($videos)) { return (new VideoDto())->create( $videos[0]['url'], $videos[0]['mediaType'], !empty($videos['0']['name']) ? $videos['0']['name'] : $videos['0']['mediaType'] ); } return null; } public function handleExternalImages(array $attachment): ?array { $images = array_filter( $attachment, fn ($val) => $this->isImageAttachment($val) ); array_shift($images); if (\count($images)) { return array_map(fn ($val) => (new ImageDto())->create( $val['url'], $val['mediaType'], !empty($val['name']) ? $val['name'] : $val['mediaType'] ), $images); } return null; } public function handleExternalVideos(array $attachment): ?array { $videos = array_filter( $attachment, fn ($val) => \in_array($val['type'], ['Document', 'Video']) && VideoManager::isVideoUrl($val['url']) ); if (\count($videos)) { return array_map(fn ($val) => (new VideoDto())->create( $val['url'], $val['mediaType'], !empty($val['name']) ? $val['name'] : $val['mediaType'] ), $videos); } return null; } /** * Update existing actor. * * @param string $actorUrl actor URL * * @return Magazine|User|null null on error */ public function updateActor(string $actorUrl): Magazine|User|null { if ($this->settingsManager->isBannedInstance($actorUrl)) { return null; } if ($this->userRepository->findOneBy(['apProfileId' => $actorUrl])) { return $this->updateUser($actorUrl); } elseif ($this->magazineRepository->findOneBy(['apProfileId' => $actorUrl])) { return $this->updateMagazine($actorUrl); } return null; } public function findOrCreateMagazineByToCCAndAudience(array $object): ?Magazine { $potentialGroups = self::getReceivers($object); $magazine = $this->magazineRepository->findByApGroupProfileId($potentialGroups); if ($magazine and $magazine->apId && !$magazine->isTrashed() && !$magazine->isSoftDeleted() && (!$magazine->apFetchedAt || $magazine->apFetchedAt->modify('+1 Day') < (new \DateTime()))) { $this->dispatchUpdateActor($magazine->apPublicUrl); } if (null === $magazine) { foreach ($potentialGroups as $potentialGroup) { $result = $this->findActorOrCreate($potentialGroup); if ($result instanceof Magazine) { $magazine = $result; break; } } } if (null === $magazine) { $magazine = $this->magazineRepository->findOneByName('random'); } return $magazine; } public static function getReceivers(array $object): array { $res = array_merge( \App\Utils\JsonldUtils::getArrayValue($object, 'audience'), \App\Utils\JsonldUtils::getArrayValue($object, 'to'), \App\Utils\JsonldUtils::getArrayValue($object, 'cc'), ); if (isset($object['object']) and \is_array($object['object'])) { $res = array_merge( $res, \App\Utils\JsonldUtils::getArrayValue($object['object'], 'audience'), \App\Utils\JsonldUtils::getArrayValue($object['object'], 'to'), \App\Utils\JsonldUtils::getArrayValue($object['object'], 'cc'), ); } elseif (isset($object['attributedTo']) && \is_array($object['attributedTo'])) { // if there is no "object" inside of this it will probably be a create activity which has an attributedTo field // this was implemented for peertube support, because they list the channel (Group) and the user in an array in that field $groups = array_filter($object['attributedTo'], fn ($item) => \is_array($item) && !empty($item['type']) && 'Group' === $item['type']); $res = array_merge($res, array_map(fn ($item) => $item['id'], $groups)); } $res = array_filter($res, fn ($i) => null !== $i and ActivityPubActivityInterface::PUBLIC_URL !== $i); return array_unique($res); } private function isImageAttachment(array $object): bool { // attachment object has acceptable object type if (!\in_array($object['type'], ['Document', 'Image'])) { return false; } // attachment is either: // - has `mediaType` field and is a recognized image types // - image url looks like a link to image return (!empty($object['mediaType']) && ImageManager::isImageType($object['mediaType'])) || ImageManager::isImageUrl($object['url']); } /** * @param string|array $apObject the object that should be like, so a post of any kind in its AP array representation or a URL * @param array $fullPayload the full message payload, only used to log it * @param callable(array $object, ?string $adjustedUrl):void $chainDispatch if we do not have the object in our db this is called to dispatch a new ChainActivityMessage. * Since the explicit object has to be set in the message this has to be done as a callback method. * The object parameter is an associative array representing the first dependency of the activity. * The $adjustedUrl parameter is only set if the object was fetched from a different url than the id of the object might suggest * * @see ChainActivityMessage */ public function getEntityObject(string|array $apObject, array $fullPayload, callable $chainDispatch): Entry|EntryComment|Post|PostComment|null { $object = null; $activity = null; $calledUrl = null; if (\is_string($apObject)) { if (false === filter_var($apObject, FILTER_VALIDATE_URL)) { $this->logger->error('[ActivityPubManager::getEntityObject] The like activity references an object by string, but that is not a URL, discarding the message', $fullPayload); return null; } // First try to find the activity object in our database $activity = $this->activityRepository->findByObjectId($apObject); $calledUrl = $apObject; if (!$activity) { if (!$this->settingsManager->isBannedInstance($apObject)) { $this->logger->debug('[ActivityPubManager::getEntityObject] Object is fetched from {url} because it is a string and could not be found in our repo', ['url' => $apObject]); $object = $this->apHttpClient->getActivityObject($apObject); } else { $this->logger->info('[ActivityPubManager::getEntityObject] The instance is banned, url: {url}', ['url' => $apObject]); return null; } } } else { $activity = $this->activityRepository->findByObjectId($apObject['id']); $calledUrl = $apObject['id']; if (!$activity) { $this->logger->debug('[ActivityPubManager::getEntityObject] Object is fetched from {url} because it is not a string and could not be found in our repo', ['url' => $apObject['id']]); $object = $apObject; } } if (!$activity && !$object) { $this->logger->error("[ActivityPubManager::getEntityObject] The activity is still null and we couldn't get the object from the url, discarding", $fullPayload); return null; } if ($object) { $adjustedUrl = null; if ($object['id'] !== $calledUrl) { $this->logger->warning('[ActivityPubManager::getEntityObject] The url {url} returned a different object id: {id}', ['url' => $calledUrl, 'id' => $object['id']]); $adjustedUrl = $object['id']; } $this->logger->debug('[ActivityPubManager::getEntityObject] Dispatching a ChainActivityMessage, because the object could not be found: {o}', ['o' => $apObject]); $this->logger->debug('[ActivityPubManager::getEntityObject] The object for ChainActivityMessage with object {o}', ['o' => $object]); $chainDispatch($object, $adjustedUrl); return null; } return $this->entityManager->getRepository($activity['type'])->find((int) $activity['id']); } public function extractMarkdownSummary(array $apObject): ?string { if (isset($apObject['source']) && isset($apObject['source']['mediaType']) && isset($apObject['source']['content']) && ApObjectExtractor::MARKDOWN_TYPE === $apObject['source']['mediaType']) { return $apObject['source']['content']; } else { $converter = new HtmlConverter(['strip_tags' => true]); return stripslashes($converter->convert($apObject['summary'])); } } public function extractMarkdownContent(array $apObject) { if (isset($apObject['source']) && isset($apObject['source']['mediaType']) && isset($apObject['source']['content']) && ApObjectExtractor::MARKDOWN_TYPE === $apObject['source']['mediaType']) { return $apObject['source']['content']; } else { $converter = new HtmlConverter(['strip_tags' => true]); return stripslashes($converter->convert($apObject['content'])); } } public function isActivityPublic(array $payload): bool { $to = array_merge( \App\Utils\JsonldUtils::getArrayValue($payload, 'to'), \App\Utils\JsonldUtils::getArrayValue($payload, 'cc'), ); foreach ($to as $receiver) { $id = null; if (\is_string($receiver)) { $id = $receiver; } elseif (\is_array($receiver) && !empty($receiver['id'])) { $id = $receiver['id']; } if (null !== $id) { $actor = $this->findActorOrCreate($id); if ($actor instanceof Magazine) { return true; } } } return false; } public function getSingleActorFromAttributedTo(string|array|null $attributedTo, bool $filterForPerson = true): ?string { $actors = $this->getActorFromAttributedTo($attributedTo, $filterForPerson); if (\sizeof($actors) > 0) { return $actors[0]; } return null; } /** * @return string[] */ public function getActorFromAttributedTo(string|array|null $attributedTo, bool $filterForPerson = true): array { if (\is_string($attributedTo)) { return [$attributedTo]; } elseif (\is_array($attributedTo)) { $actors = array_filter($attributedTo, fn ($item) => \is_string($item) || (\is_array($item) && !empty($item['type']) && (!$filterForPerson || 'Person' === $item['type']))); return array_map(fn ($item) => $item['id'], $actors); } return []; } public function extractUrl(string|array|null $url): ?string { if (\is_string($url)) { return $url; } elseif (\is_array($url)) { $urls = array_filter($url, fn ($item) => \is_string($item) || (\is_array($item) && !empty($item['type']) && 'Link' === $item['type'] && (empty($item['mediaType']) || 'text/html' === $item['mediaType']))); if (\sizeof($urls) >= 1) { if (\is_string($urls[0])) { return $urls[0]; } elseif (!empty($urls[0]['href'])) { return $urls[0]['href']; } } } return null; } public function extractTotalAmountFromCollection(mixed $collection): ?int { $id = null; if (\is_string($collection)) { if (false !== filter_var($collection, FILTER_VALIDATE_URL)) { $id = $collection; } } elseif (\is_array($collection)) { if (isset($collection['totalItems'])) { return \intval($collection['totalItems']); } elseif (isset($collection['id'])) { $id = $collection['id']; } } if ($id) { $this->apHttpClient->invalidateCollectionObjectCache($id); $collection = $this->apHttpClient->getCollectionObject($id); if (isset($collection['totalItems']) && \is_int($collection['totalItems'])) { return $collection['totalItems']; } } return null; } public function extractRemoteLikeCount(array $apObject): ?int { if (!empty($apObject['likes'])) { return $this->extractTotalAmountFromCollection($apObject['likes']); } return null; } public function extractRemoteDislikeCount(array $apObject): ?int { if (!empty($apObject['dislikes'])) { return $this->extractTotalAmountFromCollection($apObject['dislikes']); } return null; } public function extractRemoteShareCount(array $apObject): ?int { if (!empty($apObject['shares'])) { return $this->extractTotalAmountFromCollection($apObject['shares']); } return null; } } ================================================ FILE: src/Service/BadgeManager.php ================================================ magazine, $dto->name); $this->entityManager->persist($badge); $this->entityManager->flush(); return $badge; } public function edit(Badge $badge, BadgeDto $dto): Badge { Assert::same($badge->magazine->getId(), $badge->magazine->getId()); $badge->name = $dto->name; $this->entityManager->persist($badge); $this->entityManager->flush(); return $badge; } public function delete(Badge $badge): void { $this->purge($badge); } public function purge(Badge $badge): void { $this->entityManager->remove($badge); $this->entityManager->flush(); } public function assign(Entry $entry, Collection $badges): Entry { $badges = $entry->magazine->badges->filter( static function (Badge $badge) use ($badges) { return $badges->contains($badge->name); } ); $entry->setBadges(...$badges); return $entry; } } ================================================ FILE: src/Service/BookmarkManager.php ================================================ entityManager->persist($list); $this->entityManager->flush(); return $list; } public function isBookmarked(User $user, Entry|EntryComment|Post|PostComment $content): bool { if ($content instanceof Entry) { return !empty($this->bookmarkRepository->findBy(['user' => $user, 'entry' => $content])); } elseif ($content instanceof EntryComment) { return !empty($this->bookmarkRepository->findBy(['user' => $user, 'entryComment' => $content])); } elseif ($content instanceof Post) { return !empty($this->bookmarkRepository->findBy(['user' => $user, 'post' => $content])); } elseif ($content instanceof PostComment) { return !empty($this->bookmarkRepository->findBy(['user' => $user, 'postComment' => $content])); } return false; } public function isBookmarkedInList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): bool { if ($content instanceof Entry) { return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'entry' => $content]); } elseif ($content instanceof EntryComment) { return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'entryComment' => $content]); } elseif ($content instanceof Post) { return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'post' => $content]); } elseif ($content instanceof PostComment) { return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'postComment' => $content]); } return false; } public function addBookmarkToDefaultList(User $user, Entry|EntryComment|Post|PostComment $content): void { $list = $this->bookmarkListRepository->findOneByUserDefault($user); $this->addBookmark($user, $list, $content); } public function addBookmark(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): void { $bookmark = new Bookmark($user, $list); $bookmark->setContent($content); $this->entityManager->persist($bookmark); $this->entityManager->flush(); } public static function GetClassFromSubjectType(string $subjectType): string { return match ($subjectType) { 'entry' => Entry::class, 'entry_comment' => EntryComment::class, 'post' => Post::class, 'post_comment' => PostComment::class, default => throw new \LogicException("cannot match type $subjectType"), }; } } ================================================ FILE: src/Service/CacheService.php ================================================ getKey($subject)}_{$subject->getId()}"; } private function getKey(VotableInterface|FavouriteInterface $subject): string { $className = $this->entityManager->getClassMetadata(\get_class($subject))->name; $className = explode('\\', $className); return end($className); } public function getFavouritesCacheKey(FavouriteInterface $subject): string { return "favourites_{$this->getKey($subject)}_{$subject->getId()}"; } } ================================================ FILE: src/Service/ContactManager.php ================================================ contactLimiter->create($dto->ip); if (false === $limiter->consume()->isAccepted()) { throw new TooManyRequestsHttpException(); } $email = (new TemplatedEmail()) ->from(new Address($this->settings->get('KBIN_SENDER_EMAIL'), $this->settings->get('KBIN_DOMAIN'))) ->to($this->settings->get('KBIN_CONTACT_EMAIL')) ->subject($this->translator->trans('contact').' - '.$this->settings->get('KBIN_DOMAIN')) ->htmlTemplate('_email/contact.html.twig') ->context([ 'name' => $dto->name, 'senderEmail' => $dto->email, 'message' => $dto->message, ]); $this->mailer->send($email); } } ================================================ FILE: src/Service/Contracts/ContentManagerInterface.php ================================================ apInboxUrl; if ($this->settingsManager->isBannedInstance($inboxUrl)) { continue; } if ($this->settingsManager->isLocalUrl($inboxUrl)) { $this->logger->warning('tried delivering to a local url, {payload}', ['payload' => $activity]); continue; } $this->bus->dispatch(new DeliverMessage($inboxUrl, $activity, $useOldPrivateKey)); } } } ================================================ FILE: src/Service/DomainManager.php ================================================ getUrl(); if (!$domainName) { return; } $domainName = preg_replace('/^www\./i', '', parse_url($domainName)['host']); $domain = $this->repository->findOneByName($domainName); if (!$domain) { $domain = new Domain($subject, $domainName); $subject->domain = $domain; $this->entityManager->persist($domain); } $domain->addEntry($subject); $domain->updateCounts(); $this->entityManager->flush(); } public function subscribe(Domain $domain, User $user): void { $user->unblockDomain($domain); $domain->subscribe($user); $this->entityManager->flush(); $this->dispatcher->dispatch(new DomainSubscribedEvent($domain, $user)); } public function block(Domain $domain, User $user): void { $this->unsubscribe($domain, $user); $user->blockDomain($domain); $this->entityManager->flush(); $this->dispatcher->dispatch(new DomainBlockedEvent($domain, $user)); } public function unsubscribe(Domain $domain, User $user): void { $domain->unsubscribe($user); $this->entityManager->flush(); $this->dispatcher->dispatch(new DomainSubscribedEvent($domain, $user)); } public function unblock(Domain $domain, User $user): void { $user->unblockDomain($domain); $this->entityManager->flush(); $this->dispatcher->dispatch(new DomainBlockedEvent($domain, $user)); } public static function shouldRatio(string $domain): bool { $domainsWithRatio = ['youtube.com', 'streamable.com', 'youtu.be', 'm.youtube.com']; return (bool) array_filter($domainsWithRatio, fn ($item) => str_contains($domain, $item)); } } ================================================ FILE: src/Service/EntryCommentManager.php ================================================ entryCommentLimiter->create($dto->ip); if ($limiter && false === $limiter->consume()->isAccepted()) { throw new TooManyRequestsHttpException(); } } if ($dto->entry->magazine->isBanned($user) || $user->isBanned()) { throw new UserBannedException(); } if ($this->tagManager->isAnyTagBanned($this->tagManager->extract($dto->body))) { throw new TagBannedException(); } if (null !== $dto->entry->magazine->apId && $this->settingsManager->isBannedInstance($dto->entry->magazine->apInboxUrl)) { throw new InstanceBannedException(); } if ($dto->entry->isLocked) { throw new EntryLockedException(); } $comment = $this->factory->createFromDto($dto, $user); $comment->magazine = $dto->entry->magazine; $comment->lang = $dto->lang; $comment->isAdult = $dto->isAdult || $comment->magazine->isAdult; $comment->image = $dto->image ? $this->imageRepository->find($dto->image->id) : null; if ($comment->image && !$comment->image->altText) { $comment->image->altText = $dto->imageAlt; } $comment->mentions = $dto->body ? array_merge($dto->mentions ?? [], $this->mentionManager->handleChain($comment)) : $dto->mentions; $comment->visibility = $dto->visibility; $comment->apId = $dto->apId; $comment->apLikeCount = $dto->apLikeCount; $comment->apDislikeCount = $dto->apDislikeCount; $comment->apShareCount = $dto->apShareCount; $comment->magazine->lastActive = new \DateTime(); $comment->user->lastActive = new \DateTime(); $comment->lastActive = $dto->lastActive ?? $comment->lastActive; $comment->createdAt = $dto->createdAt ?? $comment->createdAt; if (empty($comment->body) && null === $comment->image) { throw new \Exception('Comment body and image cannot be empty'); } $comment->entry->addComment($comment); $comment->updateScore(); $comment->updateRanking(); $this->entityManager->persist($comment); $this->entityManager->flush(); $this->tagManager->updateEntryCommentTags($comment, $this->tagExtractor->extract($comment->body) ?? []); $this->dispatcher->dispatch(new EntryCommentCreatedEvent($comment)); return $comment; } public function canUserEditComment(EntryComment $comment, User $user): bool { $entryCommentHost = null !== $comment->apId ? parse_url($comment->apId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN'); $userHost = null !== $user->apId ? parse_url($user->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN'); $magazineHost = null !== $comment->magazine->apId ? parse_url($comment->magazine->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN'); return $entryCommentHost === $userHost || $userHost === $magazineHost || $comment->magazine->userIsModerator($user); } public function edit(EntryComment $comment, EntryCommentDto $dto, ?User $editedByUser = null): EntryComment { Assert::same($comment->entry->getId(), $dto->entry->getId()); $comment->body = $dto->body; $comment->lang = $dto->lang; $comment->isAdult = $dto->isAdult || $comment->magazine->isAdult; $oldImage = $comment->image; if ($dto->image) { $comment->image = $this->imageRepository->find($dto->image->id); } $this->tagManager->updateEntryCommentTags($comment, $this->tagManager->getTagsFromEntryCommentDto($dto)); $comment->mentions = $dto->body ? array_merge($dto->mentions ?? [], $this->mentionManager->handleChain($comment)) : $dto->mentions; $comment->visibility = $dto->visibility; $comment->editedAt = new \DateTimeImmutable('@'.time()); if (empty($comment->body) && null === $comment->image) { throw new \Exception('Comment body and image cannot be empty'); } $comment->apLikeCount = $dto->apLikeCount; $comment->apDislikeCount = $dto->apDislikeCount; $comment->apShareCount = $dto->apShareCount; $comment->updateScore(); $comment->updateRanking(); $this->entityManager->flush(); if ($oldImage && $comment->image !== $oldImage) { $this->bus->dispatch(new DeleteImageMessage($oldImage->getId())); } $this->dispatcher->dispatch(new EntryCommentEditedEvent($comment, $editedByUser)); return $comment; } public function delete(User $user, EntryComment $comment): void { if ($user->apDomain && $user->apDomain !== parse_url($comment->apId ?? '', PHP_URL_HOST) && !$comment->magazine->userIsModerator($user)) { $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m]', ['u' => $user->apId, 'm' => $comment->magazine->apId ?? $comment->magazine->name]); return; } if ($comment->isAuthor($user) && $comment->children->isEmpty()) { $this->purge($user, $comment); return; } $this->isTrashed($user, $comment) ? $comment->trash() : $comment->softDelete(); $this->dispatcher->dispatch(new EntryCommentBeforeDeletedEvent($comment, $user)); $this->entityManager->flush(); $this->dispatcher->dispatch(new EntryCommentDeletedEvent($comment, $user)); } public function trash(User $user, EntryComment $comment): void { $comment->trash(); $this->dispatcher->dispatch(new EntryCommentBeforeDeletedEvent($comment, $user)); $this->entityManager->flush(); $this->dispatcher->dispatch(new EntryCommentDeletedEvent($comment, $user)); } public function purge(User $user, EntryComment $comment): void { $this->dispatcher->dispatch(new EntryCommentBeforePurgeEvent($comment, $user)); $magazine = $comment->entry->magazine; $image = $comment->image?->getId(); $comment->entry->removeComment($comment); $this->entityManager->remove($comment); $this->entityManager->flush(); if ($image) { $this->bus->dispatch(new DeleteImageMessage($image)); } $this->dispatcher->dispatch(new EntryCommentPurgedEvent($magazine)); } private function isTrashed(User $user, EntryComment $comment): bool { return !$comment->isAuthor($user); } public function restore(User $user, EntryComment $comment): void { if (VisibilityInterface::VISIBILITY_TRASHED !== $comment->visibility) { throw new \Exception('Invalid visibility'); } $comment->visibility = VisibilityInterface::VISIBILITY_VISIBLE; $this->entityManager->persist($comment); $this->entityManager->flush(); $this->dispatcher->dispatch(new EntryCommentRestoredEvent($comment, $user)); } public function createDto(EntryComment $comment): EntryCommentDto { return $this->factory->createDto($comment); } public function detachImage(EntryComment $comment): void { $image = $comment->image->getId(); $comment->image = null; $this->entityManager->persist($comment); $this->entityManager->flush(); $this->bus->dispatch(new DeleteImageMessage($image)); } } ================================================ FILE: src/Service/EntryManager.php ================================================ entryLimiter->create($dto->ip); if (false === $limiter->consume()->isAccepted()) { throw new TooManyRequestsHttpException(); } } if ($dto->magazine->isBanned($user) || $user->isBanned()) { throw new UserBannedException(); } if ($this->tagManager->isAnyTagBanned($this->tagManager->extract($dto->body))) { throw new TagBannedException(); } if ($dto->magazine->isActorPostingRestricted($user)) { throw new PostingRestrictedException($dto->magazine, $user); } if (null !== $dto->magazine->apId && $this->settingsManager->isBannedInstance($dto->magazine->apInboxUrl)) { throw new InstanceBannedException(); } $this->logger->debug('creating entry from dto'); $entry = $this->factory->createFromDto($dto, $user); $entry->lang = $dto->lang; $entry->isAdult = $dto->isAdult || $entry->magazine->isAdult; $entry->slug = $this->slugger->slug($dto->title); $entry->image = $dto->image ? $this->imageRepository->find($dto->image->id) : null; $this->logger->debug('setting image to {imageId}, dto was {dtoImageId}', ['imageId' => $entry->image?->getId() ?? 'none', 'dtoImageId' => $dto->image?->id ?? 'none']); if ($entry->image && !$entry->image->altText) { $entry->image->altText = $dto->imageAlt; } if ($entry->url) { $entry->url = ($this->urlCleaner)($dto->url); } $entry->mentions = $dto->body ? $this->mentionManager->extract($dto->body) : null; $entry->visibility = $dto->visibility; $entry->apId = $dto->apId; $entry->apLikeCount = $dto->apLikeCount; $entry->apDislikeCount = $dto->apDislikeCount; $entry->apShareCount = $dto->apShareCount; $entry->magazine->lastActive = new \DateTime(); $entry->user->lastActive = new \DateTime(); $entry->lastActive = $dto->lastActive ?? $entry->lastActive; $entry->createdAt = $dto->createdAt ?? $entry->createdAt; if (empty($entry->body) && empty($entry->title) && null === $entry->image && null === $entry->url) { throw new \Exception('Entry body, name, url and image cannot all be empty'); } $entry = $this->setType($dto, $entry); if ($dto->badges) { $this->badgeManager->assign($entry, $dto->badges); } $entry->updateScore(); $entry->updateRanking(); $this->entityManager->persist($entry); $this->entityManager->flush(); $tags = array_unique(array_merge($this->tagExtractor->extract($entry->body) ?? [], $dto->tags ?? [])); $this->tagManager->updateEntryTags($entry, $tags); $this->dispatcher->dispatch(new EntryCreatedEvent($entry)); if ($stickyIt) { $this->pin($entry, null); } return $entry; } private function setType(EntryDto $dto, Entry $entry): Entry { if ($dto->image) { $entry->type = Entry::ENTRY_TYPE_IMAGE; $entry->hasEmbed = true; } elseif ($dto->url) { if (ImageManager::isImageUrl($dto->url)) { $entry->type = Entry::ENTRY_TYPE_IMAGE; $entry->hasEmbed = true; } else { $entry->type = Entry::ENTRY_TYPE_LINK; } } elseif ($dto->body) { $entry->type = Entry::ENTRY_TYPE_ARTICLE; } else { $this->logger->warning('entry has neither image nor url nor body; defaulting to article'); $entry->type = Entry::ENTRY_TYPE_ARTICLE; } // TODO handle ENTRY_TYPE_VIDEO return $entry; } public function canUserEditEntry(Entry $entry, User $user): bool { $entryHost = null !== $entry->apId ? parse_url($entry->apId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN'); $userHost = null !== $user->apId ? parse_url($user->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN'); $magazineHost = null !== $entry->magazine->apId ? parse_url($entry->magazine->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN'); return $entryHost === $userHost || $userHost === $magazineHost || $entry->magazine->userIsModerator($user); } public function edit(Entry $entry, EntryDto $dto, User $editedBy): Entry { Assert::same($entry->magazine->getId(), $dto->magazine->getId()); $entry->title = $dto->title; $oldUrl = $entry->url; $entry->url = $dto->url; $entry->body = $dto->body; $entry->lang = $dto->lang; $entry->isAdult = $dto->isAdult || $entry->magazine->isAdult; $entry->isLocked = $dto->isLocked; $entry->slug = $this->slugger->slug($dto->title); $entry->visibility = $dto->visibility; $oldImage = $entry->image; $entry->image = $dto->image ? $this->imageRepository->find($dto->image->id) : null; $this->logger->debug('setting image to {imageId}, dto was {dtoImageId}', ['imageId' => $entry->image?->getId() ?? 'none', 'dtoImageId' => $dto->image?->id ?? 'none']); if ($entry->image && !$entry->image->altText) { $entry->image->altText = $dto->imageAlt; } $this->tagManager->updateEntryTags($entry, $this->tagManager->getTagsFromEntryDto($dto)); $entry->mentions = $dto->body ? $this->mentionManager->extract($dto->body) : null; $entry->isOc = $dto->isOc; $entry->lang = $dto->lang; $entry->editedAt = new \DateTimeImmutable('@'.time()); if ($dto->badges) { $this->badgeManager->assign($entry, $dto->badges); } if (empty($entry->body) && empty($entry->title) && null === $entry->image && null === $entry->url) { throw new \Exception('Entry body, name, url and image cannot all be empty'); } $entry->apLikeCount = $dto->apLikeCount; $entry->apDislikeCount = $dto->apDislikeCount; $entry->apShareCount = $dto->apShareCount; $entry->updateScore(); $entry->updateRanking(); $this->entityManager->flush(); if ($oldImage && $entry->image !== $oldImage) { $this->bus->dispatch(new DeleteImageMessage($oldImage->getId())); } if ($entry->url !== $oldUrl) { $this->bus->dispatch(new EntryEmbedMessage($entry->getId())); } $this->dispatcher->dispatch(new EntryEditedEvent($entry, $editedBy)); return $entry; } public function delete(User $user, Entry $entry): void { if ($user->apDomain && $user->apDomain !== parse_url($entry->apId ?? '', PHP_URL_HOST) && !$entry->magazine->userIsModerator($user)) { $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m]', ['u' => $user->apId, 'm' => $entry->magazine->apId ?? $entry->magazine->name]); return; } if ($entry->isAuthor($user) && $entry->comments->isEmpty()) { $this->purge($user, $entry); return; } $entry->isAuthor($user) ? $entry->softDelete() : $entry->trash(); $this->dispatcher->dispatch(new EntryBeforeDeletedEvent($entry, $user)); $this->entityManager->flush(); $this->dispatcher->dispatch(new EntryDeletedEvent($entry, $user)); } public function trash(User $user, Entry $entry): void { $entry->trash(); $this->dispatcher->dispatch(new EntryBeforeDeletedEvent($entry, $user)); $this->entityManager->flush(); $this->dispatcher->dispatch(new EntryDeletedEvent($entry, $user)); } public function purge(User $user, Entry $entry): void { $this->dispatcher->dispatch(new EntryBeforePurgeEvent($entry, $user)); $image = $entry->image?->getId(); $sort = new Criteria(null, ['createdAt' => Order::Descending]); foreach ($entry->comments->matching($sort) as $comment) { $this->entryCommentManager->purge($user, $comment); } $this->entityManager->remove($entry); $this->entityManager->flush(); if ($image) { $this->bus->dispatch(new DeleteImageMessage($image)); } } public function restore(User $user, Entry $entry): void { if (VisibilityInterface::VISIBILITY_TRASHED !== $entry->visibility) { throw new \Exception('Invalid visibility'); } $entry->visibility = VisibilityInterface::VISIBILITY_VISIBLE; $this->entityManager->persist($entry); $this->entityManager->flush(); $this->dispatcher->dispatch(new EntryRestoredEvent($entry, $user)); } /** * this toggles the pin state of the entry. If it was not pinned it pins, if it was pinned it unpins it. * * @param User|null $actor this should only be null if it is a system call */ public function pin(Entry $entry, ?User $actor): Entry { $entry->sticky = !$entry->sticky; if ($entry->sticky) { $log = new MagazineLogEntryPinned($entry->magazine, $actor, $entry); } else { $log = new MagazineLogEntryUnpinned($entry->magazine, $actor, $entry); } $this->entityManager->persist($log); $this->entityManager->flush(); $this->dispatcher->dispatch(new EntryPinEvent($entry, $actor)); if (null !== $entry->magazine->apFeaturedUrl) { $this->apHttpClient->invalidateCollectionObjectCache($entry->magazine->apFeaturedUrl); } return $entry; } public function toggleLock(Entry $entry, ?User $actor): Entry { $entry->isLocked = !$entry->isLocked; if ($entry->isLocked) { $log = new MagazineLogEntryLocked($entry, $actor); } else { $log = new MagazineLogEntryUnlocked($entry, $actor); } $this->entityManager->persist($log); $this->entityManager->flush(); $this->dispatcher->dispatch(new EntryLockEvent($entry, $actor)); return $entry; } public function createDto(Entry $entry): EntryDto { return $this->factory->createDto($entry); } public function detachImage(Entry $entry): void { $image = $entry->image->getId(); $entry->image = null; $this->entityManager->persist($entry); $this->entityManager->flush(); $this->bus->dispatch(new DeleteImageMessage($image)); } public function getSortRoute(string $sortBy): string { return strtolower($this->translator->trans($sortBy)); } public function changeMagazine(Entry $entry, Magazine $magazine): void { $this->entityManager->beginTransaction(); try { $oldMagazine = $entry->magazine; $entry->magazine = $magazine; foreach ($entry->comments as $comment) { $comment->magazine = $magazine; } $this->entityManager->flush(); $this->entityManager->commit(); } catch (\Exception $e) { $this->entityManager->rollback(); return; } $oldMagazine->entryCommentCount = $this->entryRepository->countEntryCommentsByMagazine($oldMagazine); $oldMagazine->entryCount = $this->entryRepository->countEntriesByMagazine($oldMagazine); $magazine->entryCommentCount = $this->entryRepository->countEntryCommentsByMagazine($magazine); $magazine->entryCount = $this->entryRepository->countEntriesByMagazine($magazine); $this->entityManager->flush(); $this->cache->invalidateTags(['entry_'.$entry->getId()]); } } ================================================ FILE: src/Service/FactoryResolver.php ================================================ entityManager->getClassMetadata(\get_class($subject))->name) { Entry::class => $this->entryFactory, EntryComment::class => $this->entryCommentFactory, Post::class => $this->postFactory, PostComment::class => $this->postCommentFactory, Magazine::class => $this->magazineFactory, default => throw new \LogicException(), }; } } ================================================ FILE: src/Service/FavouriteManager.php ================================================ repository->findBySubject($user, $subject))) { if (self::TYPE_UNLIKE === $type) { return null; } $favourite = $this->factory->createFromEntity($user, $subject); $this->entityManager->persist($favourite); $subject->favourites->add($favourite); $subject->updateCounts(); $subject->updateScore(); $subject->updateRanking(); if ($subject instanceof Entry || $subject instanceof EntryComment || $subject instanceof Post || $subject instanceof PostComment) { if (null !== $subject->apLikeCount) { ++$subject->apLikeCount; } } } else { if (self::TYPE_LIKE === $type) { if ($subject instanceof Entry || $subject instanceof EntryComment || $subject instanceof Post || $subject instanceof PostComment) { if (null !== $subject->apLikeCount) { ++$subject->apLikeCount; } } return $favourite; } $subject->favourites->removeElement($favourite); $subject->updateCounts(); $subject->updateScore(); $subject->updateRanking(); $favourite = null; if ($subject instanceof Entry || $subject instanceof EntryComment || $subject instanceof Post || $subject instanceof PostComment) { if (null !== $subject->apLikeCount) { --$subject->apLikeCount; } } } $this->entityManager->flush(); $this->dispatcher->dispatch(new FavouriteEvent($subject, $user, null === $favourite)); return $favourite ?? null; } } ================================================ FILE: src/Service/FeedManager.php ================================================ getCriteriaFromRequest($request); $feed = $this->createFeed($criteria); $content = $this->contentRepository->findByCriteriaCursored($criteria, $this->contentRepository->guessInitialCursor($criteria->sortOption)); foreach ($this->getItems($content->getCurrentPageResults()) as $item) { $feed->add($item); } return $feed; } private function createFeed(Criteria $criteria): Feed { $feed = new Feed(); if ($criteria->magazine) { $title = "{$criteria->magazine->title} - {$this->settings->get('KBIN_META_TITLE')}"; $url = $this->urlGenerator->generate('front_magazine', ['name' => $criteria->magazine->name, 'content' => $criteria->content], UrlGeneratorInterface::ABSOLUTE_URL); } elseif ($criteria->user) { $title = "{$criteria->user->username} - {$this->settings->get('KBIN_META_TITLE')}"; $url = $this->urlGenerator->generate('user_overview', ['username' => $criteria->user->username, 'content' => $criteria->content], UrlGeneratorInterface::ABSOLUTE_URL); } else { $title = $this->settings->get('KBIN_META_TITLE'); $url = $this->urlGenerator->generate('front', ['content' => $criteria->content], UrlGeneratorInterface::ABSOLUTE_URL); } $feed->setTitle($title); $feed->setDescription($this->settings->get('KBIN_META_DESCRIPTION')); $feed->setUrl($url); return $feed; } /** * @param iterable $content * * @return \Generator */ public function getItems(iterable $content): \Generator { foreach ($content as $subject) { $item = new Item(); $item->setLastModified(\DateTime::createFromImmutable($subject->createdAt)); $item->setPublicId(IriGenerator::getIriFromResource($subject)); $item->setAuthor((new Item\Author())->setName($this->mentionManager->getUsername($subject->user->username, true))); if ($subject->image) { $media = new Item\Media(); $media->setUrl($this->mediaExtensionRuntime->getPublicPath($subject->image)); $media->setTitle($subject->image->altText); $media->setType($this->imageManager->getMimetype($subject->image)); $item->addMedia($media); } if ($subject instanceof Entry) { $link = $this->urlGenerator->generate('entry_single', [ 'magazine_name' => $subject->magazine->name, 'entry_id' => $subject->getId(), 'slug' => $subject->slug, ], UrlGeneratorInterface::ABSOLUTE_URL); $item->setContent($this->markdownConverter->convertToHtml($subject->body ?? '', 'entry')); $item->setSummary($subject->getShortDesc()); $item->setTitle($subject->title); $item->setLink($link); $item->set('comments', $link.'#comments'); } elseif ($subject instanceof Post) { $link = $this->urlGenerator->generate('post_single', [ 'magazine_name' => $subject->magazine->name, 'post_id' => $subject->getId(), 'slug' => $subject->slug, ], UrlGeneratorInterface::ABSOLUTE_URL); $item->setContent($this->markdownConverter->convertToHtml($subject->body ?? '', 'post')); $item->setSummary($subject->getShortTitle()); $item->setLink($link); $item->set('comments', $link.'#comments'); } else { continue; } foreach ($this->tagLinkRepository->getTagsOfContent($subject) as $tag) { $category = new Category(); $category->setLabel($tag); $item->addCategory($category); } yield $item; } } private function getCriteriaFromRequest(Request $request): ContentPageView { $criteria = new ContentPageView(1, $this->security); $criteria->sortOption = Criteria::SORT_NEW; $content = $request->get('content'); if ($content && \in_array($content, Criteria::CONTENT_OPTIONS, true)) { $criteria->setContent($content); } else { $criteria->setContent(Criteria::CONTENT_THREADS); } if ($magazineName = $request->get('magazine')) { $magazine = $this->magazineRepository->findOneBy(['name' => $magazineName]); if (!$magazine) { throw new NotFoundHttpException("The magazine $magazineName does not exist"); } $criteria->magazine = $magazine; } if ($userName = $request->get('user')) { $user = $this->userRepository->findOneByUsername($userName); if (!$user) { throw new NotFoundHttpException("The user $userName does not exist"); } $criteria->user = $user; } if ($domain = $request->get('domain')) { $criteria->setDomain($domain); } if ($tag = $request->get('tag')) { $criteria->tag = $tag; } if ($sortBy = $request->get('sortBy')) { $criteria->showSortOption($sortBy); } // Since we currently do not have a way of authenticating the user, these feeds do not work. // They are also not being generated and therefore not used in the sidebar. // $id = $request->get('id'); // if ('sub' === $id) { // $criteria->subscribed = true; // } elseif ('mod' === $id) { // $criteria->moderated = true; // } return $criteria; } } ================================================ FILE: src/Service/GenerateHtmlClassService.php ================================================ "entry-{$subject->getId()}", $subject instanceof EntryComment => "entry-comment-{$subject->getId()}", $subject instanceof Post => "post-{$subject->getId()}", $subject instanceof PostComment => "post-comment-{$subject->getId()}", default => throw new \LogicException(), }; } public function fromClassName(string $class, int $id): string { return match ($class) { 'Entry' => "entry-{$id}", 'EntryComment' => "entry-comment-{$id}", 'Post' => "post-{$id}", 'PostComment' => "post-comment-{$id}", default => throw new \LogicException(), }; } } ================================================ FILE: src/Service/ImageManager.php ================================================ str_replace('image/', '', $type), self::IMAGE_MIMETYPES); return \in_array($urlExt, $types); } public static function isImageType(string $mediaType): bool { return \in_array($mediaType, self::IMAGE_MIMETYPES); } /** * @throws \Exception if the file could not be found */ public function store(string $source, string $filePath): bool { $fh = fopen($source, 'rb'); try { if (filesize($source) > $this->settings->getMaxImageBytes()) { throw new ImageDownloadTooLargeException('the image is too large, max size is '.$this->settings->getMaxImageBytes()); } $this->validate($source); $this->publicUploadsFilesystem->writeStream($filePath, $fh); if (!$this->publicUploadsFilesystem->has($filePath)) { throw new \Exception('File not found'); } return true; } finally { \is_resource($fh) and fclose($fh); } } /** * Tries to compress an image until its size is smaller than $maxBytes. This overwrites the existing image. * * @return bool whether the image was compressed */ public function compressUntilSize(string $filePath, string $extension, int $maxBytes): bool { if (-1 === $this->imageCompressionQuality || filesize($filePath) <= $maxBytes) { // don't compress images if disabled or smaller than max bytes return false; } $imagine = new Imagine(); $image = $imagine->open($filePath); $bytes = filesize($filePath); $initialBytes = $bytes; $tempPath = "{$filePath}_temp_compress.$extension"; $compressed = false; $quality = 0.9; if (0.1 <= $this->imageCompressionQuality && 1 > $this->imageCompressionQuality) { $quality = $this->imageCompressionQuality; } while ($bytes > $maxBytes && $quality > 0.1) { $this->logger->debug('[ImageManager::compressUntilSize] Trying to compress "{path}" with {q}% quality', ['path' => $tempPath, 'q' => $quality * 100]); $image->save($tempPath, [ 'jpeg_quality' => $quality * 100, // jpeg max value is 100 'png_compression_level' => 9, // this is lossless compression, so always use the max 'webp_quality' => $quality * 100, // webp quality max is 100 ]); $bytes = filesize($tempPath); if ($initialBytes === $bytes) { // there were no changes, so maybe it is in a format that cannot be compressed... break; } $compressed = true; $quality -= 0.05; } $copied = false; if ($compressed) { if (copy($tempPath, $filePath)) { $copied = true; $this->logger->debug('[ImageManager::compressUntilSize] successfully compressed "{path}" with {q}% quality: {bytesBefore} -> {bytesNow}', [ 'path' => $filePath, 'q' => ($quality + 0.05) * 100, // re-add the last step, because it is always subtracted in the end if successful 'bytesBefore' => $this->formattingExtensionRuntime->abbreviateNumber($initialBytes).'B', 'bytesNow' => $this->formattingExtensionRuntime->abbreviateNumber($bytes).'B', ]); } } if (file_exists($tempPath)) { unlink($tempPath); } return $copied; } private function validate(string $filePath): bool { $violations = $this->validator->validate( $filePath, [ new Image(detectCorrupted: true), ] ); if (\count($violations) > 0) { throw new CorruptedFileException(); } return true; } public function download(string $url): ?string { $tempFile = @tempnam('/', 'kbin'); if (false === $tempFile) { throw new UnrecoverableMessageHandlingException('Couldn\'t create temporary file'); } $fh = fopen($tempFile, 'wb'); try { $response = $this->httpClient->request( 'GET', $url, [ 'timeout' => 5, 'headers' => [ 'Accept' => implode(', ', array_diff(self::IMAGE_MIMETYPES, ['image/webp', 'image/avif'])), ], ] ); foreach ($this->httpClient->stream($response) as $chunk) { fwrite($fh, $chunk->getContent()); } fclose($fh); $this->validate($tempFile); $this->logger->debug('downloaded file from {url}', ['url' => $url]); } catch (\Exception $e) { if ($fh && \is_resource($fh)) { fclose($fh); } unlink($tempFile); $this->logger->warning("couldn't download file from {url}", ['url' => $url]); return null; } return $tempFile; } /** * @return array{string, string} */ public function getFilePathAndName(string $file): array { $name = $this->getFileName($file); $path = $this->getFilePathFromName($name); return [$path, $name]; } public function getFilePath(string $file): string { $name = $this->getFileName($file); return $this->getFilePathFromName($name); } private function getFilePathFromName(string $name): string { return \sprintf( '%s/%s/%s', substr($name, 0, 2), substr($name, 2, 2), $name ); } public function getFileName(string $file): string { $hash = hash_file('sha256', $file); $mimeType = $this->mimeTypeGuesser->guessMimeType($file); if (!$mimeType) { throw new \RuntimeException("Couldn't guess MIME type of image"); } $ext = $this->mimeTypeGuesser->getExtensions($mimeType)[0] ?? null; if (!$ext) { throw new \RuntimeException("Couldn't guess extension of image (invalid image?)"); } return \sprintf('%s.%s', $hash, $ext); } public function remove(string $path): void { $this->publicUploadsFilesystem->delete($path); $this->imagineCacheManager->remove($path); } public function getPath(MbinImage $image): string { return $this->publicUploadsFilesystem->read($image->filePath); } public function getUrl(?MbinImage $image): ?string { if (!$image) { return null; } if ($image->filePath) { return $this->storageUrl.'/'.$image->filePath; } return $image->sourceUrl; } public function getMimetype(MbinImage $image): string { try { return $this->publicUploadsFilesystem->mimeType($image->filePath); } catch (\Throwable $e) { return 'none'; } } public function deleteOrphanedFiles(ImageRepository $repository, bool $dryRun, array $ignoredPaths): iterable { foreach ($this->deleteOrphanedFilesIntern($repository, $dryRun, $ignoredPaths, '/') as $deletedPath) { yield $deletedPath; } } /** * @return iterablepublicUploadsFilesystem->listContents($path, deep: true); foreach ($contents as $content) { if (GeneralUtil::shouldPathBeIgnored($ignoredPaths, $content->path())) { continue; } if ($content->isFile() && $content instanceof FileAttributes) { [$internalImagePath, $fileName] = $this->getInternalImagePathAndName($content); $image = $repository->findOneBy(['fileName' => $fileName, 'filePath' => $internalImagePath]); if (!$image) { try { if (!$dryRun) { $this->publicUploadsFilesystem->delete($content->path()); } yield [ 'path' => $content->path(), 'internalPath' => $internalImagePath, 'deleted' => true, 'successful' => true, 'fileSize' => $content->fileSize(), 'exception' => null, ]; } catch (\Throwable $e) { yield [ 'path' => $content->path(), 'internalPath' => $internalImagePath, 'deleted' => true, 'successful' => false, 'fileSize' => $content->fileSize(), 'exception' => $e, ]; } } else { yield [ 'path' => $content->path(), 'internalPath' => $internalImagePath, 'deleted' => false, 'successful' => true, 'fileSize' => $content->fileSize(), 'exception' => null, ]; } } elseif ($content->isDir()) { foreach ($this->deleteOrphanedFilesIntern($repository, $dryRun, $ignoredPaths, $content->path()) as $file) { yield $file; } } } } /** * @return array{0: string, 1: string} 0=path 1=name */ private function getInternalImagePathAndName(StorageAttributes $flySystemFile): array { if (!$flySystemFile->isFile()) { $parts = explode('/', $flySystemFile->path()); return [$flySystemFile->path(), end($parts)]; } $path = $flySystemFile->path(); if (str_starts_with($path, '/')) { $path = substr($path, 1); } if (str_starts_with($path, 'cache')) { $parts = explode('/', $path); $newParts = \array_slice($parts, 2); $path = implode('/', $newParts); $doubleExtensions = ['jpg', 'jpeg', 'gif', 'png', 'webp']; foreach ($doubleExtensions as $extension) { if (str_ends_with($path, ".$extension.webp")) { $path = str_replace(".$extension.webp", ".$extension", $path); break; } } } $parts = explode('/', $path); return [$path, end($parts)]; } public function removeCachedImage(MbinImage $image): bool { if (!$image->filePath || !$image->sourceUrl) { return false; } try { $this->publicUploadsFilesystem->delete($image->filePath); $this->imagineCacheManager->remove($image->filePath); $sql = 'UPDATE image SET file_path = NULL, downloaded_at = NULL WHERE id = :id'; $image->filePath = null; $image->downloadedAt = null; $this->entityManager->getConnection()->executeStatement($sql, ['id' => $image->getId()]); return true; } catch (\Exception|FilesystemException $e) { $this->logger->error('Unable to remove cached images for "{path}": {ex} - {m}', [ 'path' => $image->filePath, 'ex' => \get_class($e), 'm' => $e->getMessage(), 'exception' => $e, ]); return false; } } } ================================================ FILE: src/Service/ImageManagerInterface.php ================================================ user->roles = array_unique(array_merge($dto->user->roles, ['ROLE_MODERATOR'])); $this->entityManager->persist($dto->user); $this->entityManager->flush(); } public function removeModerator(User $user): void { $user->roles = array_diff($user->roles, ['ROLE_MODERATOR']); $this->entityManager->persist($user); $this->entityManager->flush(); } /** @param string[] $bannedInstances */ #[\Deprecated] public function setBannedInstances(array $bannedInstances): void { $previousBannedInstances = $this->instanceRepository->getBannedInstanceUrls(); foreach ($bannedInstances as $instance) { if (!\in_array($instance, $previousBannedInstances, true)) { $this->banInstance($this->instanceRepository->getOrCreateInstance($instance)); } } foreach ($previousBannedInstances as $instance) { if (!\in_array($instance, $bannedInstances, true)) { $this->unbanInstance($this->instanceRepository->getOrCreateInstance($instance)); } } } public function banInstance(Instance $instance): void { if ($this->settingsManager->getUseAllowList()) { throw new \LogicException('Cannot ban an instance when using an allow list'); } $instance->isBanned = true; $instance->isExplicitlyAllowed = false; $this->entityManager->flush(); } public function unbanInstance(Instance $instance): void { if ($this->settingsManager->getUseAllowList()) { throw new \LogicException('Cannot unban an instance when using an allow list'); } $instance->isBanned = false; $this->entityManager->flush(); } public function allowInstanceFederation(Instance $instance): void { if (!$this->settingsManager->getUseAllowList()) { throw new \LogicException('Cannot allow instance federation when not using an allow list'); } $instance->isExplicitlyAllowed = true; $instance->isBanned = false; $this->entityManager->flush(); } public function denyInstanceFederation(Instance $instance): void { if (!$this->settingsManager->getUseAllowList()) { throw new \LogicException('Cannot deny instance federation when not using an allow list'); } $instance->isExplicitlyAllowed = false; $this->entityManager->flush(); } } ================================================ FILE: src/Service/InstanceStatsManager.php ================================================ cache->get('instance_stats', function (ItemInterface $item) use ($periodDate, $withFederated) { $item->expiresAfter(0); $criteria = Criteria::create(); if ($periodDate) { $criteria->where( Criteria::expr() ->gt('createdAt', $periodDate) ); } if (!$withFederated) { if ($periodDate) { $criteria->andWhere( Criteria::expr()->eq('apId', null) ); } else { $criteria->where( Criteria::expr()->eq('apId', null) ); } } $userCriteria = clone $criteria; $userCriteria->andWhere(Criteria::expr()->eq('isDeleted', false)); return [ 'users' => $this->userRepository->matching($userCriteria)->count(), 'magazines' => $this->magazineRepository->matching($criteria)->count(), 'entries' => $this->statsContentRepository->aggregateStats('entry', $periodDate, null, $withFederated, null), 'comments' => $this->statsContentRepository->aggregateStats('entry_comment', $periodDate, null, $withFederated, null), 'posts' => $this->statsContentRepository->aggregateStats('post', $periodDate, null, $withFederated, null) + $this->statsContentRepository->aggregateStats('post_comment', $periodDate, null, $withFederated, null), 'votes' => $this->voteRepository->count($periodDate, $withFederated), ]; }); } } ================================================ FILE: src/Service/IpResolver.php ================================================ requestStack->getCurrentRequest(); if (null === $request) { return null; } if ($fastly = $request->server->get('HTTP_FASTLY_CLIENT_IP')) { return $fastly; } return $request->server->get('HTTP_CF_CONNECTING_IP') ?? $request->getClientIp(); } } ================================================ FILE: src/Service/MagazineManager.php ================================================ apId && true === $this->settingsManager->get('MBIN_RESTRICT_MAGAZINE_CREATION') && !$user->isAdmin() && !$user->isModerator()) { throw new AccessDeniedException(); } if ($rateLimit) { $limiter = $this->magazineLimiter->create($dto->ip); if (false === $limiter->consume()->isAccepted()) { throw new TooManyRequestsHttpException(); } } $magazine = $this->factory->createFromDto($dto, $user); $magazine->apId = $dto->apId; $magazine->apProfileId = $dto->apProfileId; $magazine->apFeaturedUrl = $dto->apFeaturedUrl; if (!$dto->apId) { $magazine = KeysGenerator::generate($magazine); $magazine->apProfileId = $this->urlGenerator->generate( 'ap_magazine', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); // default new local magazines to be discoverable $magazine->apDiscoverable = $dto->discoverable ?? true; // default new local magazines to be indexable $magazine->apIndexable = $dto->indexable ?? true; } if ($dto->nameAsTag) { $magazine->tags = [$magazine->name]; } $this->entityManager->persist($magazine); $this->entityManager->flush(); $this->logger->debug('created magazine with name {n}, apId {id} and public url {url}', ['n' => $magazine->name, 'id' => $magazine->apId, 'url' => $magazine->apProfileId]); if (!$dto->apId) { $this->subscribe($magazine, $user); } return $magazine; } public function acceptFollow(User $user, Magazine $magazine): void { if ($request = $this->requestRepository->findOneby(['user' => $user, 'magazine' => $magazine])) { $this->entityManager->remove($request); } if ($this->subscriptionRepository->findOneBy(['user' => $user, 'magazine' => $magazine])) { return; } $this->subscribe($magazine, $user, false); } public function subscribe(Magazine $magazine, User $user, $createRequest = true): void { $user->unblockMagazine($magazine); // if ($magazine->apId && $createRequest) { // if ($this->requestRepository->findOneby(['user' => $user, 'magazine' => $magazine])) { // return; // } // // $request = new MagazineSubscriptionRequest($user, $magazine); // $this->entityManager->persist($request); // $this->entityManager->flush(); // // $this->dispatcher->dispatch(new MagazineSubscribedEvent($magazine, $user)); // // return; // } $magazine->subscribe($user); $this->entityManager->flush(); $this->dispatcher->dispatch(new MagazineSubscribedEvent($magazine, $user)); } public function edit(Magazine $magazine, MagazineDto $dto, User $editedBy): Magazine { Assert::same($magazine->name, $dto->name); $magazine->title = $dto->title; $magazine->description = $dto->description; $magazine->rules = $dto->rules; $magazine->isAdult = $dto->isAdult; $magazine->postingRestrictedToMods = $dto->isPostingRestrictedToMods; if (null !== $dto->discoverable) { $magazine->apDiscoverable = $dto->discoverable; } if (null !== $dto->indexable) { $magazine->apIndexable = $dto->indexable; } $this->entityManager->flush(); $this->dispatcher->dispatch(new MagazineUpdatedEvent($magazine, $editedBy)); return $magazine; } public function delete(Magazine $magazine): void { $magazine->softDelete(); $this->entityManager->flush(); } public function restore(Magazine $magazine): void { $magazine->restore(); $this->entityManager->flush(); } public function purge(Magazine $magazine, bool $contentOnly = false): void { $this->bus->dispatch(new MagazinePurgeMessage($magazine->getId(), $contentOnly)); } public function createDto(Magazine $magazine): MagazineDto { return $this->factory->createDto($magazine); } public function block(Magazine $magazine, User $user): void { if ($magazine->isSubscribed($user)) { $this->unsubscribe($magazine, $user); } $user->blockMagazine($magazine); $this->entityManager->flush(); $this->dispatcher->dispatch(new MagazineBlockedEvent($magazine, $user)); } public function unsubscribe(Magazine $magazine, User $user): void { $magazine->unsubscribe($user); $this->entityManager->flush(); $this->dispatcher->dispatch(new MagazineSubscribedEvent($magazine, $user, true)); } public function unblock(Magazine $magazine, User $user): void { $user->unblockMagazine($magazine); $this->entityManager->flush(); $this->dispatcher->dispatch(new MagazineBlockedEvent($magazine, $user)); } public function ban(Magazine $magazine, User $user, User $bannedBy, MagazineBanDto $dto): ?MagazineBan { if ($user->isAdmin() || $magazine->userIsModerator($user)) { throw new UserCannotBeBanned(); } Assert::nullOrGreaterThan($dto->expiredAt, new \DateTimeImmutable()); $ban = $magazine->addBan($user, $bannedBy, $dto->reason, $dto->expiredAt); if (!$ban) { return null; } $this->entityManager->flush(); $this->dispatcher->dispatch(new MagazineBanEvent($ban)); return $ban; } public function unban(Magazine $magazine, User $user): ?MagazineBan { if (!$magazine->isBanned($user)) { return null; } $ban = $magazine->unban($user); $this->entityManager->flush(); $this->dispatcher->dispatch(new MagazineBanEvent($ban)); return $ban; } public function addModerator(ModeratorDto $dto, ?bool $isOwner = false): void { $magazine = $dto->magazine; $magazine->addModerator(new Moderator($magazine, $dto->user, $dto->addedBy, $isOwner, true)); $this->entityManager->flush(); $this->clearCommentsCache($dto->user); $this->dispatcher->dispatch(new MagazineModeratorAddedEvent($magazine, $dto->user, $dto->addedBy)); } private function clearCommentsCache(User $user) { $this->cache->invalidateTags([ 'post_comments_user_'.$user->getId(), 'entry_comments_user_'.$user->getId(), ]); } public function removeModerator(Moderator $moderator, ?User $removedBy): void { $user = $moderator->user; $this->entityManager->remove($moderator); $this->entityManager->flush(); $this->clearCommentsCache($user); $this->dispatcher->dispatch(new MagazineModeratorRemovedEvent($moderator->magazine, $moderator->user, $removedBy)); } public function changeTheme(MagazineThemeDto $dto): Magazine { $magazine = $dto->magazine; if ($dto->icon && $magazine->icon?->getId() !== $dto->icon->id) { $magazine->icon = $this->imageRepository->find($dto->icon->id); } if ($dto->banner && $magazine->banner?->getId() !== $dto->banner->id) { $magazine->banner = $this->imageRepository->find($dto->banner->id); } // custom css $customCss = $dto->customCss; // add custom background to custom CSS if defined $background = null; if ($dto->backgroundImage) { $background = match ($dto->backgroundImage) { 'shape1' => '/build/images/shape.png', 'shape2' => '/build/images/shape2.png', default => null, }; $background = $background ? "#middle { background: url($background); height: 100%; }" : null; if ($background) { $customCss = \sprintf('%s %s', $customCss, $background); } } $magazine->customCss = $customCss; $this->entityManager->persist($magazine); $this->entityManager->flush(); return $magazine; } public function detachIcon(Magazine $magazine): void { if (!$magazine->icon) { return; } $image = $magazine->icon->getId(); $magazine->icon = null; $this->entityManager->persist($magazine); $this->entityManager->flush(); $this->bus->dispatch(new DeleteImageMessage($image)); } public function detachBanner(Magazine $magazine): void { if (!$magazine->banner) { return; } $image = $magazine->banner->getId(); $magazine->banner = null; $this->entityManager->persist($magazine); $this->entityManager->flush(); $this->bus->dispatch(new DeleteImageMessage($image)); } public function removeSubscriptions(Magazine $magazine): void { foreach ($magazine->subscriptions as $subscription) { $this->unsubscribe($subscription->magazine, $subscription->user); } } public function toggleOwnershipRequest(Magazine $magazine, User $user): void { $request = $this->entityManager->getRepository(MagazineOwnershipRequest::class)->findOneBy([ 'magazine' => $magazine, 'user' => $user, ]); if ($request) { $this->entityManager->remove($request); $this->entityManager->flush(); return; } $request = new MagazineOwnershipRequest($magazine, $user); $this->entityManager->persist($request); $this->entityManager->flush(); } public function acceptOwnershipRequest(Magazine $magazine, User $user, ?User $addedBy): void { $owner = $magazine->getOwnerModerator(); if ($owner) { $this->removeModerator($owner, $addedBy); } $this->addModerator(new ModeratorDto($magazine, $user, $addedBy), true); $request = $this->entityManager->getRepository(MagazineOwnershipRequest::class)->findOneBy([ 'magazine' => $magazine, 'user' => $user, ]); $this->entityManager->remove($request); $this->entityManager->flush(); } public function userRequestedOwnership(Magazine $magazine, User $user): bool { $ownerRequest = $this->entityManager->getRepository(MagazineOwnershipRequest::class)->findOneBy([ 'magazine' => $magazine, 'user' => $user, ]); return null !== $ownerRequest; } /** * @return MagazineOwnershipRequest[] */ public function listOwnershipRequests(?Magazine $magazine): array { if ($magazine) { return $this->entityManager->getRepository(MagazineOwnershipRequest::class)->findBy([ 'magazine' => $magazine, ]); } else { return $this->entityManager->getRepository(MagazineOwnershipRequest::class)->findAll(); } } public function toggleModeratorRequest(Magazine $magazine, User $user): void { $request = $this->entityManager->getRepository(ModeratorRequest::class)->findOneBy([ 'magazine' => $magazine, 'user' => $user, ]); if ($request) { $this->entityManager->remove($request); $this->entityManager->flush(); return; } $request = new ModeratorRequest($magazine, $user); $this->entityManager->persist($request); $this->entityManager->flush(); } public function acceptModeratorRequest(Magazine $magazine, User $user, ?User $addedBy): void { $this->addModerator(new ModeratorDto($magazine, $user, $addedBy)); $request = $this->entityManager->getRepository(ModeratorRequest::class)->findOneBy([ 'magazine' => $magazine, 'user' => $user, ]); $this->entityManager->remove($request); $this->entityManager->flush(); } public function userRequestedModerator(Magazine $magazine, User $user): bool { $modRequest = $this->entityManager->getRepository(ModeratorRequest::class)->findOneBy([ 'magazine' => $magazine, 'user' => $user, ]); return null !== $modRequest; } /** * @return ModeratorRequest[] */ public function listModeratorRequests(?Magazine $magazine): array { if ($magazine) { return $this->entityManager->getRepository(ModeratorRequest::class)->findBy([ 'magazine' => $magazine, ]); } else { return $this->entityManager->getRepository(ModeratorRequest::class)->findAll(); } } } ================================================ FILE: src/Service/MentionManager.php ================================================ userRepository->findByUsernames($users); } return []; } public function handleChain(ActivityPubActivityInterface $activity): array { $subject = match (true) { $activity instanceof EntryComment => $activity->parent ?? $activity->entry, $activity instanceof PostComment => $activity->parent ?? $activity->post, default => throw new \LogicException(), }; $activity->mentions = array_unique( array_merge($activity->mentions ?? [], $this->extract($activity->body) ?? []) ); $subjectActor = ['@'.ltrim($subject->user->username, '@')]; $result = array_unique( array_merge( empty($subject->mentions) ? [] : $subject->mentions, empty($activity->mentions) ? [] : $activity->mentions, $subjectActor ) ); $result = array_filter( $result, function ($val) { preg_match(RegPatterns::LOCAL_USER, $val, $l); return preg_match(RegPatterns::AP_USER, $val) || $val === ($l[0] ?? ''); } ); return array_filter( $result, fn ($val) => !\in_array( $val, [ '@'.$activity->user->username, '@'.$activity->user->username.'@'.$this->settingsManager->get('KBIN_DOMAIN'), ] ) ); } /** * Try to extract mentions from the body (eg. @username@domain.tld). * * @param val Body input string * @param type Type of mentions to extract (ALL, LOCAL only or REMOTE only) * * @return string[] */ public function extract(?string $body, $type = self::ALL): ?array { if (!$body) { return null; } $result = match ($type) { self::ALL => array_merge($this->byApPrefix($body), $this->byPrefix($body)), self::LOCAL => $this->byPrefix($body), self::REMOTE => $this->byApPrefix($body), }; $result = array_map(fn ($val) => trim($val), $result); return \count($result) ? array_unique($result) : null; } /** * Remote activitypub prefix, like @username@domain.tld. * * @param value Input string * * @return string[] */ private function byApPrefix(string $value): array { preg_match_all(RegPatterns::REMOTE_USER_REGEX, $value, $matches); return \count($matches[0]) ? array_unique(array_values($matches[0])) : []; } /** * Local username prefix, like @username. * * @param value Input string * * @return string[] */ private function byPrefix(string $value): array { preg_match_all(RegPatterns::LOCAL_USER_REGEX, $value, $matches); $results = array_filter($matches[0], fn ($val) => !str_ends_with($val, '@')); return \count($results) ? array_unique(array_values($results)) : []; } public function joinMentionsToBody(string $body, array $mentions): string { $current = $this->extract($body) ?? []; $current = self::addHandle($current); $mentions = self::addHandle($mentions); $join = array_unique(array_merge(array_diff($mentions, $current))); if (!empty($join)) { $body .= PHP_EOL.PHP_EOL.implode(' ', $join); } return $body; } public function addHandle(array $mentions): array { $res = array_map( fn ($val) => 0 === substr_count($val, '@') ? '@'.$val : $val, $mentions ); return array_map( fn ($val) => substr_count($val, '@') < 2 ? $val.'@'.$this->settingsManager->get('KBIN_DOMAIN') : $val, $res ); } public function getUsername(string $value, ?bool $withApPostfix = false): string { $value = $this->addHandle([$value])[0]; if (true === $withApPostfix) { return $value; } return explode('@', $value)[1]; } public function getDomain(string $value): string { if (str_starts_with($value, '@')) { $value = substr($value, 1); } $parts = explode('@', $value); if (\count($parts) < 2) { return $this->settingsManager->get('KBIN_DOMAIN'); } else { return $parts[1]; } } public function clearLocal(?array $mentions): array { if (null === $mentions) { return []; } $domain = '@'.$this->settingsManager->get('KBIN_DOMAIN'); $mentions = array_map(fn ($val) => preg_replace('/'.preg_quote($domain, '/').'$/', '', $val), $mentions); $mentions = array_map(fn ($val) => ltrim($val, '@'), $mentions); return array_filter($mentions, fn ($val) => !str_contains($val, '@')); } public function getRoute(?array $mentions): array { if (null === $mentions) { return []; } $domain = '@'.$this->settingsManager->get('KBIN_DOMAIN'); $mentions = array_map(fn ($val) => preg_replace('/'.preg_quote($domain, '/').'$/', '', $val), $mentions); $mentions = array_map(fn ($val) => ltrim($val, '@'), $mentions); return array_map(fn ($val) => ltrim($val, '@'), $mentions); } } ================================================ FILE: src/Service/MessageManager.php ================================================ addMessage($this->toMessage($dto, $thread, $sender)); $this->entityManager->persist($thread); $this->entityManager->flush(); return $thread; } public function toMessage(MessageDto $dto, MessageThread $thread, User $sender): Message { if ($sender->isDeleted || $sender->isTrashed() || $sender->isSoftDeleted()) { throw new UserDeletedException(); } $message = new Message($thread, $sender, $dto->body, $dto->apId); foreach ($thread->participants as $participant) { if ($sender->getId() !== $participant->getId()) { if ($participant->isBlocked($sender)) { throw new UserBlockedException(); } } } $thread->setUpdatedAt(); $this->entityManager->persist($thread); $this->entityManager->flush(); $this->notificationManager->sendMessageNotification($message, $sender); $this->bus->dispatch(new CreateMessage($message->getId(), Message::class)); return $message; } public function readMessages(MessageThread $thread, User $user): void { foreach ($thread->getNewMessages($user) as $message) { /* * @var Message $message */ $this->readMessage($message, $user); } $this->entityManager->flush(); } public function readMessage(Message $message, User $user, bool $flush = false): void { $message->status = Message::STATUS_READ; $this->notificationManager->readMessageNotification($message, $user); if ($flush) { $this->entityManager->flush(); } } public function unreadMessage(Message $message, User $user, bool $flush = false): void { $message->status = Message::STATUS_NEW; $this->notificationManager->unreadMessageNotification($message, $user); if ($flush) { $this->entityManager->flush(); } } public function canUserEditMessage(Message $message, User $user): bool { return $message->sender->apId === $user->apId || $message->sender->apDomain === $user->apDomain; } /** * @throws InvalidApPostException * @throws UserBlockedException * @throws InvalidArgumentException * @throws InvalidWebfingerException * @throws Exception * @throws UserDeletedException * @throws UserCannotReceiveDirectMessage */ public function createMessage(array $object): Message|MessageThread { $this->logger->debug('creating message from {o}', ['o' => $object]); $obj_to = \App\Utils\JsonldUtils::getArrayValue($object, 'to'); $obj_cc = \App\Utils\JsonldUtils::getArrayValue($object, 'cc'); $participantIds = array_merge($obj_to, $obj_cc); $participants = array_map(fn ($participant) => $this->activityPubManager->findActorOrCreate(\is_string($participant) ? $participant : $participant['id']), $participantIds); $author = $this->activityPubManager->findActorOrCreate($object['attributedTo']); if ($author->isDeleted || $author->isTrashed() || $author->isSoftDeleted()) { throw new UserDeletedException(); } foreach ($participants as $participant) { if ($participant->isBlocked($author)) { throw new UserBlockedException(); } if (!$participant->canReceiveDirectMessage($author)) { throw new UserCannotReceiveDirectMessage($author, $participant); } } $participants[] = $author; $message = new MessageDto(); $message->body = $this->activityPubManager->extractMarkdownContent($object); $message->apId = $object['id'] ?? null; $threads = $this->messageThreadRepository->findByParticipants($participants); if (\sizeof($threads) > 0) { return $this->toMessage($message, $threads[0], $author); } else { return $this->toThread($message, $author, ...$participants); } } public function editMessage(Message $message, array $object): void { $this->logger->debug('editing message {m}', ['m' => $message->apId]); $newBody = $this->activityPubManager->extractMarkdownContent($object); if ($message->body !== $newBody) { $message->body = $newBody; $message->editedAt = new \DateTimeImmutable(); $this->entityManager->persist($message); $this->entityManager->flush(); } } /** @return string[] */ public function findAudience(MessageThread $thread): array { $res = []; foreach ($thread->participants as /* @var User $participant */ $participant) { if ($participant->apId && !$participant->isDeleted && !$participant->isBanned) { $res[] = $participant->apInboxUrl; } } return array_unique($res); } } ================================================ FILE: src/Service/Monitor.php ================================================ */ protected array $runningTwigTemplates = []; protected ?float $startSendingResponseTime = null; protected ?float $endSendingResponseTime = null; public function __construct( protected readonly EntityManagerInterface $entityManager, protected readonly LoggerInterface $logger, private readonly bool $monitoringEnabled, private readonly bool $monitoringQueryParametersEnabled, private readonly bool $monitoringQueriesEnabled, private readonly bool $monitoringQueriesPersistingEnabled, private readonly bool $monitoringTwigRendersEnabled, private readonly bool $monitoringTwigRendersPersistingEnabled, private readonly bool $monitoringCurlRequestsEnabled, private readonly bool $monitoringCurlRequestPersistingEnabled, ) { } public function shouldRecord(): bool { return $this->monitoringEnabled; } public function shouldRecordTwigRenders(): bool { return $this->shouldRecord() && $this->monitoringTwigRendersEnabled; } public function shouldRecordQueries(): bool { return $this->shouldRecord() && $this->monitoringQueriesEnabled; } public function shouldRecordCurlRequests(): bool { return $this->shouldRecord() && $this->monitoringCurlRequestsEnabled; } /** * @param string $executionType 'request'|'messenger' * @param string $userType 'anonymous'|'user'|'activity_pub'|'ajax' * @param string $path the path or the message class * @param string $handler the controller or the message handler */ public function startNewExecutionContext(string $executionType, string $userType, string $path, string $handler): void { $context = new MonitoringExecutionContext(); $context->executionType = $executionType; $context->path = $path; $context->userType = $userType; $context->handler = $handler; $context->setStartedAt(); $this->contexts[] = $context; $this->currentContext = $context; $this->oldContextSegments = array_merge($this->oldContextSegments, $this->contextSegments); $this->contextSegments = []; $this->logger->debug('[Monitor] Starting a new execution context, type: {executionType}, user: {user}, path: {path}, handler: {handler}', [ 'executionType' => $executionType, 'user' => $userType, 'path' => $path, 'handler' => $handler, ]); $this->logger->debug('[Monitor] queries: {queries}, twig: {twig}, curl: {curl}', [ 'queries' => $this->monitoringQueriesEnabled, 'twig' => $this->monitoringTwigRendersEnabled, 'curl' => $this->monitoringCurlRequestsEnabled, ]); } public function endCurrentExecutionContext(?int $statusCode = null, ?string $exception = null, ?string $stacktrace = null): void { if (null === $this->currentContext) { $this->logger->error('[Monitor] Trying to end a context, but the current one is null'); return; } $this->currentContext->setEndedAt(); $this->currentContext->setDuration(); if (null !== $statusCode) { $this->currentContext->statusCode = $statusCode; } if (null !== $exception) { $this->currentContext->exception = $exception; } if (null !== $stacktrace) { $this->currentContext->stacktrace = $stacktrace; } $this->logger->debug('[Monitor] Ending an new execution context, type: {executionType}, user: {user}, path: {path}, handler: {handler}, status code: {statusCode}, exception: {exception}, stacktrace: {stacktrace}', [ 'executionType' => $this->currentContext->executionType, 'user' => $this->currentContext->userType, 'path' => $this->currentContext->path, 'handler' => $this->currentContext->handler, 'statusCode' => $this->currentContext->statusCode, 'exception' => $this->currentContext->exception, 'stacktrace' => $this->currentContext->stacktrace, ]); $queryDuration = 0; $twigDuration = 0; $curlDuration = 0; foreach ($this->contextSegments as $contextSegment) { if ($contextSegment instanceof MonitoringQuery) { $queryDuration += $contextSegment->getDuration(); } elseif ($contextSegment instanceof MonitoringTwigRender && null === $contextSegment->parent) { $twigDuration += $contextSegment->getDuration(); } elseif ($contextSegment instanceof MonitoringCurlRequest) { $curlDuration += $contextSegment->getDuration(); } } $this->currentContext->queryDurationMilliseconds = $queryDuration; $this->currentContext->twigRenderDurationMilliseconds = $twigDuration; $this->currentContext->curlRequestDurationMilliseconds = $curlDuration; try { $this->entityManager->persist($this->currentContext); $queryStringRepo = $this->entityManager->getRepository(MonitoringQueryString::class); $queryStringsByHash = []; foreach ($this->contextSegments as $contextSegment) { if ($contextSegment instanceof MonitoringQuery) { if (!$this->monitoringQueriesPersistingEnabled) { continue; } // we don't want to compute hashes during event listening, as even sha1 will be a bit time-consuming $hash = hash('sha1', $contextSegment->queryString->query); if (\array_key_exists($hash, $queryStringsByHash)) { $contextSegment->queryString = $queryStringsByHash[$hash]; } $queryString = $queryStringRepo->find($hash); if (null !== $queryString) { $queryStringsByHash[$hash] = $queryString; $contextSegment->queryString = $queryString; } else { // not in cache and not in DB -> persist new entity $queryStringsByHash[$hash] = $contextSegment->queryString; $contextSegment->queryString->queryHash = $hash; $this->entityManager->persist($contextSegment->queryString); } } elseif ($contextSegment instanceof MonitoringTwigRender) { if (!$this->monitoringTwigRendersPersistingEnabled) { continue; } } elseif ($contextSegment instanceof MonitoringCurlRequest) { if (!$this->monitoringCurlRequestPersistingEnabled) { continue; } } $this->entityManager->persist($contextSegment); } $this->entityManager->flush(); $this->currentContext = null; } catch (\Throwable $exception) { $this->logger->critical('[Monitor] Error during context processing: {m}', [ 'm' => $exception->getMessage(), 'exception' => $exception, ]); } } public function cancelCurrentExecutionContext(): void { $this->contexts = array_filter($this->contexts, fn (MonitoringExecutionContext $context) => $this->currentContext !== $context); $this->currentContext = null; $this->contextSegments = []; } public function startQuery(string $sql, ?array $parameters = null): void { if (null === $this->currentContext) { $this->logger->error('[Monitor] Trying to start a query, but the current context is null'); return; } if (null !== $this->currentQuery) { $this->logger->error('[Monitor] Trying to start a query, but another one is still running'); return; } $this->logger->debug('[Monitor] starting a query'); $queryString = new MonitoringQueryString(); $queryString->query = $sql; $this->currentQuery = new MonitoringQuery(); $this->currentQuery->setStartedAt(); $this->currentQuery->queryString = $queryString; if ($this->monitoringQueryParametersEnabled) { $this->currentQuery->parameters = $parameters; } $this->currentQuery->context = $this->currentContext; } public function endQuery(): void { if (null === $this->currentQuery) { $this->logger->error('[Monitor] Trying to end a query, but the current one is null'); return; } $this->logger->debug('[Monitor] ending a query'); $this->currentQuery->setEndedAt(); $this->currentQuery->setDuration(); if ($this->monitoringQueryParametersEnabled) { $this->currentQuery->cleanParameterArray(); } $this->contextSegments[] = $this->currentQuery; $this->currentQuery = null; } public function startTwigRendering(string $templateName, string $type): void { if (\array_key_exists($templateName, $this->runningTwigTemplates)) { $this->logger->error('[Monitor] Trying to start a twig render which is already running ({name})', ['name' => $templateName]); return; } $this->logger->debug('[Monitor] Starting a twig render of {name}, {type} at level {level}', ['name' => $templateName, 'type' => $type, 'level' => \sizeof($this->runningTwigTemplates)]); $render = new MonitoringTwigRender(); $render->templateName = $templateName; $render->context = $this->currentContext; $render->shortDescription = $templateName; $render->setStartedAt(); $maxLevel = 0; $parent = null; foreach ($this->runningTwigTemplates as $obj) { if ($obj['level'] > $maxLevel) { $maxLevel = $obj['level']; $parent = $obj['render']; } } if (null !== $parent) { $render->parent = $parent; } $this->runningTwigTemplates[] = [ 'level' => $maxLevel + 1, 'render' => $render, ]; } public function endTwigRendering(string $templateName, ?int $memoryUsage, ?int $peakMemoryUsage, ?string $name, ?string $type, ?float $profilerDuration): void { if (0 === \sizeof($this->runningTwigTemplates)) { $this->logger->error('[Monitor] Trying to end a twig render but none have been started ({name})', ['name' => $templateName]); return; } $this->logger->debug('[Monitor] Ending a twig render of {name}', ['name' => $templateName]); $lastTemplate = array_pop($this->runningTwigTemplates); /** @var MonitoringTwigRender $render */ $render = $lastTemplate['render']; if ($templateName !== $render->templateName) { $this->logger->warning('[Monitor] the popped twig render has a different template name than the one that should be ended: {name} !== {renderTemplateName}', ['name' => $templateName, 'renderTemplateName' => $render->templateName]); } $render->setEndedAt(); $render->setDuration(); $render->memoryUsage = $memoryUsage; $render->peakMemoryUsage = $peakMemoryUsage; $render->name = $name; $render->type = $type; $render->profilerDuration = $profilerDuration; $this->contextSegments[] = $render; } public function startCurlRequest(string $targetUrl, string $method): void { if (null === $this->currentContext) { $this->logger->error('[Monitor] Trying to start a curl request, but the current context is null'); return; } if (null !== $this->currentCurlRequest) { $this->logger->warning('[Monitor] Trying to start a curl request, but another one is running'); return; } $this->logger->debug('[Monitor] Starting a curl request of {method} - {url}', ['method' => $method, 'url' => $targetUrl]); $this->currentCurlRequest = new MonitoringCurlRequest(); $this->currentCurlRequest->url = $targetUrl; $this->currentCurlRequest->method = $method; $this->currentCurlRequest->context = $this->currentContext; $this->currentCurlRequest->setStartedAt(); } public function endCurlRequest(string $url, bool $wasSuccessful, ?\Throwable $exception): void { if (null === $this->currentContext) { $this->logger->error('[Monitor] Trying to end a curl request, but the current context is null'); return; } if (null === $this->currentCurlRequest) { $this->logger->warning('[Monitor] Trying to end a curl request, but the current request is null'); return; } if ($this->currentCurlRequest->url !== $url) { // should never occur, as php is single threaded $this->logger->warning('[Monitor] Trying to end a curl request, but the current request is using another URL: {u1} !== {u2}', ['u1' => $this->currentCurlRequest->url, 'u2' => $url]); return; } $this->logger->debug('[Monitor] Ending a curl request of {url}, was successful: {success}', ['url' => $url, 'success' => $wasSuccessful]); $this->currentCurlRequest->setEndedAt(); $this->currentCurlRequest->setDuration(); $this->currentCurlRequest->wasSuccessful = $wasSuccessful; if (null !== $exception) { $this->currentCurlRequest->exception = \get_class($exception).": {$exception->getMessage()}"; } $this->contextSegments[] = $this->currentCurlRequest; $this->currentCurlRequest = null; } /** * @param iterable $collection */ protected function calculateDurationFromCollection(iterable $collection): float { $duration = 0; foreach ($collection as $item) { $duration += $item->getDuration(); } return $duration; } public function startSendingResponse(): void { if (null === $this->currentContext || 'response' !== $this->currentContext->executionType) { $this->startSendingResponseTime = null; return; } $this->startSendingResponseTime = microtime(true); } public function endSendingResponse(): void { if (null === $this->currentContext || 'response' !== $this->currentContext->executionType || null === $this->startSendingResponseTime) { $this->endSendingResponseTime = null; return; } $this->endSendingResponseTime = microtime(true); $this->currentContext->responseSendingDurationMilliseconds = ($this->endSendingResponseTime - $this->startSendingResponseTime) * 1000; } } ================================================ FILE: src/Service/MonologFilterHandler.php ================================================ shouldFilter($record); } private function shouldFilter(LogRecord $record): bool { foreach (self::TO_IGNORE as $str) { if (str_contains($record->message, $str)) { return true; } } return false; } } ================================================ FILE: src/Service/Notification/EntryCommentNotificationManager.php ================================================ user->isBanned || $subject->user->isDeleted || $subject->user->isTrashed() || $subject->user->isSoftDeleted()) { return; } if (!$subject instanceof EntryComment) { throw new \LogicException(); } $comment = $subject; $mentioned = $this->sendMentionedNotification($comment); $this->notifyMagazine(new EntryCommentCreatedNotification($comment->user, $comment)); $userIdsToNotify = $this->notificationSettingsRepository->findNotificationSubscribersByTarget($comment); $usersToNotify = $this->userRepository->findBy(['id' => $userIdsToNotify]); if (\count($mentioned)) { $usersToNotify = array_filter($usersToNotify, fn ($user) => !\in_array($user, $mentioned)); } foreach ($usersToNotify as $subscriber) { if (null !== $comment->parent && $comment->parent->isAuthor($subscriber)) { $notification = new EntryCommentReplyNotification($subscriber, $comment); } else { $notification = new EntryCommentCreatedNotification($subscriber, $comment); } $this->entityManager->persist($notification); $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); } $this->entityManager->flush(); } private function sendMentionedNotification(EntryComment $subject): array { $users = []; $mentions = $this->mentionManager->clearLocal($this->mentionManager->extract($subject->body)); foreach ($this->mentionManager->getUsersFromArray($mentions) as $user) { if (!$user->apId and !$user->isBlocked($subject->getUser())) { $notification = new EntryCommentMentionedNotification($user, $subject); $this->entityManager->persist($notification); $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); } $users[] = $user; } return $users; } private function notifyUser(EntryCommentReplyNotification $notification): void { if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) { return; } try { $iri = IriGenerator::getIriFromResource($notification->user); $update = new Update( $iri, $this->getResponse($notification) ); $this->publisher->publish($update); } catch (\Exception $e) { } } private function getResponse(Notification $notification): string { $class = explode('\\', $this->entityManager->getClassMetadata(\get_class($notification))->name); /** * @var EntryComment $comment ; */ $comment = $notification->getComment(); return json_encode( [ 'op' => end($class), 'id' => $comment->getId(), 'htmlId' => $this->classService->fromEntity($comment), 'parent' => $comment->parent ? [ 'id' => $comment->parent->getId(), 'htmlId' => $this->classService->fromEntity($comment->parent), ] : null, 'parentSubject' => [ 'id' => $comment->entry->getId(), 'htmlId' => $this->classService->fromEntity($comment->entry), ], 'title' => $comment->entry->title, 'body' => $comment->body, 'icon' => $this->imageManager->getUrl($comment->image), // 'image' => $this->imageManager->getUrl($comment->image), 'url' => $this->urlGenerator->generate('entry_comment_view', [ 'magazine_name' => $comment->magazine->name, 'entry_id' => $comment->entry->getId(), 'slug' => $comment->entry->slug, 'comment_id' => $comment->getId(), ]).'#entry-comment-'.$comment->getId(), // 'toast' => $this->twig->render('_layout/_toast.html.twig', ['notification' => $notification]), ] ); } private function notifyMagazine(Notification $notification): void { if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) { return; } try { $iri = IriGenerator::getIriFromResource($notification->getComment()->magazine); $update = new Update( ['pub', $iri], $this->getResponse($notification) ); $this->publisher->publish($update); } catch (\Exception $e) { } } public function sendEdited(ContentInterface $subject): void { if (!$subject instanceof EntryComment) { throw new \LogicException(); } $this->notifyMagazine(new EntryCommentEditedNotification($subject->user, $subject)); } public function sendDeleted(ContentInterface $subject): void { if (!$subject instanceof EntryComment) { throw new \LogicException(); } $this->notifyMagazine($notification = new EntryCommentDeletedNotification($subject->user, $subject)); } public function purgeNotifications(EntryComment $comment): void { $this->notificationRepository->removeEntryCommentNotifications($comment); } public function purgeMagazineLog(EntryComment $comment): void { $this->magazineLogRepository->removeEntryCommentLogs($comment); } } ================================================ FILE: src/Service/Notification/EntryNotificationManager.php ================================================ user->isBanned || $subject->user->isDeleted || $subject->user->isTrashed() || $subject->user->isSoftDeleted()) { return; } if (!$subject instanceof Entry) { throw new \LogicException(); } /* * @var Entry $subject */ $this->notifyMagazine(new EntryCreatedNotification($subject->user, $subject)); // Notify mentioned $mentions = $this->mentionManager->clearLocal($this->mentionManager->extract($subject->body)); foreach ($this->mentionManager->getUsersFromArray($mentions) as $user) { if (!$user->apId && !$user->isBlocked($subject->user)) { $notification = new EntryMentionedNotification($user, $subject); $this->entityManager->persist($notification); $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); } } // Notify subscribers $subscriberIds = $this->notificationSettingsRepository->findNotificationSubscribersByTarget($subject); $subscribers = $this->userRepository->findBy(['id' => $subscriberIds]); if (\count($mentions)) { $subscribers = array_filter($subscribers, fn ($s) => !\in_array($s->username, $mentions ?? [])); } foreach ($subscribers as $subscriber) { $notification = new EntryCreatedNotification($subscriber, $subject); $this->entityManager->persist($notification); $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); } $this->entityManager->flush(); } private function notifyMagazine(Notification $notification): void { if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) { return; } try { $iri = IriGenerator::getIriFromResource($notification->entry->magazine); $update = new Update( ['pub', $iri], $this->getResponse($notification) ); $this->publisher->publish($update); } catch (\Exception $e) { } } private function getResponse(Notification $notification): string { $class = explode('\\', $this->entityManager->getClassMetadata(\get_class($notification))->name); /** * @var Magazine $magazine * @var Entry $entry */ $entry = $notification->entry; $magazine = $notification->entry->magazine; return json_encode( [ 'op' => end($class), 'id' => $entry->getId(), 'htmlId' => $this->classService->fromEntity($entry), 'magazine' => [ 'name' => $magazine->name, ], 'title' => $magazine->title, 'body' => $entry->title, 'icon' => $this->imageManager->getUrl($entry->image), // 'image' => $this->imageManager->getUrl($entry->image), 'url' => $this->urlGenerator->generate('entry_single', [ 'magazine_name' => $magazine->name, 'entry_id' => $entry->getId(), 'slug' => $entry->slug, ]), // 'toast' => $this->twig->render('_layout/_toast.html.twig', ['notification' => $notification]), ] ); } public function sendEdited(ContentInterface $subject): void { if (!$subject instanceof Entry) { throw new \LogicException(); } $this->notifyMagazine(new EntryEditedNotification($subject->user, $subject)); } public function sendDeleted(ContentInterface $subject): void { if (!$subject instanceof Entry) { throw new \LogicException(); } $this->notifyMagazine($notification = new EntryDeletedNotification($subject->user, $subject)); } public function purgeNotifications(Entry $entry): void { $this->notificationRepository->removeEntryNotifications($entry); } public function purgeMagazineLog(Entry $entry): void { $this->magazineLogRepository->removeEntryLogs($entry); } } ================================================ FILE: src/Service/Notification/MagazineBanNotificationManager.php ================================================ expiredAt && new \DateTimeImmutable('now') >= $ban->expiredAt) { $notification = new MagazineUnBanNotification($ban->user, $ban); } else { $notification = new MagazineBanNotification($ban->user, $ban); } $this->entityManager->persist($notification); $this->entityManager->flush(); $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); } } ================================================ FILE: src/Service/Notification/MessageNotificationManager.php ================================================ thread; $usersToNotify = $thread->getOtherParticipants($sender); foreach ($usersToNotify as $subscriber) { $notification = new MessageNotification($subscriber, $message); $this->entityManager->persist($notification); $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); } $this->entityManager->flush(); } } ================================================ FILE: src/Service/Notification/NotificationTrait.php ================================================ $sub->user, $subscriptions); } private function merge(array $subs, array $follows): array { return array_unique( array_merge( $subs, array_filter( $follows, function ($val) use ($subs) { return !\in_array($val, $subs); } ) ), SORT_REGULAR ); } } ================================================ FILE: src/Service/Notification/PostCommentNotificationManager.php ================================================ user->isBanned || $subject->user->isDeleted || $subject->user->isTrashed() || $subject->user->isSoftDeleted()) { return; } if (!$subject instanceof PostComment) { throw new \LogicException(); } $comment = $subject; $mentions = $this->sendMentionedNotification($subject); $this->notifyMagazine(new PostCommentCreatedNotification($comment->user, $comment)); $userIdsToNotify = $this->notificationSettingsRepository->findNotificationSubscribersByTarget($comment); $usersToNotify = $this->userRepository->findBy(['id' => $userIdsToNotify]); if (\count($mentions)) { $usersToNotify = array_filter($usersToNotify, fn ($user) => !\in_array($user, $mentions)); } foreach ($usersToNotify as $subscriber) { if (null !== $comment->parent && $comment->parent->isAuthor($subscriber)) { $notification = new PostCommentReplyNotification($subscriber, $comment); } else { $notification = new PostCommentCreatedNotification($subscriber, $comment); } $this->entityManager->persist($notification); $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); } $this->entityManager->flush(); } private function sendMentionedNotification(PostComment $subject): array { $users = []; $mentions = $this->mentionManager->clearLocal($this->mentionManager->extract($subject->body)); foreach ($this->mentionManager->getUsersFromArray($mentions) as $user) { if (!$user->apId and !$user->isBlocked($subject->getUser())) { $notification = new PostCommentMentionedNotification($user, $subject); $this->entityManager->persist($notification); $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); } $users[] = $user; } return $users; } private function notifyUser(PostCommentReplyNotification $notification): void { if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) { return; } try { $iri = IriGenerator::getIriFromResource($notification->user); $update = new Update( $iri, $this->getResponse($notification) ); $this->publisher->publish($update); } catch (\Exception $e) { } } private function getResponse(Notification $notification): string { $class = explode('\\', $this->entityManager->getClassMetadata(\get_class($notification))->name); /** * @var PostComment $comment */ $comment = $notification->getComment(); return json_encode( [ 'op' => end($class), 'id' => $comment->getId(), 'htmlId' => $this->classService->fromEntity($comment), 'parent' => $comment->parent ? [ 'id' => $comment->parent->getId(), 'htmlId' => $this->classService->fromEntity($comment->parent), ] : null, 'parentSubject' => [ 'id' => $comment->post->getId(), 'htmlId' => $this->classService->fromEntity($comment->post), ], 'title' => $comment->post->body, 'body' => $comment->body, 'icon' => $this->imageManager->getUrl($comment->image), // 'image' => $this->imageManager->getUrl($comment->image), 'url' => $this->urlGenerator->generate('post_single', [ 'magazine_name' => $comment->magazine->name, 'post_id' => $comment->post->getId(), 'slug' => $comment->post->slug, ]).'#post-comment-'.$comment->getId(), // 'toast' => $this->twig->render('_layout/_toast.html.twig', ['notification' => $notification]), ] ); } private function notifyMagazine(Notification $notification): void { if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) { return; } try { $iri = IriGenerator::getIriFromResource($notification->getComment()->magazine); $update = new Update( ['pub', $iri], $this->getResponse($notification) ); $this->publisher->publish($update); } catch (\Exception $e) { } } public function sendEdited(ContentInterface $subject): void { if (!$subject instanceof PostComment) { throw new \LogicException(); } $this->notifyMagazine(new PostCommentEditedNotification($subject->user, $subject)); } public function sendDeleted(ContentInterface $subject): void { if (!$subject instanceof PostComment) { throw new \LogicException(); } $this->notifyMagazine($notification = new PostCommentDeletedNotification($subject->user, $subject)); } public function purgeNotifications(PostComment $comment): void { $this->notificationRepository->removePostCommentNotifications($comment); } public function purgeMagazineLog(PostComment $comment): void { $this->magazineLogRepository->removePostCommentLogs($comment); } } ================================================ FILE: src/Service/Notification/PostNotificationManager.php ================================================ user->isBanned || $subject->user->isDeleted || $subject->user->isTrashed() || $subject->user->isSoftDeleted()) { return; } if (!$subject instanceof Post) { throw new \LogicException(); } $this->notifyMagazine(new PostCreatedNotification($subject->user, $subject)); // Notify mentioned $mentions = $this->mentionManager->clearLocal($this->mentionManager->extract($subject->body)); foreach ($this->mentionManager->getUsersFromArray($mentions) as $user) { if (!$user->isBlocked($subject->user)) { $notification = new PostMentionedNotification($user, $subject); $this->entityManager->persist($notification); $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); } } // Notify subscribers $subscriberIds = $this->notificationSettingsRepository->findNotificationSubscribersByTarget($subject); $subscribers = $this->userRepository->findBy(['id' => $subscriberIds]); if (\count($mentions)) { $subscribers = array_filter($subscribers, fn ($s) => !\in_array($s->username, $mentions)); } foreach ($subscribers as $subscriber) { $notification2 = new PostCreatedNotification($subscriber, $subject); $this->entityManager->persist($notification2); $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification2)); } $this->entityManager->flush(); } private function notifyMagazine(Notification $notification): void { if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) { return; } try { $iri = IriGenerator::getIriFromResource($notification->post->magazine); $update = new Update( ['pub', $iri], $this->getResponse($notification) ); $this->publisher->publish($update); } catch (\Exception $e) { } } private function getResponse(Notification $notification): string { $class = explode('\\', $this->entityManager->getClassMetadata(\get_class($notification))->name); /** * @var Post $post ; */ $post = $notification->post; return json_encode( [ 'op' => end($class), 'id' => $post->getId(), 'htmlId' => $this->classService->fromEntity($post), 'magazine' => [ 'name' => $post->magazine->name, ], 'title' => $post->magazine->name, 'body' => $post->body, 'icon' => $this->imageManager->getUrl($post->image), // 'image' => $this->imageManager->getUrl($post->image), 'url' => $this->urlGenerator->generate('post_single', [ 'magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => $post->slug, ]), // 'toast' => $this->twig->render('_layout/_toast.html.twig', ['notification' => $notification]), ] ); } public function sendEdited(ContentInterface $subject): void { if (!$subject instanceof Post) { throw new \LogicException(); } $this->notifyMagazine(new PostEditedNotification($subject->user, $subject)); } public function sendDeleted(ContentInterface $subject): void { if (!$subject instanceof Post) { throw new \LogicException(); } $this->notifyMagazine($notification = new PostDeletedNotification($subject->user, $subject)); } public function purgeNotifications(Post $post): void { $this->notificationRepository->removePostNotifications($post); } public function purgeMagazineLog(Post $post): void { $this->magazineLogRepository->removePostLogs($post); } } ================================================ FILE: src/Service/Notification/ReportNotificationManager.php ================================================ magazine->moderators as /* @var Moderator $moderator */ $moderator) { if (null === $moderator->user->apId) { $receivers[] = $moderator->user; } } foreach ($this->userRepository->findAllModerators() as $moderator) { if (null === $moderator->apId) { $receivers[] = $moderator; } } foreach ($this->userRepository->findAllAdmins() as $admin) { if (null === $admin->apId) { $receivers[] = $admin; } } $map = []; foreach ($receivers as $receiver) { if (!\array_key_exists($receiver->getId(), $map)) { $map[$receiver->getId()] = true; $n = new ReportCreatedNotification($receiver, $report); $this->entityManager->persist($n); $this->dispatcher->dispatch(new NotificationCreatedEvent($n)); } } $this->entityManager->flush(); } public function sendReportRejectedNotification(Report $report): void { } public function sendReportApprovedNotification(Report $report): void { if (null === $report->reported->apId) { $notification = new ReportApprovedNotification($report->reported, $report); $this->entityManager->persist($notification); $this->entityManager->flush(); $this->dispatcher->dispatch(new NotificationCreatedEvent($notification)); } } } ================================================ FILE: src/Service/Notification/SignupNotificationManager.php ================================================ userRepository->findAllAdmins(); $receiver_moderators = $this->userRepository->findAllModerators(); $receivers = array_merge($receiver_admins, $receiver_moderators); $sentNotificationUserIds = []; foreach ($receivers as $receiver) { if (!$receiver->notifyOnUserSignup || \array_key_exists($receiver->getId(), $sentNotificationUserIds)) { continue; } $notification = new NewSignupNotification($receiver); $notification->newUser = $newUser; $this->entityManager->persist($notification); $this->dispatcher->dispatch(new NotificationCreatedEvent($notification)); $sentNotificationUserIds[$receiver->getId()] = true; } $this->entityManager->flush(); } } ================================================ FILE: src/Service/Notification/UserPushSubscriptionManager.php ================================================ getWebPush(); $criteria = ['user' => $user]; if ($specificDeviceKey) { $criteria['deviceKey'] = $specificDeviceKey; } if ($specificToken) { $criteria['apiToken'] = $specificToken; } $subs = $this->pushSubscriptionRepository->findBy($criteria); foreach ($subs as $sub) { if ($pushNotification instanceof Notification) { $toSend = $pushNotification->getMessage($this->translator, $sub->locale ?? $this->settingsManager->get('KBIN_DEFAULT_LANG'), $this->urlGenerator); } elseif ($pushNotification instanceof PushNotification) { $toSend = $pushNotification; } else { throw new \InvalidArgumentException(); } $this->logger->debug("Sending text '{t}' to {u}#{dk}. {json}", [ 't' => $toSend->title.'. '.$toSend->message, 'u' => $user->username, 'dk' => $sub->deviceKey ?? 'someOAuth', 'json' => json_encode($sub), ]); $webPush->queueNotification( new Subscription($sub->endpoint, $sub->contentEncryptionPublicKey, $sub->serverAuthKey, contentEncoding: 'aes128gcm'), payload: json_encode($toSend) ); } /** * Check sent results. * * @var MessageSentReport $report */ foreach ($webPush->flush() as $report) { $endpoint = $report->getRequest()->getUri()->__toString(); if ($report->isSuccess()) { $this->logger->debug('[v] Message sent successfully for subscription {e}.', ['e' => $endpoint]); } else { $this->logger->debug('[x] Message failed to sent for subscription {e}: {r}', ['e' => $endpoint, 'r' => $report->getReason()]); if ($report->isSubscriptionExpired()) { $subscriptions = $this->pushSubscriptionRepository->findBy(['endpoint' => $endpoint]); foreach ($subscriptions as $sub) { $this->entityManager->remove($sub); } $this->logger->info('Removed push subscription for user "{u}" at endpoint "{e}", because it expired', ['e' => $endpoint, 'u' => $user->username]); } } } } /** * @throws \ErrorException */ public function getWebPush(): WebPush { $site = $this->siteRepository->findAll()[0]; $auth = [ 'VAPID' => [ 'subject' => $this->settingsManager->get('KBIN_DOMAIN'), 'publicKey' => $site->pushPublicKey, 'privateKey' => $site->pushPrivateKey, ], ]; return new WebPush($auth); } } ================================================ FILE: src/Service/NotificationManager.php ================================================ resolver->resolve($subject)->sendCreated($subject); } public function sendEdited(ContentInterface $subject): void { $this->resolver->resolve($subject)->sendEdited($subject); } public function sendDeleted(ContentInterface $subject): void { $this->resolver->resolve($subject)->sendDeleted($subject); } public function sendMessageNotification(Message $message, User $sender): void { $this->messageNotificationManager->send($message, $sender); } public function sendMagazineBanNotification(MagazineBan $ban): void { $this->magazineBanNotificationManager->send($ban); } public function markAllAsRead(User $user): void { $notifications = $user->getNewNotifications(); foreach ($notifications as $notification) { $notification->status = Notification::STATUS_READ; } $this->entityManager->flush(); } public function clear(User $user): void { $notifications = $user->notifications; foreach ($notifications as $notification) { $this->entityManager->remove($notification); } $this->entityManager->flush(); } public function readMessageNotification(Message $message, User $user): void { $repo = $this->entityManager->getRepository(MessageNotification::class); $notifications = $repo->findBy( [ 'message' => $message, 'user' => $user, ] ); foreach ($notifications as $notification) { $notification->status = Notification::STATUS_READ; } $this->entityManager->flush(); } public function unreadMessageNotification(Message $message, User $user): void { $repo = $this->entityManager->getRepository(MessageNotification::class); $notifications = $repo->findBy( [ 'message' => $message, 'user' => $user, ] ); foreach ($notifications as $notification) { $notification->status = Notification::STATUS_NEW; } $this->entityManager->flush(); } } ================================================ FILE: src/Service/NotificationManagerTypeResolver.php ================================================ $this->entryNotificationManager, $subject instanceof EntryComment => $this->entryCommentNotificationManager, $subject instanceof Post => $this->postNotificationManager, $subject instanceof PostComment => $this->postCommentNotificationManager, default => throw new \LogicException(), }; } } ================================================ FILE: src/Service/OAuthTokenRevoker.php ================================================ entityManager->createQueryBuilder() ->update(AccessToken::class, 'at') ->set('at.revoked', ':revoked') ->where('at.userIdentifier = :userIdentifier') ->andWhere('at.client = :clientIdentifier') ->setParameter('revoked', true) ->setParameter('userIdentifier', $user->getUserIdentifier()) ->setParameter('clientIdentifier', $client->getIdentifier()) ->getQuery() ->execute(); $queryBuilder = $this->entityManager->createQueryBuilder(); $queryBuilder ->update(RefreshToken::class, 'rt') ->set('rt.revoked', ':revoked') ->where($queryBuilder->expr()->in( 'rt.accessToken', $this->entityManager->createQueryBuilder() ->select('at.identifier') ->from(AccessToken::class, 'at') ->where('at.userIdentifier = :userIdentifier') ->andWhere('at.client = :clientIdentifier') ->getDQL() )) ->setParameter('revoked', true) ->setParameter('userIdentifier', $user->getUserIdentifier()) ->setParameter('clientIdentifier', $client->getIdentifier()) ->getQuery() ->execute(); $this->entityManager->createQueryBuilder() ->update(AuthorizationCode::class, 'ac') ->set('ac.revoked', ':revoked') ->where('ac.userIdentifier = :userIdentifier') ->andWhere('ac.client = :clientIdentifier') ->setParameter('revoked', true) ->setParameter('userIdentifier', $user->getUserIdentifier()) ->setParameter('clientIdentifier', $client->getIdentifier()) ->getQuery() ->execute(); } } ================================================ FILE: src/Service/PeopleManager.php ================================================ postRepository->findUsers($magazine, true); return $this->sort( $this->userRepository->findBy( ['id' => array_map(fn ($val) => $val['id'], $users)] ), $users ); } $local = $this->postRepository->findUsers($magazine); return $this->sort( $this->userRepository->findBy(['id' => array_map(fn ($val) => $val['id'], $local)]), $local ); } private function sort(array $users, array $ids): array { $result = []; foreach ($ids as $id) { $result[] = array_values(array_filter($users, fn ($val) => $val->getId() === $id['id']))[0]; } return array_values($result); } public function general(bool $federated = false): array { if ($federated) { return $this->userRepository->findUsersForGroup(UserRepository::USERS_REMOTE); } return $this->userRepository->findUsersForGroup(UserRepository::USERS_LOCAL, false); } } ================================================ FILE: src/Service/PostCommentManager.php ================================================ postCommentLimiter->create($dto->ip); if ($limiter && false === $limiter->consume()->isAccepted()) { throw new TooManyRequestsHttpException(); } } if ($dto->post->magazine->isBanned($user) || $user->isBanned()) { throw new UserBannedException(); } if ($this->tagManager->isAnyTagBanned($this->tagManager->extract($dto->body))) { throw new TagBannedException(); } if (null !== $dto->post->magazine->apId && $this->settingsManager->isBannedInstance($dto->post->magazine->apInboxUrl)) { throw new InstanceBannedException(); } if ($dto->post->isLocked) { throw new PostLockedException(); } $comment = $this->factory->createFromDto($dto, $user); $comment->magazine = $dto->post->magazine; $comment->lang = $dto->lang; $comment->isAdult = $dto->isAdult || $comment->magazine->isAdult; $comment->image = $dto->image ? $this->imageRepository->find($dto->image->id) : null; if ($comment->image && !$comment->image->altText) { $comment->image->altText = $dto->imageAlt; } $comment->mentions = $dto->body ? array_merge($dto->mentions ?? [], $this->mentionManager->handleChain($comment)) : $dto->mentions; $comment->visibility = $dto->visibility; $comment->apId = $dto->apId; $comment->apLikeCount = $dto->apLikeCount; $comment->apDislikeCount = $dto->apDislikeCount; $comment->apShareCount = $dto->apShareCount; $comment->magazine->lastActive = new \DateTime(); $comment->user->lastActive = new \DateTime(); $comment->lastActive = $dto->lastActive ?? $comment->lastActive; $comment->createdAt = $dto->createdAt ?? $comment->createdAt; if (empty($comment->body) && null === $comment->image) { throw new \Exception('Comment body and image cannot be empty'); } $comment->post->addComment($comment); $comment->updateScore(); $comment->updateRanking(); $this->entityManager->persist($comment); $this->entityManager->flush(); $this->tagManager->updatePostCommentTags($comment, $this->tagExtractor->extract($comment->body) ?? []); $this->dispatcher->dispatch(new PostCommentCreatedEvent($comment)); return $comment; } public function canUserEditPostComment(PostComment $postComment, User $user): bool { $postCommentHost = null !== $postComment->apId ? parse_url($postComment->apId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN'); $userHost = null !== $user->apId ? parse_url($user->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN'); $magazineHost = null !== $postComment->magazine->apId ? parse_url($postComment->magazine->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN'); return $postCommentHost === $userHost || $userHost === $magazineHost || $postComment->magazine->userIsModerator($user); } /** * @throws \Exception */ public function edit(PostComment $comment, PostCommentDto $dto, ?User $editedBy = null): PostComment { Assert::same($comment->post->getId(), $dto->post->getId()); $comment->body = $dto->body; $comment->lang = $dto->lang; $comment->isAdult = $dto->isAdult || $comment->magazine->isAdult; $oldImage = $comment->image; if ($dto->image) { $comment->image = $this->imageRepository->find($dto->image->id); } $this->tagManager->updatePostCommentTags($comment, $this->tagExtractor->extract($dto->body) ?? []); $comment->mentions = $dto->body ? array_merge($dto->mentions ?? [], $this->mentionManager->handleChain($comment)) : $dto->mentions; $comment->visibility = $dto->visibility; $comment->editedAt = new \DateTimeImmutable('@'.time()); if (empty($comment->body) && null === $comment->image) { throw new \Exception('Comment body and image cannot be empty'); } $comment->apLikeCount = $dto->apLikeCount; $comment->apDislikeCount = $dto->apDislikeCount; $comment->apShareCount = $dto->apShareCount; $comment->updateScore(); $comment->updateRanking(); $this->entityManager->flush(); if ($oldImage && $comment->image !== $oldImage) { $this->bus->dispatch(new DeleteImageMessage($oldImage->getId())); } $this->dispatcher->dispatch(new PostCommentEditedEvent($comment, $editedBy)); return $comment; } public function delete(User $user, PostComment $comment): void { if ($user->apDomain && $user->apDomain !== parse_url($comment->apId ?? '', PHP_URL_HOST) && !$comment->magazine->userIsModerator($user)) { $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m]', ['u' => $user->apId, 'm' => $comment->magazine->apId ?? $comment->magazine->name]); return; } if ($comment->isAuthor($user) && $comment->children->isEmpty()) { $this->purge($user, $comment); return; } $this->isTrashed($user, $comment) ? $comment->trash() : $comment->softDelete(); $this->dispatcher->dispatch(new PostCommentBeforeDeletedEvent($comment, $user)); $this->entityManager->flush(); $this->dispatcher->dispatch(new PostCommentDeletedEvent($comment, $user)); } public function trash(User $user, PostComment $comment): void { $comment->trash(); $this->dispatcher->dispatch(new PostCommentBeforeDeletedEvent($comment, $user)); $this->entityManager->flush(); $this->dispatcher->dispatch(new PostCommentDeletedEvent($comment, $user)); } public function purge(User $user, PostComment $comment): void { $this->dispatcher->dispatch(new PostCommentBeforePurgeEvent($comment, $user)); $magazine = $comment->post->magazine; $image = $comment->image?->getId(); $comment->post->removeComment($comment); $this->entityManager->remove($comment); $this->entityManager->flush(); $this->dispatcher->dispatch(new PostCommentPurgedEvent($magazine)); if ($image) { $this->bus->dispatch(new DeleteImageMessage($image)); } } private function isTrashed(User $user, PostComment $comment): bool { return !$comment->isAuthor($user); } /** * @throws \Exception */ public function restore(User $user, PostComment $comment): void { if (VisibilityInterface::VISIBILITY_TRASHED !== $comment->visibility) { throw new \Exception('Invalid visibility'); } $comment->visibility = VisibilityInterface::VISIBILITY_VISIBLE; $this->entityManager->persist($comment); $this->entityManager->flush(); $this->dispatcher->dispatch(new PostCommentRestoredEvent($comment, $user)); } public function createDto(PostComment $comment): PostCommentDto { return $this->factory->createDto($comment); } public function detachImage(PostComment $comment): void { $image = $comment->image->getId(); $comment->image = null; $this->entityManager->persist($comment); $this->entityManager->flush(); $this->bus->dispatch(new DeleteImageMessage($image)); } } ================================================ FILE: src/Service/PostManager.php ================================================ postLimiter->create($dto->ip); if ($limiter && false === $limiter->consume()->isAccepted()) { throw new TooManyRequestsHttpException(); } } if ($dto->magazine->isBanned($user) || $user->isBanned()) { throw new UserBannedException(); } if ($this->tagManager->isAnyTagBanned($this->tagManager->extract($dto->body))) { throw new TagBannedException(); } if (null !== $dto->magazine->apId && $this->settingsManager->isBannedInstance($dto->magazine->apInboxUrl)) { throw new InstanceBannedException(); } $post = $this->factory->createFromDto($dto, $user); $post->lang = $dto->lang; $post->isAdult = $dto->isAdult || $post->magazine->isAdult; $post->slug = $this->slugger->slug($dto->body ?? $dto->magazine->name.' '.$dto->image->altText); $post->image = $dto->image ? $this->imageRepository->find($dto->image->id) : null; $this->logger->debug('setting image to {imageId}, dto was {dtoImageId}', ['imageId' => $post->image?->getId() ?? 'none', 'dtoImageId' => $dto->image?->id ?? 'none']); if ($post->image && !$post->image->altText) { $post->image->altText = $dto->imageAlt; } $post->mentions = $dto->body ? $this->mentionManager->extract($dto->body) : null; $post->visibility = $dto->visibility; $post->apId = $dto->apId; $post->apLikeCount = $dto->apLikeCount; $post->apDislikeCount = $dto->apDislikeCount; $post->apShareCount = $dto->apShareCount; $post->magazine->lastActive = new \DateTime(); $post->user->lastActive = new \DateTime(); $post->lastActive = $dto->lastActive ?? $post->lastActive; $post->createdAt = $dto->createdAt ?? $post->createdAt; if (empty($post->body) && null === $post->image) { throw new \Exception('Post body and image cannot be empty'); } $post->updateScore(); $post->updateRanking(); $this->entityManager->persist($post); $this->entityManager->flush(); $this->tagManager->updatePostTags($post, $this->tagExtractor->extract($post->body) ?? []); $this->dispatcher->dispatch(new PostCreatedEvent($post)); if ($stickyIt) { $this->pin($post); } return $post; } public function canUserEditPost(Post $post, User $user): bool { $postHost = null !== $post->apId ? parse_url($post->apId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN'); $userHost = null !== $user->apId ? parse_url($user->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN'); $magazineHost = null !== $post->magazine->apId ? parse_url($post->magazine->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN'); return $postHost === $userHost || $userHost === $magazineHost || $post->magazine->userIsModerator($user); } public function edit(Post $post, PostDto $dto, ?User $editedBy = null): Post { Assert::same($post->magazine->getId(), $dto->magazine->getId()); $post->body = $dto->body; $post->lang = $dto->lang; $post->isAdult = $dto->isAdult || $post->magazine->isAdult; $post->isLocked = $dto->isLocked; $post->slug = $this->slugger->slug($dto->body ?? $dto->magazine->name.' '.$dto->image->altText); $oldImage = $post->image; if ($dto->image) { $post->image = $this->imageRepository->find($dto->image->id); } $this->tagManager->updatePostTags($post, $this->tagExtractor->extract($dto->body) ?? []); $post->mentions = $dto->body ? $this->mentionManager->extract($dto->body) : null; $post->visibility = $dto->visibility; $post->editedAt = new \DateTimeImmutable('@'.time()); if (empty($post->body) && null === $post->image) { throw new \Exception('Post body and image cannot be empty'); } $post->apLikeCount = $dto->apLikeCount; $post->apDislikeCount = $dto->apDislikeCount; $post->apShareCount = $dto->apShareCount; $post->updateScore(); $post->updateRanking(); $this->entityManager->flush(); if ($oldImage && $post->image !== $oldImage) { $this->bus->dispatch(new DeleteImageMessage($oldImage->getId())); } $this->dispatcher->dispatch(new PostEditedEvent($post, $editedBy)); return $post; } public function delete(User $user, Post $post): void { if ($user->apDomain && $user->apDomain !== parse_url($post->apId ?? '', PHP_URL_HOST) && !$post->magazine->userIsModerator($user)) { $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m]', ['u' => $user->apId, 'm' => $post->magazine->apId ?? $post->magazine->name]); return; } if ($post->isAuthor($user) && $post->comments->isEmpty()) { $this->purge($user, $post); return; } $this->isTrashed($user, $post) ? $post->trash() : $post->softDelete(); $this->dispatcher->dispatch(new PostBeforeDeletedEvent($post, $user)); $this->entityManager->flush(); $this->dispatcher->dispatch(new PostDeletedEvent($post, $user)); } public function trash(User $user, Post $post): void { $post->trash(); $this->dispatcher->dispatch(new PostBeforeDeletedEvent($post, $user)); $this->entityManager->flush(); $this->dispatcher->dispatch(new PostDeletedEvent($post, $user)); } public function purge(User $user, Post $post): void { $this->dispatcher->dispatch(new PostBeforePurgeEvent($post, $user)); $image = $post->image?->getId(); $sort = new Criteria(null, ['createdAt' => Order::Descending]); foreach ($post->comments->matching($sort) as $comment) { $this->postCommentManager->purge($user, $comment); } $this->entityManager->remove($post); $this->entityManager->flush(); if ($image) { $this->bus->dispatch(new DeleteImageMessage($image)); } } private function isTrashed(User $user, Post $post): bool { return !$post->isAuthor($user); } public function restore(User $user, Post $post): void { if (VisibilityInterface::VISIBILITY_TRASHED !== $post->visibility) { throw new \Exception('Invalid visibility'); } $post->visibility = VisibilityInterface::VISIBILITY_VISIBLE; $this->entityManager->persist($post); $this->entityManager->flush(); $this->dispatcher->dispatch(new PostRestoredEvent($post, $user)); } /** * this toggles the pin state of the post. If it was not pinned it pins, if it was pinned it unpins it. */ public function pin(Post $post): Post { $post->sticky = !$post->sticky; $this->entityManager->flush(); if (null !== $post->magazine->apFeaturedUrl) { $this->apHttpClient->invalidateCollectionObjectCache($post->magazine->apFeaturedUrl); } return $post; } public function toggleLock(Post $post, ?User $actor): Post { $post->isLocked = !$post->isLocked; if ($post->isLocked) { $log = new MagazineLogPostLocked($post, $actor); } else { $log = new MagazineLogPostUnlocked($post, $actor); } $this->entityManager->persist($log); $this->entityManager->flush(); $this->dispatcher->dispatch(new PostLockEvent($post, $actor)); return $post; } public function createDto(Post $post): PostDto { return $this->factory->createDto($post); } public function detachImage(Post $post): void { $image = $post->image->getId(); $post->image = null; $this->entityManager->persist($post); $this->entityManager->flush(); $this->bus->dispatch(new DeleteImageMessage($image)); } public function getSortRoute(string $sortBy): string { return strtolower($this->translator->trans($sortBy)); } public function changeMagazine(Post $post, Magazine $magazine): void { $this->entityManager->beginTransaction(); try { $oldMagazine = $post->magazine; $post->magazine = $magazine; foreach ($post->comments as $comment) { $comment->magazine = $magazine; } $this->entityManager->flush(); $this->entityManager->commit(); } catch (\Exception $e) { $this->entityManager->rollback(); return; } $oldMagazine->postCommentCount = $this->postRepository->countPostCommentsByMagazine($oldMagazine); $oldMagazine->postCount = $this->postRepository->countPostsByMagazine($oldMagazine); $magazine->postCommentCount = $this->postRepository->countPostCommentsByMagazine($magazine); $magazine->postCount = $this->postRepository->countPostsByMagazine($magazine); $this->entityManager->flush(); $this->cache->invalidateTags(['post_'.$post->getId()]); } } ================================================ FILE: src/Service/ProjectInfoService.php ================================================ getCanonicalName()}/{$this->getVersion()} (+https://{$this->kbinDomain}/agent)"; } /** * Get Mbin repository URL. * * @return URL */ public function getRepositoryURL(): string { return self::REPOSITORY_URL; } } ================================================ FILE: src/Service/RemoteInstanceManager.php ================================================ getUpdatedAt() < new \DateTime('now - 1day') || $force) { $nodeInfoEndpointsRaw = $this->client->fetchInstanceNodeInfoEndpoints($instance->domain, false); $serializer = $this->getSerializer(); $linkToUse = null; if (null !== $nodeInfoEndpointsRaw) { /** @var WellKnownNodeInfo $nodeInfoEndpoints */ $nodeInfoEndpoints = $serializer->deserialize($nodeInfoEndpointsRaw, WellKnownNodeInfo::class, 'json'); foreach ($nodeInfoEndpoints->links as $link) { if (NodeInfoController::NODE_REL_v21 === $link->rel) { $linkToUse = $link; break; } elseif (null === $linkToUse && NodeInfoController::NODE_REL_v20 === $link->rel) { $linkToUse = $link; } } } if (null === $linkToUse) { $this->logger->info('Instance {i} does not supply a valid nodeinfo endpoint.', ['i' => $instance->domain]); $instance->setUpdatedAt(); return true; } $nodeInfoRaw = $this->client->fetchInstanceNodeInfo($linkToUse->href, false); $this->logger->debug('got raw nodeinfo for url {url}: {raw}', ['raw' => $nodeInfoRaw, 'url' => $linkToUse]); try { /** @var NodeInfo $nodeInfo */ $nodeInfo = $serializer->deserialize($nodeInfoRaw, NodeInfo::class, 'json'); $instance->software = $nodeInfo?->software?->name; $instance->version = $nodeInfo?->software?->version; } catch (\Error|\Exception $e) { $this->logger->warning('There as an exception decoding the nodeinfo from {url}: {e} - {m}', [ 'url' => $instance->domain, 'e' => \get_class($e), 'm' => $e->getMessage(), ]); } $instance->setUpdatedAt(); $this->entityManager->persist($instance); return true; } return false; } public function getSerializer(): Serializer { $phpDocExtractor = new PhpDocExtractor(); $typeExtractor = new PropertyInfoExtractor( typeExtractors: [ new ConstructorExtractor([$phpDocExtractor]), $phpDocExtractor, new ReflectionExtractor(), ] ); return new Serializer( normalizers: [ new ObjectNormalizer(propertyTypeExtractor: $typeExtractor), new ArrayDenormalizer(), ], encoders: ['json' => new JsonEncoder()] ); } } ================================================ FILE: src/Service/ReportManager.php ================================================ repository->findBySubject($dto->getSubject()); if ($report) { $report->increaseWeight(); $this->entityManager->flush(); throw new SubjectHasBeenReportedException(); } $report = $this->factory->createFromDto($dto); $report->reporting = $reporting; $this->entityManager->persist($report); $this->entityManager->flush(); $this->dispatcher->dispatch(new SubjectReportedEvent($report)); return $report; } public function reject(Report $report, User $moderator): void { $manager = $this->managerFactory->createManager($report->getSubject()); $report->status = Report::STATUS_REJECTED; $report->consideredBy = $moderator; $report->consideredAt = new \DateTimeImmutable(); if ($report->getSubject()->isTrashed()) { $manager->restore($moderator, $report->getSubject()); } $this->entityManager->flush(); $this->dispatcher->dispatch(new ReportRejectedEvent($report)); } public function accept(Report $report, User $moderator): void { $manager = $this->managerFactory->createManager($report->getSubject()); $report->status = Report::STATUS_APPROVED; $report->consideredBy = $moderator; $report->consideredAt = new \DateTimeImmutable(); $manager->delete($moderator, $report->getSubject()); $this->entityManager->flush(); $this->dispatcher->dispatch(new ReportApprovedEvent($report)); } } ================================================ FILE: src/Service/ReputationManager.php ================================================ ReputationRepository::TYPE_ENTRY, 'comments' => ReputationRepository::TYPE_ENTRY_COMMENT, 'posts' => ReputationRepository::TYPE_POST, 'replies' => ReputationRepository::TYPE_POST_COMMENT, 'treści' => ReputationRepository::TYPE_ENTRY, 'komentarze' => ReputationRepository::TYPE_ENTRY_COMMENT, 'wpisy' => ReputationRepository::TYPE_POST, 'odpowiedzi' => ReputationRepository::TYPE_POST_COMMENT, ]; return $routes[$value] ?? $routes[$default ?? ReputationRepository::TYPE_ENTRY]; } } ================================================ FILE: src/Service/SearchManager.php ================================================ magazineRepository->search($magazine, $page, $perPage); } public function findDomainsPaginated(string $domain, int $page = 1, int $perPage = DomainRepository::PER_PAGE): Pagerfanta { return $this->domainRepository->search($domain, $page, $perPage); } public function findPaginated( ?User $queryingUser, string $val, int $page = 1, int $perPage = SearchRepository::PER_PAGE, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null, ?\DateTimeImmutable $sinceDate = null, ): PagerfantaInterface { return $this->repository->search($queryingUser, $val, $page, authorId: $authorId, magazineId: $magazineId, specificType: $specificType, sinceDate: $sinceDate, perPage: $perPage); } public function findByApId(string $url): array { return $this->repository->findByApId($url); } public function findRelated(string $query): array { return []; } /** * Tries to find the actor or object in the DB, else will dispatch a getActorObject or getActivityObject request. * * @param string $handleOrUrl a string that may be a handle or AP URL * * @return array{'results': array{'type': 'magazine'|'user'|'subject', 'object': Magazine|User|ContentInterface}, 'errors': \Throwable[]} */ public function findActivityPubActorsOrObjects(string $handleOrUrl): array { $handle = ActorHandle::parse($handleOrUrl); if (null !== $handle) { $handleOrUrl = $handle->plainHandle(); $isUrl = false; } elseif (filter_var($handleOrUrl, FILTER_VALIDATE_URL)) { $isUrl = true; } else { return [ 'results' => [], 'errors' => [], ]; } // try resolving it as an actor try { $actor = $this->activityPubManager->findActorOrCreate($handleOrUrl); if (null !== $actor) { $objects = $this->mapApResultsToSearchModel([$actor]); return [ 'results' => $objects, 'errors' => [], ]; } elseif (!$isUrl) { // lookup of handle failed -> give up return [ 'results' => [], 'errors' => [], ]; } } catch (\Throwable $e) { if (!$isUrl) { // lookup of handle failed -> give up return [ 'results' => [], 'errors' => [$e], ]; } } $url = $handleOrUrl; $exceptions = []; $objects = $this->findByApId($url); if (0 === \sizeof($objects)) { // the url could resolve to a different id. try { $body = $this->apHttpClient->getActivityObject($url); if (null !== $body && isset($body['id'])) { $apId = $body['id']; $objects = $this->findByApId($apId); } else { $apId = $url; } } catch (\Throwable $e) { $body = null; $apId = $url; $exceptions[] = $e; } if (0 === \sizeof($objects) && null !== $body) { // maybe it is an entry, post, etc. try { // process the message in the sync transport, so that the created content is directly visible $this->bus->dispatch(new CreateMessage($body), [new TransportNamesStamp('sync')]); $objects = $this->findByApId($apId); } catch (\Throwable $e) { $exceptions[] = $e; } } if (0 === \sizeof($objects)) { // maybe it is a magazine or user try { $this->activityPubManager->findActorOrCreate($apId); $objects = $this->findByApId($apId); } catch (\Throwable $e) { $exceptions[] = $e; } } } return [ 'results' => $this->mapApResultsToSearchModel($objects), 'errors' => $exceptions, ]; } private function mapApResultsToSearchModel(array $objects): array { return array_map(function ($object) { if ($object instanceof Magazine) { $type = 'magazine'; } elseif ($object instanceof User) { $type = 'user'; } else { $type = 'subject'; } return [ 'type' => $type, 'object' => $object, ]; }, $objects); } // region deprecated functions kept for API compatibility /** * @param string $query One or more canonical ActivityPub usernames, such as kbinMeta@kbin.social or @ernest@kbin.social (anything that matches RegPatterns::AP_USER) * * @return array a list of magazines or users that were found using the given identifiers, empty if none were found or no @ is in the query */ #[\Deprecated] public function findActivityPubActorsByUsername(string $query): array { if (false === str_contains($query, '@')) { return []; } $objects = []; $name = str_starts_with($query, '!') ? '@'.substr($query, 1) : $query; $name = str_starts_with($name, '@') ? $name : '@'.$name; preg_match(RegPatterns::AP_USER, $name, $matches); if (\count(array_filter($matches)) >= 4) { try { $webfinger = $this->activityPubManager->webfinger($name); foreach ($webfinger->getProfileIds() as $profileId) { $object = $this->activityPubManager->findActorOrCreate($profileId); if (!empty($object)) { if ($object instanceof Magazine) { $type = 'magazine'; } elseif ($object instanceof User) { $type = 'user'; } $objects[] = [ 'type' => $type, 'object' => $object, ]; } } } catch (\Exception $e) { } } return $objects ?? []; } /** * @param string $query a string that may or may not be a URL * * @return array A list of objects found by the given query, or an empty array if none were found. * Will dispatch a getActivityObject request if a valid URL was provided but no item was found * locally. */ #[\Deprecated] public function findActivityPubObjectsByURL(string $query): array { if (false === filter_var($query, FILTER_VALIDATE_URL)) { return []; } $objects = $this->findByApId($query); if (!$objects) { $body = $this->apHttpClient->getActivityObject($query, false); $this->bus->dispatch(new ActivityMessage($body)); } return $objects ?? []; } // endregion } ================================================ FILE: src/Service/SettingsManager.php ================================================ kernel->getEnvironment()) { $results = $this->repository->findAll(); $newUsersNeedApprovalDb = $this->find($results, 'MBIN_NEW_USERS_NEED_APPROVAL'); if ('true' === $newUsersNeedApprovalDb) { $newUsersNeedApprovalEdited = true; } elseif ('false' === $newUsersNeedApprovalDb) { $newUsersNeedApprovalEdited = false; } else { $newUsersNeedApprovalEdited = $this->mbinNewUsersNeedApproval; } $dto = new SettingsDto( $this->kbinDomain, $this->find($results, 'KBIN_TITLE') ?? $this->kbinTitle, $this->find($results, 'KBIN_META_TITLE') ?? $this->kbinMetaTitle, $this->find($results, 'KBIN_META_KEYWORDS') ?? $this->kbinMetaKeywords, $this->find($results, 'KBIN_META_DESCRIPTION') ?? $this->kbinMetaDescription, $this->find($results, 'KBIN_DEFAULT_LANG') ?? $this->kbinDefaultLang, $this->find($results, 'KBIN_CONTACT_EMAIL') ?? $this->kbinContactEmail, $this->find($results, 'KBIN_SENDER_EMAIL') ?? $this->kbinSenderEmail, $this->find($results, 'MBIN_DEFAULT_THEME') ?? $this->mbinDefaultTheme, $this->find($results, 'KBIN_JS_ENABLED', FILTER_VALIDATE_BOOLEAN) ?? $this->kbinJsEnabled, $this->find( $results, 'KBIN_FEDERATION_ENABLED', FILTER_VALIDATE_BOOLEAN ) ?? $this->kbinFederationEnabled, $this->find( $results, 'KBIN_REGISTRATIONS_ENABLED', FILTER_VALIDATE_BOOLEAN ) ?? $this->kbinRegistrationsEnabled, $this->find($results, 'KBIN_HEADER_LOGO', FILTER_VALIDATE_BOOLEAN) ?? $this->kbinHeaderLogo, $this->find($results, 'KBIN_CAPTCHA_ENABLED', FILTER_VALIDATE_BOOLEAN) ?? $this->kbinCaptchaEnabled, $this->find($results, 'KBIN_MERCURE_ENABLED', FILTER_VALIDATE_BOOLEAN) ?? false, $this->find($results, 'KBIN_FEDERATION_PAGE_ENABLED', FILTER_VALIDATE_BOOLEAN) ?? $this->kbinFederationPageEnabled, $this->find($results, 'KBIN_ADMIN_ONLY_OAUTH_CLIENTS', FILTER_VALIDATE_BOOLEAN) ?? $this->kbinAdminOnlyOauthClients, $this->find($results, 'MBIN_SSO_ONLY_MODE', FILTER_VALIDATE_BOOLEAN) ?? $this->mbinSsoOnlyMode, $this->find($results, 'MBIN_PRIVATE_INSTANCE', FILTER_VALIDATE_BOOLEAN) ?? false, $this->find($results, 'KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', FILTER_VALIDATE_BOOLEAN) ?? true, $this->find($results, 'MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY', FILTER_VALIDATE_BOOLEAN) ?? false, $this->find($results, 'MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY', FILTER_VALIDATE_BOOLEAN) ?? false, $this->find($results, 'MBIN_SSO_REGISTRATIONS_ENABLED', FILTER_VALIDATE_BOOLEAN) ?? true, $this->find($results, 'MBIN_RESTRICT_MAGAZINE_CREATION', FILTER_VALIDATE_BOOLEAN) ?? false, $this->find($results, 'MBIN_SSO_SHOW_FIRST', FILTER_VALIDATE_BOOLEAN) ?? false, $this->find($results, 'MBIN_DOWNVOTES_MODE') ?? $this->mbinDownvotesMode->value, $newUsersNeedApprovalEdited, $this->find($results, 'MBIN_USE_FEDERATION_ALLOW_LIST', FILTER_VALIDATE_BOOLEAN) ?? $this->mbinUseFederationAllowList, ); $this->instanceDto = $dto; } else { $this->instanceDto = self::$dto; } } private function find(array $results, string $name, ?int $filter = null) { $res = array_values(array_filter($results, fn ($s) => $s->name === $name)); if (\count($res)) { $res = $res[0]->value ?? $res[0]->json; if ($filter) { $res = filter_var($res, $filter); } return $res; } return null; } public function getDto(): SettingsDto { return $this->instanceDto; } public function save(SettingsDto $dto): void { foreach ($dto as $name => $value) { $s = $this->repository->findOneByName($name); if (\is_bool($value)) { $value = $value ? 'true' : 'false'; } if (!\is_string($value) && !\is_array($value)) { $value = \strval($value); } if (!$s) { $s = new Settings($name, $value); } if (\is_array($value)) { $s->json = $value; } else { $s->value = $value; } $this->entityManager->persist($s); } $this->entityManager->flush(); } #[Pure] public function isLocalUrl(string $url): bool { return parse_url($url, PHP_URL_HOST) === $this->get('KBIN_DOMAIN'); } /** * Check if an instance is banned by * checking if the instance URL has a match with the banned instances list. * * @param string $inboxUrl the inbox URL to check */ public function isBannedInstance(string $inboxUrl): bool { $host = parse_url($inboxUrl, PHP_URL_HOST); if (null === $host) { // Try to retrieve the caller function (commented-out for performance reasons) // $bt = debug_backtrace(); // $caller_function = ($bt[1]) ? $bt[1]['function'] : 'Unknown function caller'; $this->logger->error('SettingsManager::isBannedInstance: unable to parse host from URL: {url}', ['url' => $inboxUrl]); // Do not retry, retrying will always cause a failure throw new UnrecoverableMessageHandlingException(\sprintf('Invalid URL provided: %s', $inboxUrl)); } $finalUrl = str_replace('www.', '', $host); if (!$this->getUseAllowList()) { return \in_array($finalUrl, $this->instanceRepository->getBannedInstanceUrls()); } else { // when using an allow list the instance is considered banned if it does not exist or if it is not explicitly allowed $instance = $this->instanceRepository->findOneBy(['domain' => $finalUrl]); return null === $instance || !$instance->isExplicitlyAllowed; } } /** @return Instance[] */ public function getBannedInstances(): array { return $this->instanceRepository->getBannedInstances(); } public function getUseAllowList(): bool { return $this->getDto()->MBIN_USE_FEDERATION_ALLOW_LIST; } public function getAllowedInstances(): array { return $this->instanceRepository->getAllowedInstances($this->getUseAllowList()); } public function get(string $name) { return $this->instanceDto->{$name}; } public function getDownvotesMode(): DownvotesMode { return DownvotesMode::from($this->getDto()->MBIN_DOWNVOTES_MODE); } public function getNewUsersNeedApproval(): bool { return $this->getDto()->MBIN_NEW_USERS_NEED_APPROVAL; } public function set(string $name, $value): void { $this->instanceDto->{$name} = $value; $this->save($this->instanceDto); } public function getValue(string $name): string { return $this->instanceDto->{$name}; } public function getLocale(): string { $request = $this->requestStack->getCurrentRequest(); return $request->cookies->get('mbin_lang') ?? $request->getLocale() ?? $this->get('KBIN_DEFAULT_LANG'); } public function getMaxImageBytes(): int { return $this->mbinMaxImageBytes; } public function getMaxImageByteString(): string { $bytes = $this->mbinMaxImageBytes; // We use 1000 for MB (instead of 1024, which would be MiB) // Linux is using SI standard, see also: https://wiki.ubuntu.com/UnitsPolicy $megaBytes = round($bytes / pow(1000, 2), 2); return $megaBytes.' MB'; } /** * this should only be called in the test environment. */ public static function resetDto(): void { self::$dto = null; } } ================================================ FILE: src/Service/StatsManager.php ================================================ contentRepository->getOverallStats($user, $magazine, $onlyLocal); $labels = array_map(fn ($val) => ($val['month'] < 10 ? '0' : '').$val['month'].'/'.$val['year'], $stats['entries']); return $this->createGeneralDataset($stats, $labels); } private function createGeneralDataset(array $stats, array $labels): Chart { $dataset = [ [ 'label' => $this->translator->trans('threads'), 'borderColor' => '#4382AD', 'data' => array_map(fn ($val) => $val['count'], $stats['entries']), ], [ 'label' => $this->translator->trans('comments'), 'borderColor' => '#6253ac', 'data' => array_map(fn ($val) => $val['count'], $stats['comments']), ], [ 'label' => $this->translator->trans('posts'), 'borderColor' => '#ac5353', 'data' => array_map(fn ($val) => $val['count'], $stats['posts']), ], [ 'label' => $this->translator->trans('replies'), 'borderColor' => '#09a084', 'data' => array_map(fn ($val) => $val['count'], $stats['replies']), ], ]; $chart = $this->chartBuilder->createChart(Chart::TYPE_LINE); return $chart->setData([ 'labels' => $labels, 'datasets' => $dataset, ]); } public function drawDailyContentStatsByTime(\DateTime $start, ?User $user = null, ?Magazine $magazine = null, ?bool $onlyLocal = null): Chart { $stats = $this->contentRepository->getStatsByTime($start, $user, $magazine, $onlyLocal); $labels = array_map(fn ($val) => $val['day']->format('Y-m-d'), $stats['entries']); return $this->createGeneralDataset($stats, $labels); } public function drawMonthlyVotesChart(?User $user = null, ?Magazine $magazine = null, ?bool $onlyLocal = null): Chart { $stats = $this->votesRepository->getOverallStats($user, $magazine, $onlyLocal); $labels = array_map(fn ($val) => ($val['month'] < 10 ? '0' : '').$val['month'].'/'.$val['year'], $stats['entries']); return $this->createVotesDataset($stats, $labels); } private function createVotesDataset(array $stats, array $labels): Chart { $results = []; foreach ($stats['entries'] as $index => $entry) { $entry['up'] = array_sum(array_map(fn ($type) => $type[$index]['up'], $stats)); $entry['down'] = DownvotesMode::Disabled !== $this->settingsManager->getDownvotesMode() ? 0 : array_sum(array_map(fn ($type) => $type[$index]['down'], $stats)); $entry['boost'] = array_sum(array_map(fn ($type) => $type[$index]['boost'], $stats)); $results[] = $entry; } $dataset = [ [ 'label' => $this->translator->trans('up_votes'), 'borderColor' => '#92924c', 'data' => array_map(fn ($val) => $val['boost'], $results), ], [ 'label' => $this->translator->trans('favourites'), 'borderColor' => '#3c5211', 'data' => array_map(fn ($val) => $val['up'], $results), ], [ 'label' => $this->translator->trans('down_votes'), 'borderColor' => '#8f0b00', 'data' => DownvotesMode::Disabled !== $this->settingsManager->getDownvotesMode() ? [] : array_map(fn ($val) => $val['down'], $results), ], ]; $chart = $this->chartBuilder->createChart(Chart::TYPE_LINE); return $chart->setData([ 'labels' => $labels, 'datasets' => $dataset, ]); } public function drawDailyVotesStatsByTime(\DateTime $start, ?User $user = null, ?Magazine $magazine = null, ?bool $onlyLocal = null): Chart { $stats = $this->votesRepository->getStatsByTime($start, $user, $magazine, $onlyLocal); $labels = array_map(fn ($val) => $val['day']->format('Y-m-d'), $stats['entries']); return $this->createVotesDataset($stats, $labels); } public function resolveType(?string $value, ?string $default = null): string { $routes = [ 'general' => StatsRepository::TYPE_GENERAL, 'content' => StatsRepository::TYPE_CONTENT, 'votes' => StatsRepository::TYPE_VOTES, ]; return $routes[$value] ?? $routes[$default ?? StatsRepository::TYPE_GENERAL]; } } ================================================ FILE: src/Service/SubjectOverviewManager.php ================================================ getCurrentPageResults(), fn ($val) => $val instanceof Entry || $val instanceof Post ); $comments = array_filter( $activity->getCurrentPageResults(), fn ($val) => $val instanceof EntryComment || $val instanceof PostComment ); $results = []; foreach ($postsAndEntries as $parent) { if ($parent instanceof Entry) { $children = array_filter( $comments, fn ($val) => $val instanceof EntryComment && $val->entry === $parent ); $comments = array_filter( $comments, fn ($val) => $val instanceof PostComment || $val instanceof EntryComment && $val->entry !== $parent ); } else { $children = array_filter( $comments, fn ($val) => $val instanceof PostComment && $val->post === $parent ); $comments = array_filter( $comments, fn ($val) => $val instanceof EntryComment || $val instanceof PostComment && $val->post !== $parent ); } $results[] = $parent; foreach ($children as $child) { $parent->children[] = $child; } } $parents = []; foreach ($comments as $comment) { $inParents = false; $parent = $comment->entry ?? $comment->post; foreach ($parents as $val) { if ($val instanceof $parent && $parent === $val) { $val->children[] = $comment; $inParents = true; } } if (!$inParents) { $parent->children[] = $comment; $parents[] = $parent; } } $merged = array_merge($results, $parents); uasort($merged, fn ($a, $b) => $a->getCreatedAt() > $b->getCreatedAt() ? -1 : 1); $results = []; foreach ($merged as $entry) { $results[] = $entry; uasort($entry->children, fn ($a, $b) => $a->getCreatedAt() < $b->getCreatedAt() ? -1 : 1); foreach ($entry->children as $child) { $results[] = $child; } } return $results; } } ================================================ FILE: src/Service/TagExtractor.php ================================================ extract($body) ?? []; $join = array_unique(array_merge(array_diff($tags, $current))); if (!empty($join)) { if (!empty($body)) { $lastTag = end($current); if (($lastTag && !str_ends_with($body, $lastTag)) || !$lastTag) { $body = $body.PHP_EOL.PHP_EOL; } } $body = $body.' #'.implode(' #', $join); } return $body; } public function extract(?string $val, ?string $magazineName = null): ?array { if (null === $val) { return null; } preg_match_all(RegPatterns::LOCAL_TAG, $val, $matches); $result = $matches[1]; $result = array_map(fn ($tag) => mb_strtolower(trim($tag)), $result); $result = array_values($result); $result = array_map(fn ($tag) => $this->transliterate($tag), $result); if ($magazineName) { $result = array_diff($result, [$magazineName]); } if ($urls = UrlUtils::extractUrlsFromString($val)) { $htmlIds = array_map(fn ($url) => parse_url($url, PHP_URL_FRAGMENT), $urls); $result = array_diff($result, $htmlIds); } return \count($result) ? array_unique(array_values($result)) : null; } /** * transliterate and normalize a hashtag identifier. * * mostly recreates Mastodon's hashtag normalization rules, using ICU rules * - try to transliterate modified latin characters to ASCII regions * - normalize widths for fullwidth/halfwidth letters * - strip characters that shouldn't be part of a hashtag * (borrowed the character set from Mastodon) * * @param string $tag input hashtag identifier to normalize * * @return string normalized hashtag identifier * * @see https://github.com/mastodon/mastodon/blob/main/app/lib/hashtag_normalizer.rb * @see https://github.com/mastodon/mastodon/blob/main/app/models/tag.rb */ public function transliterate(string $tag): string { $rules = <<<'ENDRULE' :: Latin-ASCII; :: [\uFF00-\uFFEF] NFKC; :: [^[:alnum:][\u0E47-\u0E4E][_\u00B7\u30FB\u200c]] Remove; ENDRULE; $normalizer = \Transliterator::createFromRules($rules); return iconv('UTF-8', 'UTF-8//TRANSLIT', $normalizer->transliterate($tag)); } } ================================================ FILE: src/Service/TagManager.php ================================================ tagExtractor->extract($val, $magazineName); } /** * @param string[] $newTags */ public function updateEntryTags(Entry $entry, array $newTags): void { $this->updateTags($newTags, fn () => $this->tagLinkRepository->getTagsOfContent($entry), fn (Hashtag $hashtag) => $this->tagLinkRepository->removeTagOfEntry($entry, $hashtag), fn (Hashtag $hashtag) => $this->tagLinkRepository->addTagToEntry($entry, $hashtag) ); } public function getTagsFromEntryDto(EntryDto $dto): array { return array_unique( array_filter( array_merge( $dto->tags ?? [], $this->tagExtractor->extract($dto->body ?? '') ?? [] ) ) ); } /** * @param string[] $newTags */ public function updateEntryCommentTags(EntryComment $entryComment, array $newTags): void { $this->updateTags($newTags, fn () => $this->tagLinkRepository->getTagsOfContent($entryComment), fn (Hashtag $hashtag) => $this->tagLinkRepository->removeTagOfEntryComment($entryComment, $hashtag), fn (Hashtag $hashtag) => $this->tagLinkRepository->addTagToEntryComment($entryComment, $hashtag) ); } public function getTagsFromEntryCommentDto(EntryCommentDto $dto): array { return array_unique( array_filter( array_merge( $dto->tags ?? [], $this->tagExtractor->extract($dto->body ?? '') ?? [] ) ) ); } /** * @param string[] $newTags */ public function updatePostTags(Post $post, array $newTags): void { $this->updateTags($newTags, fn () => $this->tagLinkRepository->getTagsOfContent($post), fn (Hashtag $hashtag) => $this->tagLinkRepository->removeTagOfPost($post, $hashtag), fn (Hashtag $hashtag) => $this->tagLinkRepository->addTagToPost($post, $hashtag) ); } /** * @param string[] $newTags */ public function updatePostCommentTags(PostComment $postComment, array $newTags): void { $this->updateTags($newTags, fn () => $this->tagLinkRepository->getTagsOfContent($postComment), fn (Hashtag $hashtag) => $this->tagLinkRepository->removeTagOfPostComment($postComment, $hashtag), fn (Hashtag $hashtag) => $this->tagLinkRepository->addTagToPostComment($postComment, $hashtag) ); } /** * @param string[] $newTags * @param callable(): string[] $getTags a callable that should return all the tags of the entity as a string array * @param callable(Hashtag): void $removeTag a callable that gets a string as parameter and should remove the tag * @param callable(Hashtag): void $addTag */ private function updateTags(array $newTags, callable $getTags, callable $removeTag, callable $addTag): void { $oldTags = $getTags(); $actions = $this->intersectOldAndNewTags($oldTags, $newTags); foreach ($actions['tagsToRemove'] as $tag) { $removeTag($this->tagRepository->findOneBy(['tag' => $tag])); } foreach ($actions['tagsToCreate'] as $tag) { $tagEntity = $this->tagRepository->findOneBy(['tag' => $tag]); if (null === $tagEntity) { $tagEntity = $this->tagRepository->create($tag); } $addTag($tagEntity); } } #[ArrayShape([ 'tagsToRemove' => 'string[]', 'tagsToCreate' => 'string[]', ])] private function intersectOldAndNewTags(array $oldTags, array $newTags): array { /** @var string[] $tagsToRemove */ $tagsToRemove = []; /** @var string[] $tagsToCreate */ $tagsToCreate = []; foreach ($oldTags as $tag) { if (!\in_array($tag, $newTags)) { $tagsToRemove[] = $tag; } } foreach ($newTags as $tag) { if (!\in_array($tag, $oldTags)) { $tagsToCreate[] = $tag; } } return [ 'tagsToCreate' => $tagsToCreate, 'tagsToRemove' => $tagsToRemove, ]; } public function ban(Hashtag $hashtag): void { $hashtag->banned = true; $this->entityManager->persist($hashtag); $this->entityManager->flush(); } public function unban(Hashtag $hashtag): void { $hashtag->banned = false; $this->entityManager->persist($hashtag); $this->entityManager->flush(); } public function isAnyTagBanned(?array $tags): bool { if ($tags) { $result = $this->tagRepository->findBy(['tag' => $tags, 'banned' => true]); if ($result && 0 !== \sizeof($result)) { return true; } } return false; } } ================================================ FILE: src/Service/TwoFactorManager.php ================================================ generateCodes(); $user->setBackupCodes($codes); $this->entityManager->persist($user); $this->entityManager->flush(); return $codes; } public function remove2FA(User $user): void { $user->setTotpSecret(null); $this->entityManager->persist($user); $this->entityManager->flush(); } private function generateCodes(): array { return array_map( fn () => substr(str_shuffle((string) hexdec(bin2hex(random_bytes(6)))), 0, 8), range(0, 9), ); } } ================================================ FILE: src/Service/UserManager.php ================================================ requestRepository->findOneby(['follower' => $follower, 'following' => $following])) { $this->entityManager->remove($request); } if ($this->userFollowRepository->findOneBy(['follower' => $follower, 'following' => $following])) { return; } $this->follow($follower, $following, false); } public function follow(User $follower, User $following, $createRequest = true): void { if ($following->apManuallyApprovesFollowers && $createRequest) { if ($this->requestRepository->findOneby(['follower' => $follower, 'following' => $following])) { return; } $request = new UserFollowRequest($follower, $following); $this->entityManager->persist($request); $this->entityManager->flush(); $this->dispatcher->dispatch(new UserFollowEvent($follower, $following)); return; } $follower->unblock($following); $follower->follow($following); $this->entityManager->flush(); $this->dispatcher->dispatch(new UserFollowEvent($follower, $following)); } public function unblock(User $blocker, User $blocked): void { $blocker->unblock($blocked); $this->entityManager->flush(); $this->dispatcher->dispatch(new UserBlockEvent($blocker, $blocked)); } public function rejectFollow(User $follower, User $following): void { if ($request = $this->requestRepository->findOneby(['follower' => $follower, 'following' => $following])) { $this->entityManager->remove($request); $this->entityManager->flush(); } } public function block(User $blocker, User $blocked): void { if ($blocker->isFollowing($blocked)) { $this->unfollow($blocker, $blocked); } $blocker->block($blocked); $this->entityManager->flush(); $this->dispatcher->dispatch(new UserBlockEvent($blocker, $blocked)); } public function unfollow(User $follower, User $following): void { if ($request = $this->requestRepository->findOneby(['follower' => $follower, 'following' => $following])) { $this->entityManager->remove($request); } $follower->unfollow($following); $this->entityManager->flush(); $this->dispatcher->dispatch(new UserFollowEvent($follower, $following, true)); } public function create(UserDto $dto, bool $verifyUserEmail = true, $rateLimit = true, ?bool $preApprove = null): User { if ($rateLimit) { $limiter = $this->userRegisterLimiter->create($dto->ip); if (false === $limiter->consume()->isAccepted()) { throw new TooManyRequestsHttpException(); } } $status = EApplicationStatus::Approved; if (true !== $preApprove && $this->settingsManager->getNewUsersNeedApproval()) { $status = EApplicationStatus::Pending; } $user = new User($dto->email, $dto->username, '', ($dto->isBot) ? 'Service' : 'Person', $dto->apProfileId, $dto->apId, applicationStatus: $status, applicationText: $dto->applicationText); $user->setPassword($this->passwordHasher->hashPassword($user, $dto->plainPassword)); if (!$dto->apId) { $user = KeysGenerator::generate($user); // default new local users to be discoverable $user->apDiscoverable = $dto->discoverable ?? true; // default new local users to be indexable $user->apIndexable = true; } $this->entityManager->persist($user); $this->entityManager->flush(); if (!$dto->apId) { try { $this->bus->dispatch(new SentNewSignupNotificationMessage($user->getId())); } catch (\Throwable $e) { } } if ($verifyUserEmail) { try { $this->bus->dispatch(new UserCreatedMessage($user->getId())); } catch (\Throwable $e) { } } return $user; } public function edit(User $user, UserDto $dto): User { $this->entityManager->beginTransaction(); $mailUpdated = false; try { $user->about = $dto->about; $user->title = $dto->title; $oldAvatar = $user->avatar; if ($dto->avatar) { $image = $this->imageRepository->find($dto->avatar->id); $user->avatar = $image; } $oldCover = $user->cover; if ($dto->cover) { $image = $this->imageRepository->find($dto->cover->id); $user->cover = $image; } if ($dto->plainPassword) { $user->setPassword($this->passwordHasher->hashPassword($user, $dto->plainPassword)); } if ($dto->email !== $user->email) { $mailUpdated = true; $user->isVerified = false; $user->email = $dto->email; } if ($this->security->isGranted('edit_profile', $user)) { $user->username = $dto->username; } if ($this->security->isGranted('edit_profile', $user) && !$user->isTotpAuthenticationEnabled() && $dto->totpSecret) { $user->setTotpSecret($dto->totpSecret); } $user->lastActive = new \DateTime(); $this->entityManager->flush(); $this->entityManager->commit(); } catch (\Exception $e) { $this->entityManager->rollback(); throw $e; } if ($oldAvatar && $user->avatar !== $oldAvatar) { $this->bus->dispatch(new DeleteImageMessage($oldAvatar->getId())); } if ($oldCover && $user->cover !== $oldCover) { $this->bus->dispatch(new DeleteImageMessage($oldCover->getId())); } if ($mailUpdated) { $this->bus->dispatch(new UserUpdatedMessage($user->getId())); } $this->dispatcher->dispatch(new UserEditedEvent($user->getId())); return $user; } public function delete(User $user): void { $this->bus->dispatch(new DeleteUserMessage($user->getId())); } public function createDto(User $user, ?int $reputationPoints = null): UserDto { return $this->factory->createDto($user, $reputationPoints); } public function verify(Request $request, User $user): void { $this->verifier->handleEmailConfirmation($request, $user); } public function adminUserVerify(User $user): void { $user->isVerified = true; $this->entityManager->persist($user); $this->entityManager->flush(); } public function toggleTheme(User $user): void { $user->toggleTheme(); $this->entityManager->flush(); } public function logout(): void { $this->tokenStorage->setToken(null); $this->requestStack->getSession()->invalidate(); } public function ban(User $user, ?User $bannedBy, ?string $reason): void { if ($user->isAdmin() || $user->isModerator()) { throw new UserCannotBeBanned(); } $user->isBanned = true; $user->banReason = $reason; $this->entityManager->persist($user); $this->entityManager->flush(); $this->eventDispatcher->dispatch(new InstanceBanEvent($user, $bannedBy, $reason)); } public function unban(User $user, ?User $bannedBy, ?string $reason): void { $user->isBanned = false; $user->banReason = $reason; $this->entityManager->persist($user); $this->entityManager->flush(); $this->eventDispatcher->dispatch(new InstanceBanEvent($user, $bannedBy, $reason)); } public function detachAvatar(User $user): void { if (!$user->avatar) { return; } $image = $user->avatar->getId(); $user->avatar = null; $this->entityManager->persist($user); $this->entityManager->flush(); $this->bus->dispatch(new DeleteImageMessage($image)); } public function detachCover(User $user): void { if (!$user->cover) { return; } $image = $user->cover->getId(); $user->cover = null; $this->entityManager->persist($user); $this->entityManager->flush(); $this->bus->dispatch(new DeleteImageMessage($image)); } /** * @param User $user the user that should be deleted * @param bool $immediately if true we will immediately dispatch a DeleteMessage, * if false the account will be marked for deletion in 30 days. * Our scheduler will then take care of deleting the account via ClearDeletedUserMessage and ClearDeletedUserHandler * * @see ClearDeletedUserMessage * @see ClearDeletedUserHandler */ public function deleteRequest(User $user, bool $immediately): void { if (!$immediately) { $user->softDelete(); $this->entityManager->persist($user); $this->entityManager->flush(); } else { $this->delete($user); } } /** * If the user is marked for deletion this will remove that mark and restore the user. * * @param User $user the user to be checked */ public function removeDeleteRequest(User $user): void { if (null !== $user->markedForDeletionAt) { $user->restore(); $this->entityManager->persist($user); $this->entityManager->flush(); } } /** * Suspend user. */ public function suspend(User $user): void { $user->visibility = VisibilityInterface::VISIBILITY_TRASHED; $this->entityManager->persist($user); $this->entityManager->flush(); } /** * Unsuspend user. */ public function unsuspend(User $user): void { $user->visibility = VisibilityInterface::VISIBILITY_VISIBLE; $this->entityManager->persist($user); $this->entityManager->flush(); } public function removeFollowing(User $user): void { foreach ($user->follows as $follow) { $this->unfollow($user, $follow->following); } } /** * Get user reputation total add it behind a cache. */ public function getReputationTotal(User $user): int { return $this->cache->get( "user_reputation_{$user->getId()}", function (ItemInterface $item) use ($user) { $item->expiresAfter(60); return $this->reputationRepository->getUserReputationTotal($user); } ); } /** * @return User[] */ public function getUsersMarkedForDeletionBefore(?\DateTime $dateTime = null): array { $dateTime ??= new \DateTime(); return $this->userRepository->createQueryBuilder('u') ->where('u.markedForDeletionAt <= :datetime') ->setParameter(':datetime', $dateTime) ->getQuery() ->getResult(); } public function rejectUserApplication(User $user): void { if (EApplicationStatus::Rejected === $user->getApplicationStatus()) { return; } $user->setApplicationStatus(EApplicationStatus::Rejected); $this->entityManager->persist($user); $this->entityManager->flush(); $this->logger->debug('Rejecting application for {u}', ['u' => $user->username]); $this->eventDispatcher->dispatch(new UserApplicationRejectedEvent($user)); } public function approveUserApplication(User $user): void { if (EApplicationStatus::Approved === $user->getApplicationStatus()) { return; } $user->setApplicationStatus(EApplicationStatus::Approved); $this->entityManager->persist($user); $this->entityManager->flush(); $this->logger->debug('Approving application for {u}', ['u' => $user->username]); $this->eventDispatcher->dispatch(new UserApplicationApprovedEvent($user)); } public function getAllInboxesOfInteractions(User $user): array { $sql = ' -- remote magazines the user is subscribed to SELECT res.ap_inbox_url FROM magazine_subscription INNER JOIN public.magazine res ON magazine_subscription.magazine_id = res.id AND res.ap_id IS NOT NULL INNER JOIN public.user u on magazine_subscription.user_id = u.id WHERE u.id = :id UNION DISTINCT -- local magazines the user is subscribed to -> their remote subscribers SELECT res.ap_inbox_url FROM magazine_subscription ms INNER JOIN public.magazine m ON ms.magazine_id = m.id AND m.ap_id IS NULL INNER JOIN public.user res ON ms.user_id = res.id AND res.ap_id IS NOT NULL WHERE EXISTS (SELECT id FROM magazine_subscription ms2 WHERE ms2.magazine_id=m.id AND user_id = :id) UNION DISTINCT -- users followed by the user SELECT res.ap_inbox_url FROM user_follow INNER JOIN public.user res on user_follow.follower_id = res.id AND res.ap_id IS NOT NULL WHERE user_follow.following_id = :id UNION DISTINCT -- users following the user SELECT res.ap_inbox_url FROM user_follow INNER JOIN public.user res on user_follow.following_id = res.id AND res.ap_id IS NOT NULL WHERE user_follow.follower_id = :id UNION DISTINCT -- remote magazines the user posted threads to SELECT res.ap_inbox_url FROM entry INNER JOIN public.magazine res on entry.magazine_id = res.id AND res.ap_id IS NOT NULL WHERE entry.user_id = :id UNION DISTINCT -- remote magazines the user posted thread comments to SELECT res.ap_inbox_url FROM entry_comment INNER JOIN public.magazine res on entry_comment.magazine_id = res.id AND res.ap_id IS NOT NULL WHERE entry_comment.user_id = :id UNION DISTINCT -- remote magazines the user posted microblogs to SELECT res.ap_inbox_url FROM post INNER JOIN public.magazine res on post.magazine_id = res.id AND res.ap_id IS NOT NULL WHERE post.user_id = :id UNION DISTINCT -- remote magazine the user posted microblog comments to SELECT res.ap_inbox_url FROM post_comment INNER JOIN public.magazine res on post_comment.magazine_id = res.id AND res.ap_id IS NOT NULL WHERE post_comment.user_id = :id UNION DISTINCT -- local magazines the user posted threads to -> their subscribers SELECT res.ap_inbox_url FROM entry INNER JOIN magazine m on entry.magazine_id = m.id AND m.ap_id IS NULL INNER JOIN magazine_subscription ms ON m.id = ms.magazine_id INNER JOIN public.user res ON ms.user_id = res.id AND res.ap_id IS NOT NULL WHERE entry.user_id = :id UNION DISTINCT -- local magazines the user posted thread comments to -> their subscribers SELECT res.ap_inbox_url FROM entry_comment INNER JOIN magazine m on entry_comment.magazine_id = m.id AND m.ap_id IS NULL INNER JOIN magazine_subscription ms ON m.id = ms.magazine_id INNER JOIN public.user res ON ms.user_id = res.id AND res.ap_id IS NOT NULL WHERE entry_comment.user_id = :id UNION DISTINCT -- local magazines the user posted microblogs to -> their subscribers SELECT res.ap_inbox_url FROM post INNER JOIN magazine m on post.magazine_id = m.id AND m.ap_id IS NULL INNER JOIN magazine_subscription ms ON m.id = ms.magazine_id INNER JOIN public.user res ON ms.user_id = res.id AND res.ap_id IS NOT NULL WHERE post.user_id = :id UNION DISTINCT -- local magazine the user posted microblog comments to -> their subscribers SELECT res.ap_inbox_url FROM post_comment INNER JOIN magazine m on post_comment.magazine_id = m.id AND m.ap_id IS NULL INNER JOIN magazine_subscription ms ON m.id = ms.magazine_id INNER JOIN public.user res ON ms.user_id = res.id AND res.ap_id IS NOT NULL WHERE post_comment.user_id = :id UNION DISTINCT -- author of micro blogs the user commented on SELECT res.ap_inbox_url FROM post_comment INNER JOIN public.post p on post_comment.post_id = p.id INNER JOIN public.user res on p.user_id = res.id AND res.ap_id IS NOT NULL WHERE post_comment.user_id = :id UNION DISTINCT -- author of the microblog comment the user commented on SELECT res.ap_inbox_url FROM post_comment pc1 INNER JOIN post_comment pc2 ON pc1.parent_id=pc2.id INNER JOIN public.user res ON pc2.user_id=res.id AND res.ap_id IS NOT NULL WHERE pc1.user_id = :id UNION DISTINCT -- author of threads the user commented on SELECT res.ap_inbox_url FROM entry_comment INNER JOIN public.entry e on entry_comment.entry_id = e.id INNER JOIN public.user res on e.user_id = res.id AND res.ap_id IS NOT NULL WHERE entry_comment.user_id = :id UNION DISTINCT -- author of thread comments the user commented on SELECT res.ap_inbox_url FROM entry_comment ec1 INNER JOIN entry_comment ec2 ON ec1.parent_id=ec2.id INNER JOIN public.user res ON ec2.user_id=res.id AND res.ap_id IS NOT NULL WHERE ec1.user_id = :id UNION DISTINCT -- author of thread the user voted on SELECT res.ap_inbox_url FROM entry_vote INNER JOIN public.user res on entry_vote.author_id = res.id AND res.ap_id IS NOT NULL WHERE entry_vote.user_id = :id UNION DISTINCT -- magazine of thread the user voted on SELECT res.ap_inbox_url FROM entry_vote INNER JOIN entry ON entry_vote.entry_id = entry.id INNER JOIN magazine res ON entry.magazine_id=res.id AND res.ap_id IS NOT NULL WHERE entry_vote.user_id = :id UNION DISTINCT -- author of thread comment the user voted on SELECT res.ap_inbox_url FROM entry_comment_vote INNER JOIN public.user res on entry_comment_vote.author_id = res.id AND res.ap_id IS NOT NULL WHERE entry_comment_vote.user_id = :id UNION DISTINCT -- magazine of thread comment the user voted on SELECT res.ap_inbox_url FROM entry_comment_vote INNER JOIN entry_comment ON entry_comment_vote.comment_id = entry_comment.id INNER JOIN magazine res ON entry_comment.magazine_id=res.id AND res.ap_id IS NOT NULL WHERE entry_comment_vote.user_id = :id UNION DISTINCT -- author of microblog the user voted on SELECT res.ap_inbox_url FROM post_vote INNER JOIN public.user res on post_vote.author_id = res.id AND res.ap_id IS NOT NULL WHERE post_vote.user_id = :id UNION DISTINCT -- magazine of microblog the user voted on SELECT res.ap_inbox_url FROM post_vote INNER JOIN entry ON post_vote.post_id = entry.id INNER JOIN magazine res ON entry.magazine_id=res.id AND res.ap_id IS NOT NULL WHERE post_vote.user_id = :id UNION DISTINCT -- author of microblog comment the user voted on SELECT res.ap_inbox_url FROM post_comment_vote INNER JOIN public.user res on post_comment_vote.author_id = res.id AND res.ap_id IS NOT NULL WHERE post_comment_vote.user_id = :id UNION DISTINCT -- magazine of microblog comment the user voted on SELECT res.ap_inbox_url FROM post_comment_vote INNER JOIN post_comment ON post_comment_vote.comment_id = post_comment.id INNER JOIN magazine res ON post_comment.magazine_id=res.id AND res.ap_id IS NOT NULL WHERE post_comment_vote.user_id = :id UNION DISTINCT -- voters of threads of the user SELECT res.ap_inbox_url FROM entry_vote INNER JOIN public.user res on entry_vote.user_id = res.id AND res.ap_id IS NOT NULL WHERE entry_vote.author_id = :id UNION DISTINCT -- voters of thread comments of the user SELECT res.ap_inbox_url FROM entry_comment_vote INNER JOIN public.user res on entry_comment_vote.user_id = res.id AND res.ap_id IS NOT NULL WHERE entry_comment_vote.author_id = :id UNION DISTINCT -- voters of microblog of the user SELECT res.ap_inbox_url FROM post_vote INNER JOIN public.user res on post_vote.user_id = res.id AND res.ap_id IS NOT NULL WHERE post_vote.author_id = :id UNION DISTINCT -- voters of microblog comments of the user SELECT res.ap_inbox_url FROM post_comment_vote INNER JOIN public.user res on post_comment_vote.user_id = res.id WHERE post_comment_vote.author_id = :id AND res.ap_id IS NOT NULL UNION DISTINCT -- favourites of entries of the user SELECT res.ap_inbox_url FROM favourite f INNER JOIN public.user res on f.user_id = res.id AND res.ap_id IS NOT NULL INNER JOIN entry e ON f.entry_id = e.id WHERE e.user_id = :id UNION DISTINCT -- favourites of entry comments of the user SELECT res.ap_inbox_url FROM favourite f INNER JOIN public.user res on f.user_id = res.id AND res.ap_id IS NOT NULL INNER JOIN entry_comment ec ON f.entry_comment_id = ec.id WHERE ec.user_id = :id UNION DISTINCT -- favourites of posts of the user SELECT res.ap_inbox_url FROM favourite f INNER JOIN public.user res on f.user_id = res.id AND res.ap_id IS NOT NULL INNER JOIN post p ON f.post_id = p.id WHERE p.user_id = :id UNION DISTINCT -- favourites of post comments of the user SELECT res.ap_inbox_url FROM favourite f INNER JOIN public.user res on f.user_id = res.id AND res.ap_id IS NOT NULL INNER JOIN post_comment pc ON f.entry_id = pc.id WHERE pc.user_id = :id UNION DISTINCT -- favourites of the user: entries SELECT res.ap_inbox_url FROM favourite f INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id INNER JOIN entry e ON f.entry_id = e.id INNER JOIN public.user res ON e.user_id=res.id AND res.ap_id IS NOT NULL UNION DISTINCT -- favourites of the user: entry comments SELECT res.ap_inbox_url FROM favourite f INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id INNER JOIN entry_comment ec ON f.entry_comment_id = ec.id INNER JOIN public.user res ON ec.user_id=res.id AND res.ap_id IS NOT NULL UNION DISTINCT -- favourites of the user: posts SELECT res.ap_inbox_url FROM favourite f INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id INNER JOIN post p ON f.post_id = p.id INNER JOIN public.user res ON p.user_id=res.id AND res.ap_id IS NOT NULL UNION DISTINCT -- favourites of the user: post comments SELECT res.ap_inbox_url FROM favourite f INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id INNER JOIN post_comment pc ON f.post_comment_id = pc.id INNER JOIN public.user res ON pc.user_id=res.id AND res.ap_id IS NOT NULL UNION DISTINCT -- favourites of the user: entries -> their magazine SELECT res.ap_inbox_url FROM favourite f INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id INNER JOIN entry e ON f.entry_id = e.id INNER JOIN magazine res ON e.magazine_id=res.id AND res.ap_id IS NOT NULL UNION DISTINCT -- favourites of the user: entry comments -> their magazine SELECT res.ap_inbox_url FROM favourite f INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id INNER JOIN entry_comment ec ON f.entry_comment_id = ec.id INNER JOIN magazine res ON ec.magazine_id=res.id AND res.ap_id IS NOT NULL UNION DISTINCT -- favourites of the user: posts -> their magazine SELECT res.ap_inbox_url FROM favourite f INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id INNER JOIN post p ON f.post_id = p.id INNER JOIN magazine res ON p.magazine_id=res.id AND res.ap_id IS NOT NULL UNION DISTINCT -- favourites of the user: post comments -> their magazine SELECT res.ap_inbox_url FROM favourite f INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id INNER JOIN post_comment pc ON f.post_comment_id = pc.id INNER JOIN magazine res ON pc.magazine_id=res.id AND res.ap_id IS NOT NULL '; $rsm = new ResultSetMapping(); $rsm->addScalarResult('ap_inbox_url', 0); $result = $this->entityManager->createNativeQuery($sql, $rsm) ->setParameter(':id', $user->getId()) // ->execute([":id" => $user->getId()]); ->getScalarResult(); return array_filter(array_map(fn ($row) => $row[0], $result)); } /** * @return string[] * * @throws Exception */ public function findAllKnownInboxesNotBannedNotDead(): array { $sql = ' SELECT ap_inbox_url FROM ( SELECT u.ap_inbox_url, u.ap_id, u.ap_domain FROM "user" u UNION ALL SELECT m.ap_inbox_url, m.ap_id, m.ap_domain FROM magazine m ) inn LEFT JOIN instance i ON ap_domain = i.domain WHERE ( -- either no instance found, or instance not banned and not dead i IS NULL OR ( i.is_banned = false -- not dead AND NOT ( i.failed_delivers >= :numToDead AND (i.last_successful_deliver < :dateBeforeDead OR i.last_successful_deliver IS NULL) AND (i.last_successful_receive < :dateBeforeDead OR i.last_successful_receive IS NULL) ) ) ) AND ap_id IS NOT NULL AND ap_inbox_url IS NOT NULL GROUP BY ap_inbox_url'; $stmt = $this->entityManager->getConnection()->prepare($sql); $stmt->bindValue(':numToDead', Instance::NUMBER_OF_FAILED_DELIVERS_UNTIL_DEAD, ParameterType::INTEGER); $stmt->bindValue(':dateBeforeDead', Instance::getDateBeforeDead(), 'datetime_immutable'); $results = $stmt->executeQuery()->fetchAllAssociative(); return array_map(fn ($item) => $item['ap_inbox_url'], $results); } /** * This method will return all image paths that the user **owns**, * meaning that that image belongs only to posts from the user and not to anybody else's. * * @return string[] */ public function getAllImageFilePathsOfUser(User $user): array { $sql = ' SELECT i1.file_path FROM entry e INNER JOIN image i1 ON e.image_id = i1.id WHERE e.user_id = :userId AND i1.file_path IS NOT NULL AND NOT EXISTS (SELECT id FROM entry e2 WHERE e2.user_id <> :userId AND e2.image_id = i1.id) AND NOT EXISTS (SELECT id FROM post p2 WHERE p2.user_id <> :userId AND p2.image_id = i1.id) AND NOT EXISTS (SELECT id FROM entry_comment ec2 WHERE ec2.user_id <> :userId AND ec2.image_id = i1.id) AND NOT EXISTS (SELECT id FROM post_comment pc2 WHERE pc2.user_id <> :userId AND pc2.image_id = i1.id) UNION DISTINCT SELECT i2.file_path FROM post p INNER JOIN image i2 ON p.image_id = i2.id WHERE p.user_id = :userId AND i2.file_path IS NOT NULL AND NOT EXISTS (SELECT id FROM entry e2 WHERE e2.user_id <> :userId AND e2.image_id = i2.id) AND NOT EXISTS (SELECT id FROM post p2 WHERE p2.user_id <> :userId AND p2.image_id = i2.id) AND NOT EXISTS (SELECT id FROM entry_comment ec2 WHERE ec2.user_id <> :userId AND ec2.image_id = i2.id) AND NOT EXISTS (SELECT id FROM post_comment pc2 WHERE pc2.user_id <> :userId AND pc2.image_id = i2.id) UNION DISTINCT SELECT i3.file_path FROM entry_comment ec INNER JOIN image i3 ON ec.image_id = i3.id WHERE ec.user_id = :userId AND i3.file_path IS NOT NULL AND NOT EXISTS (SELECT id FROM entry e2 WHERE e2.user_id <> :userId AND e2.image_id = i3.id) AND NOT EXISTS (SELECT id FROM post p2 WHERE p2.user_id <> :userId AND p2.image_id = i3.id) AND NOT EXISTS (SELECT id FROM entry_comment ec2 WHERE ec2.user_id <> :userId AND ec2.image_id = i3.id) AND NOT EXISTS (SELECT id FROM post_comment pc2 WHERE pc2.user_id <> :userId AND pc2.image_id = i3.id) UNION DISTINCT SELECT i4.file_path FROM post_comment pc INNER JOIN image i4 ON pc.image_id = i4.id WHERE pc.user_id = :userId AND i4.file_path IS NOT NULL AND NOT EXISTS (SELECT id FROM entry e2 WHERE e2.user_id <> :userId AND e2.image_id = i4.id) AND NOT EXISTS (SELECT id FROM post p2 WHERE p2.user_id <> :userId AND p2.image_id = i4.id) AND NOT EXISTS (SELECT id FROM entry_comment ec2 WHERE ec2.user_id <> :userId AND ec2.image_id = i4.id) AND NOT EXISTS (SELECT id FROM post_comment pc2 WHERE pc2.user_id <> :userId AND pc2.image_id = i4.id) '; $rsm = new ResultSetMapping(); $rsm->addScalarResult('file_path', 0); $result = $this->entityManager->createNativeQuery($sql, $rsm) ->setParameter(':userId', $user->getId()) ->getScalarResult(); return array_filter(array_map(fn ($row) => $row[0], $result)); } } ================================================ FILE: src/Service/UserNoteManager.php ================================================ clear($user, $target); $note = new UserNote($user, $target, $body); $this->entityManager->persist($note); $this->entityManager->flush(); return $note; } public function clear(User $user, User $target): void { $note = $this->repository->findOneBy([ 'user' => $user, 'target' => $target, ]); if ($note) { $this->entityManager->remove($note); $this->entityManager->flush(); } } public function createDto(User $user, User $target): UserNoteDto { $dto = new UserNoteDto(); $dto->target = $target; $note = $this->repository->findOneBy([ 'user' => $user, 'target' => $target, ]); if ($note) { $dto->body = $note->body; } return $dto; } } ================================================ FILE: src/Service/UserSettingsManager.php ================================================ notifyOnNewEntry, $user->notifyOnNewEntryReply, $user->notifyOnNewEntryCommentReply, $user->notifyOnNewPost, $user->notifyOnNewPostReply, $user->notifyOnNewPostCommentReply, $user->hideAdult, $user->showProfileSubscriptions, $user->showProfileFollowings, $user->addMentionsEntries, $user->addMentionsPosts, $user->homepage, $user->frontDefaultSort, $user->commentDefaultSort, $user->showBoostsOfFollowing, $user->featuredMagazines, $user->preferredLanguages, $user->customCss, $user->ignoreMagazinesCustomCss, $user->notifyOnUserSignup, $user->directMessageSetting, $user->frontDefaultContent, $user->apDiscoverable, $user->apIndexable, ); } public function update(User $user, UserSettingsDto $dto): void { $user->notifyOnNewEntry = $dto->notifyOnNewEntry; $user->notifyOnNewPost = $dto->notifyOnNewPost; $user->notifyOnNewPostReply = $dto->notifyOnNewPostReply; $user->notifyOnNewEntryCommentReply = $dto->notifyOnNewEntryCommentReply; $user->notifyOnNewEntryReply = $dto->notifyOnNewEntryReply; $user->notifyOnNewPostCommentReply = $dto->notifyOnNewPostCommentReply; $user->homepage = $dto->homepage; $user->frontDefaultSort = $dto->frontDefaultSort; $user->commentDefaultSort = $dto->commentDefaultSort; $user->showBoostsOfFollowing = $dto->showFollowingBoosts ?? false; $user->hideAdult = $dto->hideAdult; $user->showProfileSubscriptions = $dto->showProfileSubscriptions; $user->showProfileFollowings = $dto->showProfileFollowings; $user->addMentionsEntries = $dto->addMentionsEntries; $user->addMentionsPosts = $dto->addMentionsPosts; $user->featuredMagazines = $dto->featuredMagazines ? array_unique($dto->featuredMagazines) : null; $user->preferredLanguages = $dto->preferredLanguages ? array_unique($dto->preferredLanguages) : []; $user->customCss = $dto->customCss; $user->ignoreMagazinesCustomCss = $dto->ignoreMagazinesCustomCss; $user->directMessageSetting = $dto->directMessageSetting; $user->frontDefaultContent = $dto->frontDefaultContent; if (null !== $dto->notifyOnUserSignup) { $user->notifyOnUserSignup = $dto->notifyOnUserSignup; } if (null !== $dto->discoverable) { $user->apDiscoverable = $dto->discoverable; } if (null !== $dto->indexable) { $user->apIndexable = $dto->indexable; } $this->entityManager->flush(); } } ================================================ FILE: src/Service/VideoManager.php ================================================ str_replace('video/', '', $type), self::VIDEO_MIMETYPES); return \in_array($urlExt, $types); } } ================================================ FILE: src/Service/VotableRepositoryResolver.php ================================================ $this->entryRepository, EntryComment::class => $this->entryCommentRepository, Post::class => $this->postRepository, PostComment::class => $this->postCommentRepository, default => throw new \LogicException(), }; } } ================================================ FILE: src/Service/VoteManager.php ================================================ voteLimiter->create($user->username); if (false === $limiter->consume()->isAccepted()) { throw new TooManyRequestsHttpException(); } } $downVotesMode = $this->settingsManager->getDownvotesMode(); if (DownvotesMode::Disabled === $downVotesMode && VotableInterface::VOTE_DOWN === $choice) { throw new \LogicException('cannot downvote, because that is disabled'); } if (VotableInterface::VOTE_DOWN === $choice && 'Service' === $user->type) { throw new AccessDeniedHttpException('Bots are not allowed to vote on items!'); } $vote = $votable->getUserVote($user); $votedAgain = false; if ($vote) { $votedAgain = true; $choice = $this->guessUserChoice($choice, $votable->getUserChoice($user)); if ($votable instanceof Entry || $votable instanceof EntryComment || $votable instanceof Post || $votable instanceof PostComment) { if (VotableInterface::VOTE_UP === $vote->choice && null !== $votable->apShareCount) { --$votable->apShareCount; } elseif (VotableInterface::VOTE_DOWN === $vote->choice && null !== $votable->apDislikeCount) { --$votable->apDislikeCount; } if (VotableInterface::VOTE_UP === $choice && null !== $votable->apShareCount) { ++$votable->apShareCount; } elseif (VotableInterface::VOTE_DOWN === $choice && null !== $votable->apDislikeCount) { ++$votable->apDislikeCount; } } $vote->choice = $choice; } else { if (VotableInterface::VOTE_UP === $choice) { return $this->upvote($votable, $user); } if ($votable instanceof Entry || $votable instanceof EntryComment || $votable instanceof Post || $votable instanceof PostComment) { if (null !== $votable->apDislikeCount) { ++$votable->apDislikeCount; } } $vote = $this->factory->create($choice, $votable, $user); $this->entityManager->persist($vote); } $votable->updateVoteCounts(); $this->entityManager->flush(); $this->dispatcher->dispatch(new VoteEvent($votable, $vote, $votedAgain)); return $vote; } private function guessUserChoice(int $choice, int $vote): int { if (VotableInterface::VOTE_NONE === $choice) { return $choice; } if (VotableInterface::VOTE_UP === $vote) { return match ($choice) { VotableInterface::VOTE_UP => VotableInterface::VOTE_NONE, VotableInterface::VOTE_DOWN => VotableInterface::VOTE_DOWN, default => throw new \LogicException(), }; } if (VotableInterface::VOTE_DOWN === $vote) { return match ($choice) { VotableInterface::VOTE_UP => VotableInterface::VOTE_UP, VotableInterface::VOTE_DOWN => VotableInterface::VOTE_NONE, default => throw new \LogicException(), }; } return $choice; } public function upvote(VotableInterface $votable, User $user): Vote { // @todo save activity pub object id $vote = $votable->getUserVote($user); if ($vote) { return $vote; } $vote = $this->factory->create(1, $votable, $user); $votable->updateVoteCounts(); $votable->lastActive = new \DateTime(); if ($votable instanceof PostComment) { $votable->post->lastActive = new \DateTime(); } if ($votable instanceof EntryComment) { $votable->entry->lastActive = new \DateTime(); } if ($votable instanceof Entry || $votable instanceof EntryComment || $votable instanceof Post || $votable instanceof PostComment) { if (null !== $votable->apShareCount) { ++$votable->apShareCount; } } $this->entityManager->flush(); $this->dispatcher->dispatch(new VoteEvent($votable, $vote, false)); return $vote; } public function removeVote(VotableInterface $votable, User $user): ?Vote { // @todo save activity pub object id $vote = $votable->getUserVote($user); if (!$vote) { return null; } if (VotableInterface::VOTE_UP === $vote->choice) { if ($votable instanceof Entry || $votable instanceof EntryComment || $votable instanceof Post || $votable instanceof PostComment) { if (null !== $votable->apShareCount) { --$votable->apShareCount; } } } elseif (VotableInterface::VOTE_DOWN === $vote->choice) { if ($votable instanceof Entry || $votable instanceof EntryComment || $votable instanceof Post || $votable instanceof PostComment) { if (null !== $votable->apDislikeCount) { --$votable->apDislikeCount; } } } $vote->choice = VotableInterface::VOTE_NONE; $votable->updateVoteCounts(); $this->entityManager->flush(); $this->dispatcher->dispatch(new VoteEvent($votable, $vote, false)); return $vote; } } ================================================ FILE: src/Twig/Components/ActiveUsersComponent.php ================================================ cache->get("active_users_{$magazine?->getId()}", function (ItemInterface $item) use ($magazine) { $item->expiresAfter(60 * 5); // 5 minutes return $this->userRepository->findActiveUsers($magazine); } ); $this->users = $this->userRepository->findBy(['id' => $activeUserIds]); } } ================================================ FILE: src/Twig/Components/AnnouncementComponent.php ================================================ twig->render( 'components/announcement.html.twig', [ 'content' => $this->repository->findAll()[0]->announcement ?? '', ] ); } } ================================================ FILE: src/Twig/Components/BlurhashImageComponent.php ================================================ cache->get( 'bh_'.hash('sha256', serialize($context)), function (ItemInterface $item) use ($blurhash, $width, $height) { $item->expiresAfter(3600); $pixels = Blurhash::decode($blurhash, $width, $height); $image = imagecreatetruecolor($width, $height); for ($y = 0; $y < $height; ++$y) { for ($x = 0; $x < $width; ++$x) { [$r, $g, $b] = $pixels[$y][$x]; imagesetpixel($image, $x, $y, imagecolorallocate($image, $r, $g, $b)); } } // I do not like this ob_start(); imagepng($image); $out = ob_get_contents(); ob_end_clean(); return 'data:image/png;base64,'.base64_encode($out); } ); } } ================================================ FILE: src/Twig/Components/BookmarkListComponent.php ================================================ formDest = match (true) { $this->subject instanceof Entry => 'entry', $this->subject instanceof EntryComment => 'entry_comment', $this->subject instanceof Post => 'post', $this->subject instanceof PostComment => 'post_comment', default => throw new \LogicException(), }; return $attr; } } ================================================ FILE: src/Twig/Components/CursorPaginationComponent.php ================================================ entry->getId(); $userId = $this->security->getUser()?->getId(); $locale = $this->requestStack->getCurrentRequest()?->getLocale(); return $this->cache->get( "entries_cross_{$entryId}_{$userId}_{$locale}", function (ItemInterface $item) use ($entryId) { $item->expiresAfter(60); $entries = $this->repository->findCross($this->entry); $item->tag(['entry_'.$entryId]); foreach ($entries as $entry) { $item->tag(['entry_'.$entry->getId()]); } return $this->twig->render( 'components/entries_cross.html.twig', [ 'entries' => $this->repository->findCross($this->entry), ] ); } ); } } ================================================ FILE: src/Twig/Components/EntryCommentComponent.php ================================================ canSeeTrashed(); return $attr; } public function getLevel(): int { if (ThemeSettingsController::CLASSIC === $this->requestStack->getMainRequest()->cookies->get( ThemeSettingsController::ENTRY_COMMENTS_VIEW )) { return min($this->level, 2); } return min($this->level, 10); } public function canSeeTrashed(): bool { if (VisibilityInterface::VISIBILITY_VISIBLE === $this->comment->visibility) { return true; } if (VisibilityInterface::VISIBILITY_TRASHED === $this->comment->visibility && $this->authorizationChecker->isGranted( 'moderate', $this->comment ) && $this->canSeeTrash) { return true; } $this->comment->image = null; return false; } } ================================================ FILE: src/Twig/Components/EntryCommentInlineComponent.php ================================================ canSeeTrashed(); if ($this->isSingle) { if (isset($attr['class'])) { $attr['class'] = trim('entry--single section--top '.$attr['class']); } else { $attr['class'] = 'entry--single section--top'; } } return $attr; } public function canSeeTrashed(): bool { if (VisibilityInterface::VISIBILITY_VISIBLE === $this->entry->visibility) { return true; } if (VisibilityInterface::VISIBILITY_TRASHED === $this->entry->visibility && $this->authorizationChecker->isGranted( 'moderate', $this->entry ) && $this->canSeeTrash) { return true; } $this->showBody = false; $this->showShortSentence = false; $this->entry->image = null; return false; } } ================================================ FILE: src/Twig/Components/EntryCrossComponent.php ================================================ formDest = match (true) { $this->subject instanceof Entry => 'entry', $this->subject instanceof EntryComment => 'entry_comment', $this->subject instanceof Post => 'post', $this->subject instanceof PostComment => 'post_comment', default => throw new \LogicException(), }; return $attr; } } ================================================ FILE: src/Twig/Components/FeaturedMagazinesComponent.php ================================================ render(); } private function render(): string { $magazines = $this->repository->findBy( ['apId' => null, 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE], ['lastActive' => 'DESC'], 28 ); if ($this->magazine && !\in_array($this->magazine, $magazines)) { array_unshift($magazines, $this->magazine); } usort($magazines, fn ($a, $b) => $a->lastActive < $b->lastActive ? 1 : -1); return $this->twig->render( 'components/featured_magazines.html.twig', [ 'magazines' => array_map(fn ($mag) => $mag->name, $magazines), 'magazine' => $this->magazine, ] ); } } ================================================ FILE: src/Twig/Components/FilterListComponent.php ================================================ oauthGoogleId); } public function discordEnabled(): bool { return !empty($this->oauthDiscordId); } public function facebookEnabled(): bool { return !empty($this->oauthFacebookId); } public function githubEnabled(): bool { return !empty($this->oauthGithubId); } public function privacyPortalEnabled(): bool { return !empty($this->oauthPrivacyPortalId); } public function keycloakEnabled(): bool { return !empty($this->oauthKeycloakId); } public function simpleloginEnabled(): bool { return !empty($this->oauthSimpleLoginId); } public function zitadelEnabled(): bool { return !empty($this->oauthZitadelId); } public function authentikEnabled(): bool { return !empty($this->oauthAuthentikId); } public function azureEnabled(): bool { return !empty($this->oauthAzureId); } } ================================================ FILE: src/Twig/Components/MagazineBoxComponent.php ================================================ security->getUser(); if ($user instanceof User) { $this->status = $this->repository->findOneByTarget($user, $this->target)?->getStatus() ?? ENotificationStatus::Default; } } } ================================================ FILE: src/Twig/Components/PostCombinedComponent.php ================================================ canSeeTrashed(); return $attr; } public function canSeeTrashed(): bool { if (VisibilityInterface::VISIBILITY_VISIBLE === $this->comment->visibility) { return true; } if (VisibilityInterface::VISIBILITY_TRASHED === $this->comment->visibility && $this->authorizationChecker->isGranted( 'moderate', $this->comment ) && $this->canSeeTrash) { return true; } $this->comment->image = null; return false; } } ================================================ FILE: src/Twig/Components/PostCommentComponent.php ================================================ canSeeTrashed(); return $attr; } public function getLevel(): int { if (ThemeSettingsController::CLASSIC === $this->requestStack->getMainRequest()->cookies->get( ThemeSettingsController::POST_COMMENTS_VIEW )) { return min($this->level, 2); } return min($this->level, 10); } public function canSeeTrashed(): bool { if (VisibilityInterface::VISIBILITY_VISIBLE === $this->comment->visibility) { return true; } if (VisibilityInterface::VISIBILITY_TRASHED === $this->comment->visibility && $this->authorizationChecker->isGranted( 'moderate', $this->comment ) && $this->canSeeTrash) { return true; } $this->comment->image = null; return false; } } ================================================ FILE: src/Twig/Components/PostCommentInlineComponent.php ================================================ post->getId(); $userId = $this->security->getUser()?->getId(); return $this->cache->get( "post_comment_preview_{$postId}_{$userId}_{$this->requestStack->getCurrentRequest()?->getLocale()}", function (ItemInterface $item) use ($postId, $userId, $attributes) { $item->expiresAfter(3600); $item->tag(['post_comments_user_'.$userId]); $item->tag(['post_'.$postId]); return $this->twig->render( 'components/post_comments_preview.html.twig', [ 'attributes' => new ComponentAttributes($attributes->all(), new EscaperRuntime()), 'post' => $this->post, 'comments' => $this->post->lastActive < (new \DateTime('-4 hours')) ? $this->post->getBestComments($this->security->getUser()) : $this->post->getLastComments($this->security->getUser()), ] ); } ); } } ================================================ FILE: src/Twig/Components/PostComponent.php ================================================ canSeeTrashed(); if ($this->isSingle) { $this->showMagazineName = false; if (isset($attr['class'])) { $attr['class'] = trim('post--single '.$attr['class']); } else { $attr['class'] = 'post--single'; } } return $attr; } public function canSeeTrashed(): bool { if (VisibilityInterface::VISIBILITY_VISIBLE === $this->post->visibility) { return true; } if (VisibilityInterface::VISIBILITY_TRASHED === $this->post->visibility && $this->authorizationChecker->isGranted( 'moderate', $this->post ) && $this->canSeeTrash) { return true; } $this->post->image = null; return false; } } ================================================ FILE: src/Twig/Components/PostInlineMdComponent.php ================================================ title = 'related_entries'; $this->type = self::TYPE_TAG; } if ($magazine) { $this->title = 'related_entries'; $this->type = self::TYPE_MAGAZINE; } $entryId = $this->entry?->getId(); $magazine = str_replace('@', '', $magazine ?? ''); /** @var User|null $user */ $user = $this->security->getUser(); $cacheKey = "related_entries_{$magazine}_{$tag}_{$entryId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}"; $entryIds = $this->cache->get( $cacheKey, function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $entries = match ($this->type) { self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user), self::TYPE_MAGAZINE => $this->repository->findRelatedByTag( $this->mentionManager->getUsername($magazine), $this->limit + 20, user: $user, ), default => $this->repository->findLast($this->limit + 150, user: $user), }; $entries = array_filter($entries, fn (Entry $e) => !$e->isAdult && !$e->magazine->isAdult); if (\count($entries) > $this->limit) { shuffle($entries); // randomize the order $entries = \array_slice($entries, 0, $this->limit); } return array_map(fn (Entry $entry) => $entry->getId(), $entries); } ); $this->entries = $this->repository->findBy(['id' => $entryIds]); } } ================================================ FILE: src/Twig/Components/RelatedMagazinesComponent.php ================================================ title = 'related_magazines'; $this->type = self::TYPE_TAG; } if ($magazine) { $this->title = 'related_magazines'; $this->type = self::TYPE_MAGAZINE; } $magazine = str_replace('@', '', $magazine ?? ''); /** @var User|null $user */ $user = $this->security->getUser(); $magazineIds = $this->cache->get( "related_magazines_{$magazine}_{$tag}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}", function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $magazines = match ($this->type) { self::TYPE_TAG => $this->repository->findRelated($tag, user: $user), self::TYPE_MAGAZINE => $this->repository->findRelated($magazine, user: $user), default => $this->repository->findRandom(user: $user), }; $magazines = array_filter($magazines, fn ($m) => $m->name !== $magazine); return array_map(fn (Magazine $magazine) => $magazine->getId(), $magazines); } ); $this->magazines = $this->repository->findBy(['id' => $magazineIds]); } } ================================================ FILE: src/Twig/Components/RelatedPostsComponent.php ================================================ title = 'related_posts'; $this->type = self::TYPE_TAG; } if ($magazine) { $this->title = 'related_posts'; $this->type = self::TYPE_MAGAZINE; } /** @var User|null $user */ $user = $this->security->getUser(); $postId = $this->post?->getId(); $magazine = str_replace('@', '', $magazine ?? ''); $postIds = $this->cache->get( "related_posts_{$magazine}_{$tag}_{$postId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}", function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $posts = match ($this->type) { self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user), self::TYPE_MAGAZINE => $this->repository->findRelatedByTag( $this->mentionManager->getUsername($magazine), $this->limit + 20, user: $user ), default => $this->repository->findLast($this->limit + 150, user: $user), }; $posts = array_filter($posts, fn (Post $p) => !$p->isAdult && !$p->magazine->isAdult); if (\count($posts) > $this->limit) { shuffle($posts); // randomize the order $posts = \array_slice($posts, 0, $this->limit); } return array_map(fn (Post $post) => $post->getId(), $posts); } ); $this->posts = $this->repository->findBy(['id' => $postIds]); } } ================================================ FILE: src/Twig/Components/ReportListComponent.php ================================================ magazines = []; if (ThemeSettingsController::ALPHABETICALLY === $this->sort) { $this->magazines = $this->magazineRepository->findMagazineSubscriptionsOfUser($this->user, SubscriptionSort::Alphabetically, $max); } else { $this->magazines = $this->magazineRepository->findMagazineSubscriptionsOfUser($this->user, SubscriptionSort::LastActive, $max); } if (\sizeof($this->magazines) === $max) { $this->tooManyMagazines = true; } } } ================================================ FILE: src/Twig/Components/TagActionComponent.php ================================================ formDest = match (true) { $this->subject instanceof Entry => 'entry', $this->subject instanceof EntryComment => 'entry_comment', $this->subject instanceof Post => 'post', $this->subject instanceof PostComment => 'post_comment', default => throw new \LogicException(), }; return $attr; } } ================================================ FILE: src/Twig/Components/VotersInlineComponent.php ================================================ cache->get( $this->cacheService->getVotersCacheKey($this->subject), function (ItemInterface $item) use ($attributes) { $item->expiresAfter(3600); /** * @var Collection $votes */ $votes = $this->subject->votes; $votes = $votes->matching( new Criteria( Criteria::expr()->eq('choice', VotableInterface::VOTE_UP), ['createdAt' => Order::Descending] ) )->slice(0, 4); return $this->twig->render( 'components/voters_inline.html.twig', [ 'attributes' => new ComponentAttributes($attributes->all(), new EscaperRuntime()), 'voters' => array_map(fn ($vote) => $vote->user->username, $votes), 'count' => $this->subject->countUpVotes(), 'url' => $this->url, ] ); } ); } } ================================================ FILE: src/Twig/Extension/AdminExtension.php ================================================ (bool) $value), new TwigFilter('abbreviateNumber', [FormattingExtensionRuntime::class, 'abbreviateNumber']), new TwigFilter('uuidEnd', [FormattingExtensionRuntime::class, 'uuidEnd']), new TwigFilter('formatQuery', [FormattingExtensionRuntime::class, 'formatQuery']), ]; } public function getFunctions(): array { return [ new TwigFunction('get_short_sentence', [FormattingExtensionRuntime::class, 'getShortSentence']), ]; } } ================================================ FILE: src/Twig/Extension/FrontExtension.php ================================================ monitor->shouldRecordTwigRenders() || null === $this->monitor->currentContext) { return; } $profile->enter(); $label = $this->getLabelTitle($profile); $this->monitor->startTwigRendering($label, $profile->getType()); $this->runningTemplates[] = $label; } /** * This method is called when the execution of a block, a macro or a * template is finished. * * @param Profile $profile The profiling data */ public function leave(Profile $profile): void { if (!$this->monitor->shouldRecordTwigRenders() || null === $this->monitor->currentContext) { return; } $profile->leave(); $key = $this->getLabelTitle($profile); $popped = array_pop($this->runningTemplates); if ($popped !== $key) { $this->logger->warning('Trying to leave a node, but the last entered one is of a different template: {popped} !== {key}', ['popped' => $popped, 'key' => $key]); return; } $this->monitor->endTwigRendering($key, $profile->getMemoryUsage(), $profile->getPeakMemoryUsage(), $profile->getName(), $profile->getType(), $profile->getDuration() * 1000); } public function getNodeVisitors(): array { return [new ProfilerNodeVisitor(self::class)]; } /** * Gets a short description for the segment. * * @param Profile $profile The profiling data */ private function getLabelTitle(Profile $profile): string { switch (true) { case $profile->isRoot(): return $profile->getName(); case $profile->isTemplate(): return $profile->getTemplate(); default: return \sprintf('%s::%s(%s)', $profile->getTemplate(), $profile->getType(), $profile->getName()); } } } ================================================ FILE: src/Twig/Extension/NavbarExtension.php ================================================ ['html']]), new TwigFilter('apDomain', [UserExtensionRuntime::class, 'apDomain'], ['is_safe' => ['html']]), ]; } } ================================================ FILE: src/Twig/Runtime/AdminExtensionRuntime.php ================================================ security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedException(); } $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); if (null === $hashtag) { return false; } return $hashtag->banned; } public function doNewUsersNeedApproval(): bool { // show the signup requests page even if they are deactivated if there are any remaining return $this->settingsManager->getNewUsersNeedApproval() || $this->userRepository->findAllSignupRequestsPaginated()->count() > 0; } public function isMonitoringEnabled(): bool { return $this->monitoringEnabled; } } ================================================ FILE: src/Twig/Runtime/BookmarkExtensionRuntime.php ================================================ bookmarkListRepository->findByUser($user); } public function getBookmarkListEntryCount(BookmarkList $list): int { return $list->entities->count(); } public function isContentBookmarked(User $user, Entry|EntryComment|Post|PostComment $content): bool { return $this->bookmarkManager->isBookmarked($user, $content); } public function isContentBookmarkedInList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): bool { return $this->bookmarkManager->isBookmarkedInList($user, $list, $content); } } ================================================ FILE: src/Twig/Runtime/ContextExtensionRuntime.php ================================================ getCurrentRouteName(), $needle); } public function isRouteNameStartsWith(string $needle): bool { return str_starts_with($this->getCurrentRouteName(), $needle); } public function isRouteNameEndWith(string $needle): bool { return str_ends_with($this->getCurrentRouteName(), $needle); } public function isRouteName(string $needle): bool { return $this->getCurrentRouteName() === $needle; } public function isRouteParamsContains(string $paramName, $value): bool { return $this->requestStack->getMainRequest()->get($paramName) === $value; } public function routeHasParam(string $name, string $needle): bool { return $this->requestStack->getCurrentRequest()->get($name) === $needle; } public function routeParamExists(string $name): bool { return (bool) $this->requestStack->getCurrentRequest()->get($name); } private function getCurrentRouteName(): string { return $this->requestStack->getCurrentRequest()->get('_route') ?? 'front'; } public function getActiveSortOption(): string { $defaultSort = $this->getDefaultSortOption(); $requestSort = $this->requestStack->getCurrentRequest()->get('sortBy'); return 'default' !== $requestSort ? ($requestSort ?? $defaultSort) : $defaultSort; } public function getDefaultSortOption(): string { $defaultSort = 'hot'; $user = $this->security->getUser(); if ($user instanceof User) { $defaultSort = $user->frontDefaultSort; } return $defaultSort; } public function getActiveSortOptionForComments(): string { $defaultSort = $this->getDefaultSortOptionForComments(); $requestSort = $this->requestStack->getCurrentRequest()->get('sortBy'); return 'default' !== $requestSort ? ($requestSort ?? $defaultSort) : $defaultSort; } public function getDefaultSortOptionForComments(): string { $defaultSort = 'hot'; $user = $this->security->getUser(); if ($user instanceof User) { $defaultSort = $user->commentDefaultSort; } return $defaultSort; } public function getRouteParam(string $name): ?string { return $this->requestStack->getCurrentRequest()->get($name); } public function getTimeParamTranslated(): string { $paramValue = $this->getRouteParam('time'); if (!\in_array($paramValue, Criteria::TIME_ROUTES_EN) || '∞' === $paramValue || 'all' === $paramValue ) { return $this->translator->trans('all_time'); } return $this->translator->trans($paramValue); } public function now(): \DateTimeImmutable { return new \DateTimeImmutable('now'); } } ================================================ FILE: src/Twig/Runtime/CounterExtensionRuntime.php ================================================ searchRepository->countBoosts($user); } public function countUserModerated(User $user): int { return $this->searchRepository->countModerated($user); } } ================================================ FILE: src/Twig/Runtime/DomainExtensionRuntime.php ================================================ security->getUser()) { return false; } return $domain->isSubscribed($this->security->getUser()); } public function isBlocked(Domain $domain): bool { if (!$this->security->getUser()) { return false; } return $this->security->getUser()->isBlockedDomain($domain); } } ================================================ FILE: src/Twig/Runtime/EmailExtensionRuntime.php ================================================ entrypointLookupInterface->reset(); $entry = $this->container->get(EntrypointLookupInterface::class); $source = ''; $files = $entry->getCssFiles($entryName); foreach ($files as $file) { $source .= file_get_contents($this->publicDir.'/'.$file); } return $source; } } ================================================ FILE: src/Twig/Runtime/FormattingExtensionRuntime.php ================================================ markdownConverter->convertToHtml($value, $sourceType) : ''; } public function getShortSentence(?string $val, $length = 330, $striptags = false, bool $onlyFirstParagraph = true): string { if (!$val) { return ''; } $body = $striptags ? strip_tags(html_entity_decode($val)) : $val; if ($onlyFirstParagraph) { $body = wordwrap(trim($body), $length); $lines = explode("\n", $body); $shortened = trim($lines[0]); $ellipsis = isset($lines[1]) ? ' ...' : ''; } elseif (\strlen($body) <= $length) { $shortened = $body; $ellipsis = ''; } else { $sentenceTolerance = 12; $limit = $length - 1; $sentenceDelimiters = ['. ', ', ', '; ', "\n", "\t", "\f", "\v"]; $sentencePreLimit = self::strrposMulti($body, $sentenceDelimiters, $limit); if ($sentencePreLimit > -1 && $sentencePreLimit >= $length - $sentenceTolerance) { $limit = $sentencePreLimit; $ellipsis = ' ...'; } else { $ellipsis = '...'; } $shortened = trim(substr($body, 0, $limit + 1)); } return $shortened.$ellipsis; } private static function strrposMulti(string $haystack, array $needle, int $offset): int { $offset = $offset - \strlen($haystack); $pos = -1; foreach ($needle as $n) { $idx = strrpos($haystack, $n, $offset); if (false !== $idx) { $pos = max($pos, $idx); } } return $pos; } public function abbreviateNumber(int|float $value): string { // this implementation is offly simple, but therefore fast if ($value < 1000) { return ''.$value; } elseif ($value < 1000000) { return round($value / 1000, 2).'K'; } elseif ($value < 1000000000) { return round($value / 1000000, 2).'M'; } else { return round($value / 1000000000, 2).'B'; } } public function uuidEnd(?Uuid $uuid): string { $string = $uuid->toString(); $parts = explode('-', $string); return end($parts); } public function formatQuery(string $query): string { $formatter = new SqlFormatter(new NullHighlighter()); return $formatter->format($query); } } ================================================ FILE: src/Twig/Runtime/FrontExtensionRuntime.php ================================================ requestStack->getCurrentRequest(); $attrs = $request->attributes; $route = $routeName ?? $attrs->get('_route'); $params = array_merge($attrs->get('_route_params', []), $request->query->all()); $params = array_replace($params, $additionalParams); $params = array_filter($params, fn ($v) => null !== $v); $params[$name] = $value; if (str_starts_with($route, 'front') && !str_contains($route, '_magazine')) { $route = $this->getFrontRoute($route, $params); } return $this->urlGenerator->generate($route, $params); } /** * Upgrades shorter `front_*` routes to a front route that can fit all specified params. */ private function getFrontRoute(string $currentRoute, array $params): string { $content = $params['content'] ?? null; $subscription = $params['subscription'] ?? null; if ('home' === $subscription) { $subscription = null; } if ($content && $subscription) { return 'front'; } elseif ($subscription) { return 'front_sub'; } elseif ('all' !== $content) { return 'front_content'; } else { return 'front_short'; } } public function getClass(mixed $object): string { return \get_class($object); } public function getSubjectType(mixed $object): string { if ($object instanceof Entry) { return 'entry'; } elseif ($object instanceof EntryComment) { return 'entry_comment'; } elseif ($object instanceof Post) { return 'post'; } elseif ($object instanceof PostComment) { return 'post_comment'; } else { throw new \LogicException('unknown class '.\get_class($object)); } } public function getNotificationSettingSubjectType(mixed $object): string { if ($object instanceof Entry) { return 'entry'; } elseif ($object instanceof Post) { return 'post'; } elseif ($object instanceof User) { return 'user'; } elseif ($object instanceof Magazine) { return 'magazine'; } else { throw new \LogicException('unknown class '.\get_class($object)); } } } ================================================ FILE: src/Twig/Runtime/LinkExtensionRuntime.php ================================================ settingsManager->get('KBIN_DOMAIN') === parse_url($url, PHP_URL_HOST)) { return 'follow'; } return 'nofollow noopener noreferrer'; } public function getHtmlClass(ContentInterface $content): string { $service = $this->generateHtmlClassService; return $service->fromEntity($content); } public function getLinkDomain(string $url): string { $domain = parse_url($url, PHP_URL_HOST); if (null === $domain) { return $this->settingsManager->get('KBIN_DOMAIN'); } return $domain; } } ================================================ FILE: src/Twig/Runtime/MagazineExtensionRuntime.php ================================================ security->getUser()) { return false; } return $magazine->isSubscribed($this->security->getUser()); } public function isBlocked(Magazine $magazine): bool { if (!$this->security->getUser()) { return false; } return $this->security->getUser()->isBlockedMagazine($magazine); } public function hasLocalSubscribers(Magazine $magazine): bool { $subscribers = $this->magazineSubscriptionRepository->findMagazineSubscribers(1, $magazine); return $subscribers->getNbResults() > 0; } public function getInstanceOfMagazine(Magazine $magazine): ?Instance { return $this->instanceRepository->getInstanceOfMagazine($magazine); } public function isInstanceOfMagazineBanned(Magazine $magazine): bool { if (null === $magazine->apId) { return false; } return $this->settingsManager->isBannedInstance($magazine->apProfileId); } } ================================================ FILE: src/Twig/Runtime/MediaExtensionRuntime.php ================================================ filePath) { return $this->storageUrl.'/'.$image->filePath; } return $image->sourceUrl; } } ================================================ FILE: src/Twig/Runtime/NavbarExtensionRuntime.php ================================================ isRouteNameStartsWith('front')) { return $this->frontExtension->frontOptionsUrl( 'content', 'threads', $magazine instanceof Magazine ? 'front_magazine' : 'front', ['name' => $magazine?->name, 'p' => null, 'cursor' => null, 'cursor2' => null], ); } if ($magazine instanceof Magazine) { return $this->urlGenerator->generate('front_magazine', [ 'name' => $magazine->name, ...$this->getActiveOptions(), ]); } if ($domain = $this->requestStack->getCurrentRequest()->get('domain')) { return $this->urlGenerator->generate('domain_entries', [ 'name' => $domain->name, ...$this->getActiveOptions(), ]); } if ($this->isRouteNameStartsWith('tag')) { return $this->urlGenerator->generate( 'tag_entries', ['name' => $this->requestStack->getCurrentRequest()->get('name')] ); } return $this->urlGenerator->generate('front_content', [ ...$this->getActiveOptions(), 'content' => 'threads', ]); } public function navbarCombinedUrl(?Magazine $magazine): string { if ($this->isRouteNameStartsWith('front')) { return $this->frontExtension->frontOptionsUrl( 'content', 'combined', $magazine instanceof Magazine ? 'front_magazine' : 'front', ['name' => $magazine?->name, 'p' => null, 'cursor' => null, 'cursor2' => null], ); } if ($magazine instanceof Magazine) { return $this->urlGenerator->generate('front_magazine', [ 'name' => $magazine->name, ...$this->getActiveOptions(), 'content' => 'combined', ]); } if ($domain = $this->requestStack->getCurrentRequest()->get('domain')) { return $this->urlGenerator->generate('domain_entries', [ 'name' => $domain->name, ...$this->getActiveOptions(), ]); } if ($this->isRouteNameStartsWith('tag')) { return $this->urlGenerator->generate( 'tag_entries', ['name' => $this->requestStack->getCurrentRequest()->get('name')] ); } return $this->urlGenerator->generate('front_content', [ ...$this->getActiveOptions(), 'content' => 'combined', ]); } public function navbarPostsUrl(?Magazine $magazine): string { if ($this->isRouteNameStartsWith('front')) { return $this->frontExtension->frontOptionsUrl( 'content', 'microblog', $magazine instanceof Magazine ? 'front_magazine' : 'front', ['name' => $magazine?->name, 'p' => null, 'cursor' => null, 'cursor2' => null, 'type' => null], ); } if ($magazine instanceof Magazine) { return $this->urlGenerator->generate('magazine_posts', [ 'name' => $magazine->name, ...$this->getActiveOptions(), ]); } if ($this->isRouteNameStartsWith('tag')) { return $this->urlGenerator->generate( 'tag_posts', ['name' => $this->requestStack->getCurrentRequest()->get('name')] ); } if ($this->isRouteNameEndWith('_subscribed')) { return $this->urlGenerator->generate('posts_subscribed', $this->getActiveOptions()); } if ($this->isRouteNameEndWith('_favourite')) { return $this->urlGenerator->generate('posts_favourite', $this->getActiveOptions()); } if ($this->isRouteNameEndWith('_moderated')) { return $this->urlGenerator->generate('posts_moderated', $this->getActiveOptions()); } return $this->urlGenerator->generate('posts_front', $this->getActiveOptions()); } public function navbarPeopleUrl(?Magazine $magazine): string { if ($this->isRouteNameStartsWith('tag')) { return $this->urlGenerator->generate( 'tag_people', ['name' => $this->requestStack->getCurrentRequest()->get('name')] ); } if ($magazine instanceof Magazine) { return $this->urlGenerator->generate('magazine_people', ['name' => $magazine->name]); } return $this->urlGenerator->generate('people_front'); } private function getCurrentRouteName(): string { return $this->requestStack->getCurrentRequest()->get('_route') ?? 'front'; } private function getActiveOptions(): array { $options = []; // don't use sortBy or time options on comment pages // for the navbar links, so sorting comments by new does not mean // changing the entry and microblog views to newest if (!$this->isRouteName('root') && !$this->isRouteNameStartsWith('front') && !$this->isRouteNameStartsWith('posts') && !$this->isRouteName('magazine_posts') ) { return $options; } $sortOption = $this->getActiveSortOption(); $timeOption = $this->getActiveTimeOption(); $subscriptionOption = $this->getActiveSubscriptionOption(); $contentOption = $this->getActiveContentOption(); // don't add the current options if they are the defaults. // this isn't bad, but keeps urls shorter for instance // showing /microblog rather than /microblog/hot/∞ // which would be equivalent anyways if ('hot' !== $sortOption) { $options['sortBy'] = $sortOption; } if ('∞' !== $timeOption) { $options['time'] = $timeOption; } if ('default' !== $contentOption) { $options['content'] = $contentOption; } if (!\in_array($subscriptionOption, [null, 'home'])) { $options['subscription'] = $subscriptionOption; } return $options; } private function getActiveSubscriptionOption(): ?string { return $this->requestStack->getCurrentRequest()->get('subscription'); } private function getActiveSortOption(): string { return $this->requestStack->getCurrentRequest()->get('sortBy') ?? 'hot'; } private function getActiveTimeOption(): string { return $this->requestStack->getCurrentRequest()->get('time') ?? '∞'; } private function getActiveContentOption(): string { return $this->requestStack->getCurrentRequest()->get('content') ?? 'default'; } private function isRouteNameStartsWith(string $needle): bool { return str_starts_with($this->getCurrentRouteName(), $needle); } private function isRouteNameEndWith(string $needle): bool { return str_ends_with($this->getCurrentRouteName(), $needle); } private function isRouteName(string $needle): bool { return $this->getCurrentRouteName() === $needle; } } ================================================ FILE: src/Twig/Runtime/SettingsExtensionRuntime.php ================================================ settings->get('KBIN_DOMAIN'); } public function kbinTitle(): string { return $this->settings->get('KBIN_TITLE'); } #[Pure] public function kbinMetaTitle(): string { return $this->settings->get('KBIN_META_TITLE'); } #[Pure] public function kbinDescription(): string { return $this->settings->get('KBIN_META_DESCRIPTION'); } #[Pure] public function kbinKeywords(): string { return $this->settings->get('KBIN_META_KEYWORDS'); } #[Pure] public function kbinRegistrationsEnabled(): bool { return $this->settings->get('KBIN_REGISTRATIONS_ENABLED'); } #[Pure] public function mbinSsoRegistrationsEnabled(): bool { return $this->settings->get('MBIN_SSO_REGISTRATIONS_ENABLED'); } public function mbinSsoOnlyMode(): bool { return $this->settings->get('MBIN_SSO_ONLY_MODE'); } public function kbinDefaultLang(): string { return $this->settings->get('KBIN_DEFAULT_LANG'); } #[Pure] public function mbinDefaultTheme(): string { return $this->settings->get('MBIN_DEFAULT_THEME'); } public function kbinHeaderLogo(): bool { return $this->settings->get('KBIN_HEADER_LOGO'); } public function kbinCaptchaEnabled(): bool { return $this->settings->get('KBIN_CAPTCHA_ENABLED'); } public function kbinMercureEnabled(): bool { return $this->settings->get('KBIN_MERCURE_ENABLED'); } public function kbinFederationPageEnabled(): bool { return $this->settings->get('KBIN_FEDERATION_PAGE_ENABLED'); } public function kbinFederatedSearchOnlyLoggedIn(): bool { return $this->settings->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN'); } public function mbinDownvotesMode(): DownvotesMode { return $this->settings->getDownvotesMode(); } public function mbinCurrentVersion(): string { return $this->projectInfo->getVersion(); } public function mbinRestrictMagazineCreation(): bool { return $this->settings->get('MBIN_RESTRICT_MAGAZINE_CREATION'); } public function mbinPrivateInstance(): bool { return $this->settings->get('MBIN_PRIVATE_INSTANCE'); } public function mbinSsoShowFirst(): bool { return $this->settings->get('MBIN_SSO_SHOW_FIRST'); } public function mbinLang(): string { return $this->settings->getLocale(); } } ================================================ FILE: src/Twig/Runtime/SubjectExtensionRuntime.php ================================================ urlGenerator->generate('entry_single', [ 'magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'slug' => empty($entry->slug) ? '-' : $entry->slug, ]); } public function entryFavouritesUrl(Entry $entry): string { return $this->urlGenerator->generate('entry_fav', [ 'magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'slug' => empty($entry->slug) ? '-' : $entry->slug, ]); } public function entryVotersUrl(Entry $entry, string $type): string { return $this->urlGenerator->generate('entry_voters', [ 'magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'slug' => empty($entry->slug) ? '-' : $entry->slug, 'type' => $type, ]); } public function entryEditUrl(Entry $entry): string { return $this->urlGenerator->generate('entry_edit', [ 'magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'slug' => empty($entry->slug) ? '-' : $entry->slug, ]); } public function entryModerateUrl(Entry $entry): string { return $this->urlGenerator->generate('entry_moderate', [ 'magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'slug' => empty($entry->slug) ? '-' : $entry->slug, ]); } public function entryDeleteUrl(Entry $entry): string { return $this->urlGenerator->generate('entry_delete', [ 'magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'slug' => empty($entry->slug) ? '-' : $entry->slug, ]); } public function entryCommentCreateUrl(EntryComment $comment): string { return $this->urlGenerator->generate('entry_comment_create', [ 'magazine_name' => $comment->magazine->name, 'entry_id' => $comment->entry->getId(), 'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug, 'parent_comment_id' => $comment->getId(), ]); } public function entryCommentViewUrl(EntryComment $comment): string { return $this->urlGenerator->generate('entry_comment_view', [ 'magazine_name' => $comment->magazine->name, 'entry_id' => $comment->entry->getId(), 'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug, 'comment_id' => $comment->getId(), ]); } public function entryCommentEditUrl(EntryComment $comment): string { return $this->urlGenerator->generate('entry_comment_edit', [ 'magazine_name' => $comment->magazine->name, 'entry_id' => $comment->entry->getId(), 'comment_id' => $comment->getId(), 'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug, ]); } public function entryCommentDeleteUrl(EntryComment $comment): string { return $this->urlGenerator->generate('entry_comment_delete', [ 'magazine_name' => $comment->magazine->name, 'entry_id' => $comment->entry->getId(), 'comment_id' => $comment->getId(), 'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug, ]); } public function entryCommentVotersUrl(EntryComment $comment, string $type): string { return $this->urlGenerator->generate('entry_comment_voters', [ 'magazine_name' => $comment->magazine->name, 'entry_id' => $comment->entry->getId(), 'comment_id' => $comment->getId(), 'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug, 'type' => $type, ]); } public function entryCommentFavouritesUrl(EntryComment $comment): string { return $this->urlGenerator->generate('entry_comment_favourites', [ 'magazine_name' => $comment->magazine->name, 'entry_id' => $comment->entry->getId(), 'comment_id' => $comment->getId(), 'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug, ]); } public function entryCommentModerateUrl(EntryComment $comment): string { return $this->urlGenerator->generate('entry_comment_moderate', [ 'magazine_name' => $comment->magazine->name, 'entry_id' => $comment->entry->getId(), 'comment_id' => $comment->getId(), 'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug, ]); } public function postUrl(Post $post): string { return $this->urlGenerator->generate('post_single', [ 'magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => empty($post->slug) ? '-' : $post->slug, ]); } public function postEditUrl(Post $post): string { return $this->urlGenerator->generate('post_edit', [ 'magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => empty($post->slug) ? '-' : $post->slug, ]); } public function postFavouritesUrl(Post $post): string { return $this->urlGenerator->generate('post_favourites', [ 'magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => empty($post->slug) ? '-' : $post->slug, ]); } public function postVotersUrl(Post $post, string $type): string { return $this->urlGenerator->generate('post_voters', [ 'magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => empty($post->slug) ? '-' : $post->slug, 'type' => $type, ]); } public function postModerateUrl(Post $post): string { return $this->urlGenerator->generate('post_moderate', [ 'magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => empty($post->slug) ? '-' : $post->slug, ]); } public function postDeleteUrl(Post $post): string { return $this->urlGenerator->generate('post_delete', [ 'magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => empty($post->slug) ? '-' : $post->slug, ]); } public function postCommentReplyUrl(PostComment $comment): string { return $this->urlGenerator->generate('post_comment_create', [ 'magazine_name' => $comment->magazine->name, 'post_id' => $comment->post->getId(), 'slug' => empty($comment->post->slug) ? '-' : $comment->post->slug, 'parent_comment_id' => $comment->getId(), ]); } public function postCommentEditUrl(PostComment $comment): string { return $this->urlGenerator->generate('post_comment_edit', [ 'magazine_name' => $comment->magazine->name, 'post_id' => $comment->post->getId(), 'comment_id' => $comment->getId(), 'slug' => empty($comment->post->slug) ? '-' : $comment->post->slug, ]); } public function postCommentModerateUrl(PostComment $comment): string { return $this->urlGenerator->generate('post_comment_moderate', [ 'magazine_name' => $comment->magazine->name, 'post_id' => $comment->post->getId(), 'comment_id' => $comment->getId(), 'slug' => empty($comment->post->slug) ? '-' : $comment->post->slug, ]); } public function postCommentVotersUrl(PostComment $comment): string { return $this->urlGenerator->generate('post_comment_voters', [ 'magazine_name' => $comment->magazine->name, 'post_id' => $comment->post->getId(), 'comment_id' => $comment->getId(), 'slug' => empty($comment->post->slug) ? '-' : $comment->post->slug, ]); } public function postCommentFavouritesUrl(PostComment $comment): string { return $this->urlGenerator->generate('post_comment_favourites', [ 'magazine_name' => $comment->magazine->name, 'post_id' => $comment->post->getId(), 'comment_id' => $comment->getId(), 'slug' => empty($comment->post->slug) ? '-' : $comment->post->slug, ]); } public function postCommentDeleteUrl(PostComment $comment): string { return $this->urlGenerator->generate('post_comment_delete', [ 'magazine_name' => $comment->magazine->name, 'post_id' => $comment->post->getId(), 'comment_id' => $comment->getId(), 'slug' => empty($comment->post->slug) ? '-' : $comment->post->slug, ]); } // $additionalParams indicates extra parameters to set in addition to [$name] = $value // Set $value to null to indicate deleting a parameter // TODO: It'd be better to have just a single $params which is an associative array public function optionsUrl(string $name, ?string $value, ?string $routeName = null, array $additionalParams = []): string { $route = $routeName ?? $this->requestStack->getCurrentRequest()->attributes->get('_route'); $params = $this->requestStack->getCurrentRequest()->attributes->get('_route_params', []); $queryParams = $this->requestStack->getCurrentRequest()->query->all(); if (\is_array($queryParams)) { $params = array_merge($params, $queryParams); } // Apply logic for additionalParams: set if value is not null, unset if value is null foreach ($additionalParams as $key => $val) { if (null !== $val) { // Set or update the parameter $params[$key] = $val; } else { // Unset the parameter if value is null unset($params[$key]); } } $params[$name] = $value; return $this->urlGenerator->generate($route, $params); } public function mentionUrl(string $username): string { return $this->mentionManager->getRoute([$username])[0]; } public function getCursorUrlValue(mixed $cursor): mixed { if ($cursor instanceof \DateTime || $cursor instanceof \DateTimeImmutable) { return $cursor->format(DATE_ATOM); } return $cursor; } } ================================================ FILE: src/Twig/Runtime/UserExtensionRuntime.php ================================================ security->getUser()) { return false; } return $this->security->getUser()->isFollower($followed); } public function isBlocked(User $blocked) { if (!$this->security->getUser()) { return false; } return $this->security->getUser()->isBlocked($blocked); } public function username(string $value, ?bool $withApPostfix = false): string { return $this->mentionManager->getUsername($value, $withApPostfix); } public function apDomain(string $value): string { return $this->mentionManager->getDomain($value); } public function getReputationTotal(User $user): int { return $this->userManager->getReputationTotal($user); } public function getInstanceOfUser(User $user): ?Instance { return $this->instanceRepository->getInstanceOfUser($user); } public function isInstanceOfUserBanned(User $user): bool { if (null === $user->apId) { return false; } return $this->settingsManager->isBannedInstance($user->apProfileId); } public function getUserAttitude(User $user): float { $attitude = $this->reputationRepository->getUserAttitudes($user->getId()); return $attitude[$user->getId()] ?? -1; } } ================================================ FILE: src/Utils/AddErrorDetailsStampListener.php ================================================ getThrowable(); if ($throwable instanceof HandlerFailedException) { $throwable = $throwable->getPrevious(); } if (null === $throwable) { return; } $stamp = new ErrorDetailsStamp($throwable::class, $throwable->getCode(), $throwable->getMessage()); $previousStamp = $event->getEnvelope()->last(ErrorDetailsStamp::class); // Do not append duplicate information if (null === $previousStamp || !$previousStamp->equals($stamp)) { $event->addStamps($stamp); } } public static function getSubscribedEvents(): array { return [ // must have higher priority than SendFailedMessageForRetryListener WorkerMessageFailedEvent::class => ['onMessageFailed', 200], ]; } } ================================================ FILE: src/Utils/ArrayUtils.php ================================================ name => self::Enabled->value, self::Hidden->name => self::Hidden->value, self::Disabled->name => self::Disabled->value, ]; } } ================================================ FILE: src/Utils/Embed.php ================================================ cache); unset($this->settings); unset($this->logger); unset($this->dispatcher); } public function fetch($url): self { if ($this->settings->isLocalUrl($url)) { if (ImageManager::isImageUrl($url)) { return $this->createLocalImage($url); } return $this; } $this->logger->debug('[Embed::fetch] leftover data', [ 'url' => $this->url, 'title' => $this->title, 'description' => $this->description, 'image' => $this->image, 'html' => $this->html, ]); return $this->cache->get( 'embed_'.md5($url), function (ItemInterface $item) use ($url) { $item->expiresAfter(3600); $this->dispatcher->dispatch(new CurlRequestBeginningEvent($url)); try { $embed = $this->fetchEmbed($url); $oembed = $embed->getOEmbed(); $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, true)); } catch (\Exception $e) { $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, false, exception: $e)); $this->logger->info('[Embed::fetch] Fetch failed: '.$e->getMessage()); $c = clone $this; return $c; } $c = clone $this; $c->url = $url; $c->title = $embed->title; $c->description = $embed->description; $c->image = (string) $embed->image; $c->html = $this->cleanIframe($oembed->html('html')); try { if (!$c->html && $embed->code) { $c->html = $this->cleanIframe($embed->code->html); } } catch (\TypeError $e) { $this->logger->info('[Embed::fetch] HTML prepare failed: '.$e->getMessage()); } $this->logger->debug('[Embed::fetch] Fetch success, returning', [ 'url' => $c->url, 'title' => $c->title, 'description' => $c->description, 'image' => $c->image, 'html' => $c->html, ]); return $c; } ); } private function fetchEmbed(string $url): Extractor { $fetcher = new BaseEmbed(); $embed = $fetcher->get($url); if ($this->detectFaultyRedirectEmbed($embed)) { $this->logger->debug('[Embed::fetch] Suspecting faulty redirect, refetching', [ 'requestUrl' => $url, 'responseUrl' => $embed->getUri(), ]); $embed = $fetcher->get((string) $embed->getUri()); } return $embed; } private function detectFaultyRedirectEmbed(Extractor $embed): bool { $request = $embed->getRequest(); $response = $embed->getResponse(); $isRedirected = $embed->getUri() !== $request->getUri() && !\in_array($response->getStatusCode(), [301, 302]) && $response->getHeaderLine('location'); $isEmptyEmbed = !( $embed->title || $embed->description || $embed->image || $embed->code?->html ); return $isRedirected && $isEmptyEmbed; } private function cleanIframe(?string $html): ?string { if (!$html || str_contains($html, 'wp-embedded-content')) { return null; } return $html; } private function createLocalImage(string $url): self { $c = clone $this; $c->url = $url; $c->html = \sprintf('', $url); return $c; } public function getType(): string { if ($this->isImageUrl()) { return Entry::ENTRY_TYPE_IMAGE; } if ($this->isVideoUrl()) { return Entry::ENTRY_TYPE_IMAGE; } if ($this->isVideoEmbed()) { return Entry::ENTRY_TYPE_VIDEO; } return Entry::ENTRY_TYPE_LINK; } public function isImageUrl(): bool { if (!$this->url) { return false; } return ImageManager::isImageUrl($this->url); } private function isVideoUrl(): bool { return false; } private function isVideoEmbed(): bool { if (!$this->html) { return false; } return str_contains($this->html, 'video') || str_contains($this->html, 'youtube') || str_contains($this->html, 'vimeo') || str_contains($this->html, 'streamable'); // @todo } } ================================================ FILE: src/Utils/ExifCleanMode.php ================================================ exiftoolPath = $params->get('exif_exiftool_path'); $this->timeout = $params->get('exif_exiftool_timeout') ?? self::EXIFTOOL_TIMEOUT_SECONDS; $this->exiftool = $this->getExifToolBinary(); } public function cleanImage(string $filePath, ExifCleanMode $mode) { if (ExifCleanMode::None === $mode) { $this->logger->debug("ExifCleaner:cleanImage: cleaning mode is 'None', nothing will be done."); return; } if (!$this->exiftool) { $this->logger->info('ExifCleaner:cleanImage: exiftool binary was not found, nothing will be done.'); return; } try { $ps = $this->buildProcess($mode, $filePath, $this->exiftool); $ps->mustRun(); $this->logger->debug( 'ExifCleaner:cleanImage: exiftool success:', ['stdout' => $ps->getOutput()], ); } catch (ProcessFailedException $e) { $this->logger->warning('ExifCleaner:cleanImage: exiftool failed: '.$e->getMessage()); } } private function getExifToolBinary(): ?string { if ($this->exiftoolPath && is_executable($this->exiftoolPath)) { return $this->exiftoolPath; } $which = new ExecutableFinder(); $cmdpath = $which->find(self::EXIFTOOL_COMMAND_NAME); return $cmdpath; } private function getCleaningArguments(ExifCleanMode $mode): array { return match ($mode) { ExifCleanMode::None => [], ExifCleanMode::Sanitize => self::EXIFTOOL_ARGS_SANITIZE, ExifCleanMode::Scrub => self::EXIFTOOL_ARGS_SCRUB, }; } private function buildProcess(ExifCleanMode $mode, string $filePath, string $exiftool): Process { $ps = new Process(array_merge( [$exiftool, $filePath], self::EXIFTOOL_ARGS_COMMON, $this->getCleaningArguments($mode), )); $ps->setTimeout($this->timeout); return $ps; } } ================================================ FILE: src/Utils/GeneralUtil.php ================================================ pluralize($shortClassName)[0]); return strtolower("/api/{$pluralName}/{$apiResource->getId()}"); } } ================================================ FILE: src/Utils/JsonldUtils.php ================================================ slug($this->getWords($val), '-', 'en')->toString(), 0, 255); } private function getWords(string $sentence, int $count = 10): string { preg_match("/(?:\S+(?:\W+|$)){0,$count}/", $sentence, $matches); return $matches[0]; } } ================================================ FILE: src/Utils/SqlHelpers.php ================================================ 0) { $where .= ' AND '; } $where .= "($whereClause)"; ++$i; } return $where; } /** * This method rewrites the parameter array and the native sql string to make use of array parameters * which are not supported by sql directly. Keep in mind that postgresql has a limit of 65k parameters * and each one of the array values counts as one parameter (because it only works that way). * * @return array{sql: string, parameters: array}> */ public static function rewriteArrayParameters(array $parameters, string $sql): array { $newParameters = []; $newSql = $sql; foreach ($parameters as $name => $value) { if (\is_array($value)) { $size = \sizeof($value); $newParameterNames = []; for ($i = 0; $i < $size; ++$i) { $newParameters["$name$i"] = $value[$i]; $newParameterNames[] = ":$name$i"; } if (\sizeof($newParameterNames) > 0) { $newParameterName = join(',', $newParameterNames); $newSql = str_replace(":$name", $newParameterName, $newSql); } else { // for dealing with empty array parameters we put a -1 in there, // because just an empty `IN ()` will throw a syntax error $newParameters[$name] = -1; } } else { $newParameters[$name] = $value; } } return [ 'parameters' => $newParameters, 'sql' => $newSql, ]; } public static function getSqlType(mixed $value): string|int { if ($value instanceof \DateTimeImmutable) { return Types::DATETIMETZ_IMMUTABLE; } elseif ($value instanceof \DateTime) { return Types::DATETIMETZ_MUTABLE; } elseif (\is_int($value)) { return Types::INTEGER; } return Types::STRING; } public static function invertOrderings(array $orderings): array { $newOrderings = []; foreach ($orderings as $ordering) { if (str_contains($ordering, 'DESC')) { $newOrderings[] = str_replace('DESC', 'ASC', $ordering); } elseif (str_contains($ordering, 'ASC')) { $newOrderings[] = str_replace('ASC', 'DESC', $ordering); } else { // neither ASC nor DESC means ASC $newOrderings[] = $ordering.' DESC'; } } return $newOrderings; } public function getBlockedMagazinesDql(User $user): string { return $this->entityManager->createQueryBuilder() ->select('bm') ->from(MagazineBlock::class, 'bm') ->where('bm.magazine = m') ->andWhere('bm.user = :user') ->setParameter('user', $user) ->getDQL(); } public function getBlockedUsersDql(User $user): string { return $this->entityManager->createQueryBuilder() ->select('ub') ->from(UserBlock::class, 'ub') ->where('ub.blocker = :user') ->andWhere('ub.blocked = u') ->setParameter('user', $user) ->getDql(); } /** * @return int[] the ids of the users $user follows */ public function getCachedUserFollows(User $user): array { try { $sql = 'SELECT following_id FROM user_follow WHERE follower_id = :uId'; if ('test' === $this->kernel->getEnvironment()) { return $this->fetchSingleColumnAsArray($sql, $user); } return $this->cache->get(self::USER_FOLLOWS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } } public function clearCachedUserFollows(User $user): void { $this->logger->debug('Clearing cached user follows for user {u}', ['u' => $user->username]); try { $this->cache->delete(self::USER_FOLLOWS_KEY.$user->getId()); } catch (InvalidArgumentException $exception) { $this->logger->warning('There was an error clearing the cached user follows of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); } } /** * @return int[] the ids of the magazines $user is subscribed to */ public function getCachedUserSubscribedMagazines(User $user): array { try { $sql = 'SELECT magazine_id FROM magazine_subscription WHERE user_id = :uId'; if ('test' === $this->kernel->getEnvironment()) { return $this->fetchSingleColumnAsArray($sql, $user); } return $this->cache->get(self::USER_MAGAZINE_SUBSCRIPTION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } } public function clearCachedUserSubscribedMagazines(User $user): void { $this->logger->debug('Clearing cached magazine subscriptions for user {u}', ['u' => $user->username]); try { $this->cache->delete(self::USER_MAGAZINE_SUBSCRIPTION_KEY.$user->getId()); } catch (InvalidArgumentException $exception) { $this->logger->warning('There was an error clearing the cached subscribed Magazines of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); } } /** * @return int[] the ids of the magazines $user moderates */ public function getCachedUserModeratedMagazines(User $user): array { try { $sql = 'SELECT magazine_id FROM moderator WHERE user_id = :uId'; if ('test' === $this->kernel->getEnvironment()) { return $this->fetchSingleColumnAsArray($sql, $user); } return $this->cache->get(self::USER_MAGAZINE_MODERATION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } } public function clearCachedUserModeratedMagazines(User $user): void { $this->logger->debug('Clearing cached moderated magazines for user {u}', ['u' => $user->username]); try { $this->cache->delete(self::USER_MAGAZINE_MODERATION_KEY.$user->getId()); } catch (InvalidArgumentException $exception) { $this->logger->warning('There was an error clearing the cached moderated magazines of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); } } /** * @return int[] the ids of the domains $user is subscribed to */ public function getCachedUserSubscribedDomains(User $user): array { try { $sql = 'SELECT domain_id FROM domain_subscription WHERE user_id = :uId'; if ('test' === $this->kernel->getEnvironment()) { return $this->fetchSingleColumnAsArray($sql, $user); } return $this->cache->get(self::USER_DOMAIN_SUBSCRIPTION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } } public function clearCachedUserSubscribedDomains(User $user): void { $this->logger->debug('Clearing cached domain subscriptions for user {u}', ['u' => $user->username]); try { $this->cache->delete(self::USER_DOMAIN_SUBSCRIPTION_KEY.$user->getId()); } catch (InvalidArgumentException $exception) { $this->logger->warning('There was an error clearing the cached subscribed domains of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); } } /** * @return int[] the ids of the domains $user is subscribed to */ public function getCachedUserBlocks(User $user): array { try { $sql = 'SELECT blocked_id FROM user_block WHERE blocker_id = :uId'; if ('test' === $this->kernel->getEnvironment()) { return $this->fetchSingleColumnAsArray($sql, $user); } return $this->cache->get(self::USER_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } } public function clearCachedUserBlocks(User $user): void { $this->logger->debug('Clearing cached user blocks for user {u}', ['u' => $user->username]); try { $this->cache->delete(self::USER_BLOCKS_KEY.$user->getId()); } catch (InvalidArgumentException $exception) { $this->logger->warning('There was an error clearing the cached blocked user of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); } } /** * @return int[] the ids of the domains $user is subscribed to */ public function getCachedUserMagazineBlocks(User $user): array { try { $sql = 'SELECT magazine_id FROM magazine_block WHERE user_id = :uId'; if ('test' === $this->kernel->getEnvironment()) { return $this->fetchSingleColumnAsArray($sql, $user); } return $this->cache->get(self::USER_MAGAZINE_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } } public function clearCachedUserMagazineBlocks(User $user): void { $this->logger->debug('Clearing cached magazine blocks for user {u}', ['u' => $user->username]); try { $this->cache->delete(self::USER_MAGAZINE_BLOCKS_KEY.$user->getId()); } catch (InvalidArgumentException $exception) { $this->logger->warning('There was an error clearing the cached blocked magazines of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); } } /** * @return int[] the ids of the domains $user is subscribed to */ public function getCachedUserDomainBlocks(User $user): array { try { $sql = 'SELECT domain_id FROM domain_block WHERE user_id = :uId'; if ('test' === $this->kernel->getEnvironment()) { return $this->fetchSingleColumnAsArray($sql, $user); } return $this->cache->get(self::USER_DOMAIN_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } } public function clearCachedUserDomainBlocks(User $user): void { $this->logger->debug('Clearing cached domain blocks for user {u}', ['u' => $user->username]); try { $this->cache->delete(self::USER_DOMAIN_BLOCKS_KEY.$user->getId()); } catch (InvalidArgumentException $exception) { $this->logger->warning('There was an error clearing the cached blocked domains of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); } } /** * @param string $sql the sql to fetch the single column, should contain a 'uId' Parameter * * @return int[] * * @throws Exception */ public function fetchSingleColumnAsArray(string $sql, User $user): array { $conn = $this->entityManager->getConnection(); $stmt = $conn->prepare($sql); $stmt->bindValue('uId', $user->getId(), ParameterType::INTEGER); $result = $stmt->executeQuery(); $rows = $result->fetchAllAssociative(); $result = []; foreach ($rows as $row) { $result[] = $row[array_key_first($row)]; } $this->logger->debug('Fetching single column row from {sql}: {res}', ['sql' => $sql, 'res' => $result]); return $result; } public static function getRealClassName(EntityManagerInterface $entityManager, mixed $object): string { return $entityManager->getClassMetadata(\get_class($object))->getName(); } /** * This method is useful for gathering more entities than the parameter limit allows for. * * @template-covariant T * * @param ServiceEntityRepository $repository * * @return T[] */ public static function findByAdjusted(ServiceEntityRepository $repository, string $columnName, array $values): array { $split = ArrayUtils::sliceArrayIntoEqualPieces($values, 65000); $results = []; foreach ($split as $part) { $results[] = $repository->findBy([$columnName => $part]); } return array_merge(...$results); } } ================================================ FILE: src/Utils/SubscriptionSort.php ================================================ removeVar($url, $tag); } return $url; } private function removeVar(string $url, string $var): string { [$urlPart, $qsPart] = array_pad(explode('?', $url), 2, ''); parse_str($qsPart, $qsVars); unset($qsVars[$var]); $newQs = http_build_query($qsVars); return $this->validate(trim($urlPart.'?'.$newQs, '?')); } private function validate(string $url): string { // @todo checkdnsrr? if (!filter_var($url, FILTER_VALIDATE_URL)) { throw new BadUrlException($url); } return $url; } } ================================================ FILE: src/Utils/UrlUtils.php ================================================ headers->get('Accept', default: 'html'); return str_contains($acceptValue, 'application/activity+json') || str_contains($acceptValue, 'application/ld+json'); } public static function getCacheKeyForMarkdownUrl(string $url): string { $key = preg_replace(RegPatterns::INVALID_TAG_CHARACTERS, '_', $url); return "markdown_url_$key"; } public static function getCacheKeyForMarkdownUserMention(string $url): string { $key = preg_replace(RegPatterns::INVALID_TAG_CHARACTERS, '_', $url); return "markdown_user_mention_$key"; } public static function getCacheKeyForMarkdownMagazineMention(string $url): string { $key = preg_replace(RegPatterns::INVALID_TAG_CHARACTERS, '_', $url); return "markdown_magazine_mention_$key"; } public static function extractUrlsFromString(string $text): array { $words = preg_split(RegPatterns::URL_SEPARATOR_REGEX, $text); $urls = []; foreach ($words as $word) { if (filter_var($word, FILTER_VALIDATE_URL)) { $urls[] = $word; } } return $urls; } } ================================================ FILE: src/Validator/NoSurroundingWhitespace.php ================================================ 'NO_SURROUNDING_WHITESPACE_ERROR', ]; public string $message = 'The value must not have whitespaces at the beginning or end.'; #[HasNamedArguments] public function __construct( public bool $allowEmpty = false, ) { parent::__construct(); } public function getTargets(): array { return [Constraint::PROPERTY_CONSTRAINT]; } } ================================================ FILE: src/Validator/NoSurroundingWhitespaceValidator.php ================================================ allowEmpty) { return; } $this->context->buildViolation($constraint->message) ->setCode(NoSurroundingWhitespace::NOT_UNIQUE_ERROR) ->addViolation(); } if (trim($value) !== $value) { $this->context->buildViolation($constraint->message) ->setCode(NoSurroundingWhitespace::NOT_UNIQUE_ERROR) ->addViolation(); } } } ================================================ FILE: src/Validator/Unique.php ================================================ 'NOT_UNIQUE_ERROR', ]; public string $message = 'This value is already used.'; /** * @param non-empty-array $fields DTO -> entity field mapping * @param array $idFields DTO -> entity ID field mapping * @param class-string $entityClass */ #[HasNamedArguments] public function __construct( public string $entityClass, public string $errorPath, public array $fields, public array $idFields = [], ) { parent::__construct(); if (0 === \count($fields)) { throw new InvalidOptionsException('`fields` option must have at least one field', ['fields']); } if (null === $entityClass || '' === $entityClass) { throw new InvalidOptionsException('Bad entity class', ['entityClass']); } } public function getTargets(): array { return [Constraint::CLASS_CONSTRAINT]; } } ================================================ FILE: src/Validator/UniqueValidator.php ================================================ entityManager->createQueryBuilder() ->select('COUNT(e)') ->from($constraint->entityClass, 'e'); $propertyAccessor = PropertyAccess::createPropertyAccessor(); foreach ($constraint->fields as $dtoField => $entityField) { if (\is_int($dtoField)) { $dtoField = $entityField; } $fieldValue = $propertyAccessor->getValue($value, $dtoField); if (\is_string($fieldValue)) { $qb->andWhere($qb->expr()->eq("LOWER(e.$entityField)", ":f_$entityField")); $qb->setParameter("f_$entityField", mb_strtolower($fieldValue)); } else { $qb->andWhere($qb->expr()->eq("e.$entityField", ":f_$entityField")); $qb->setParameter("f_$entityField", $fieldValue); } } foreach ($constraint->idFields as $dtoField => $entityField) { if (\is_int($dtoField)) { $dtoField = $entityField; } $fieldValue = $propertyAccessor->getValue($value, $dtoField); if (null !== $fieldValue) { $qb->andWhere($qb->expr()->neq("e.$entityField", ":i_$entityField")); $qb->setParameter("i_$entityField", $fieldValue); } } $count = $qb->getQuery()->getSingleScalarResult(); if ($count > 0) { $this->context->buildViolation($constraint->message) ->setCode(Unique::NOT_UNIQUE_ERROR) ->atPath($constraint->errorPath) ->addViolation(); } } } ================================================ FILE: templates/_email/application_approved.html.twig ================================================ {% extends '_email/email_base.html.twig' %} {%- block title -%} {{- 'email_application_approved_title'|trans }} {%- endblock -%} {% block body %}

    {{ 'email_application_approved_body'|trans({ '%link%': url('app_login'), '%siteName%': kbin_domain(), })|raw }}

    {% if user.isVerified is same as false %}

    {{ 'email_verification_pending'|trans }}

    {% endif %} {% endblock %} ================================================ FILE: templates/_email/application_rejected.html.twig ================================================ {% extends '_email/email_base.html.twig' %} {%- block title -%} {{- 'email_application_rejected_title'|trans }} {%- endblock -%} {% block body %}

    {{ 'email_application_rejected_body'|trans }}

    {% endblock %} ================================================ FILE: templates/_email/confirmation_email.html.twig ================================================ {% extends "_email/email_base.html.twig" %} {%- block title -%} {{- 'email_confirm_header'|trans }} {%- endblock -%} {% block body %}

    {{ 'email_confirm_header'|trans }}

    {{ 'email_confirm_content'|trans }}

    {{ 'email_verify'|trans }}

    {% if user.getApplicationStatus() is not same as enum('App\\Enums\\EApplicationStatus').Approved %}

    {{ 'email_application_pending'|trans }}

    {% endif %}

    {{ 'email_confirm_expire'|trans }}

    Cheers!

    {% endblock %} ================================================ FILE: templates/_email/contact.html.twig ================================================ {% extends "_email/email_base.html.twig" %} {%- block title -%} {{- 'contact'|trans }} {%- endblock -%} {% block body %}

    {{ 'contact'|trans }}

    Name: {{ name }}

    Email: {{ senderEmail }}

    Message: {{ message }}

    {% endblock %} ================================================ FILE: templates/_email/delete_account_request.html.twig ================================================ {% extends "_email/email_base.html.twig" %} {%- block title -%} {{- 'email.delete.title'|trans }} {%- endblock -%} {% block body %}

    {{'email.delete.title'|trans}}

    {{'email.delete.description'|trans}}

    Username: {{ username }}

    Email: {{ mail }}

    {% endblock %} ================================================ FILE: templates/_email/email_base.html.twig ================================================ {% apply inline_css(encore_entry_css_source('email')) %} {%- block title -%}{{ kbin_meta_title() }}{%- endblock -%}
    {% block body %}{% endblock %}
    {% endapply %} ================================================ FILE: templates/_email/reset_pass_confirm.html.twig ================================================ {% extends "_email/email_base.html.twig" %} {%- block title -%} {{- 'password_confirm_header'|trans }} {%- endblock -%} {% block body %}

    {{ 'password_confirm_header'|trans }}

    {{ 'email_confirm_expire'|trans }}

    {{'email_confirm_button_text'|trans}}

    Cheers!

    {{'email_confirm_link_help'|trans}}: {{ url('app_reset_password', {token: resetToken.token}) }}

    {% endblock %} ================================================ FILE: templates/admin/_options.html.twig ================================================ {%- set TYPE_GENERAL = constant('App\\Repository\\StatsRepository::TYPE_GENERAL') -%} {%- set TYPE_CONTENT = constant('App\\Repository\\StatsRepository::TYPE_CONTENT') -%} {%- set TYPE_VOTES = constant('App\\Repository\\StatsRepository::TYPE_VOTES') -%} {%- set STATUS_PENDING = constant('App\\Entity\\Report::STATUS_PENDING') -%} ================================================ FILE: templates/admin/dashboard.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'dashboard'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-admin-dashboard{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'admin/_options.html.twig' %}
    {% include 'stats/_filters.html.twig' %}

    {{ 'users'|trans|upper }}

    {{ users }}

    {{ 'magazines'|trans|upper }}

    {{ magazines }}

    {{ 'votes'|trans|upper }}

    {{ votes }}

    {{ 'threads'|trans|upper }}

    {{ entries }}

    {{ 'comments'|trans|upper }}

    {{ comments }}

    {{ 'posts'|trans|upper }}

    {{ posts }}

    {% endblock %} ================================================ FILE: templates/admin/deletion_magazines.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'deletion'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-admin-deletion{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'admin/_options.html.twig' %} {% if magazines|length %}
    {% for magazine in magazines %} {% endfor %}
    {{ 'name'|trans }} {{ 'threads'|trans }} {{ 'comments'|trans }} {{ 'posts'|trans }} {{ 'marked_for_deletion'|trans }}
    {{ component('magazine_inline', { magazine: magazine, stretchedLink: true, showAvatar: true, showNewIcon: true}) }} {{ magazine.entryCount }} {{ magazine.entryCommentCount }} {{ magazine.postCount + magazine.postCommentCount }} {{ component('date', {date: magazine.markedForDeletionAt}) }}
    {% else %} {% endif %} {% if(magazines.haveToPaginate is defined and magazines.haveToPaginate) %} {{ pagerfanta(magazines, null, {'pageParameter':'[p]'}) }} {% endif %} {% endblock %} ================================================ FILE: templates/admin/deletion_users.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'deletion'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-admin-deletion{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'admin/_options.html.twig' %} {% if users|length %}
    {% for user in users %} {% endfor %}
    {{ 'username'|trans }} {{ 'email'|trans }} {{ 'created_at'|trans }} {{ 'marked_for_deletion'|trans }}
    {{ component('user_inline', {user: user, showNewIcon: true}) }} {{ user.email }} {{ component('date', {date: user.createdAt}) }} {{ component('date', {date: user.markedForDeletionAt}) }}
    {% else %} {% endif %} {% if(users.haveToPaginate is defined and users.haveToPaginate) %} {{ pagerfanta(users, null, {'pageParameter':'[p]'}) }} {% endif %} {% endblock %} ================================================ FILE: templates/admin/federation.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'federation'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-admin-federation page-settings{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'admin/_options.html.twig' %}
    {{ form_start(form) }}
    {{ form_label(form.federationEnabled, 'federation_enabled') }} {{ form_widget(form.federationEnabled) }}
    {{ form_label(form.federationPageEnabled, 'federation_page_enabled') }} {{ form_widget(form.federationPageEnabled) }}
    {{ form_label(form.federationUsesAllowList, 'federation_uses_allowlist') }} {{ form_widget(form.federationUsesAllowList) }}
    {{ form_help(form.federationUsesAllowList) }}
    {{ form_row(form.submit, { 'label': 'save'|trans, attr: {class: 'btn btn__primary'} }) }}
    {{ form_end(form) }}
    {% if useAllowList %}
    {% else %}
    {% endif %}

    {% if useAllowList %} {{ 'allowed_instances'|trans }} {% else %} {{ 'banned_instances'|trans }} {% endif %}

    {{ component('instance_list', {'instances': instances, 'showDenyButton': useAllowList, 'showUnBanButton': not useAllowList}) }}

    {{ 'instances'|trans }}

    {{ component('instance_list', { 'instances': allInstances, 'showDenyButton': useAllowList, 'showUnBanButton': not useAllowList, 'showAllowButton': useAllowList, 'showBanButton': not useAllowList }) }}
    {% endblock %} ================================================ FILE: templates/admin/federation_defederate_instance.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'defederating_instance'|trans }} {{ instance.domain }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-federation page-defederation{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'admin/_options.html.twig' %} {% include 'layout/_flash.html.twig' %}

    {{ 'defederating_instance'|trans({'%i': instance.domain}) }}

    {{ 'magazines'|trans }} {{ counts['magazines'] }}
    {{ 'users'|trans }} {{ counts['users'] }}
    {{ 'their_user_follows'|trans }} {{ counts['ourUserFollows'] }}
    {{ 'our_user_follows'|trans }} {{ counts['theirUserFollows'] }}
    {{ 'their_magazine_subscriptions'|trans }} {{ counts['ourSubscriptions'] }}
    {{ 'our_magazine_subscriptions'|trans }} {{ counts['theirSubscriptions'] }}
    {{ form_start(form) }} {{ form_errors(form) }}
    {{ form_errors(form.confirm) }}
    {{ form_label(form.confirm, 'confirm_defederation') }} {{ form_widget(form.confirm) }}
    {{ form_row(form.submit, { 'label': useAllowList ? 'btn_deny'|trans : 'ban'|trans, attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }}
    {% endblock %} ================================================ FILE: templates/admin/magazine_ownership.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'ownership_requests'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-admin-ownership-requests{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'admin/_options.html.twig' %} {% if requests|length %}
    {% for request in requests %} {% endfor %}
    {{ 'magazine'|trans }} {{ 'user'|trans }} {{ 'reputation_points'|trans }} {{ 'action'|trans }}
    {{ component('magazine_inline', {magazine: request.magazine, showNewIcon: true}) }} {{ component('user_inline', {user: request.user, showNewIcon: true}) }} {{ get_reputation_total(request.user) }}
    {% else %} {% endif %} {% if(requests.haveToPaginate is defined and requests.haveToPaginate) %} {{ pagerfanta(requests, null, {'pageParameter':'[p]'}) }} {% endif %} {% endblock %} ================================================ FILE: templates/admin/moderators.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'moderators'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazine-panel page-magazine-moderators{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'admin/_options.html.twig' %} {% if moderators|length %}
      {% for moderator in moderators %}
    • {% if moderator.avatar %} {{ component('user_avatar', {user: moderator}) }} {% endif %}
      {{ moderator.username|username(true) }} {{ component('date', {date: moderator.createdAt}) }}
    • {% endfor %}
    {% if(moderators.haveToPaginate is defined and moderators.haveToPaginate) %} {{ pagerfanta(moderators, null, {'pageParameter':'[p]'}) }} {% endif %} {% else %} {% endif %}
    {{ form_start(form) }}
    {{ form_errors(form.user) }}
    {{ form_label(form.user, 'username') }} {{ form_widget(form.user) }}
    {{ form_row(form.submit, { 'label': 'add_moderator', attr: {class: 'btn btn__primary'} }) }}
    {{ form_end(form) }}
    {% endblock %} ================================================ FILE: templates/admin/monitoring/_monitoring_single_options.html.twig ================================================
  • {{ 'overview'|trans }}
  • {% if context.queries|length %}
  • {{ 'monitoring_queries'|trans({'%count%': 2}) }}
  • {% endif %} {% if context.twigRenders|length %}
  • {{ 'monitoring_twig_renders'|trans }}
  • {% endif %} {% if context.curlRequests|length %}
  • {{ 'monitoring_curl_requests'|trans({'%count%': 2}) }}
  • {% endif %}
    ================================================ FILE: templates/admin/monitoring/_monitoring_single_overview.html.twig ================================================ {% set queryDuration = context.queryDurationMilliseconds %} {% set twigDuration = context.twigRenderDurationMilliseconds %} {% set curlRequestDuration = context.curlRequestDurationMilliseconds %}
    {{ 'monitoring_user_type'|trans }}{{ context.userType }}
    {{ 'monitoring_path'|trans }}{{ context.path }}
    {{ 'monitoring_handler'|trans }}{{ context.handler }}
    {{ 'monitoring_started'|trans }}{{ component('date', {'date': context.startedAt}) }}
    {{ 'monitoring_duration'|trans }}{{ context.duration|round(2) }}ms
    {{ 'monitoring_queries'|trans({'%count%': 2}) }}{{ context.queries.count() }} in {{ queryDuration|round(2) }}ms ({{ (100 / context.duration * queryDuration)|round }}%)
    {{ 'monitoring_twig_renders'|trans() }}{{ context.twigRenders.count() }} in {{ twigDuration|round(2) }}ms ({{ (100 / context.duration * twigDuration)|round }}%)
    {{ 'monitoring_curl_requests'|trans() }}{{ context.curlRequests.count() }} in {{ curlRequestDuration|round(2) }}ms ({{ (100 / context.duration * curlRequestDuration)|round }}%)
    {{ 'monitoring_duration_sending_response'|trans() }}{{ context.responseSendingDurationMilliseconds|round(2) }}ms ({{ (100 / context.duration * context.responseSendingDurationMilliseconds)|round }}%)
    ================================================ FILE: templates/admin/monitoring/_monitoring_single_queries.html.twig ================================================ {{ 'monitoring_queries'|trans({'%count%': 2}) }} {% if groupSimilar %} {{ 'monitoring_dont_group_similar'|trans }} {% else %} {{ 'monitoring_group_similar'|trans }} {% endif %} {% if formatQuery %} {{ 'monitoring_dont_format_query'|trans }} {% else %} {{ 'monitoring_format_query'|trans }} {% endif %} {% if showParameters %} {{ 'monitoring_dont_show_parameters'|trans }} {% else %} {{ 'monitoring_show_parameters'|trans }} {% endif %} {% if groupSimilar is same as false %} {% else %} {% endif %} {% if groupSimilar is same as false %} {% for query in context.getQueriesSorted() %} {% endfor %} {% else %} {% for query in context.getGroupedQueries() %} {% endfor %} {% endif %}
    {{ 'monitoring_queries'|trans({'%count%': 1}) }}{{ 'monitoring_duration'|trans }}{{ 'monitoring_duration_min'|trans }} {{ 'monitoring_duration_mean'|trans }} {{ 'monitoring_duration_max'|trans }} {{ 'monitoring_query_count'|trans }} {{ 'monitoring_query_total'|trans }}
    {% if formatQuery %}
    {{ query.queryString.query|formatQuery }}
    {% else %}
    {{ query.queryString.query }}
    {% endif %}
    {% if showParameters %}
    {{ 'parameters'|trans }} = {{ query.parameters|json_encode(constant('JSON_PRETTY_PRINT')) }}
    {% endif %}
    {{ query.duration|round(2) }}ms
    {% if formatQuery %}
    {{ query.query|formatQuery }}
    {% else %}
    {{ query.query }}
    {% endif %}
    {{ query.minExecutionTime|round(2) }}ms {{ query.maxExecutionTime|round(2) }}ms {{ query.meanExecutionTime|round(2) }}ms {{ query.count }} {{ query.totalExecutionTime|round(2) }}ms
    ================================================ FILE: templates/admin/monitoring/_monitoring_single_requests.html.twig ================================================ {% for request in context.getRequestsSorted() %} {% endfor %}
    {{ 'monitoring_http_method'|trans }} {{ 'monitoring_url'|trans }} {{ 'monitoring_request_successful'|trans }} {{ 'monitoring_duration'|trans }}
    {{ request.method }} {{ request.url }} {{ request.wasSuccessful ? 'yes'|trans : 'no'|trans }} {{ request.duration|round(2) }}ms
    ================================================ FILE: templates/admin/monitoring/_monitoring_single_twig.html.twig ================================================ {% if compareToParent %} {{ 'monitoring_twig_compare_to_total'|trans }} {% else %} {{ 'monitoring_twig_compare_to_parent'|trans }} {% endif %} {% for render in context.getRootTwigRenders() %} {{ component('monitoring_twig_render', {'render': render, 'compareToParent': compareToParent}) }} {% endfor %} ================================================ FILE: templates/admin/monitoring/monitoring.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'pages'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-admin-monitoring{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'admin/_options.html.twig' %}
    {% if chart is not same as null %}

    {{ 'monitoring_route_overview'|trans }}

    {% if configuration['monitoringEnabled'] is not same as true %} {{ 'monitoring_disabled'|trans }} {% else %} {% if configuration['monitoringQueriesEnabled'] is same as true %} {% if configuration['monitoringQueriesPersistingEnabled'] is same as true %} {{ 'monitoring_queries_enabled_persisted'|trans }} {% else %} {{ 'monitoring_queries_enabled_not_persisted'|trans }} {% endif %} {% else %} {{ 'monitoring_queries_disabled'|trans }} {% endif %} {% if configuration['monitoringTwigRendersEnabled'] is same as true %} {% if configuration['monitoringTwigRendersPersistingEnabled'] is same as true %} {{ 'monitoring_twig_renders_enabled_persisted'|trans }} {% else %} {{ 'monitoring_twig_renders_enabled_not_persisted'|trans }} {% endif %} {% else %} {{ 'monitoring_twig_renders_disabled'|trans }} {% endif %} {% if configuration['monitoringCurlRequestsEnabled'] is same as true %} {% if configuration['monitoringCurlRequestPersistingEnabled'] is same as true %} {{ 'monitoring_curl_requests_enabled_persisted'|trans }} {% else %} {{ 'monitoring_curl_requests_enabled_not_persisted'|trans }} {% endif %} {% else %} {{ 'monitoring_curl_requests_disabled'|trans }} {% endif %} {% endif %}

    {{ 'monitoring_route_overview_description'|trans }}

    {{ render_chart(chart, {'data-chart-unit-value': 'ms'}) }} {% endif %} {{ form_start(form) }}
    {{ form_row(form.executionType) }}
    {{ form_row(form.userType) }}
    {{ form_row(form.path) }}
    {{ form_row(form.handler) }}
    {{ form_row(form.durationMinimum) }}
    {{ form_row(form.hasException) }}
    {{ form_row(form.createdFrom) }}
    {{ form_row(form.createdTo) }}
    {{ form_row(form.chartOrdering) }}
    {{ form_row(form.submit, {'attr': {'class': 'btn btn__primary'}}) }}
    {{ form_end(form) }} {% for context in executionContexts %} {% if context.executionType is same as 'messenger' %} {% else %} {% endif %} {% endfor %}
    {{ 'monitoring_user_type'|trans }} {{ 'monitoring_path'|trans }} {{ 'monitoring_handler'|trans }} {{ 'monitoring_started'|trans }} {{ 'monitoring_duration'|trans }}
    {{ context.uuid|uuidEnd }} messenger{{ context.userType }}{{ context.path }} {{ context.handler }} {{ context.startedAt|date }} {{ context.duration|round(2) }}ms
    {% if(executionContexts.haveToPaginate is defined and executionContexts.haveToPaginate) %} {{ pagerfanta(executionContexts, null, {'pageParameter':'[p]'}) }} {% endif %} {% endblock %} ================================================ FILE: templates/admin/monitoring/monitoring_single.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'pages'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-admin-monitoring{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'admin/_options.html.twig' %} {% include 'admin/monitoring/_monitoring_single_options.html.twig' %}
    {% if page is same as 'overview' %} {{ include('admin/monitoring/_monitoring_single_overview.html.twig') }} {% elseif page is same as 'queries' %} {{ include('admin/monitoring/_monitoring_single_queries.html.twig') }} {% elseif page is same as 'twig' %} {{ include('admin/monitoring/_monitoring_single_twig.html.twig') }} {% elseif page is same as 'requests' %} {{ include('admin/monitoring/_monitoring_single_requests.html.twig') }} {% endif %}
    {% endblock %} ================================================ FILE: templates/admin/pages.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'pages'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-admin-settings page-settings{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'admin/_options.html.twig' %}
    {{ form_start(form) }} {{ component('editor_toolbar', {id: 'page_body'}) }} {{ form_row(form.body, {label: false, attr: {placeholder: 'body', 'data-controller': 'rich-textarea autogrow', 'data-entry-link-create-target': 'admin_pages'}}) }}
    {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }}
    {% endblock %} ================================================ FILE: templates/admin/reports.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'reports'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-admin-federation{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {# global mods can see this page, but not navigate to any other menu option, so hiding it for now #} {% if is_granted('ROLE_ADMIN') %} {% include 'admin/_options.html.twig' %} {% endif %} {{ component('report_list', {reports: reports}) }} {% endblock %} ================================================ FILE: templates/admin/settings.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'settings'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-admin-settings page-settings{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'admin/_options.html.twig' %}

    {{ 'settings'|trans }}

    {{ form_start(form) }}

    {{ 'general'|trans }}

    {{ form_row(form.KBIN_DOMAIN, {label: 'domain'}) }} {{ form_row(form.KBIN_CONTACT_EMAIL, {label: 'contact_email'}) }}
    {{ form_label(form.MBIN_DEFAULT_THEME, 'default_theme') }} {{ form_widget(form.MBIN_DEFAULT_THEME, {attr: {'aria-label': 'change_theme'|trans}}) }}

    {{ 'meta'|trans }}

    {{ form_row(form.KBIN_META_TITLE, {label: 'title'}) }} {{ form_row(form.KBIN_META_DESCRIPTION, {label: 'description'}) }} {{ form_row(form.KBIN_META_KEYWORDS, {label: 'keywords'}) }}

    {{ 'instance'|trans }}

    {{ form_row(form.KBIN_TITLE, {label: 'title'}) }}
    {{ form_label(form.MBIN_DOWNVOTES_MODE, 'downvotes_mode') }} {{ form_widget(form.MBIN_DOWNVOTES_MODE, {attr: {'aria-label': 'change_downvotes_mode'|trans}}) }}
    {{ form_label(form.KBIN_HEADER_LOGO, 'header_logo') }} {{ form_widget(form.KBIN_HEADER_LOGO) }}
    {{ form_label(form.KBIN_REGISTRATIONS_ENABLED, 'registrations_enabled') }} {{ form_widget(form.KBIN_REGISTRATIONS_ENABLED) }}
    {{ form_label(form.MBIN_SSO_REGISTRATIONS_ENABLED, 'sso_registrations_enabled') }} {{ form_widget(form.MBIN_SSO_REGISTRATIONS_ENABLED) }}
    {{ form_label(form.MBIN_SSO_ONLY_MODE, 'sso_only_mode') }} {{ form_widget(form.MBIN_SSO_ONLY_MODE) }}
    {{ form_label(form.KBIN_CAPTCHA_ENABLED, 'captcha_enabled') }} {{ form_widget(form.KBIN_CAPTCHA_ENABLED) }}
    {{ form_label(form.KBIN_MERCURE_ENABLED, 'mercure_enabled') }} {{ form_widget(form.KBIN_MERCURE_ENABLED) }}
    {{ form_label(form.KBIN_ADMIN_ONLY_OAUTH_CLIENTS, 'restrict_oauth_clients') }} {{ form_widget(form.KBIN_ADMIN_ONLY_OAUTH_CLIENTS) }}
    {{ form_label(form.MBIN_PRIVATE_INSTANCE, 'private_instance') }} {{ form_widget(form.MBIN_PRIVATE_INSTANCE) }}
    {{ form_label(form.KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN, 'federated_search_only_loggedin') }} {{ form_widget(form.KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN) }}
    {{ form_label(form.MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY, 'sidebar_sections_random_local_only') }} {{ form_widget(form.MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY) }}
    {% if form.MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY.vars.errors|length > 0 %}
    {% for error in form.MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY.vars.errors %} {{ error.message }} {% endfor %}
    {% endif %}
    {{ form_label(form.MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY, 'sidebar_sections_users_local_only') }} {{ form_widget(form.MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY) }}
    {{ form_label(form.MBIN_RESTRICT_MAGAZINE_CREATION, 'restrict_magazine_creation') }} {{ form_widget(form.MBIN_RESTRICT_MAGAZINE_CREATION) }}
    {{ form_label(form.MBIN_SSO_SHOW_FIRST, 'sso_show_first') }} {{ form_widget(form.MBIN_SSO_SHOW_FIRST) }}
    {{ form_label(form.MBIN_NEW_USERS_NEED_APPROVAL, 'new_users_need_approval') }} {{ form_widget(form.MBIN_NEW_USERS_NEED_APPROVAL) }}
    {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }}
    {% endblock %} ================================================ FILE: templates/admin/signup_requests.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'signup_requests'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-admin-federation{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {# global mods can see this page, but not navigate to any other menu option, so hiding it for now #} {% if is_granted('ROLE_ADMIN') %} {% include 'admin/_options.html.twig' %} {% endif %}

    {{ 'signup_requests_header'|trans }}

    {{ 'signup_requests_paragraph'|trans }}

    {% if username is defined and username is not same as null %}

    {{ 'viewing_one_signup_request'|trans({'%username%': username}) }}

    {{ 'return'|trans }}

    {% endif %} {% if requests|length %} {% for request in requests %}
    {{ component('user_inline', {user: request, showNewIcon: true}) }}, {{ component('date', {date: request.createdAt}) }}
    {{ request.applicationText }}
    {% endfor %} {% else %} {% endif %} {% endblock %} ================================================ FILE: templates/admin/users.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'users'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-admin-users{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'admin/_options.html.twig' %} {% if(users.haveToPaginate is defined and users.haveToPaginate) %} {{ pagerfanta(users, null, {'pageParameter':'[p]'}) }} {% endif %}
    {% if searchTerm is defined %}
    {% endif %}
    {% if withFederated is defined %}
    {% endif %}
    {% if not users|length %} {% else %} {% for name, field in {'username': 'username', 'email': 'email', 'created_at': 'createdAt', 'last_active': 'lastActive'} %} {% endfor %} {% if attitudes is defined %} {% endif %} {% for user in users %} {% if attitudes is defined %} {% endif %} {% endfor %}
    {{ name|trans }} {% if sortField is defined and field is same as sortField %} {% if order is defined and order is same as 'ASC' %} {% elseif order is defined and order is same as 'DESC' %} {% endif %} {% endif %} {{ 'attitude'|trans }}
    {{ component('user_inline', {user: user, showNewIcon: true}) }} {{ user.apId ? '-' : user.email }} {{ component('date', {date: user.createdAt}) }} {{ component('date', {date: user.lastActive}) }} {% if attitudes[user.id] is defined %} {% set attitude = attitudes[user.id] %} {% if attitude < 0 %} - {% else %} {{ attitudes[user.id]|number_format(2) }}% {% endif %} {% else %} - {% endif %}
    {% endif %}
    {% if(users.haveToPaginate is defined and users.haveToPaginate) %} {{ pagerfanta(users, null, {'pageParameter':'[p]'}) }} {% endif %} {% endblock %} ================================================ FILE: templates/base.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set V_LEFT = constant('App\\Controller\\User\\ThemeSettingsController::LEFT') -%} {%- set V_RIGHT = constant('App\\Controller\\User\\ThemeSettingsController::RIGHT') -%} {%- set V_FIXED = constant('App\\Controller\\User\\ThemeSettingsController::FIXED') -%} {%- set V_LAST_ACTIVE = constant('App\\Controller\\User\\ThemeSettingsController::LAST_ACTIVE') -%} {%- set FONT_SIZE = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_FONT_SIZE'), '100') -%} {%- set THEME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_THEME'), mbin_default_theme()) -%} {%- set PAGE_WIDTH = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_PAGE_WIDTH'), V_FIXED) -%} {%- set ROUNDED_EDGES = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_ROUNDED_EDGES'), V_TRUE) -%} {%- set TOPBAR = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_TOPBAR'), V_FALSE) -%} {%- set FIXED_NAVBAR = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_FIXED_NAVBAR'), V_FALSE) -%} {%- set SIDEBAR_POSITION = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_SIDEBAR_POSITION'), V_RIGHT) -%} {%- set COMPACT = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_COMPACT'), V_FALSE) -%} {%- set SUBSCRIPTIONS_SHOW = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SHOW'), V_TRUE) -%} {%- set SUBSCRIPTIONS_SEPARATE = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR'), V_FALSE) -%} {%- set SUBSCRIPTIONS_SAME_SIDE = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE'), V_FALSE) -%} {%- set SUBSCRIPTIONS_SORT = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SORT'), V_LAST_ACTIVE) -%} {%- block title -%}{{ kbin_meta_title() }}{%- endblock -%} {% if magazine is defined and magazine and magazine.apIndexable is same as false %} {% elseif user is defined and user and user.apIndexable is same as false %} {% elseif entry is defined and entry and entry.user and entry.user.apIndexable is same as false %} {% elseif post is defined and post and post.user and post.user.apIndexable is same as false %} {% endif %} {% if kbin_header_logo() %} {% endif %} {% block stylesheets %} {{ encore_entry_link_tags('app') }} {% endblock %} {% block javascripts %} {{ encore_entry_script_tags('app') }} {% endblock %} {% include 'layout/_header.html.twig' with {header_nav: block('header_nav')} %} {{ component('announcement') }}
    {% block body %}{% endblock %}
    {% if not mbin_private_instance() or (mbin_private_instance() and app.user is defined and app.user is not same as null) %} {% endif %} {% if app.user is defined and app.user is not same as null and SUBSCRIPTIONS_SHOW is not same as V_FALSE and SUBSCRIPTIONS_SEPARATE is same as V_TRUE %} {{ component('sidebar_subscriptions', { openMagazine: magazine is defined ? magazine : null, user: app.user, sort: SUBSCRIPTIONS_SORT }) }} {% endif %}
    {% include 'layout/_topbar.html.twig' %} ================================================ FILE: templates/bookmark/_form_edit.html.twig ================================================ {{ form_start(form, {attr: {class: 'bookmark_edit'}}) }} {{ form_row(form.name, {label: 'bookmark_list_create_label'}) }}
    {{ form_row(form.isDefault, {label: 'bookmark_list_make_default', row_attr: {class: 'checkbox'}}) }}
    {% set btn_label = is_create ? 'bookmark_list_create' : 'bookmark_list_edit' %} {{ form_row(form.submit, {label: btn_label, attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }} ================================================ FILE: templates/bookmark/_options.html.twig ================================================ {% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %} ================================================ FILE: templates/bookmark/edit.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'bookmarks_list'|trans({'%list%': list.name}) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-bookmarks{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'bookmarks_list_edit'|trans }}

    {% include 'bookmark/_form_edit.html.twig' with {is_create: false} %}
    {% endblock %} ================================================ FILE: templates/bookmark/front.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'bookmarks_list'|trans({'%list%': list.name}) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-bookmarks{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'bookmarks'|trans }}

    {% include 'bookmark/_options.html.twig' %}
    {% include 'layout/_subject_list.html.twig' %}
    {% endblock %} ================================================ FILE: templates/bookmark/overview.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'bookmark_lists'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-bookmark-lists{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'bookmark_lists'|trans }}

    {% include('bookmark/_form_edit.html.twig') with {is_create: true} %}
    {% if lists|length %}
    {% for list in lists %} {% endfor %}
    {{ 'name'|trans }} {{ 'count'|trans }}
    {% if list.isDefault %} {% endif %} {{ list.name }} {{ get_bookmark_list_entry_count(list) }} {% if not list.isDefault %}
    {% endif %}
    {% else %} {% endif %} {% endblock %} ================================================ FILE: templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig ================================================ {% extends '@!NelmioApiDoc/SwaggerUi/index.html.twig' %} {% block stylesheets %} {{ parent() }} {{ encore_entry_link_tags('app') }} {% endblock %} {% block header_block %} {% endblock %} ================================================ FILE: templates/bundles/TwigBundle/Exception/error.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'login'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-error{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'error'|trans }}

    {% endblock %} ================================================ FILE: templates/bundles/TwigBundle/Exception/error403.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'error'|trans }} 403 - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-error{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{'errors.server403.title'|trans}}

    {% endblock %} ================================================ FILE: templates/bundles/TwigBundle/Exception/error404.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'error'|trans }} 404 - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-error{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{'errors.server404.title'|trans}}

    {% endblock %} ================================================ FILE: templates/bundles/TwigBundle/Exception/error429.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'login'|trans }} 429 - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-error{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{'errors.server429.title'|trans}}

    {% endblock %} ================================================ FILE: templates/bundles/TwigBundle/Exception/error500.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'login'|trans }} 500 - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-error{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{'errors.server500.title'|trans}}

    {{ 'errors.server500.description'|trans({ '%link_start%': '', '%link_end%': '' })|raw }}
    {% endblock %} ================================================ FILE: templates/components/_ajax.html.twig ================================================ {{ component(component, attributes) }} ================================================ FILE: templates/components/_cached.html.twig ================================================ {{ this.getHtml(attributes)|raw }} ================================================ FILE: templates/components/_comment_collapse_button.html.twig ================================================ ================================================ FILE: templates/components/_details_label.css.twig ================================================ :root { --mbin-details-detail-label: "{{ 'details'|trans|e('css') }}"; --mbin-details-spoiler-label: "{{ 'spoiler'|trans|e('css') }}"; } ================================================ FILE: templates/components/_entry_comments_nested_hidden_private_threads.html.twig ================================================ {% if app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::ENTRY_COMMENTS_VIEW')) is same as constant('App\\Controller\\User\\ThemeSettingsController::CLASSIC') %} {% for reply in comment.nested %} {% if reply.visibility is same as 'private' %} {% if app.user and reply.user.isFollower(app.user) %} {% if not app.user.isBlocked(reply.user) %} {{ component('entry_comment', {comment: reply, showNested: false, level: 3, showEntryTitle:false, showMagazineName:false}) }} {% endif %} {% else %}
    {{ 'Private' }}
    {% endif %} {% else %} {% if not app.user or (app.user and not app.user.isBlocked(reply.user)) %} {{ component('entry_comment', {comment: reply, showNested: false, level: 3, showEntryTitle:false, showMagazineName:false}) }} {% endif %} {% endif %} {% endfor %} {% else %} {% for reply in comment.children %} {% if reply.visibility is same as 'private' %} {% if app.user and reply.user.isFollower(app.user) %} {% if not app.user.isBlocked(reply.user) %} {{ component('entry_comment', {comment: reply, showNested:true, level: level + 1, showEntryTitle:false, showMagazineName:false}) }} {% endif %} {% else %}
    {{ 'Private' }}
    {% endif %} {% else %} {% if not app.user or (app.user and not app.user.isBlocked(reply.user)) %} {{ component('entry_comment', {comment: reply, showNested:true, level: level + 1, showEntryTitle:false, showMagazineName:false}) }} {% endif %} {% endif %} {% endfor %} {% endif %} ================================================ FILE: templates/components/_figure_entry.html.twig ================================================ {# this fragment is only meant to be used in entry component #} {% with {is_single: is_route_name('entry_single'), image: entry.image} %} {% set sensitive_id = 'sensitive-check-%s-%s'|format(entry.id, image.id) %} {% set lightbox_alt_id = 'thumb-alt-%s-%s'|format(entry.id, image.id) %} {% set image_path = image.filePath ? asset(image.filePath)|imagine_filter('entry_thumb') : image.sourceUrl %} {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set LIST_LIGHTBOX = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_LIST_IMAGE_LIGHTBOX'), V_TRUE) -%} {% if LIST_LIGHTBOX is same as V_TRUE %} {% set route = uploaded_asset(image) %} {% elseif type is same as 'image' %} {% set route = is_single ? uploaded_asset(image) : entry_url(entry) %} {% elseif type is same as 'link' %} {% set route = is_single ? entry.url : entry_url(entry) %} {% endif %} {% set is_single_image = is_single and type is same as 'image' %}
    {% if image.altText %} {% endif %} {% if entry.isAdult %} {% endif %} {{ image.altText }}
    {% if image_path|lower ends with '.gif' %}
    GIF
    {% endif %} {% if image.altText %}
    ALT
    {% endif %}
    {% if entry.isAdult %} {% endif %}
    {% endwith %} ================================================ FILE: templates/components/_figure_image.html.twig ================================================ {% with { sensitive_id: 'sensitive-check-%s-%s'|format(parent_id, image.id), lightbox_alt_id: 'thumb-alt-%s-%s'|format(parent_id, image.id), image_path: (image.filePath ? asset(image.filePath)|imagine_filter(thumb_filter) : image.sourceUrl) } %}
    {% if image.altText %} {% endif %}
    {% if image_path|lower ends with '.gif' %}
    GIF
    {% endif %} {% if image.altText %}
    ALT
    {% endif %}
    {% if is_adult %} {% endif %}
    {% endwith %} ================================================ FILE: templates/components/_loading_icon.html.twig ================================================ ================================================ FILE: templates/components/_post_comments_nested_hidden_private_threads.html.twig ================================================ {% if app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::ENTRY_COMMENTS_VIEW')) is same as constant('App\\Controller\\User\\ThemeSettingsController::CLASSIC') %} {% for reply in comment.nested %} {% if reply.visibility is same as 'private' %} {% if app.user and reply.user.isFollower(app.user) %} {% if not app.user.isBlocked(reply.user) %} {{ component('post_comment', {comment: reply, showNested:false, level: 3}) }} {% endif %} {% else %}
    {{ 'Private' }}
    {% endif %} {% else %} {% if not app.user or (app.user and not app.user.isBlocked(reply.user)) %} {{ component('post_comment', {comment: reply, showNested:false, level: 3}) }} {% endif %} {% endif %} {% endfor %} {% else %} {% for reply in comment.children %} {% if reply.visibility is same as 'private' %} {% if app.user and reply.user.isFollower(app.user) %} {% if not app.user.isBlocked(reply.user) %} {{ component('entry_comment', {comment: reply, showNested:true, level: level + 1, showEntryTitle:false, showMagazineName:false}) }} {% endif %} {% else %}
    {{ 'Private' }}
    {% endif %} {% else %} {% if not app.user or (app.user and not app.user.isBlocked(reply.user)) %} {{ component('post_comment', {comment: reply, showNested:true, level: level + 1}) }} {% endif %} {% endif %} {% endfor %} {% endif %} ================================================ FILE: templates/components/_settings_row_enum.html.twig ================================================
    {{ label }}
    {% for value in values %} {% endfor %}
    ================================================ FILE: templates/components/_settings_row_switch.html.twig ================================================
    {{ label }}
    ================================================ FILE: templates/components/active_users.html.twig ================================================ {% if users|length %}

    {{ 'active_users'|trans }}

    {% for user in users %} {{ component('user_avatar', {user: user, width: 65, height: 65, asLink: true}) }} {% endfor %}
    {% endif %} ================================================ FILE: templates/components/announcement.html.twig ================================================ {% if content is not empty %}
    {{ content|markdown|raw }}
    {% endif %} ================================================ FILE: templates/components/blurhash_image.html.twig ================================================ ================================================ FILE: templates/components/bookmark_list.html.twig ================================================
  • {% if is_bookmarked_in_list(app.user, list, subject) %} {{ 'bookmark_remove_from_list'|trans({'%list%': list.name}) }} {% else %} {{ 'bookmark_add_to_list'|trans({'%list%': list.name}) }} {% endif %}
  • ================================================ FILE: templates/components/bookmark_menu_list.html.twig ================================================
    {% for list in bookmarkLists %} {{ component('bookmark_list', { subject: subject, list: list }) }} {% endfor %}
    ================================================ FILE: templates/components/bookmark_standard.html.twig ================================================
  • {% if is_bookmarked(app.user, subject) %} {% else %} {% endif %}
  • ================================================ FILE: templates/components/boost.html.twig ================================================ {%- set VOTE_UP = constant('App\\Entity\\Contracts\\VotableInterface::VOTE_UP') -%} {%- set user_choice = is_granted('ROLE_USER') ? subject.userChoice(app.user) : null -%}
    ================================================ FILE: templates/components/cursor_pagination.html.twig ================================================
    {% if pagination.hasPreviousPage() %} {% set cursors = pagination.getPreviousPage() %} {% else %}
    {% endif %}
    {% if app.request.query.get('cursor') %} {% endif %}
    {% if pagination.hasNextPage() %} {% set cursors = pagination.getNextPage() %} {% endif %}
    {% if not pagination.hasNextPage() %} {% endif %}
    ================================================ FILE: templates/components/date.html.twig ================================================ ================================================ FILE: templates/components/date_edited.html.twig ================================================ {% if editedAt %} {% if editedAt|date('U') - createdAt|date('U') > 300 %} ({{ 'edited'|trans }} ) {% endif %} {% endif %} ================================================ FILE: templates/components/domain.html.twig ================================================

    {{ 'domain'|trans }}

    {{ component('domain_sub', {domain: domain}) }} ================================================ FILE: templates/components/domain_sub.html.twig ================================================
    {{ domain.subscriptionsCount }}
    ================================================ FILE: templates/components/editor_toolbar.html.twig ================================================ ================================================ FILE: templates/components/entries_cross.html.twig ================================================ {% if entries|length %} {% set batch_size = entries|length % 2 == 0 ? 2 : 3 %} {% for entry_batch in entries|batch(batch_size) %}
    {% for entry in entry_batch %} {{ component('entry_cross', {entry: entry}) }} {% endfor %}
    {% endfor %} {% endif %} ================================================ FILE: templates/components/entry.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set SHOW_PREVIEW = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_SHOW_PREVIEW'), V_FALSE) -%} {%- set SHOW_THUMBNAILS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_SHOW_THUMBNAILS'), V_TRUE) -%} {%- set SHOW_USER_AVATARS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_SHOW_USERS_AVATARS'), V_TRUE) -%} {%- set SHOW_MAGAZINE_ICONS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_SHOW_MAGAZINES_ICONS'), V_TRUE) -%} {%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%} {%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%} {% if not app.user or (app.user and not app.user.isBlocked(entry.user)) %} {% if entry.visibility is same as 'private' and (not app.user or not app.user.isFollower(entry.user)) %}
    Private
    {% elseif entry.cross %} {{ component('entry_cross', {entry: entry}) }} {% else %}
    {% if entry.visibility in ['visible', 'private'] or (entry.visibility is same as 'trashed' and this.canSeeTrashed) %} {% if isSingle %}

    {% if entry.isAdult %}18+{% endif %} {% if entry.isOc %}OC{% endif %} {% if entry.url %} {{ entry.title }} {% else %} {{ entry.title }} {% endif %} {% if entry.url %} ( {{ get_url_domain(entry.url) }} ) {% endif %} {% if entry.lang is not same as app.request.locale and entry.lang is not same as kbin_default_lang() %} {{ entry.lang|language_name }} {% endif %}

    {% else %}

    {% if entry.isAdult %}18+{% endif %} {% if entry.isOc %}OC{% endif %} {{ entry.title }} {% if entry.url %} ( {{ get_url_domain(entry.url) }} ) {% endif %} {% if entry.lang is not same as app.request.locale and entry.lang is not same as kbin_default_lang() %} {{ entry.lang|language_name }} {% endif %}

    {% endif %} {% elseif(entry.visibility is same as 'trashed') %}

    [{{ 'deleted_by_moderator'|trans }}]

    {% elseif(entry.visibility is same as 'soft_deleted') %}

    [{{ 'deleted_by_author'|trans }}]

    {% endif %}
    {% if entry.visibility in ['visible', 'private'] or (entry.visibility is same as 'trashed' and this.canSeeTrashed) %} {% if entry.body and showShortSentence %}

    {{ get_short_sentence(entry.body|markdown|raw, striptags = true) }}

    {% endif %} {% if entry.body and showBody %}
    {{ entry.body|markdown("entry")|raw }}
    {% endif %} {% endif %} {% if SHOW_THUMBNAILS is same as V_TRUE %} {% if entry.image %} {% if entry.type is same as 'link' or entry.type is same as 'video' %} {{ include('components/_figure_entry.html.twig', {entry: entry, type: 'link'}) }} {% elseif entry.type is same as 'image' or entry.type is same as 'article' %} {{ include('components/_figure_entry.html.twig', {entry: entry, type: 'image'}) }} {% endif %} {% else %} {% endif %} {% endif %} {% if entry.visibility in ['visible', 'private'] %} {{ component('vote', { subject: entry, }) }} {% endif %}
    {% if entry.visibility in ['visible', 'private'] %} {% if entry.sticky %}
  • {% endif %} {% if entry.type is same as 'article' %} {% elseif entry.type is same as 'link' and entry.hasEmbed is same as false %} {% endif %} {% if entry.hasEmbed %} {% set preview_url = entry.type is same as 'image' and entry.image ? uploaded_asset(entry.image) : entry.url %}
  • {% endif %}
  • {% if not entry.isLocked %} {{ entry.commentCount|abbreviateNumber }} {{ 'comments_count'|trans({'%count%': entry.commentCount}) }} {% else %} {{ entry.commentCount|abbreviateNumber }} {{ 'comments_count'|trans({'%count%': entry.commentCount}) }} {% endif %}
  • {{ component('boost', { subject: entry }) }}
  • {% if app.user is defined and app.user is not same as null %} {{ component('bookmark_standard', { subject: entry }) }} {% endif %} {% include 'entry/_menu.html.twig' %} {% if app.user is defined and app.user is not same as null and not showShortSentence %} {{ component('notification_switch', {target: entry}) }} {% endif %}
  • Loading...
  • {% elseif (entry.visibility is same as 'trashed' and this.canSeeTrashed) %}
  • {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, entry) %} {{ component('bookmark_standard', { subject: entry }) }} {% endif %}
  • Loading...
  • {% else %} {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, entry) %} {{ component('bookmark_standard', { subject: entry }) }} {% endif %}
  • Loading...
  • {% endif %}
    {% endif %} {% endif %} ================================================ FILE: templates/components/entry_comment.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set V_TREE = constant('App\\Controller\\User\\ThemeSettingsController::TREE') -%} {%- set SHOW_PREVIEW = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_SHOW_PREVIEW'), V_FALSE) -%} {%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%} {%- set VIEW_STYLE = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::ENTRY_COMMENTS_VIEW'), V_TREE) -%} {%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%} {%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%} {% if not app.user or (app.user and not app.user.isBlocked(comment.user)) %} {% if comment.visibility is same as 'private' and (not app.user or not app.user.isFollower(comment.user)) %}
    Private
    {% else %}
    {% if comment.isAdult %}18+{% endif %} {{ component('user_inline', {user: comment.user, showAvatar: false, showNewIcon: true, fullName: SHOW_USER_FULLNAME is same as V_TRUE}) }} {% if comment.entry.user.id() is same as comment.user.id() %} {{ 'user_badge_op'|trans }} {% endif %} {% if (comment.user.type) == "Service" %} {{ 'user_badge_bot'|trans }} {% endif %} {% if comment.user.admin() %} {{ 'user_badge_admin'|trans }} {% elseif comment.user.moderator() %} {{ 'user_badge_global_moderator'|trans }} {% elseif comment.magazine.userIsModerator(comment.user) %} {{ 'user_badge_moderator'|trans }} {% endif %} , {% if dateAsUrl %} {{ component('date', {date: comment.createdAt}) }} {% else %} {{ component('date', {date: comment.createdAt}) }} {% endif %} {{ component('date_edited', {createdAt: comment.createdAt, editedAt: comment.editedAt}) }} {% if showMagazineName %}{{ 'to'|trans }} {{ component('magazine_inline', {magazine: comment.magazine, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE}) }}{% endif %} {% if showEntryTitle %}{{ 'in'|trans }} {{ component('entry_inline', {entry: comment.entry}) }}{% endif %} {% if comment.lang is not same as app.request.locale and comment.lang is not same as kbin_default_lang() %} {% endif %}
    {{ component('user_avatar', { user: comment.user, width: 40, height: 40, asLink: true }) }}
    {% if comment.visibility in ['visible', 'private'] or (comment.visibility is same as 'trashed' and this.canSeeTrashed) %} {{ comment.body|markdown("entry")|raw }} {% elseif(comment.visibility is same as 'trashed') %}

    [{{ 'deleted_by_moderator'|trans }}]

    {% elseif(comment.visibility is same as 'soft_deleted') %}

    [{{ 'deleted_by_author'|trans }}]

    {% endif %}
    {% if comment.visibility in ['visible', 'private'] %} {{ component('vote', { subject: comment }) }} {% endif %} {{ include('components/_comment_collapse_button.html.twig', { comment: comment, showNested: showNested, }) }}
    {% if (comment.visibility in ['visible', 'private'] or comment.visibility is same as 'trashed' and this.canSeeTrashed) and comment.image %} {{ include('components/_figure_image.html.twig', { image: comment.image, parent_id: comment.id, is_adult: comment.isAdult, thumb_filter: 'post_thumb', gallery_name: 'ec-%d'|format(comment.id), }) }} {% endif %} {% if comment.visibility in ['visible', 'private'] %}
  • {{ 'reply'|trans }}
  • {{ component('boost', {subject: comment}) }}
  • {% if app.user is defined and app.user is not same as null %} {{ component('bookmark_standard', { subject: comment }) }} {% endif %} {% include 'entry/comment/_menu.html.twig' %}
  • Loading...
  • {% elseif(comment.visibility is same as 'trashed' and this.canSeeTrashed) %}
  • {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, comment) %} {{ component('bookmark_standard', { subject: comment }) }} {% endif %}
  • Loading...
  • {% else %} {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, comment) %} {{ component('bookmark_standard', { subject: comment }) }} {% endif %}
  • Loading...
  • {% endif %}
    {% endif %} {% if showNested %} {{ component('entry_comments_nested', { comment: comment, level: level, showNested: true, view: VIEW_STYLE, criteria: criteria, }) }} {% endif %} {% endif %} ================================================ FILE: templates/components/entry_comment_inline_md.html.twig ================================================ {% if rich is same as true %} {{ component('user_inline', {user: comment.user, fullName: userFullName, showNewIcon: true}) }}: {% if comment.image is not same as null %} {% endif %} {{ comment.getShortTitle() }} {{ 'answered'|trans }} {% if comment.parent is not same as null %} {{ comment.parent.getShortTitle() }} {{ 'by'|trans }} {{ component('user_inline', {user: comment.parent.user, fullName: userFullName, showNewIcon: true}) }} {% else %} {% if comment.entry.image is not same as null %} {% endif %} {{ comment.entry.getShortTitle() }} {{ 'by'|trans }} {{ component('user_inline', {user: comment.entry.user, fullName: userFullName, showNewIcon: true}) }} {% endif %} {% if comment.magazine.name is not same as 'random' %} {{ 'in'|trans }} {{ component('magazine_inline', {magazine: comment.magazine, fullName: magazineFullName, showNewIcon: true}) }} {% endif %} {% else %} {% if comment.apId is same as null %} {{ url('entry_comment_view', {magazine_name: comment.magazine.name, entry_id: comment.entry.id, comment_id: comment.id, slug: '-'}) }} {% else %} {{ comment.apId }} {% endif %} {% endif %} ================================================ FILE: templates/components/entry_comments_nested.html.twig ================================================ {% if view is same as constant('App\\Controller\\User\\ThemeSettingsController::CLASSIC') %} {% for reply in comment.nested %} {{ component('entry_comment', { comment: reply, showNested: false, level: 3, showEntryTitle: false, showMagazineName: false }) }} {% endfor %} {% else %} {% for reply in comment.getChildrenByCriteria(criteria, mbin_downvotes_mode(), app.user, 'comments') %} {{ component('entry_comment', { comment: reply, showNested: true, level: level + 1, showEntryTitle: false, showMagazineName: false, criteria: criteria, }) }} {% endfor %} {% endif %} ================================================ FILE: templates/components/entry_cross.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%} {%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%} {% if entry.visibility in ['visible', 'private'] %} {{ component('vote', { subject: entry, }) }} {% endif %} ================================================ FILE: templates/components/entry_inline.html.twig ================================================ {{ entry.title }} ================================================ FILE: templates/components/entry_inline_md.html.twig ================================================ {% if rich is same as true %} {{ component('user_inline', {user: entry.user, fullName: userFullName, showNewIcon: true}) }}: {% if entry.image is not same as null %} {% endif %} {{ entry.title }} {% if entry.magazine.name is not same as 'random' %} {{ 'in'|trans }} {{ component('magazine_inline', {magazine: entry.magazine, fullName: magazineFullName, showNewIcon: true}) }} {% endif %} {% else %} {% if entry.apId is same as null %} {{ url('entry_single', {magazine_name: entry.magazine.name, entry_id: entry.id, slug: '-'}, ) }} {% else %} {{ entry.apId }} {% endif %} {% endif %} ================================================ FILE: templates/components/favourite.html.twig ================================================
    ================================================ FILE: templates/components/featured_magazines.html.twig ================================================ {% for mag in magazines %}
  • {{ mag }}
  • {% endfor %}
    ================================================ FILE: templates/components/filter_list.html.twig ================================================

    {{ list.name }}{% if list.isExpired %} ({{ 'expired'|trans|lower }}){% endif %}

    {{ 'filter_lists_filter_words'|trans }}: {{ list.words|map(x => x.word)|join(', ') }}
    {{ 'filter_lists_filter_location'|trans }}: {{ list.getRealmStrings()|map(location => location|trans)|join(', ') }}
    ================================================ FILE: templates/components/instance_list.html.twig ================================================ {% if app.user is defined and app.user is not same as null and app.user.admin %} {% if showUnBanButton or showBanButton or showDenyButton or showAllowButton %} {% endif %} {% endif %} {% for instance in instances %} {% if app.user is defined and app.user is not same as null and app.user.admin %} {% if showUnBanButton or showBanButton or showDenyButton or showAllowButton %} {% endif %} {% endif %} {% endfor %}
    {{ 'domain'|trans }} {{ 'server_software'|trans }} {{ 'version'|trans }}{{ 'last_successful_deliver'|trans }} {{ 'last_successful_receive'|trans }}
    {{instance.domain}} {{ instance.software ?? '' }} {{ instance.version ?? '' }} {% if instance.lastSuccessfulDeliver is not same as null %} {{ component('date', { date: instance.lastSuccessfulDeliver }) }} {% endif %} {% if instance.lastSuccessfulReceive is not same as null %} {{ component('date', { date: instance.lastSuccessfulReceive }) }} {% endif %}
    {% if showUnBanButton and instance.isBanned %}
    {% endif %} {% if showBanButton and not instance.isBanned %}
    {% endif %} {% if showDenyButton and instance.isExplicitlyAllowed %}
    {% endif %} {% if showAllowButton and not instance.isExplicitlyAllowed %}
    {% endif %}
    ================================================ FILE: templates/components/loader.html.twig ================================================
    Loading...
    ================================================ FILE: templates/components/login_socials.html.twig ================================================ {# @var this App\Twig\Components\LoginSocialsComponent #} {%- set HAS_ANY_SOCIAL = this.googleEnabled or this.facebookEnabled or this.discordEnabled or this.githubEnabled or this.keycloakEnabled or this.simpleloginEnabled or this.zitadelEnabled or this.authentikEnabled or this.azureEnabled or this.privacyPortalEnabled -%} {% if HAS_ANY_SOCIAL %} {% if not mbin_sso_only_mode() and not mbin_sso_show_first() %}
    {% endif %} {% if not mbin_sso_only_mode() and mbin_sso_show_first() %}
    {% endif %} {% endif %} ================================================ FILE: templates/components/magazine_box.html.twig ================================================ {% if showSectionTitle %}

    {{ 'magazine'|trans }}

    {% endif %} {% if app.user and (magazine.userIsOwner(app.user) or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR')) and not is_route_name_contains('magazine_panel') %} {% endif %}
    {% if computed.magazine.icon and showCover and (app.user or magazine.isAdult is same as false) %}
    {{ computed.magazine.name ~ ' ' ~ 'icon'|trans|lower }}
    {% endif %}

    {{ computed.magazine.title }} {% if magazine.postingRestrictedToMods %} {% endif %} {% if magazine.isNew() %} {% set days = constant('App\\Entity\\Magazine::NEW_FOR_DAYS') %} {% endif %}

    {{ ('@'~magazine.name)|username(true) }} {% if magazine.isAdult %}18+{% endif %} {% if magazine.apId %} {% endif %}

    {{ component('magazine_sub', {magazine: magazine}) }} {% if app.user is defined and app.user is not same as null %}
    {{ component('notification_switch', {target: magazine}) }}
    {% endif %} {% if computed.magazine.description and showDescription %}
    {{ computed.magazine.description|markdown|raw }}
    {% endif %} {% if computed.magazine.rules and showRules %}

    {{ 'rules'|trans }}

    {{ computed.magazine.rules|markdown|raw }}
    {% endif %} {% if showInfo %}
    • {{ 'created_at'|trans }}: {{ component('date', {date: computed.magazine.createdAt}) }}
    • {% if app.user is defined and app.user is not null and app.user.admin() and computed.magazine.apId is not null and computed.magazine.apFetchedAt is not same as null %}
    • {{ 'last_updated'|trans }}: {{ component('date', {date: computed.magazine.apFetchedAt}) }}
    • {% endif %}
    • {{ 'subscribers'|trans }}: {{ computed.magazine.subscriptionsCount|abbreviateNumber }}
    • {% set instance = get_instance_of_magazine(computed.magazine) %} {% if instance is not same as null %}
    • {{ 'server_software'|trans }}:
      {{ instance.software }}{% if instance.version is not same as null and app.user is defined and app.user is not null and app.user.admin() %} v{{ instance.version }}{% endif %}
    • {% endif %}
    {% endif %} {% if showMeta %}
      {{ _self.meta_item( 'threads'|trans, path('front_magazine', {'name': computed.magazine.name }), computed.magazine.entryCount|abbreviateNumber) }} {{ _self.meta_item( 'comments'|trans, path('magazine_entry_comments', {'name': computed.magazine.name}), computed.magazine.entryCommentCount|abbreviateNumber) }} {{ _self.meta_item( 'posts'|trans, path('magazine_posts', {'name': computed.magazine.name}), computed.magazine.postCount|abbreviateNumber) }} {{ _self.meta_item( 'replies'|trans, path('magazine_posts', {'name': computed.magazine.name}), computed.magazine.postCommentCount|abbreviateNumber) }} {{ _self.meta_item( 'moderators'|trans, path('magazine_moderators', {'name': computed.magazine.name}), computed.magazine.getModeratorCount()) }} {{ _self.meta_item( 'mod_log'|trans, path('magazine_modlog', {'name': computed.magazine.name}), computed.magazine.logs|length) }}
    {% endif %} {% macro meta_item(name, url, count) %}
  • {{ name }} {{ count }}
  • {% endmacro %} {% if showTags and magazine.tags %}

    {{ 'tags'|trans }}

    {% for tag in magazine.tags %} #{{ tag }} {% endfor %}
    {% endif %} ================================================ FILE: templates/components/magazine_inline.html.twig ================================================ {% if magazine.icon and showAvatar and (app.user or magazine.isAdult is same as false) %} {{ magazine.name ~ ' ' ~ 'icon'|trans|lower }} {% endif %} {{ magazine.title -}} {%- if fullName -%} @{{- magazine.name|apDomain -}} {%- endif -%} {% if magazine.isAdult %} 18+{% endif %} {% if magazine.postingRestrictedToMods %} {% endif %} {% if magazine.isNew() and showNewIcon %} {% set days = constant('App\\Entity\\Magazine::NEW_FOR_DAYS') %} {% endif %} ================================================ FILE: templates/components/magazine_inline_md.html.twig ================================================ {% if rich is same as true %} {{ component('magazine_inline', { magazine: magazine, stretchedLink: stretchedLink, fullName: fullName, showAvatar: showAvatar, }) }} {% else %} !{{- magazine.name|username -}} {%- if fullName -%} @{{- magazine.name|apDomain -}} {%- endif -%} {% endif %} ================================================ FILE: templates/components/magazine_sub.html.twig ================================================
    {{ magazine.subscriptionsCount|abbreviateNumber }}
    {% if not is_instance_of_magazine_blocked(magazine) %}
    {% endif %} ================================================ FILE: templates/components/monitoring_twig_render.html.twig ================================================
    {% if compareToParent %}
    {{ render.shortDescription }} | {{ render.profilerDuration|round(2) }}ms / {{ render.getPercentageOfParentDuration()|round }}%
    {% else %}
    {{ render.shortDescription }} | {{ render.profilerDuration|round(2) }}ms / {{ render.getPercentageOfTotalDuration()|round }}%
    {% endif %}
    {% for child in render.children %} {{ component('monitoring_twig_render', {'render': child, 'compareToParent': compareToParent}) }} {% endfor %}
    ================================================ FILE: templates/components/notification_switch.html.twig ================================================ ================================================ FILE: templates/components/post.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set SHOW_PREVIEW = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_POSTS_SHOW_PREVIEW'), V_FALSE) -%} {%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%} {%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%} {% if not app.user or (app.user and not app.user.isBlocked(post.user)) %}
    {% if post.visibility is same as 'private' and (not app.user or not app.user.isFollower(post.user)) %}
    Private
    {% else %} {% if post.visibility in ['visible', 'private'] %} {{ component('vote', { subject: post, showDownvote: false }) }} {% endif %}
    {% if post.isAdult %}18+{% endif %} {{ component('user_inline', {user: post.user, showAvatar: true, showNewIcon: true, fullName: SHOW_USER_FULLNAME is same as V_TRUE}) }} {% if (post.user.type) == "Service" %} {{ 'user_badge_bot'|trans }} {% endif %} {% if post.user.admin() %} {{ 'user_badge_admin'|trans }} {% elseif post.user.moderator() %} {{ 'user_badge_global_moderator'|trans }} {% elseif post.magazine.userIsModerator(post.user) %} {{ 'user_badge_moderator'|trans }} {% endif %} , {% if dateAsUrl %} {{ component('date', {date: post.createdAt}) }} {% else %} {{ component('date', {date: post.createdAt}) }} {% endif %} {{ component('date_edited', {createdAt: post.createdAt, editedAt: post.editedAt}) }} {% if showMagazineName %}{{ 'to'|trans }} {{ component('magazine_inline', {magazine: post.magazine, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE, showNewIcon: true}) }}{% endif %} {% if post.lang is not same as app.request.locale and post.lang is not same as kbin_default_lang() %} {{ post.lang|language_name }} {% endif %}
    {% if post.visibility in ['visible', 'private'] or (post.visibility is same as 'trashed' and this.canSeeTrashed) %} {{ post.body|markdown("post")|raw }} {% elseif(post.visibility is same as 'trashed') %}

    [{{ 'deleted_by_moderator'|trans }}]

    {% elseif(post.visibility is same as 'soft_deleted') %}

    [{{ 'deleted_by_author'|trans }}]

    {% endif %} {% if post.image %} {{ include('components/_figure_image.html.twig', { image: post.image, parent_id: post.id, is_adult: post.isAdult, thumb_filter: 'post_thumb', gallery_name: 'post-%d'|format(post.id), }) }} {% endif %}
    {% if post.visibility in ['visible', 'private'] %} {% if post.sticky %}
  • {% endif %}
  • {% if not post.isLocked %} {{ 'reply'|trans }} {% else %} {{ 'reply'|trans }} {% endif %}
  • {% if not is_route_name('post_single', true) and ((not showCommentsPreview and post.commentCount > 0) or post.commentCount > 2) %}
  • {{ 'expand'|trans }} ({{ post.commentCount|abbreviateNumber }})
  • {{ 'collapse'|trans }} ({{ post.commentCount|abbreviateNumber }})
  • {% endif %}
  • {{ component('boost', { subject: post }) }}
  • {% if app.user is defined and app.user is not same as null %} {{ component('bookmark_standard', { subject: post }) }} {% endif %} {% include 'post/_menu.html.twig' %} {% if app.user is defined and app.user is not same as null and isSingle is defined and isSingle %} {{ component('notification_switch', {target: post}) }} {% endif %}
  • Loading...
  • {{ component('voters_inline', { subject: post, url: post_voters_url(post, 'up'), 'data-post-target': 'voters' }) }} {% elseif(post.visibility is same as 'trashed' and this.canSeeTrashed) %}
  • {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, post) %} {{ component('bookmark_standard', { subject: post }) }} {% endif %}
  • Loading...
  • {% else %} {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, post) %} {{ component('bookmark_standard', { subject: post }) }} {% endif %}
  • Loading...
  • {% endif %}
    {% endif %}
    {% if(showCommentsPreview and post.commentCount) %} {{ component('post_comments_preview', {post: post}) }} {% endif %}
    {% endif %} ================================================ FILE: templates/components/post_combined.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set SHOW_PREVIEW = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_SHOW_PREVIEW'), V_FALSE) -%} {%- set SHOW_THUMBNAILS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_SHOW_THUMBNAILS'), V_TRUE) -%} {%- set SHOW_USER_AVATARS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_SHOW_USERS_AVATARS'), V_TRUE) -%} {%- set SHOW_MAGAZINE_ICONS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_SHOW_MAGAZINES_ICONS'), V_TRUE) -%} {%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%} {%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%} {% if not app.user or (app.user and not app.user.isBlocked(post.user)) %} {% if post.visibility is same as 'private' and (not app.user or not app.user.isFollower(post.user)) %}
    Private
    {% else %}
    {% with %} {% set hasTitle = post.visibility in ['visible', 'private'] or (post.visibility is same as 'trashed' and this.canSeeTrashed) %} {% set isAdult = post.isAdult %} {% set hasLang = post.lang is not same as app.request.locale and post.lang is not same as kbin_default_lang() %} {% set isModDeleted = post.visibility is same as 'trashed' %} {% set isUserDeleted = post.visibility is same as 'soft_deleted' %} {% set needsHeader = (hasTitle and (isAdult or hasLang)) or isModDeleted or isUserDeleted %}
    {% if hasTitle %}

    {% if isAdult %}18+{% endif %} {% if hasLang %} {{ post.lang|language_name }} {% endif %}

    {% elseif isModDeleted %}

    [{{ 'deleted_by_moderator'|trans }}]

    {% elseif isUserDeleted %}

    [{{ 'deleted_by_author'|trans }}]

    {% endif %}
    {% endwith %} {% if post.visibility in ['visible', 'private'] or (post.visibility is same as 'trashed' and this.canSeeTrashed) %} {% if post.body %}

    {{ get_short_sentence(post.body|markdown|raw, striptags = true, onlyFirstParagraph = false) }}

    {% endif %} {% endif %} {% if SHOW_THUMBNAILS is same as V_TRUE %} {% if post.image %} {{ include('components/_figure_entry.html.twig', {entry: post, type: 'image'}) }} {% else %} {% endif %} {% endif %} {% if post.visibility in ['visible', 'private'] %} {{ component('vote', { subject: post, showDownvote: false }) }} {% endif %}
    {% if post.visibility in ['visible', 'private'] %} {% if post.sticky %}
  • {% endif %} {% if post.image %}
  • {% endif %}
  • {{ post.commentCount }} {{ 'comments_count'|trans({'%count%': post.commentCount}) }}
  • {{ component('boost', { subject: post }) }}
  • {% if app.user is defined and app.user is not same as null %} {{ component('bookmark_standard', { subject: post }) }} {% endif %} {% include 'post/_menu.html.twig' %}
  • Loading...
  • {% elseif (post.visibility is same as 'trashed' and this.canSeeTrashed) %}
  • {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, post) %} {{ component('bookmark_standard', { subject: post }) }} {% endif %}
  • Loading...
  • {% else %} {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, post) %} {{ component('bookmark_standard', { subject: post }) }} {% endif %}
  • Loading...
  • {% endif %}
    {% endif %} {% endif %} ================================================ FILE: templates/components/post_comment.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set V_TREE = constant('App\\Controller\\User\\ThemeSettingsController::TREE') -%} {%- set SHOW_PREVIEW = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_POSTS_SHOW_PREVIEW'), V_FALSE) -%} {%- set VIEW_STYLE = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::POST_COMMENTS_VIEW'), V_TREE) -%} {%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%} {% if withPost %} {{ component('post', {post: comment.post}) }} {% endif %} {% if not app.user or (app.user and not app.user.isBlocked(comment.user)) %} {% if comment.visibility is same as 'private' and (not app.user or not app.user.isFollower(comment.user)) %}
    Private
    {% else %}
    {% if comment.isAdult %}18+{% endif %} {{ component('user_inline', {user: comment.user, showAvatar: false, showNewIcon: true, fullName: SHOW_USER_FULLNAME is same as V_TRUE}) }} {% if comment.post.user.id() is same as comment.user.id() %} {{ 'user_badge_op'|trans }} {% endif %} {% if (comment.user.type) == "Service" %} {{ 'user_badge_bot'|trans }} {% endif %} {% if comment.user.admin() %} {{ 'user_badge_admin'|trans }} {% elseif comment.user.moderator() %} {{ 'user_badge_global_moderator'|trans }} {% elseif comment.magazine.userIsModerator(comment.user) %} {{ 'user_badge_moderator'|trans }} {% endif %} , {% if dateAsUrl %} {{ component('date', {date: comment.createdAt}) }} {% else %} {{ component('date', {date: comment.createdAt}) }} {% endif %} {{ component('date_edited', {createdAt: comment.createdAt, editedAt: comment.editedAt}) }} {% if comment.lang is not same as app.request.locale and comment.lang is not same as kbin_default_lang() %} {% endif %}
    {{ component('user_avatar', { user: comment.user, width: 40, height: 40, asLink: true }) }}
    {% if comment.visibility in ['visible', 'private'] or (comment.visibility is same as 'trashed' and this.canSeeTrashed) %} {{ comment.body|markdown("post")|raw }} {% elseif(comment.visibility is same as 'trashed') %}

    [{{ 'deleted_by_moderator'|trans }}]

    {% elseif(comment.visibility is same as 'soft_deleted') %}

    [{{ 'deleted_by_author'|trans }}]

    {% endif %}
    {% if comment.visibility in ['visible', 'private'] %} {{ component('vote', { subject: comment, showDownvote: false }) }} {% endif %} {{ include('components/_comment_collapse_button.html.twig', { comment: comment, showNested: showNested, }) }}
    {% if (comment.visibility in ['visible', 'private'] or comment.visibility is same as 'trashed' and this.canSeeTrashed) and comment.image %} {{ include('components/_figure_image.html.twig', { image: comment.image, parent_id: comment.id, is_adult: comment.isAdult, thumb_filter: 'post_thumb', gallery_name: 'pc-%d'|format(comment.id), }) }} {% endif %} {% if comment.visibility in ['visible', 'private'] %}
  • {{ 'reply'|trans }}
  • {{ component('boost', { subject: comment }) }}
  • {% if app.user is defined and app.user is not same as null %} {{ component('bookmark_standard', { subject: comment }) }} {% endif %} {% include 'post/comment/_menu.html.twig' %}
  • Loading...
  • {% elseif(comment.visibility is same as 'trashed' and this.canSeeTrashed) %}
  • {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, comment) %} {{ component('bookmark_standard', { subject: comment }) }} {% endif %}
  • Loading...
  • {% else %} {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, comment) %} {{ component('bookmark_standard', { subject: comment }) }} {% endif %}
  • Loading...
  • {% endif %} {{ component('voters_inline', { subject: comment, url: post_comment_voters_url(comment, 'up') }) }}
    {% endif %} {% if showNested %} {{ component('post_comments_nested', { comment: comment, level: level, showNested: true, view: VIEW_STYLE, criteria: criteria, }) }} {% endif %} {% endif %} ================================================ FILE: templates/components/post_comment_combined.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set SHOW_PREVIEW = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_SHOW_PREVIEW'), V_FALSE) -%} {%- set SHOW_THUMBNAILS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_SHOW_THUMBNAILS'), V_TRUE) -%} {%- set SHOW_USER_AVATARS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_SHOW_USERS_AVATARS'), V_TRUE) -%} {%- set SHOW_MAGAZINE_ICONS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_ENTRIES_SHOW_MAGAZINES_ICONS'), V_TRUE) -%} {%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%} {%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%} {% if not app.user or (app.user and not app.user.isBlocked(comment.user)) %} {% if comment.visibility is same as 'private' and (not app.user or not app.user.isFollower(comment.user)) %}
    Private
    {% else %}
    {% with %} {% set hasTitle = comment.visibility in ['visible', 'private'] or (comment.visibility is same as 'trashed' and this.canSeeTrashed) %} {% set isAdult = comment.isAdult %} {% set hasLang = comment.lang is not same as app.request.locale and comment.lang is not same as kbin_default_lang() %} {% set isModDeleted = comment.visibility is same as 'trashed' %} {% set isUserDeleted = comment.visibility is same as 'soft_deleted' %} {% set needsHeader = (hasTitle and (isAdult or hasLang)) or isModDeleted or isUserDeleted %}
    {% if hasTitle %}

    {% if isAdult %}18+{% endif %} {% if hasLang %} {{ comment.lang|language_name }} {% endif %}

    {% elseif isModDeleted %}

    [{{ 'deleted_by_moderator'|trans }}]

    {% elseif isUserDeleted %}

    [{{ 'deleted_by_author'|trans }}]

    {% endif %}
    {% endwith %} {% if comment.visibility in ['visible', 'private'] or (comment.visibility is same as 'trashed' and this.canSeeTrashed) %} {% if comment.body %}

    {{ get_short_sentence(comment.body|markdown|raw, striptags = true, onlyFirstParagraph = false) }}

    {% endif %} {% endif %} {% if SHOW_THUMBNAILS is same as V_TRUE %} {% if comment.image %} {{ include('components/_figure_entry.html.twig', {entry: comment, type: 'image'}) }} {% else %} {% endif %} {% endif %} {% if comment.visibility in ['visible', 'private'] %} {{ component('vote', { subject: comment, showDownvote: false }) }} {% endif %}
    {% if comment.visibility in ['visible', 'private'] %} {% if comment.image %}
  • {% endif %}
  • {{ 'parent_post'|trans }}
  • {{ component('boost', { subject: comment }) }}
  • {% if app.user is defined and app.user is not same as null %} {{ component('bookmark_standard', { subject: comment }) }} {% endif %} {% include 'post/comment/_menu.html.twig' %}
  • Loading...
  • {% elseif (comment.visibility is same as 'trashed' and this.canSeeTrashed) %}
  • {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, comment) %} {{ component('bookmark_standard', { subject: comment }) }} {% endif %}
  • Loading...
  • {% else %} {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, comment) %} {{ component('bookmark_standard', { subject: comment }) }} {% endif %}
  • Loading...
  • {% endif %}
    {% endif %} {% endif %} ================================================ FILE: templates/components/post_comment_inline_md.html.twig ================================================ {% if rich is same as true %} {{ component('user_inline', {user: comment.user, fullName: userFullName, showNewIcon: true}) }}: {% if comment.image is not same as null %} {% endif %} {{ comment.getShortTitle() }} {{ 'answered'|trans }} {% if comment.parent is not same as null %} {{ comment.parent.getShortTitle() }} {{ 'by'|trans }} {{ component('user_inline', {user: comment.parent.user, fullName: userFullName, showNewIcon: true}) }} {% else %} {% if comment.post.image is not same as null %} {% endif %} {{ comment.post.getShortTitle() }} {{ 'by'|trans }} {{ component('user_inline', {user: comment.post.user, fullName: userFullName, showNewIcon: true}) }} {% endif %} {% if comment.magazine.name is not same as 'random' %} {{ 'in'|trans }} {{ component('magazine_inline', {magazine: comment.magazine, fullName: magazineFullName, showNewIcon: true}) }} {% endif %} {% else %} {% if comment.apId is same as null %} {{ url('post_single', {magazine_name: comment.magazine.name, post_id: comment.post.id, slug: '-'}) }}#post-comment-{{ comment.id }} {% else %} {{ comment.apId }} {% endif %} {% endif %} ================================================ FILE: templates/components/post_comments_nested.html.twig ================================================ {% if view is same as constant('App\\Controller\\User\\ThemeSettingsController::CLASSIC') %} {% for reply in comment.nested %} {{ component('post_comment', { comment: reply, showNested: false, level: 3, criteria: criteria, }) }} {% endfor %} {% else %} {% for reply in comment.getChildrenByCriteria(criteria, app.user, 'comments') %} {{ component('post_comment', { comment: reply, showNested: true, level: level + 1, criteria: criteria, }) }} {% endfor %} {% endif %} ================================================ FILE: templates/components/post_comments_preview.html.twig ================================================ {% for comment in comments %} {{ component('post_comment', { comment: comment, showNested: false, level: 2, }) }} {% endfor %} ================================================ FILE: templates/components/post_inline_md.html.twig ================================================ {% if rich is same as true %} {{ component('user_inline', {user: post.user, fullName: userFullName, showNewIcon: true}) }}: {% if post.image is not same as null %} {% endif %} {{ post.getShortTitle() }} {% if post.magazine.name is not same as 'random' %} {{ 'in'|trans }} {{ component('magazine_inline', {magazine: post.magazine, fullName: magazineFullName}) }} {% endif %} {% else %} {% if post.apId is same as null %} {{ url('post_single', {magazine_name: post.magazine.name, post_id: post.id, slug: '-'}) }} {% else %} {{ post.apId }} {% endif %} {% endif %} ================================================ FILE: templates/components/related_entries.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%} {% if entries|length %}

    {{ title|trans }}

    {% for entry in entries %}
    {% if entry.image %} {{ entry.image.alt|default('') }} {% endif %}

    {{ entry.title }}

    {{ 'show_more'|trans }}
    {{ component('date', {date: entry.createdAt}) }} {{ 'to'|trans }} {{ component('magazine_inline', {magazine: entry.magazine, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE}) }}
    {% endfor %}
    {% endif %} ================================================ FILE: templates/components/related_magazines.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%} {% if magazines|length %}

    {{ title|trans }}

      {% for magazine in magazines %}
    • {{ component('magazine_inline', {magazine: magazine, showAvatar: true, showNewIcon: true, fullName:false, stretchedLink:true, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE}) }}
    • {% endfor %}
    {% endif %} ================================================ FILE: templates/components/related_posts.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%} {% if posts|length %}

    {{ title|trans }}

    {% for post in posts %}
    {% if post.image %} {{ post.image.alt|default('') }} {% endif %} {% if post.body %}
    {{ get_short_sentence(post.body)|markdown|raw }}
    {% endif %} {{ 'show_more'|trans }}
    {{ component('date', {date: post.createdAt}) }} {{ 'to'|trans }} {{ component('magazine_inline', {magazine: post.magazine, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE, showNewIcon: true}) }}
    {% endfor %}
    {% endif %} ================================================ FILE: templates/components/report_list.html.twig ================================================ {%- set REPORT_ANY = constant('App\\Entity\\Report::STATUS_ANY') -%} {%- set REPORT_PENDING = constant('App\\Entity\\Report::STATUS_PENDING') -%} {%- set REPORT_APPROVED = constant('App\\Entity\\Report::STATUS_APPROVED') -%} {%- set REPORT_REJECTED = constant('App\\Entity\\Report::STATUS_REJECTED') -%} {%- set REPORT_CLOSED = constant('App\\Entity\\Report::STATUS_CLOSED') -%} {% for report in reports %}
    {{ component('user_inline', {user: report.reporting, showNewIcon: true}) }}, {{ component('date', {date: report.createdAt}) }}
    {% include 'layout/_subject_link.html.twig' with {subject: report.subject} -%}
    {{ report.reason }}
    {% if app.request.get('status') is same as REPORT_ANY %} {{ report.status }} {% endif %} {% if report.status is not same as REPORT_CLOSED %} {% if report.status is not same as REPORT_REJECTED %}
    {% endif %} {% if report.status is not same as REPORT_APPROVED %}
    {% endif %} {% endif %} {{ 'ban'|trans }} ({{ report.reported.username|username(true) }})
    {% endfor %} {% if(reports.haveToPaginate is defined and reports.haveToPaginate) %} {{ pagerfanta(reports, null, {'pageParameter':'[p]'}) }} {% endif %} {% if not reports|length %} {% endif %} ================================================ FILE: templates/components/tag_actions.html.twig ================================================ ================================================ FILE: templates/components/user_actions.html.twig ================================================
    {{ user.followersCount|abbreviateNumber }}
    {% if not app.user or app.user is not same as user and not is_instance_of_user_banned(user) %}
    {% elseif app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %} {% endif %} {% if user.markedForDeletionAt and is_granted("ROLE_ADMIN") %}
    {{ 'marked_for_deletion'|trans }}
    {% endif %} ================================================ FILE: templates/components/user_avatar.html.twig ================================================ {% if asLink %} {% endif %} {% if user.avatar %} {{ user.username ~' '~ 'avatar'|trans|lower }} {% else %}
    {% endif %} {% if asLink %}
    {% endif %} ================================================ FILE: templates/components/user_box.html.twig ================================================
    {% if user.cover %} {% if app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %} {% endif %} {{ user.username ~' '~ 'cover'|trans|lower  }} {% if app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %} {% endif %} {% endif %}
    {% if user.avatar %} {% if app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %} {% endif %} {{ component('user_avatar', { user: user, width: 100, height: 100 }) }} {% if app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %} {% endif %} {% endif %} {% if stretchedLink %}

    {{ user.title ?? user.username|username(false) }} {% if (user.type) == "Service" %} {{ 'user_badge_bot'|trans }} {% endif %} {% if user.isNew() %} {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} {% if user.isCakeDay() %} {% endif %} {% if user.admin() %} {{ 'user_badge_admin'|trans }} {% elseif user.moderator() %} {{ 'user_badge_global_moderator'|trans }} {% endif %}

    {% else %}

    {{ user.title ?? user.apPreferredUsername ?? user.username|username(false) }} {% if (user.type) == "Service" %} {{ 'user_badge_bot'|trans }} {% endif %} {% if user.isNew() %} {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} {% if user.isCakeDay() %} {% endif %} {% if user.admin() %} {{ 'user_badge_admin'|trans }} {% elseif user.moderator() %} {{ 'user_badge_global_moderator'|trans }} {% endif %}

    {% endif %} {{ user.username|username(true) }} {% if user.apManuallyApprovesFollowers is same as true %} {% endif %}
    {{ component('user_actions', {user: user}) }} {% if app.user is defined and app.user is not same as null and app.user is not same as user %}
    {{ component('notification_switch', {target: user}) }}
    {% endif %}
    {% if user.about|length %}
    {{ user.about|markdown|raw }}
    {% endif %}
    ================================================ FILE: templates/components/user_form_actions.html.twig ================================================
    {% if showLogin %}

    {{ 'already_have_account'|trans }} {{ 'login'|trans }}

    {% endif %} {% if kbin_registrations_enabled() %} {% if showRegister %}

    {{ 'dont_have_account'|trans }} {{ 'register'|trans }}

    {% endif %} {% endif %} {% if showPasswordReset %}

    {{ 'you_cant_login'|trans }} {{ 'reset_password'|trans }}

    {% endif %} {# {% if showResendEmail %}#} {#

    {{ 'resend_account_activation_email_question'|trans }} #} {# {{ 'resend_account_activation_email'|trans }}#} {#

    #} {# {% endif %}#}
    ================================================ FILE: templates/components/user_image_component.html.twig ================================================ {% if user.avatar and showAvatar %} {{ user.username ~ ' avatar' }} {% endif %} ================================================ FILE: templates/components/user_inline.html.twig ================================================ {% if user.avatar and showAvatar %} {{ user.username ~' '~ 'avatar'|trans|lower }} {% endif %} {{ user.title ?? user.apPreferredUsername ?? user.username|username -}} {%- if fullName is defined and fullName is same as true -%} @{{- user.username|apDomain -}} {%- endif -%} {% if user.isNew() and showNewIcon %} {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} {% if user.isCakeDay() %} {% endif %} ================================================ FILE: templates/components/user_inline_box.html.twig ================================================
    {% if user.cover %} {{ user.username ~' '~ 'cover'|trans|lower  }} {% endif %}
    {% if user.avatar %} {% if app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %} {% endif %} {{ component('user_avatar', { user: user, width: 100, height: 100 }) }} {% if app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %} {% endif %} {% endif %}

    {{ user.title ?? user.apPreferredUsername ?? user.username|username(false) }} {% if (user.type) == "Service" %} {{ 'user_badge_bot'|trans }} {% endif %} {% if user.isNew() %} {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} {% if user.isCakeDay() %} {% endif %} {% if user.admin() %} {{ 'user_badge_admin'|trans }} {% elseif user.moderator() %} {{ 'user_badge_global_moderator'|trans }} {% endif %}

    {{ user.username|username(true) }} {% if user.apManuallyApprovesFollowers is same as true %} {% endif %} {{ component('user_actions', {user: user}) }} {% if app.user is defined and app.user is not same as null and app.user is not same as user %}
    {{ component('notification_switch', {target: user}) }}
    {% endif %}
    {% if user.about|length %}
    {{ user.about|markdown|raw }}
    {% endif %}
    ================================================ FILE: templates/components/user_inline_md.html.twig ================================================ {% if rich is same as true %} {{ component('user_inline', { user: user, showAvatar: showAvatar, showNewIcon: showNewIcon, fullName: fullName, }) }} {% else %} @{{- user.title ?? user.username|username -}} {%- if fullName -%} @{{- user.username|apDomain -}} {%- endif -%} {% endif %} ================================================ FILE: templates/components/vote.html.twig ================================================ {%- set VOTE_NONE = constant('App\\Entity\\Contracts\\VotableInterface::VOTE_NONE') -%} {%- set VOTE_UP = constant('App\\Entity\\Contracts\\VotableInterface::VOTE_UP') -%} {%- set VOTE_DOWN = constant('App\\Entity\\Contracts\\VotableInterface::VOTE_DOWN') -%} {%- set DOWNVOTES_HIDDEN = constant('App\\Utils\\DownvotesMode::Hidden') %} {%- set DOWNVOTES_DISABLED = constant('App\\Utils\\DownvotesMode::Disabled') %} {% if app.user %} {%- set user_choice = is_granted('ROLE_USER') ? subject.userChoice(app.user) : null -%} {% set upUrl = path(formDest~'_favourite', {id: subject.id, choice: VOTE_UP}) %} {% set downUrl = path(formDest~'_vote', {id: subject.id, choice: VOTE_DOWN}) %} {% if(user_choice is same as(VOTE_UP)) %} {% set choice = VOTE_UP %} {% elseif(user_choice is same as(VOTE_DOWN)) %} {% set choice = VOTE_DOWN %} {% else %} {% set choice = VOTE_NONE %} {% endif %} {% else %} {% set choice = VOTE_NONE %} {% set upUrl = path(formDest~'_favourite', {id: subject.id, choice: VOTE_NONE}) %} {% set downUrl = path(formDest~'_vote', {id: subject.id, choice: VOTE_NONE}) %} {% endif %}
    {% set downvoteMode = mbin_downvotes_mode() %} {% if showDownvote and downvoteMode is not same as DOWNVOTES_DISABLED %}
    {% endif %} ================================================ FILE: templates/components/voters_inline.html.twig ================================================ {% if voters|length %} + {% for voter in voters %} {{ voter|username }} {%- if not loop.last %},{% endif %} {% endfor %} {% if count > 4 %} +{{ count - 4 }} {{ 'more'|trans }} {% endif %} {% endif %} ================================================ FILE: templates/content/_list.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%} {%- set INFINITE_SCROLL = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL'), V_FALSE) -%} {%- set SHOW_COMMENTS_AVATAR = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')) -%} {%- set SHOW_POST_AVATAR = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS')) -%} {% if criteria is defined and criteria.content is same as constant('APP\\Repository\\Criteria::CONTENT_THREADS') %} {% set data_action = DYNAMIC_LISTS is same as V_TRUE ? 'notifications:EntryCreatedNotification@window->subject-list#addMainSubject' : 'notifications:EntryCreatedNotification@window->subject-list#increaseCounter' %} {% elseif criteria is defined and criteria.content is same as constant('APP\\Repository\\Criteria::CONTENT_MICROBLOG') %} {% set data_action = DYNAMIC_LISTS is same as V_TRUE ? 'notifications:PostCreatedNotification@window->subject-list#addMainSubject' : 'notifications:PostCreatedNotification@window->subject-list#increaseCounter' %} {% else %} {% set data_action = DYNAMIC_LISTS is same as V_TRUE ? 'notifications:EntryCreatedNotification@window->subject-list#addMainSubject notifications:PostCreatedNotification@window->subject-list#addMainSubject' : 'notifications:EntryCreatedNotification@window->subject-list#increaseCounter notifications:PostCreatedNotification@window->subject-list#increaseCounter' %} {% endif %}
    {% if magazine is defined and magazine %} {% include 'layout/_subject_list.html.twig' with {postAttributes: {showMagazineName: false}, entryAttributes: {showMagazineName: false}} %} {% else %} {% include 'layout/_subject_list.html.twig' %} {% endif %}
    ================================================ FILE: templates/content/front.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {%- if magazine is defined and magazine -%} {% if get_active_sort_option('sortBy') is not same as get_default_sort_option() %} {{- get_active_sort_option('sortBy')|trans|capitalize }} - {{ magazine.title }} - {{ parent() -}} {% else %} {{- magazine.title }} - {{ parent() -}} {% endif %} {%- else -%} {% if criteria.getOption('content') == 'threads' %} {% if get_active_sort_option('sortBy') is not same as get_default_sort_option() %} {{- get_active_sort_option('sortBy')|trans|capitalize }} - {{ 'thread'|trans }} - {{ parent() }} {% else %} {{- 'thread'|trans }} - {{ parent() -}} {% endif %} {% elseif criteria.getOption('content') == 'microblog' %} {% if get_active_sort_option('sortBy') is not same as get_default_sort_option() %} {{- get_active_sort_option('sortBy')|trans|capitalize }} - {{ 'microblog'|trans }} - {{ parent() }} {% else %} {{- 'microblog'|trans }} - {{ parent() -}} {% endif %} {% else %} {% if get_active_sort_option('sortBy') is not same as get_default_sort_option() %} {{- get_active_sort_option('sortBy')|trans|capitalize }} - {{ parent() }} - {{ kbin_meta_description() -}} {% else %} {{- parent() }} - {{ kbin_meta_description() -}} {% endif %} {% endif %} {%- endif -%} {%- endblock -%} {% block description %} {%- if magazine is defined and magazine -%} {{- magazine.description ? get_short_sentence(magazine.description) : '' -}} {%- else -%} {{- parent() -}} {%- endif -%} {% endblock %} {% block image %} {%- if magazine is defined and magazine and magazine.icon -%} {{- uploaded_asset(magazine.icon) -}} {%- else -%} {{- parent() -}} {%- endif -%} {% endblock %} {% block mainClass %}page-entry-front{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}
    {% if magazine is defined and magazine %}

    {{ magazine.title }}

    {% if magazine.banner is not same as null %}
    {{ magazine.name ~ ' ' ~ 'banner'|trans|lower }}
    {% endif %} {% else %}

    {{ get_active_sort_option()|trans }}

    {% endif %}
    {% if criteria is defined %} {% if criteria.getOption('content') == 'microblog' %}
    {% include 'post/_form_post.html.twig' %}
    {% include 'post/_options.html.twig' %} {% else %} {% include 'entry/_options.html.twig' %} {% endif %} {% endif %} {% include 'layout/_flash.html.twig' %} {% if magazine is defined and magazine %} {% include 'magazine/_restricted_info.html.twig' %} {% include 'magazine/_federated_info.html.twig' %} {% include 'magazine/_visibility_info.html.twig' %} {% endif %}
    {{ include('content/_list.html.twig') }}
    {% endblock %} ================================================ FILE: templates/domain/_header_nav.html.twig ================================================
  • {{ 'threads'|trans }}
  • {{ 'comments'|trans }}
  • ================================================ FILE: templates/domain/_list.html.twig ================================================ {% if domains|length %}
    {% for domain in domains %} {% endfor %}
    {{ 'name'|trans }} {{ 'threads'|trans }}
    {{ domain.name }} {{ domain.entries|length }} {{ component('domain_sub', {domain: domain}) }}
    {% else %} {% endif %} ================================================ FILE: templates/domain/_options.html.twig ================================================ {% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %} ================================================ FILE: templates/domain/comment/front.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} title {%- endblock -%} {% block mainClass %}page-domain-comments-front{% endblock %} {% block header_nav %} {% include 'domain/_header_nav.html.twig' %} {% endblock %} {% block sidebar_top %} {{ component('domain', {domain: domain}) }} {% endblock %} {% block body %}

    {{ domain.name }}

    {% include 'entry/comment/_options.html.twig' %}
    {% include 'entry/comment/_list.html.twig' %}
    {% endblock %} ================================================ FILE: templates/domain/front.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{ domain.name }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-domain-entry-front{% endblock %} {% block header_nav %} {% include 'domain/_header_nav.html.twig' %} {% endblock %} {% block sidebar_top %} {{ component('domain', {domain: domain}) }} {% endblock %} {% block body %}

    {{ domain.name }}

    {% include 'domain/_options.html.twig' %}
    {% include 'entry/_list.html.twig' %}
    {% endblock %} ================================================ FILE: templates/entry/_create_options.html.twig ================================================ ================================================ FILE: templates/entry/_form_edit.html.twig ================================================ {% form_theme form.lang 'form/lang_select.html.twig' %} {{ form_start(form, {attr: {class: 'entry_edit'}}) }}
    {% set label %} URL
    Loading...
    {% endset %} {{ form_label(form.url, label, {'label_html': true}) }} {{ form_errors(form.url) }} {{ form_widget(form.url, {attr: {'data-action': 'entry-link-create#fetchLink', 'data-entry-link-create-target': 'url'}}) }}
    {{ form_row(form.title, { label: 'title', attr: { 'data-controller' : "input-length autogrow", 'data-entry-link-create-target': 'title', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value' : constant('App\\Entity\\Entry::MAX_TITLE_LENGTH') }}) }} {{ component('editor_toolbar', {id: 'entry_edit_body'}) }} {{ form_row(form.body, { label: false, attr: { placeholder: 'body', 'data-controller': 'rich-textarea input-length autogrow', 'data-entry-link-create-target': 'description', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value' : constant('App\\Entity\\Entry::MAX_BODY_LENGTH') }}) }} {{ form_row(form.magazine, {label: false}) }} {{ form_row(form.tags, {label: 'tags'}) }} {# form_row(form.badges, {label: 'badges'}) #}
    {{ form_row(form.isAdult, {label: 'is_adult', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.isOc, {label: 'oc', row_attr: {class: 'checkbox'}}) }}
      {% if entry.image is not same as null %} {{ entry.image.altText }} {% endif %}
    • {{ form_row(form.lang, {label: false}) }}
    • {{ form_row(form.submit, {label: 'edit_entry', attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }} ================================================ FILE: templates/entry/_form_entry.html.twig ================================================ {% form_theme form.lang 'form/lang_select.html.twig' %} {% set hasImage = false %} {% if edit is not defined %} {% set edit = false %} {% elseif entry.image %} {% set hasImage = true %} {% endif %} {{ form_start(form, {attr: {class: edit ? 'entry_edit' : 'entry-create'}}) }}
    {% set label %} URL
    Loading...
    {% endset %} {{ form_label(form.url, label, {'label_html': true}) }} {{ form_errors(form.url) }} {{ form_widget(form.url, {attr: {'data-action': 'entry-link-create#fetchLink','data-entry-link-create-target': 'url'}}) }}
    {{ form_row(form.title, { label: 'title', attr: { 'data-controller' : "input-length autogrow", 'data-entry-link-create-target': 'title', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value' : constant('App\\Entity\\Entry::MAX_TITLE_LENGTH') }}) }} {{ component('editor_toolbar', {id: 'entry_body'}) }} {{ form_row(form.body, { label: false, attr: { placeholder: 'body', 'data-controller': 'rich-textarea input-length autogrow', 'data-entry-link-create-target': 'description', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value' : constant('App\\Entity\\Entry::MAX_BODY_LENGTH') }}) }} {{ form_row(form.magazine, {label: false}) }} {{ form_row(form.tags, {label: 'tags'}) }} {# form_row(form.badges, {label: 'badges'}) #}
    {{ form_row(form.isAdult, {label: 'is_adult', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.isOc, {label: 'oc', row_attr: {class: 'checkbox'}}) }}
      {% if hasImage %} {{ entry.image.altText }} {% endif %}
    • {{ form_row(form.lang, {label: false}) }}
    • {{ form_row(form.submit, {label: edit ? 'edit_article' : 'add_new_article', attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }} ================================================ FILE: templates/entry/_info.html.twig ================================================ ================================================ FILE: templates/entry/_list.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%} {%- set INFINITE_SCROLL = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL'), V_FALSE) -%}
    {% for entry in entries %} {{ component('entry', { entry: entry, showMagazineName: magazine is not defined or not magazine }) }} {% endfor %} {% if(entries.haveToPaginate is defined and entries.haveToPaginate) %} {% if INFINITE_SCROLL is same as V_TRUE %}
    {{ component('loader', {'data-infinite-scroll-target': 'loader'}) }}
    {{ pagerfanta(entries, null, {'pageParameter':'[p]'}) }}
    {% else %} {{ pagerfanta(entries, null, {'pageParameter':'[p]'}) }} {% endif %} {% endif %} {% if not entries|length %} {% if magazine is defined and magazine.postCount > 0 %} {% endif %} {% endif %}
    ================================================ FILE: templates/entry/_menu.html.twig ================================================ ================================================ FILE: templates/entry/_moderate_panel.html.twig ================================================
  • {% if is_granted('purge', entry) %}
  • {% endif %} {% if is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}
  • {% endif %}
  • {{ form_start(form, {action: path('entry_change_lang', {magazine_name: magazine.name, entry_id: entry.id})}) }} {{ form_row(form.lang, {label: false, row_attr: {class: 'checkbox'}}) }} {{ form_row(form.submit, {label: 'change_language'|trans, attr: {class: 'btn btn__secondary'}}) }} {{ form_end(form) }}
  • ================================================ FILE: templates/entry/_options.html.twig ================================================ {% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %} ================================================ FILE: templates/entry/_options_activity.html.twig ================================================ {%- set downvoteMode = mbin_downvotes_mode() %} {%- set DOWNVOTES_ENABLED = constant('App\\Utils\\DownvotesMode::Enabled') %} ================================================ FILE: templates/entry/comment/_form_comment.html.twig ================================================ {% form_theme form.lang 'form/lang_select.html.twig' %} {% set hasImage = false %} {% if comment is defined and comment is not null and comment.image %} {% set hasImage = true %} {% endif %} {% if edit is not defined %} {% set edit = false %} {% endif %} {% if edit %} {% set title = 'edit_comment'|trans %} {% set action = path('entry_comment_edit', {magazine_name: entry.magazine.name, entry_id: entry.id, comment_id: comment.id}) %} {% else %} {% set title = 'add_comment'|trans %} {% set action = path('entry_comment_create', {magazine_name: entry.magazine.name, entry_id: entry.id, parent_comment_id: parent is defined and parent ? parent.id : null}) %} {% endif %} {{ form_start(form, {action: action, attr: {class: edit ? 'comment-edit replace' : 'comment-add'}}) }} {{ component('editor_toolbar', {id: form.body.vars.id}) }} {{ form_row(form.body, {label: false, attr: { 'data-controller': 'input-length rich-textarea autogrow', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value': constant('App\\DTO\\EntryCommentDto::MAX_BODY_LENGTH') }}) }}
      {% if hasImage %} {{ comment.image.altText }} {% endif %}
    • {{ form_row(form.lang, {label: false}) }}
    • {{ form_row(form.submit, {label: edit ? 'update_comment' : 'add_comment' , attr: {class: 'btn btn__primary', 'data-action': 'subject#sendForm'}}) }}
    {{ form_end(form) }} ================================================ FILE: templates/entry/comment/_list.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set V_CHAT = constant('App\\Controller\\User\\ThemeSettingsController::CHAT') -%} {%- set V_TREE = constant('App\\Controller\\User\\ThemeSettingsController::TREE') -%} {%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%} {%- set SHOW_COMMENT_USER_AVATARS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR'), V_TRUE) -%} {%- set VIEW_STYLE = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::ENTRY_COMMENTS_VIEW'), V_TREE) -%} {% if showNested is not defined %} {% if VIEW_STYLE is same as V_CHAT %} {% set showNested = false %} {% else %} {% set showNested = true %} {% endif %} {% endif %} {% set autoAction = 'notifications:EntryCommentCreatedNotification@window->subject-list#addComment' %} {% set manualAction = 'notifications:EntryCommentCreatedNotification@window->subject-list#increaseCounter' %}
    {% for comment in comments %} {{ component('entry_comment', { comment: comment, showNested: showNested, dateAsUrl: dateAsUrl is defined ? dateAsUrl : true, showMagazineName: magazine is not defined or not magazine, showEntryTitle: entry is not defined or not entry, criteria: criteria, }) }} {% endfor %} {% if(comments.haveToPaginate is defined and comments.haveToPaginate) %} {% if app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL')) is same as 'true' %}
    {{ component('loader', {'data-infinite-scroll-target': 'loader'}) }}
    {{ pagerfanta(comments, null, {'pageParameter':'[p]'}) }}
    {% else %} {{ pagerfanta(comments, null, {'pageParameter':'[p]'}) }} {% endif %} {% endif %} {% if not comments|length %} {% elseif VIEW_STYLE is same as V_TREE %}
    {% endif %}
    ================================================ FILE: templates/entry/comment/_menu.html.twig ================================================ ================================================ FILE: templates/entry/comment/_moderate_panel.html.twig ================================================
  • {% if is_granted('purge', comment) %}
  • {% endif %}
  • {{ form_start(form, {action: path('entry_comment_change_lang', {magazine_name: magazine.name, entry_id: entry.id, comment_id: comment.id})}) }} {{ form_row(form.lang, {label: false, row_attr: {class: 'checkbox'}}) }} {{ form_row(form.submit, {label: 'change_language'|trans, attr: {class: 'btn btn__secondary'}}) }} {{ form_end(form) }}
  • ================================================ FILE: templates/entry/comment/_no_comments.html.twig ================================================ ================================================ FILE: templates/entry/comment/_options.html.twig ================================================ {% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %} ================================================ FILE: templates/entry/comment/_options_activity.html.twig ================================================ {% set downvoteMode = mbin_downvotes_mode() %} {%- set DOWNVOTES_ENABLED = constant('App\\Utils\\DownvotesMode::Enabled') %} ================================================ FILE: templates/entry/comment/create.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'add_comment'|trans }} - {{ entry.title }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-entry-comment-create{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {{ component('entry', { entry: entry, isSingle: true, showShortSentence: false, showBody:false }) }}

    {{ 'browsing_one_thread'|trans }}

    {{ 'return'|trans }}

    {% if parent is defined and parent %} {{ component('entry_comment', { comment: parent, showEntryTitle: false, showNested: false }) }} {% endif %} {% include 'layout/_flash.html.twig' %} {% if user.visibility is same as 'visible' %}
    {% include 'entry/comment/_form_comment.html.twig' %}
    {% endif %} {% endblock %} ================================================ FILE: templates/entry/comment/edit.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'edit_comment'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-entry-comment-edit{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {{ component('entry_comment', { comment: comment, dateAsUrl: false, showEntryTitle: false, showMagazineName: false, }) }} {% include 'layout/_flash.html.twig' %}

    {{ 'browsing_one_thread'|trans }}

    {{ 'return'|trans }}

    {% include 'entry/comment/_form_comment.html.twig' with {edit: true} %}
    {% endblock %} ================================================ FILE: templates/entry/comment/favourites.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'favourites'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-entry-comment-favourites{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {{ component('entry_comment', { comment: comment, showEntryTitle: false, showMagazineName: false }) }} {% include 'layout/_flash.html.twig' %} {% include 'entry/comment/_options_activity.html.twig' %}
    {% include 'layout/_user_activity_list.html.twig' with {list: favourites} %}
    {% endblock %} ================================================ FILE: templates/entry/comment/front.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {% if magazine is defined and magazine %} {{- get_active_sort_option('sortBy')|trans|capitalize }} - {{ 'comments'|trans }} - {{ magazine.title }} - {{ parent() -}} {% else %} {{- get_active_sort_option('sortBy')|trans|capitalize }} - {{ 'comments'|trans }} - {{ parent() -}} {% endif %} {%- endblock -%} {% block mainClass %}page-entry-comments-front{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% if magazine is defined and magazine %}

    {{ magazine.title }}

    {% else %}

    {{ get_active_sort_option()|trans }}

    {% endif %} {% include 'entry/comment/_options.html.twig' %} {% include 'layout/_flash.html.twig' %} {% if magazine is defined and magazine %} {% include 'magazine/_federated_info.html.twig' %} {% include 'magazine/_visibility_info.html.twig' %} {% endif %}
    {% include 'entry/comment/_list.html.twig' %}
    {% endblock %} ================================================ FILE: templates/entry/comment/moderate.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'moderate'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ magazine.title }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-entry-moderate{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}
    {{ component('entry_comment', { comment: comment, dateAsUrl: false, }) }} {% include 'layout/_flash.html.twig' %}
    {% include 'entry/comment/_moderate_panel.html.twig' %}
    {% endblock %} ================================================ FILE: templates/entry/comment/view.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%} {%- set SHOW_COMMENT_USER_AVATARS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR'), V_TRUE) -%} {% extends 'base.html.twig' %} {%- block title -%} {{- 'comments'|trans }} - {{ entry.title }} - {{ parent() -}} {%- endblock -%} {% block stylesheets %} {{ parent() }} {% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {{ component('entry', { entry: entry, isSingle: true, showShortSentence: false, showBody:false }) }}

    {{ 'browsing_one_thread'|trans }}

    {{ 'return'|trans }}

    {% set autoAction = 'notifications:EntryCommentCreatedNotification@window->subject-list#addComment' %} {% set manualAction = 'notifications:EntryCommentCreatedNotification@window->subject-list#increaseCounter' %}
    {% set entryComment = comment.root ?? comment %} {% if entryComment is defined and entryComment is not null %} {{ component('entry_comment', { comment: entryComment, showEntryTitle: false, showMagazineName: false, showNested: true, criteria: criteria, }) }} {% else %}

    {{ 'back'|trans }} {{ 'comment_not_found'|trans }}

    {% endif %}
    {% endblock %} ================================================ FILE: templates/entry/comment/voters.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'activity'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-entry-comment-voters{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {{ component('entry_comment', { comment: comment, showEntryTitle: false, showMagazineName: false }) }} {% include 'layout/_flash.html.twig' %} {% include 'entry/comment/_options_activity.html.twig' %}
    {% include 'layout/_user_activity_list.html.twig' with {list: votes} %}
    {% endblock %} ================================================ FILE: templates/entry/create_entry.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'add_new_article'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-entry-create page-entry-create-article{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'entry/_create_options.html.twig' %}

    {{ 'add_new_article'|trans }}

    {% include 'layout/_flash.html.twig' %} {% include('user/_visibility_info.html.twig') %} {% if user.visibility is same as 'visible' %}
    {% include 'entry/_form_entry.html.twig' %}
    {% endif %} {% endblock %} ================================================ FILE: templates/entry/edit_entry.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'edit_entry'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-entry-create page-entry-edit-article{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'edit_entry'|trans }}

    {% include 'layout/_flash.html.twig' %}
    {% include 'entry/_form_edit.html.twig' %}
    {% endblock %} ================================================ FILE: templates/entry/favourites.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'favourites'|trans }} - {{ entry.title}} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-entry-favourites{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}
    {{ component('entry', { entry: entry, isSingle: true, showBody: false }) }} {% include 'layout/_flash.html.twig' %} {% include 'entry/_options_activity.html.twig' %}
    {% include 'layout/_user_activity_list.html.twig' with {list: favourites} %}
    {% endblock %} ================================================ FILE: templates/entry/moderate.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'moderate'|trans }} - {{ entry.title }} - {{ magazine.title }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-entry-moderate{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}
    {{ component('entry', { entry: entry, isSingle: true, showShortSentence: false, showBody:true, moderate:true, class: 'section--top' }) }} {% include 'layout/_flash.html.twig' %}
    {% include 'entry/_moderate_panel.html.twig' %}
    {% endblock %} ================================================ FILE: templates/entry/single.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- entry.title }} - {{ magazine.title }} - {{ parent() -}} {%- endblock -%} {% block description %} {{- entry.body ? get_short_sentence(entry.body) : '' -}} {% endblock %} {% block image %} {%- if entry.image -%} {{- uploaded_asset(entry.image) -}} {%- elseif entry.magazine.icon -%} {{- uploaded_asset(entry.magazine.icon) -}} {%- else -%} {{- parent() -}} {%- endif -%} {% endblock %} {% block mainClass %}page-entry-single{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}
    {{ component('entry', { entry: entry, isSingle: true, showShortSentence: false, showBody:true }) }} {{ component('entries_cross', {entry: entry}) }} {% include 'layout/_flash.html.twig' %} {% include('user/_visibility_info.html.twig') %} {% if user is defined and user and user.visibility is same as 'visible' and not entry.isLocked and (user_settings.comment_reply_position == constant('App\\Controller\\User\\ThemeSettingsController::TOP')) %}
    {% include 'entry/comment/_form_comment.html.twig' %}
    {% endif %} {% include 'entry/comment/_options.html.twig' %} {% if entry.isLocked %}

    {{ 'comments_locked'|trans }}

    {% endif %}
    {% include 'entry/comment/_list.html.twig' %}
    {% if user is defined and user and user.visibility is same as 'visible' and not entry.isLocked and (user_settings.comment_reply_position == constant('App\\Controller\\User\\ThemeSettingsController::BOTTOM')) %}
    {% include 'entry/comment/_form_comment.html.twig' %}
    {% endif %} {% include 'entry/_options_activity.html.twig' %}
    {% endblock %} ================================================ FILE: templates/entry/voters.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- route_has_param('type', 'up') ? 'up_votes'|trans : 'down_votes'|trans }} - {{ entry.title}} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-entry-voters{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}
    {{ component('entry', {entry: entry, isSingle: true, showBody: false}) }} {% include 'layout/_flash.html.twig' %} {% include 'entry/_options_activity.html.twig' %}
    {% include 'layout/_user_activity_list.html.twig' with {list: votes} %}
    {% endblock %} ================================================ FILE: templates/form/lang_select.html.twig ================================================ {# This block needed as the default one (of which this is a very close copy) does not respect preferred_choices like it should #} {%- block choice_widget_options -%} {% for group_label, choice in options %} {%- if choice is iterable -%} {% set options = choice %} {{- block('choice_widget_options') -}} {%- elseif render_preferred_choices|default(false) or (not render_preferred_choices|default(false) and choice not in preferred_choices) -%} {%- endif -%} {% endfor %} {%- endblock choice_widget_options -%} ================================================ FILE: templates/layout/_domain_activity_list.html.twig ================================================ {% if actor is not defined %} {% set actor = 'magazine' %} {% endif %} {% if list|length %}
    {% if(list.haveToPaginate is defined and list.haveToPaginate) %} {{ pagerfanta(list, null, {'pageParameter':'[p]'}) }} {% endif %} {% else %} {% endif %} ================================================ FILE: templates/layout/_flash.html.twig ================================================ {% for flash_error in app.flashes('error') %}
    {{ flash_error|trans }}
    {% endfor %} {% for flash_success in app.flashes('success') %}
    {{ flash_success|trans }}
    {% endfor %} ================================================ FILE: templates/layout/_form_media.html.twig ================================================
    {% if form.image is defined %} {{ form_row(form.image, {label: 'image', attr: {class: 'image-input'}}) }} {% endif %} {% if maxSize is defined %}
    {{ 'max_image_size'|trans }}: {{ maxSize }}
    {% endif %} {{ form_row(form.imageAlt, {label: 'image_alt'}) }}
    ================================================ FILE: templates/layout/_generic_subject_list.html.twig ================================================
    {{ include('layout/_subject_list.html.twig') }}
    ================================================ FILE: templates/layout/_header.html.twig ================================================ {%- set STATUS_PENDING = constant('App\\Entity\\Report::STATUS_PENDING') -%} ================================================ FILE: templates/layout/_header_bread.html.twig ================================================ {% set filter_option = criteria is defined ? criteria.getOption('subscription') : null %} {% if magazine is defined and magazine %} {% elseif is_route_name_starts_with('domain_') %} {% elseif tag is defined and tag %} {% elseif filter_option == 'subscribed' or is_route_name_end_with('_subscribed') %}
    /sub
    {% elseif filter_option == 'favourites' or is_route_name_end_with('_favourite') %}
    /fav
    {% elseif filter_option == 'moderated' or is_route_name_end_with('_moderated') %}
    /mod
    {% endif %} ================================================ FILE: templates/layout/_header_nav.html.twig ================================================ {% set activeLink = '' %} {% if (is_route_name_contains('people') or is_route_name_starts_with('user')) and not is_route_name_contains('settings') %} {% set activeLink = 'people' %} {% elseif is_route_name('magazine_list_all') %} {% set activeLink = 'magazines' %} {% elseif criteria is defined %} {% if criteria.getOption('content') == 'threads' %} {% set activeLink = 'threads' %} {% elseif criteria.getOption('content') == 'microblog' %} {% set activeLink = 'microblog' %} {% elseif criteria.getOption('content') == 'combined' %} {% set activeLink = 'combined' %} {% endif %} {% elseif entry is defined and entry %} {% set activeLink = 'threads' %} {% elseif post is defined and post %} {% set activeLink = 'microblog' %} {% endif %} {% if header_nav is empty %}
  • {{ 'combined'|trans }}
  • {{ 'threads'|trans }} {% if magazine is defined and magazine %}({{ magazine.entryCount }}){% endif %}
  • {{ 'microblog'|trans }} {% if magazine is defined and magazine %}({{ magazine.postCount }}){% endif %}
  • {{ 'people'|trans }}
  • {% if app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_TOPBAR')) is not same as 'true' %}
  • {{ 'magazines'|trans }}
  • {% endif %} {% else %} {{ header_nav|raw }} {% endif %} ================================================ FILE: templates/layout/_magazine_activity_list.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%} {% if actor is not defined %} {% set actor = 'magazine' %} {% endif %} {% if list|length %}
    {% if(list.haveToPaginate is defined and list.haveToPaginate) %} {{ pagerfanta(list, null, {'pageParameter':'[p]'}) }} {% endif %} {% else %} {% endif %} ================================================ FILE: templates/layout/_options_appearance.html.twig ================================================
    {{ 'general'|trans }}
    {{ component('settings_row_enum', {label: 'sidebar_position'|trans, settingsKey: 'KBIN_GENERAL_SIDEBAR_POSITION', values: [ {name: 'left'|trans , value: 'LEFT'}, {name: 'right'|trans , value: 'RIGHT' } ], defaultValue: 'RIGHT' } ) }} {{ component('settings_row_enum', {label: 'page_width'|trans, settingsKey: 'KBIN_PAGE_WIDTH', values: [ {name: 'page_width_max'|trans , value: 'MAX'}, {name: 'page_width_auto'|trans , value: 'AUTO' }, {name: 'page_width_fixed'|trans , value: 'FIXED' } ], defaultValue: 'FIXED', class: 'width-setting' } ) }} {{ component('settings_row_enum', {label: 'filter_labels'|trans, settingsKey: 'KBIN_GENERAL_FILTER_LABELS', values: [ {name: 'on'|trans , value: 'ON'}, {name: 'auto'|trans , value: 'AUTO' }, {name: 'off'|trans , value: 'OFF' } ], defaultValue: 'ON' } ) }} {% if kbin_mercure_enabled() %} {{ component('settings_row_switch', {label: 'dynamic_lists'|trans, settingsKey: 'KBIN_GENERAL_DYNAMIC_LISTS'}) }} {% endif %} {{ component('settings_row_switch', {label: 'rounded_edges'|trans, settingsKey: 'KBIN_GENERAL_ROUNDED_EDGES', defaultValue: true}) }} {{ component('settings_row_switch', {label: 'infinite_scroll'|trans, help: 'infinite_scroll_help'|trans , settingsKey: 'KBIN_GENERAL_INFINITE_SCROLL'}) }} {{ component('settings_row_switch', {label: 'sticky_navbar'|trans, help: 'sticky_navbar_help'|trans, settingsKey: 'KBIN_GENERAL_FIXED_NAVBAR'}) }} {{ component('settings_row_switch', {label: 'show_top_bar'|trans, settingsKey: 'KBIN_GENERAL_TOPBAR'}) }} {{ component('settings_row_switch', {label: 'show_related_magazines'|trans, settingsKey: 'MBIN_GENERAL_SHOW_RELATED_MAGAZINES', defaultValue: 'true'}) }} {{ component('settings_row_switch', {label: 'show_related_entries'|trans, settingsKey: 'MBIN_GENERAL_SHOW_RELATED_ENTRIES', defaultValue: 'true'}) }} {{ component('settings_row_switch', {label: 'show_related_posts'|trans, settingsKey: 'MBIN_GENERAL_SHOW_RELATED_POSTS', defaultValue: 'true'}) }} {{ component('settings_row_switch', {label: 'show_active_users'|trans, settingsKey: 'MBIN_GENERAL_SHOW_ACTIVE_USERS', defaultValue: 'true'}) }} {{ component('settings_row_switch', {label: 'show_user_domains'|trans, settingsKey: 'MBIN_SHOW_USER_DOMAIN', defaultValue: false}) }} {{ component('settings_row_switch', {label: 'show_magazine_domains'|trans, settingsKey: 'MBIN_SHOW_MAGAZINE_DOMAIN', defaultValue: false}) }}
    {% if app.user is defined and app.user is not same as null %} {{ 'subscriptions'|trans }}
    {{ component('settings_row_switch', {label: 'show_subscriptions'|trans, settingsKey: 'KBIN_SUBSCRIPTIONS_SHOW', defaultValue: 'true'}) }} {{ component('settings_row_switch', {label: 'show_magazines_icons'|trans, settingsKey: 'KBIN_SUBSCRIPTIONS_SHOW_MAGAZINE_ICON', defaultValue: 'true'}) }} {% if app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SHOW')) is not same as 'false' %} {{ component('settings_row_enum', {label: 'subscription_sort'|trans, settingsKey: 'KBIN_SUBSCRIPTIONS_SORT', values: [ {name: 'alphabetically'|trans , value: 'ALPHABETICALLY'}, {name: 'last_active'|trans , value: 'LAST_ACTIVE' } ], defaultValue: 'LAST_ACTIVE'}) }} {{ component('settings_row_switch', {label: 'subscriptions_in_own_sidebar'|trans, settingsKey: 'KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR'}) }} {% if app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR')) is same as 'true' %} {{ component('settings_row_switch', {label: 'sidebars_same_side'|trans, settingsKey: 'KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE'}) }} {% else %} {{ component('settings_row_switch', {label: 'subscription_panel_large'|trans, settingsKey: 'KBIN_SUBSCRIPTIONS_LARGE_PANEL'}) }} {% endif %} {% endif %}
    {% endif %} {{ 'threads'|trans }}
    {{ component('settings_row_switch', {label: 'auto_preview'|trans, help: 'auto_preview_help'|trans, settingsKey: 'KBIN_ENTRIES_SHOW_PREVIEW'}) }} {{ component('settings_row_switch', {label: 'compact_view'|trans, help: 'compact_view_help'|trans, settingsKey: 'KBIN_ENTRIES_COMPACT'}) }} {{ component('settings_row_switch', {label: 'show_users_avatars'|trans, help: 'show_users_avatars_help'|trans, settingsKey: 'KBIN_ENTRIES_SHOW_USERS_AVATARS', defaultValue: true}) }} {{ component('settings_row_switch', {label: 'show_magazines_icons'|trans, help: 'show_magazines_icons_help'|trans, settingsKey: 'KBIN_ENTRIES_SHOW_MAGAZINES_ICONS', defaultValue: true}) }} {{ component('settings_row_switch', {label: 'show_thumbnails'|trans, help: 'show_thumbnails_help'|trans, settingsKey: 'KBIN_ENTRIES_SHOW_THUMBNAILS', defaultValue: true}) }} {{ component('settings_row_switch', {label: 'image_lightbox_in_list'|trans, help: 'image_lightbox_in_list_help'|trans, settingsKey: 'MBIN_LIST_IMAGE_LIGHTBOX', defaultValue: true}) }} {{ component('settings_row_switch', {label: 'show_rich_mention'|trans, help: 'show_rich_mention_help'|trans, settingsKey: 'MBIN_ENTRIES_SHOW_RICH_MENTION', defaultValue: true}) }} {{ component('settings_row_switch', {label: 'show_rich_mention_magazine'|trans, help: 'show_rich_mention_magazine_help'|trans, settingsKey: 'MBIN_ENTRIES_SHOW_RICH_MENTION_MAGAZINE', defaultValue: true}) }} {{ component('settings_row_switch', {label: 'show_rich_ap_link'|trans, help: 'show_rich_ap_link_help'|trans, settingsKey: 'MBIN_ENTRIES_SHOW_RICH_AP_LINK', defaultValue: true}) }}
    {{ 'microblog'|trans }}
    {{ component('settings_row_switch', {label: 'auto_preview'|trans, help: 'auto_preview_help'|trans, settingsKey: 'KBIN_POSTS_SHOW_PREVIEW'}) }} {{ component('settings_row_switch', {label: 'show_users_avatars'|trans, help: 'show_users_avatars_help'|trans, settingsKey: 'KBIN_POSTS_SHOW_USERS_AVATARS', defaultValue: true}) }} {{ component('settings_row_switch', {label: 'show_rich_mention'|trans, help: 'show_rich_mention_help'|trans, settingsKey: 'MBIN_POSTS_SHOW_RICH_MENTION', defaultValue: false}) }} {{ component('settings_row_switch', {label: 'show_rich_mention_magazine'|trans, help: 'show_rich_mention_magazine_help'|trans, settingsKey: 'MBIN_POSTS_SHOW_RICH_MENTION_MAGAZINE', defaultValue: true}) }} {{ component('settings_row_switch', {label: 'show_rich_ap_link'|trans, help: 'show_rich_ap_link_help'|trans, settingsKey: 'MBIN_POSTS_SHOW_RICH_AP_LINK', defaultValue: true}) }}
    {{ 'single_settings'|trans }}
    {{ component('settings_row_enum', {label: 'comment_reply_position'|trans, help: 'comment_reply_position_help'|trans, settingsKey: 'KBIN_COMMENTS_REPLY_POSITION', values: [ {name: 'position_top'|trans , value: 'TOP'}, {name: 'position_bottom'|trans , value: 'BOTTOM' } ], defaultValue: 'TOP' } ) }} {{ component('settings_row_switch', {label: 'show_avatars_on_comments'|trans, help: 'show_avatars_on_comments_help'|trans, settingsKey: 'KBIN_COMMENTS_SHOW_USER_AVATAR', defaultValue: true}) }}
    {{ 'mod_log'|trans }}
    {{ component('settings_row_switch', {label: 'show_users_avatars'|trans, help: 'show_users_avatars_help'|trans, settingsKey: 'MBIN_MODERATION_LOG_SHOW_USER_AVATARS', defaultValue: false}) }} {{ component('settings_row_switch', {label: 'show_magazines_icons'|trans, help: 'show_magazines_icons_help'|trans, settingsKey: 'MBIN_MODERATION_LOG_SHOW_MAGAZINE_ICONS', defaultValue: false}) }} {{ component('settings_row_switch', {label: 'show_new_icons'|trans, help: 'show_new_icons_help'|trans, settingsKey: 'MBIN_MODERATION_LOG_SHOW_NEW_ICONS', defaultValue: true}) }}
    ================================================ FILE: templates/layout/_options_font_size.html.twig ================================================ ================================================ FILE: templates/layout/_options_theme.html.twig ================================================
    {% set theme = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_THEME')) %} {% set theme_key = constant('App\\Controller\\User\\ThemeSettingsController::KBIN_THEME') %}
    ================================================ FILE: templates/layout/_pagination.html.twig ================================================ {%- block pager_widget -%} {%- endblock pager_widget -%} {%- block pager -%} {# Previous Page Link #} {%- if pagerfanta.hasPreviousPage() -%} {%- set path = route_generator.route(pagerfanta.getPreviousPage()) -%} {{- block('previous_page_link') -}} {%- else -%} {{- block('previous_page_link_disabled') -}} {%- endif -%} {# First Page Link #} {%- if start_page > 1 -%} {%- set page = 1 -%} {%- set path = route_generator.route(page) -%} {{- block('page_link') -}} {%- endif -%} {# Second Page Link, displays if we are on page 3 #} {%- if start_page == 3 -%} {%- set page = 2 -%} {%- set path = route_generator.route(page) -%} {{- block('page_link') -}} {%- endif -%} {# Separator, creates a "..." separator to limit the number of items if we are starting beyond page 3 #} {%- if start_page > 3 -%} {{- block('ellipsis') -}} {%- endif -%} {# Page Links #} {%- for page in range(start_page, end_page) -%} {%- set path = route_generator.route(page) -%} {%- if page == current_page -%} {{- block('current_page_link') -}} {%- else -%} {{- block('page_link') -}} {%- endif -%} {%- endfor -%} {# Separator, creates a "..." separator to limit the number of items if we are over 3 pages away from the last page #} {%- if end_page < (nb_pages - 2) -%} {{- block('ellipsis') -}} {%- endif -%} {# Second to Last Page Link, displays if we are on the third from last page #} {%- if end_page == (nb_pages - 2) -%} {%- set page = (nb_pages - 1) -%} {%- set path = route_generator.route(page) -%} {{- block('page_link') -}} {%- endif -%} {# Last Page Link #} {%- if nb_pages > end_page -%} {%- set page = nb_pages -%} {%- set path = route_generator.route(page) -%} {{- block('page_link') -}} {%- endif -%} {# Next Page Link #} {%- if pagerfanta.hasNextPage() -%} {%- set path = route_generator.route(pagerfanta.getNextPage()) -%} {{- block('next_page_link') -}} {%- else -%} {{- block('next_page_link_disabled') -}} {%- endif -%} {%- endblock pager -%} {%- block page_link -%} {{- page -}} {%- endblock page_link -%} {%- block current_page_link -%} {{- page -}} {%- endblock current_page_link -%} {%- block previous_page_link -%} {%- endblock previous_page_link -%} {%- block previous_page_link_disabled -%} {{- block('previous_page_message') -}} {%- endblock previous_page_link_disabled -%} {%- block previous_page_message -%} {%- if options['prev_message'] is defined -%} {{- options['prev_message'] -}} {%- else -%} « {%- endif -%} {%- endblock previous_page_message -%} {%- block next_page_link -%} {%- endblock next_page_link -%} {%- block next_page_link_disabled -%} {{- block('next_page_message') -}} {%- endblock next_page_link_disabled -%} {%- block next_page_message -%} {%- if options['next_message'] is defined -%} {{- options['next_message'] -}} {%- else -%} » {%- endif -%} {%- endblock next_page_message -%} {%- block ellipsis -%} {%- endblock ellipsis -%} ================================================ FILE: templates/layout/_sidebar.html.twig ================================================ {% set show_related_magazines = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_GENERAL_SHOW_RELATED_MAGAZINES')) %} {% set show_related_entries = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_GENERAL_SHOW_RELATED_ENTRIES')) %} {% set show_related_posts = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_GENERAL_SHOW_RELATED_POSTS')) %} {% set show_active_users = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_GENERAL_SHOW_ACTIVE_USERS')) %} {% set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') %} {% if sidebar_top is empty %} {% else %} {{ sidebar_top|raw }} {% endif %} {% if user is defined and user and is_route_name_starts_with('user') %} {% include 'user/_info.html.twig' %} {% endif %} {% if app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SHOW')) is not same as 'false' and app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR')) is not same as 'true' and app.user is defined and app.user is not same as null %} {{ component('sidebar_subscriptions', { openMagazine: magazine is defined ? magazine : null, user: app.user, sort: app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SORT')) }) }} {% endif %} {% if entry is defined and magazine %} {% include 'entry/_info.html.twig' %} {% endif %} {% if post is defined and magazine %} {% include 'post/_info.html.twig' %} {% endif %} {% if magazine is defined and magazine %} {{ component('magazine_box', { magazine: magazine, showSectionTitle: true }) }} {% include 'magazine/_moderators_sidebar.html.twig' %} {% endif %} {% if tag is defined and tag %} {% include 'tag/_panel.html.twig' %} {% endif %} {% if not is_route_name_contains('login') %} {% if show_related_magazines is not same as V_FALSE %} {{ component('related_magazines', {magazine: magazine is defined and magazine ? magazine.name : null, tag: tag is defined and tag ? tag : null}) }} {% endif %} {% if not is_route_name_contains('people') and show_active_users is not same as V_FALSE %} {{ component('active_users', {magazine: magazine is defined and magazine ? magazine : null}) }} {% endif %} {% if show_related_posts is not same as V_FALSE %} {{ component('related_posts', {magazine: magazine is defined and magazine ? magazine.name : null, tag: tag is defined and tag ? tag : null}) }} {% endif%} {% if show_related_entries is not same as V_FALSE %} {{ component('related_entries', {magazine: magazine is defined and magazine ? magazine.name : null, tag: tag is defined and tag ? tag : null}) }} {% endif%} {% endif %}

    {{ kbin_domain() }}

    • {{ 'about_instance'|trans }}
    • {{ 'contact'|trans }}
    • {{ 'faq'|trans }}
    • {{ 'terms'|trans }}
    • {{ 'privacy_policy'|trans }}
    • {% if kbin_federation_page_enabled() %}
    • {{ 'federation'|trans }}
    • {% endif %}
    • {{ 'mod_log'|trans }}
    • {{ 'stats'|trans }}
    • {% if magazine is defined and magazine %} {% set args = {'magazine': magazine.name ?? '' } %} {% elseif user is defined and user %} {% set args = {'user': user.username ?? '' } %} {% elseif tag is defined and tag %} {% set args = {'tag': tag ?? '' } %} {% elseif domain is defined and domain %} {% set args = {'domain': domain.name ?? '' } %} {% else %} {% set args = {} %} {% endif %} {% if criteria is defined and criteria %} {% if criteria.getOption('content') is same as 'threads' %} {% set args = {...args, 'content': 'threads'} %} {% elseif criteria.getOption('content') is same as 'microblog' %} {% set args = {...args, 'content': 'microblog'} %} {% elseif criteria.getOption('content') is same as 'combined' %} {% set args = {...args, 'content': 'combined'} %} {% endif %} {% endif %}
    • {{ 'rss'|trans }}
      {% set header_accept_language = app.request.headers.has('accept_language') ? app.request.headers.get('accept_language')|slice(0,2) : null %} {% set current = app.request.cookies.get('mbin_lang') ?? header_accept_language ?? kbin_default_lang() %}
    Clone repo

    {{ 'kbin_promo_title'|trans }}

    {{ 'kbin_promo_desc'|trans({ '%link_start%': '', '%link_end%': '' })|raw }}

    ================================================ FILE: templates/layout/_subject.html.twig ================================================ {% if attributes is not defined %} {% set attributes = {} %} {% endif %} {% if entryCommentAttributes is not defined %} {% set entryCommentAttributes = {} %} {% endif %} {% if entryAttributes is not defined %} {% set entryAttributes = {} %} {% endif %} {% if postAttributes is not defined %} {% set postAttributes = {} %} {% endif %} {% if postCommentAttributes is not defined %} {% set postCommentAttributes = {} %} {% endif %} {% if magazineAttributes is not defined %} {% set magazineAttributes = {} %} {% endif %} {% if userAttributes is not defined %} {% set userAttributes = {} %} {% endif %} {% set forCombined = (route_param_exists('content') and get_route_param('content') is same as 'combined') or (criteria is defined and criteria.getOption('content') is same as 'combined') %} {% if subject is entry %} {{ component('entry', {entry: subject}|merge(attributes)|merge(entryAttributes)) }} {% elseif subject is entry_comment %} {{ component('entry_comment', {comment: subject, showEntryTitle: forCombined is same as true}|merge(attributes)|merge(entryCommentAttributes)) }} {% elseif subject is post %} {% if forCombined is same as true %} {{ component('post_combined', {post: subject}|merge(attributes)|merge(postAttributes)) }} {% else %} {{ component('post', {post: subject}|merge(attributes)|merge(postAttributes)) }} {% endif %} {% elseif subject is post_comment %} {% if forCombined is same as true %} {{ component('post_comment_combined', {comment: subject}|merge(attributes)|merge(postCommentAttributes)) }} {% else %} {{ component('post_comment', {comment: subject}|merge(attributes)|merge(postCommentAttributes)) }} {% endif %} {% elseif subject is magazine %} {{ component('magazine_box', {magazine: subject}|merge(attributes, magazineAttributes)) }} {% elseif subject is user %} {{ component('user_inline_box', {user: subject}|merge(attributes, userAttributes)) }} {% endif %} ================================================ FILE: templates/layout/_subject_link.html.twig ================================================ {%- if subject is entry -%} {{ subject.shortTitle }} {%- elseif subject is entry_comment -%} {{ subject.shortTitle }} {%- elseif subject is post -%} {{ subject.shortTitle }} {%- elseif subject is post_comment -%} {{ subject.shortTitle }} {%- endif -%} ================================================ FILE: templates/layout/_subject_list.html.twig ================================================ {% if attributes is not defined %} {% set attributes = {} %} {% endif %} {% if entryCommentAttributes is not defined %} {% set entryCommentAttributes = {} %} {% endif %} {% if entryAttributes is not defined %} {% set entryAttributes = {} %} {% endif %} {% if postAttributes is not defined %} {% set postAttributes = {} %} {% endif %} {% if postCommentAttributes is not defined %} {% set postCommentAttributes = {} %} {% endif %} {% for subject in results %} {% include 'layout/_subject.html.twig' %} {% endfor %} {% if pagination is defined and pagination %} {% if(pagination.haveToPaginate is defined and pagination.haveToPaginate) %} {% if app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL')) is same as 'true' %}
    {{ component('loader', {'data-infinite-scroll-target': 'loader'}) }}
    {{ pagerfanta(pagination, null, {'pageParameter':'[p]'}) }}
    {% elseif pagination.getCurrentCursor is defined %} {{ component('cursor_pagination', {'pagination': pagination}) }} {% else %} {{ pagerfanta(pagination, null, {'pageParameter':'[p]'}) }} {% endif %} {% endif %} {% else %} {% if(results.haveToPaginate is defined and results.haveToPaginate) %} {% if app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL')) is same as 'true' %}
    {{ component('loader', {'data-infinite-scroll-target': 'loader'}) }}
    {% if results.getCurrentCursor is defined %} {{ component('cursor_pagination', {'pagination': results}) }} {% else %} {{ pagerfanta(results, null, {'pageParameter':'[p]'}) }} {% endif %}
    {% elseif results.getCurrentCursor is defined %} {{ component('cursor_pagination', {'pagination': results}) }} {% else %} {{ pagerfanta(results, null, {'pageParameter':'[p]'}) }} {% endif %} {% endif %} {% endif %} {% if not results|length %} {% endif %} ================================================ FILE: templates/layout/_topbar.html.twig ================================================ {% set show_topbar = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_TOPBAR')) %} {% set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') %} {% if show_topbar is same as V_TRUE %}
  • {{ 'all'|trans }}
  • {{ 'subscribed'|trans }}
  • {{ 'moderated'|trans }}
  • {{ 'favourites'|trans }}
  • {{ component('featured_magazines', {magazine: magazine is defined and magazine ? magazine : null}) }}
  • {{ 'all_magazines'|trans }}
  • {% endif %} ================================================ FILE: templates/layout/_user_activity_list.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%} {% if actor is not defined %} {% set actor = 'user' %} {% endif %} {% if list|length %}
    {% if(list.haveToPaginate is defined and list.haveToPaginate) %} {{ pagerfanta(list, null, {'pageParameter':'[p]'}) }} {% endif %} {% else %} {% endif %} ================================================ FILE: templates/layout/sidebar_subscriptions.html.twig ================================================ {% with %} {% set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') %} {% set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') %} {% set V_LEFT = constant('App\\Controller\\User\\ThemeSettingsController::LEFT') %} {% set KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR = constant('App\\Controller\\User\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR') %} {% set KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE = constant('App\\Controller\\User\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE') %} {% set KBIN_GENERAL_SIDEBAR_POSITION = constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_SIDEBAR_POSITION') %} {% set KBIN_SUBSCRIPTIONS_LARGE_PANEL = constant('App\\Controller\\User\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_LARGE_PANEL') %} {% set KBIN_SUBSCRIPTIONS_SHOW_MAGAZINE_ICON = constant('App\\Controller\\User\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SHOW_MAGAZINE_ICON') %} {% set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) %} {# for whatever reason doing {% set TRUE = ... %} would crash #} {% endwith %} ================================================ FILE: templates/magazine/_federated_info.html.twig ================================================ {% if magazine.apId %} {# I.e. if we're federated #} {% if entries is defined and entries and not entries.hasNextPage %} {# Then show a link to original if we're at the end of content #}

    {{ 'federated_magazine_info'|trans }} {{ 'go_to_original_instance'|trans }}

    {% endif %} {% if is_instance_of_magazine_blocked(magazine) %}
    {{ 'magazine_instance_defederated_info'|trans }}
    {% elseif not magazine_has_local_subscribers(magazine) %} {# Also show a warning if we're not actively receiving updates #} {% set lastOriginUpdate = magazine.lastOriginUpdate %}
    {% if lastOriginUpdate is not null %} {% set currentTime = "now"|date('U') %} {% set secondsDifference = currentTime - (lastOriginUpdate|date('U')) %} {% set daysDifference = (secondsDifference / 86400)|round(0, 'floor') %}

    {{ 'disconnected_magazine_info'|trans({'%days%': daysDifference}) }} {% if app.user %} {{ 'subscribe_for_updates'|trans }} {% endif %}

    {% else %}

    {{ 'always_disconnected_magazine_info'|trans }} {% if app.user %} {{ 'subscribe_for_updates'|trans }} {% endif %}

    {% endif %}
    {% endif %} {% endif %} ================================================ FILE: templates/magazine/_list.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%} {% if magazines|length %}
    {% if view is same as 'cards'|trans|lower %}
    {% for magazine in magazines %} {{ component('magazine_box', {magazine: magazine, showMeta: false, showInfo: false}) }} {% endfor %}
    {% elseif view is same as 'columns'|trans|lower %}
    {% else %} {% set sortBy = criteria.sortOption %}
    {% for column in ['threads', 'comments', 'posts'] %} {% endfor %} {% for magazine in magazines %} {% endfor %}
    {{ 'name'|trans }} {% if sortBy is same as column %} {{ column|trans }} {% else %} {{ column|trans }} {% endif %} {% if sortBy is same as 'hot' %} {{ 'subscriptions'|trans }} {% else %} {{ 'subscriptions'|trans }} {% endif %}
    {{ component('magazine_inline', { magazine: magazine, stretchedLink: true, showAvatar: true, showNewIcon: true, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE}) }} {% if magazine.isAdult %}18+{% endif %} {{ magazine.entryCount|abbreviateNumber }} {{ magazine.entryCommentCount|abbreviateNumber }} {{ (magazine.postCount + magazine.postCommentCount)|abbreviateNumber }} {{ component('magazine_sub', {magazine: magazine}) }}
    {% for magazine in magazines %}
    {{ component('magazine_inline', { magazine: magazine, stretchedLink: true, showAvatar: true, showNewIcon: true, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE}) }} {% if magazine.isAdult %}18+{% endif %}
    {{ magazine.entryCount|abbreviateNumber }}{{ 'threads'|trans }} {{ magazine.entryCommentCount|abbreviateNumber }}{{ 'comments'|trans }} {{ (magazine.postCount + magazine.postCommentCount)|abbreviateNumber }}{{ 'posts'|trans }}
    {{ component('magazine_sub', {magazine: magazine}) }}
    {% endfor %}
    {% endif %}
    {% if(magazines.haveToPaginate is defined and magazines.haveToPaginate) %} {{ pagerfanta(magazines, null, {'pageParameter':'[p]'}) }} {% endif %} {% else %} {% endif %} ================================================ FILE: templates/magazine/_moderators_list.html.twig ================================================
      {% for moderator in moderators %}
    • {% if moderator.user.avatar %} {{ component('user_avatar', {user: moderator.user}) }} {% endif %}
      {{ moderator.user.username|username(true) }} {{ component('date', {date: moderator.createdAt}) }}
      {% if is_granted('edit', magazine) and not moderator.isOwner and (magazine.apId is same as null or moderator.user.apId is same as null) %}
      {% endif %}
    • {% endfor %}
    ================================================ FILE: templates/magazine/_moderators_sidebar.html.twig ================================================

    {{ 'moderators'|trans }} {% if app.user and magazine.apId is same as null and app.user.visibility is same as 'visible' %} {% if is_granted('edit', magazine) and magazine.moderatorRequests|length %} ({{ magazine.moderatorRequests|length }}) {% endif %} {% endif %}

      {% for moderator in magazine.moderators|slice(0, 5) %}
    • {{ component('user_inline', { user: moderator.user, showNewIcon: true }) }}
    • {% endfor %}
    {% if magazine.moderators|length > 5 %} {% endif %}
    ================================================ FILE: templates/magazine/_options.html.twig ================================================ ================================================ FILE: templates/magazine/_restricted_info.html.twig ================================================ {% if magazine.postingRestrictedToMods and (app.user is not defined or app.user is same as null or magazine.isActorPostingRestricted(app.user)) %}
    {{ 'magazine_posting_restricted_to_mods_warning'|trans }}
    {% endif %} ================================================ FILE: templates/magazine/_visibility_info.html.twig ================================================ {% if magazine.visibility is same as 'soft_deleted' %}

    {{ 'magazine_is_deleted'|trans({ '%link_target%': path('magazine_panel_general', {'name': magazine.name}) })|raw }}

    {% endif %} ================================================ FILE: templates/magazine/create.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'create_new_magazine'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-entry-create page-entry-create-link{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'entry/_create_options.html.twig' %} {% include('user/_visibility_info.html.twig') %} {% if user.visibility is same as 'visible' %}

    {{ 'create_new_magazine'|trans }}

    {{ form_start(form) }} {{ form_row(form.name, {label: 'name', attr: { placeholder: '/m/', 'data-controller': 'input-length', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value': constant('App\\DTO\\MagazineDto::MAX_NAME_LENGTH') }}) }} {{ form_row(form.title, {label: 'title', attr: { 'data-controller': 'input-length autogrow', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value': constant('App\\DTO\\MagazineDto::MAX_TITLE_LENGTH') }}) }} {{ component('editor_toolbar', {id: 'magazine_description'}) }} {{ form_row(form.description, {label: false, attr: { placeholder: 'description', 'data-controller': 'input-length rich-textarea autogrow', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value': constant('App\\Entity\\Magazine::MAX_DESCRIPTION_LENGTH') }}) }} {{ form_row(form.isAdult, {label:'is_adult', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.isPostingRestrictedToMods, {label:'magazine_posting_restricted_to_mods',row_attr: {class: 'checkbox'}}) }}
    {{ form_label(form.discoverable, 'discoverable') }} {{ form_widget(form.discoverable) }}
    {{ form_help(form.discoverable) }}
    {{ form_label(form.indexable, 'indexable_by_search_engines') }} {{ form_widget(form.indexable) }}
    {{ form_help(form.indexable) }}
    {{ form_label(form.nameAsTag, 'magazine_name_as_tag') }} {{ form_widget(form.nameAsTag) }}
    {{ form_help(form.nameAsTag) }}
    {{ form_row(form.submit, {label: 'create_new_magazine', attr: {class: 'btn btn__primary'}, row_attr: {class: 'float-end'}}) }}
    {{ form_end(form) }}
    {% endif %} {% endblock %} ================================================ FILE: templates/magazine/list_abandoned.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'magazines'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazines page-settings{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'magazines'|trans }}

    {% include 'magazine/_options.html.twig' %}
    {% if magazines|length %}
    {% for column in ['threads', 'comments', 'posts'] %} {% endfor %} {% for magazine in magazines %} {% endfor %}
    {{ 'name'|trans }}{{ column|trans }}
    {{ component('magazine_inline', { magazine: magazine, stretchedLink: true, showAvatar: true, showNewIcon: true, fullName: true}) }} {{ magazine.entryCount }} {{ magazine.entryCommentCount }} {{ magazine.postCount + magazine.postCommentCount }}
    {% for magazine in magazines %}
    {{ component('magazine_inline', { magazine: magazine, stretchedLink: true, showAvatar: true, showNewIcon: true, fullName: true}) }}
    {{ magazine.entryCount }}{{ 'threads'|trans }} {{ magazine.entryCommentCount }}{{ 'comments'|trans }} {{ magazine.postCount + magazine.postCommentCount }}{{ 'posts'|trans }}
    {% endfor %}
    {% if(magazines.haveToPaginate is defined and magazines.haveToPaginate) %} {{ pagerfanta(magazines, null, {'pageParameter':'[p]'}) }} {% endif %} {% else %} {% endif %}
    {% endblock %} ================================================ FILE: templates/magazine/list_all.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'magazines'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazines page-settings{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'magazines'|trans }}

    {% include 'magazine/_options.html.twig' %}
    {{ form_start(form) }}
    {{ form_widget(form.query, {'attr': {'class': 'form-control'}}) }}
    {{ form_widget(form.fields, {attr: {'aria-label': 'filter.fields.label'|trans}}) }} {{ form_widget(form.federation, {attr: {'aria-label': 'filter.origin.label'|trans}}) }} {{ form_widget(form.adult, {attr: {'aria-label': 'filter.adult.label'|trans}}) }}
    {{ form_end(form) }}
    {% include 'magazine/_list.html.twig' %}
    {% endblock %} ================================================ FILE: templates/magazine/moderators.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'moderators'|trans }} - {{ magazine.title }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazine-panel page-magazine-moderators{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'moderators'|trans }}

    {% if app.user and app.user.visibility is same as 'visible' %} {% if magazine.apId is same as null and not magazine.userIsModerator(app.user) %}
    {% endif %} {% if magazine.isAbandoned() and not magazine.userIsOwner(app.user) %}
    {% endif %} {% endif %}
    {% if moderators|length %} {% include 'magazine/_moderators_list.html.twig' %} {% if(moderators.haveToPaginate is defined and moderators.haveToPaginate) %} {{ pagerfanta(moderators, null, {'pageParameter':'[p]'}) }} {% endif %} {% else %} {% endif %} {% endblock %} ================================================ FILE: templates/magazine/panel/_options.html.twig ================================================ {%- set STATUS_PENDING = constant('App\\Entity\\Report::STATUS_PENDING') -%} ================================================ FILE: templates/magazine/panel/_stats_pills.html.twig ================================================ {%- set TYPE_CONTENT = constant('App\\Repository\\StatsRepository::TYPE_CONTENT') -%} {%- set TYPE_VOTES = constant('App\\Repository\\StatsRepository::TYPE_VOTES') -%} ================================================ FILE: templates/magazine/panel/badges.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'badges'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazine-panel page-magazine-badges{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'magazine/panel/_options.html.twig' %} {% include 'magazine/_visibility_info.html.twig' %}

    {{ 'badges'|trans }}

    {% if badges|length %}
      {% for badge in badges %}
    • {{ badge.name }}
      {% if is_granted('edit', magazine) %}
      {% endif %}
    • {% endfor %}
    {% endif %} {% if(badges.haveToPaginate is defined and badges.haveToPaginate) %} {{ pagerfanta(badges, null, {'pageParameter':'[p]'}) }} {% endif %} {% if not badges|length %} {% endif %}
    {{ form_start(form) }}
    {{ form_errors(form.name) }}
    {{ form_label(form.name, 'name') }} {{ form_widget(form.name) }}
    {{ form_row(form.submit, { 'label': 'add_badge', attr: {class: 'btn btn__primary'} }) }}
    {{ form_end(form) }}
    {% endblock %} ================================================ FILE: templates/magazine/panel/ban.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'bans'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazine-panel page-magazine-bans{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'magazine/panel/_options.html.twig' %} {% include 'magazine/_visibility_info.html.twig' %}

    {{ 'ban'|trans }}

    {{ component('user_box', {user: user}) }}
    {{ form_start(form) }}
    {{ form_label(form.reason, 'reason') }} {{ form_widget(form.reason) }}
    {{ form_label(form.expiredAt, 'expired_at') }} {{ form_widget(form.expiredAt) }}
    {{ form_row(form.submit, { 'label': 'ban', attr: {class: 'btn btn__primary'} }) }}
    {{ form_end(form) }}
    {% endblock %} ================================================ FILE: templates/magazine/panel/bans.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'bans'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazine-panel page-magazine-bans{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'magazine/panel/_options.html.twig' %} {% include 'magazine/_visibility_info.html.twig' %}

    {{ 'bans'|trans }}

    {% if bans|length %}
    {% for ban in bans %} {% endfor %}
    {{ 'name'|trans }} {{ 'reason'|trans }} {{ 'created'|trans }} {{ 'expires'|trans }}
    {{ component('user_inline', {user: ban.user, showNewIcon: true}) }} {{ ban.reason }} {{ component('date', {date: ban.createdAt}) }} {% if ban.expiredAt %} {{ component('date', {date: ban.expiredAt}) }} {% else %} {{ 'perm'|trans }} {% endif %}
    {% endif %} {% if(bans.haveToPaginate is defined and bans.haveToPaginate) %} {{ pagerfanta(bans, null, {'pageParameter':'[p]'}) }} {% endif %} {% if not bans|length %} {% endif %}
    {% endblock %} ================================================ FILE: templates/magazine/panel/general.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'general'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazine-panel page-magazine-general{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'magazine/panel/_options.html.twig' %} {% include 'magazine/_visibility_info.html.twig' %}

    {{ 'general'|trans }}

    {% include 'layout/_flash.html.twig' %}
    {{ form_start(form) }}
    {{ form_label(form.name) }} {{ form_widget(form.name) }}
    {{ form_label(form.title) }} {{ form_widget(form.title) }}
    {{ component('editor_toolbar', {id: 'magazine_description'}) }} {{ form_row(form.description, {label: false, attr: {placeholder: 'description', 'data-entry-link-create-target': 'magazine_description'}}) }}
    {% if form.rules is defined and form.rules %}
    {{ component('editor_toolbar', {id: 'magazine_rules'}) }} {{ form_row(form.rules, {label: false, attr: {placeholder: 'rules', 'data-entry-link-create-target': 'magazine_rules'}}) }}
    {% endif %}
    {{ form_label(form.isAdult) }} {{ form_widget(form.isAdult) }}
    {{ form_label(form.isPostingRestrictedToMods) }} {{ form_widget(form.isPostingRestrictedToMods) }}
    {{ form_label(form.discoverable, 'discoverable') }} {{ form_widget(form.discoverable) }}
    {{ form_help(form.discoverable) }}
    {{ form_label(form.indexable, 'indexable_by_search_engines') }} {{ form_widget(form.indexable) }}
    {{ form_help(form.indexable) }}
    {{ form_row(form.submit, { 'label': 'done'|trans, 'attr': {'class': 'btn btn__primary'} }) }}
    {{ form_end(form) }}

    {{ 'magazine_deletion'|trans }}

    {% if magazine.visibility is same as 'visible' %}
    {% else %}
    {% endif %}
    {% if is_granted('ROLE_ADMIN') %}
    {% endif %} {% if is_granted('purge', magazine) %}
    {% endif %}
    {% endblock %} ================================================ FILE: templates/magazine/panel/moderator_requests.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'moderators'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazine-panel page-magazine-moderators{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'magazine/panel/_options.html.twig' %} {% include 'magazine/_visibility_info.html.twig' %}

    {{ 'moderators'|trans }}

    {% if requests|length %}
    {% for request in requests %} {% endfor %}
    {{ 'magazine'|trans }} {{ 'user'|trans }} {{ 'reputation_points'|trans }}
    {{ component('magazine_inline', {magazine: request.magazine, showNewIcon: true}) }} {{ component('user_inline', {user: request.user, showNewIcon: true}) }} {{ get_reputation_total(request.user) }}
    {% else %} {% endif %} {% if(requests.haveToPaginate is defined and requests.haveToPaginate) %} {{ pagerfanta(requests, null, {'pageParameter':'[p]'}) }} {% endif %} {% endblock %} ================================================ FILE: templates/magazine/panel/moderators.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'moderators'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazine-panel page-magazine-moderators{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'magazine/panel/_options.html.twig' %} {% include 'magazine/_visibility_info.html.twig' %}

    {{ 'moderators'|trans }}

    {% include 'magazine/_moderators_list.html.twig' %} {% if(moderators.haveToPaginate is defined and moderators.haveToPaginate) %} {{ pagerfanta(moderators, null, {'pageParameter':'[p]'}) }} {% endif %} {% if not moderators|length %} {% endif %}
    {{ form_start(form) }}
    {{ form_errors(form.user) }}
    {{ form_label(form.user, 'username') }} {{ form_widget(form.user) }}
    {{ form_row(form.submit, { 'label': 'add_moderator', attr: {class: 'btn btn__primary'} }) }}
    {{ form_end(form) }}
    {% endblock %} ================================================ FILE: templates/magazine/panel/reports.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'reports'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazine-panel page-magazine-reports{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'magazine/panel/_options.html.twig' %} {% include 'magazine/_visibility_info.html.twig' %}

    {{ 'reports'|trans }}

    {{ component('report_list', {reports: reports, routeName: 'magazine_panel_reports', magazineName: magazine.name}) }} {% endblock %} ================================================ FILE: templates/magazine/panel/stats.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'stats'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazine-panel page-magazine-stats{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'magazine/panel/_options.html.twig' %} {% include 'magazine/_visibility_info.html.twig' %} {% include 'magazine/panel/_stats_pills.html.twig' %}
    {% include 'stats/_filters.html.twig' %} {{ render_chart(chart) }}
    {% endblock %} ================================================ FILE: templates/magazine/panel/tags.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'tags'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazine-panel page-magazine-tags{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'magazine/panel/_options.html.twig' %} {% include 'magazine/_visibility_info.html.twig' %}

    {{ 'tags'|trans }}

    {{ 'magazine_panel_tags_info'|trans }}
    {{ form_start(form) }}
    {{ form_row(form.submit, { 'label': 'save'|trans, attr: {class: 'btn btn__primary'} }) }}
    {{ form_end(form) }}
    {% endblock %} ================================================ FILE: templates/magazine/panel/theme.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'appearance'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazine-panel page-magazine-theme{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'magazine/panel/_options.html.twig' %} {% include 'magazine/_visibility_info.html.twig' %}

    {{ 'appearance'|trans }}

    {% include 'layout/_flash.html.twig' %}
    {{ form_start(form) }}
    {{ form_label(form.icon, 'icon') }} {{ form_widget(form.icon) }} {{ form_help(form.icon) }} {{ form_errors(form.icon) }}
    {% if magazine.icon is not same as null %}
      {{ magazine.icon.altText }}
    {% endif %}
    {{ form_label(form.banner, 'banner') }} {{ form_widget(form.banner) }} {{ form_help(form.banner) }} {{ form_errors(form.banner) }}
    {% if magazine.banner is not same as null %}
      {{ magazine.banner.altText }}
    {% endif %}
    {{ form_label(form.customCss, 'CSS') }} {{ form_widget(form.customCss) }} {{ form_help(form.customCss) }} {{ form_errors(form.customCss) }}
    {{ form_label(form.backgroundImage, 'Background') }} {{ form_widget(form.backgroundImage) }} {{ form_help(form.backgroundImage) }} {{ form_errors(form.backgroundImage) }}
    {{ form_row(form.submit, { 'label': 'done', attr: {class: 'btn btn__primary'} }) }}
    {{ form_end(form) }}
    {% endblock %} ================================================ FILE: templates/magazine/panel/trash.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'trash'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-magazine-panel page-magazine-trash{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'magazine/panel/_options.html.twig' %} {% include 'magazine/_visibility_info.html.twig' %}

    {{ 'trash'|trans }}

    {% if results|length %} {% for subject in results %} {% include 'layout/_subject.html.twig' with {attributes: {canSeeTrash: true, showMagazineName: true, showEntryTitle: true}} %} {% endfor %} {% endif %} {% if(results.haveToPaginate is defined and results.haveToPaginate) %} {{ pagerfanta(results, null, {'pageParameter':'[p]'}) }} {% endif %} {% if not results|length %} {% endif %} {% endblock %} ================================================ FILE: templates/messages/_form_create.html.twig ================================================ {{ form_start(form, {attr: {class: 'message-form'}}) }}
    {{ form_row(form.submit, {attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }} ================================================ FILE: templates/messages/front.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'messages'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-messages{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'messages'|trans }}

    {% for thread in threads %} {% set lastMessage = thread.getLastMessage() %} {% if lastMessage is not same as null %}
    {% set i = 0 %} {% set participants = thread.participants|filter(p => p is not same as app.user) %} {% for user in participants %} {% if i > 0 and i is same as (participants|length - 1) %} {{ 'and'|trans }} {% elseif i > 0 %} , {% endif %} {{ component('user_inline', {user: user, showAvatar: false, showNewIcon: true}) }} {% set i = i + 1 %} {% endfor %}
    {{ component('user_inline', {user: lastMessage.sender, showAvatar: true, showNewIcon: true}) -}}: {{ lastMessage.getTitle() }}
    {{ component('date', {date: thread.updatedAt}) }}
    {% endif %} {% endfor %} {% if threads|length == 0 %} {% endif %} {% if(threads.haveToPaginate is defined and threads.haveToPaginate) %} {{ pagerfanta(threads, null, {'pageParameter':'[p]'}) }} {% endif %} {% endblock %} ================================================ FILE: templates/messages/single.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'message'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-messages page-message{% endblock %} {% block header_nav %} {% endblock %} {% block javascripts %} {{ encore_entry_script_tags('app') }} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}
    {% set i = 0 %} {% set participants = thread.participants|filter(p => p is not same as app.user) %} {% for user in participants %} {% if i > 0 and i is same as (participants|length - 1) %} {{ 'and'|trans }} {% elseif i > 0 %} , {% endif %} {{ component('user_inline', {user: user, showNewIcon: true}) }} {% set i = i + 1 %} {% endfor %}
    {% for message in thread.messages %}
    {{ component('user_inline', {user: message.sender, showNewIcon: true}) }}
    {{ message.body|markdown|raw }}
    {{ component('date', {date: message.createdAt}) }} {% if message.editedAt %} ({{ 'edited'|trans }} {{ component('date', {date: message.editedAt}) }}) {% endif %}
    {% endfor %}
    {% include 'messages/_form_create.html.twig' %}
    {% endblock %} ================================================ FILE: templates/modlog/_blocks.html.twig ================================================ {% block log_entry_deleted %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'removed_thread_by'|trans|lower }} {{ component('user_inline', {user: log.entry.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.entry.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - {{ log.entry.shortTitle(300) }} {% endblock %} {% block log_entry_restored %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'restored_thread_by'|trans|lower }} {{ component('user_inline', {user: log.entry.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.entry.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - {{ log.entry.shortTitle(300) }} {% endblock %} {% block log_entry_comment_deleted %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'removed_comment_by'|trans|lower }} {{ component('user_inline', {user: log.comment.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.comment.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - {{ log.comment.shortTitle(300) }} {% endblock %} {% block log_entry_comment_restored %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'restored_comment_by'|trans|lower }} {{ component('user_inline', {user: log.comment.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.comment.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - {{ log.comment.shortTitle(300) }} {% endblock %} {% block log_post_deleted %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'removed_post_by'|trans|lower }} {{ component('user_inline', {user: log.post.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.post.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - {{ log.post.shortTitle(300) }} {% endblock %} {% block log_post_restored %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'restored_post_by'|trans|lower }} {{ component('user_inline', {user: log.post.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.post.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - {{ log.post.shortTitle(300) }} {% endblock %} {% block log_post_comment_deleted %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'removed_comment_by'|trans|lower }} {{ component('user_inline', {user: log.comment.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.comment.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - {{ log.comment.shortTitle(300) }} {% endblock %} {% block log_post_comment_restored %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'restored_comment_by'|trans|lower }} {{ component('user_inline', {user: log.comment.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.comment.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - {{ log.comment.shortTitle(300) }} {% endblock %} {% block log_ban %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {% if log.meta is same as 'ban' %} {{ 'he_banned'|trans|lower }} {% else %} {{ 'he_unbanned'|trans|lower }} {% endif %} {{ component('user_inline', {user: log.ban.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.ban.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %}{% if log.ban.reason %} - {{ log.ban.reason }}{% endif %} {% endblock %} {% block log_moderator_add %} {% if log.actingUser is not same as null %} {{ component('user_inline', {user: log.actingUser, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {% else %} {{ 'someone'|trans }} {% endif %} {{ 'magazine_log_mod_added'|trans -}} {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%}: {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {% endblock %} {% block log_moderator_remove %} {% if log.actingUser is not same as null %} {{ component('user_inline', {user: log.actingUser, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {% else %} {{ 'someone'|trans }} {% endif %} {{ 'magazine_log_mod_removed'|trans -}} {% if showMagazine %} {{ 'from'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%}: {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {% endblock %} {% block log_entry_pinned %} {% if log.actingUser is not same as null %} {{ component('user_inline', {user: log.actingUser, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {% else %} {{ 'someone'|trans }} {% endif %} {{ 'magazine_log_entry_pinned'|trans }} {{ log.entry.shortTitle(300) }} {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%} {% endblock %} {% block log_entry_unpinned %} {% if log.actingUser is not same as null %} {{ component('user_inline', {user: log.actingUser, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {% else %} {{ 'someone'|trans }} {% endif %} {{ 'magazine_log_entry_unpinned'|trans }} {{ log.entry.shortTitle(300) }} {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%} {% endblock %} {% block log_entry_locked %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'magazine_log_entry_locked'|trans }} {{ log.entry.shortTitle(300) }} {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%} {% endblock %} {% block log_entry_unlocked %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'magazine_log_entry_unlocked'|trans }} {{ log.entry.shortTitle(300) }} {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%} {% endblock %} {% block log_post_locked %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'magazine_log_entry_locked'|trans }} {{ log.post.shortTitle(300) }} {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%} {% endblock %} {% block log_post_unlocked %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'magazine_log_entry_unlocked'|trans }} {{ log.post.shortTitle(300) }} {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%} {% endblock %} ================================================ FILE: templates/modlog/front.html.twig ================================================ {% extends 'base.html.twig' %} {% set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') %} {% set MBIN_MODERATION_LOG_SHOW_USER_AVATARS = constant('App\\Controller\\User\\ThemeSettingsController::MBIN_MODERATION_LOG_SHOW_USER_AVATARS') %} {% set showAvatars = app.request.cookies.get(MBIN_MODERATION_LOG_SHOW_USER_AVATARS) is same as V_TRUE %} {% set MBIN_MODERATION_LOG_SHOW_MAGAZINE_ICONS = constant('App\\Controller\\User\\ThemeSettingsController::MBIN_MODERATION_LOG_SHOW_MAGAZINE_ICONS') %} {% set showIcons = app.request.cookies.get(MBIN_MODERATION_LOG_SHOW_MAGAZINE_ICONS) is same as V_TRUE %} {% set MBIN_MODERATION_LOG_SHOW_NEW_ICONS = constant('App\\Controller\\User\\ThemeSettingsController::MBIN_MODERATION_LOG_SHOW_NEW_ICONS') %} {% set showNewIcons = app.request.cookies.get(MBIN_MODERATION_LOG_SHOW_NEW_ICONS, V_TRUE) is same as V_TRUE %} {% use 'modlog/_blocks.html.twig' %} {% set hasMagazine = magazine is defined and magazine ? true : false %} {%- block title -%} {% if hasMagazine %} {{- 'mod_log'|trans }} - {{ magazine.title }} - {{ parent() -}} {% else %} {{- 'mod_log'|trans }} - {{ parent() -}} {% endif %} {%- endblock -%} {% block mainClass %}page-modlog{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'mod_log'|trans }}

    {{ 'mod_log_alert'|trans }}

    {{ form_start(form) }}
    {{ form_widget(form.magazine, {'attr': {'onchange': '(function (e){ e.target.form.submit();})(event)'}}) }}
    {{ form_widget(form.types) }}
    {{ form_end(form) }} {% for log in logs %}
    {%- with { log: log, showMagazine: not hasMagazine, showAvatars: showAvatars, showIcons: showIcons, showNewIcons: showNewIcons, } only -%} {{ block(log.type) }} {%- endwith -%}
    {{ component('date', {date: log.createdAt}) }}
    {% endfor %} {% if(logs.haveToPaginate is defined and logs.haveToPaginate) %} {{ pagerfanta(logs, null, {'pageParameter':'[p]'}) }} {% endif %} {% if not logs|length %} {% endif %} {% endblock %} ================================================ FILE: templates/notifications/_blocks.html.twig ================================================ {% block entry_created_notification %} {{ component('user_inline', {user: notification.entry.user, showNewIcon: true}) }} {{ 'added_new_thread'|trans|lower }} - {{ notification.entry.shortTitle }} {% endblock entry_created_notification %} {% block entry_edited_notification %} {{ component('user_inline', {user: notification.entry.user, showNewIcon: true}) }} {{ 'edited_thread'|trans|lower }} - {{ notification.entry.shortTitle }} {% endblock entry_edited_notification %} {% block entry_deleted_notification %} {{ notification.entry.shortTitle }} {% if notification.entry.isTrashed %}{{ 'removed'|trans|lower }}{% else %}{{ 'deleted'|trans|lower }}{% endif %} {% endblock entry_deleted_notification %} {% block entry_mentioned_notification %} {{ component('user_inline', {user: notification.entry.user, showNewIcon: true}) }} {{ 'mentioned_you'|trans|lower }} - {{ notification.entry.shortTitle }} {% endblock entry_mentioned_notification %} {% block entry_comment_created_notification %} {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'added_new_comment'|trans|lower }} - {{ notification.comment.shortTitle }} {% endblock entry_comment_created_notification %} {% block entry_comment_edited_notification %} {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'edited_comment'|trans|lower }} - {{ notification.comment.shortTitle }} {% endblock entry_comment_edited_notification %} {% block entry_comment_reply_notification %} {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'replied_to_your_comment'|trans|lower }} - {{ notification.comment.shortTitle }} {% endblock entry_comment_reply_notification %} {% block entry_comment_deleted_notification %} {{ 'comment'|trans }} {{ notification.comment.shortTitle }} - {% if notification.comment.isTrashed %}{{ 'removed'|trans|lower }}{% else %}{{ 'deleted'|trans|lower }}{% endif %} {% endblock entry_comment_deleted_notification %} {% block entry_comment_mentioned_notification %} {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'mentioned_you'|trans|lower }} - {{ notification.comment.shortTitle }} {% endblock entry_comment_mentioned_notification %} {% block post_created_notification %} {{ component('user_inline', {user: notification.post.user, showNewIcon: true}) }} {{ 'added_new_post'|trans|lower }} - {{ notification.post.shortTitle }} {% endblock post_created_notification %} {% block post_edited_notification %} {{ component('user_inline', {user: notification.post.user, showNewIcon: true}) }} {{ 'edit_post'|trans|lower }} - {{ notification.post.shortTitle }} {% endblock post_edited_notification %} {% block post_deleted_notification %} {{ 'post'|trans }} {{ notification.post.shortTitle }} - {% if notification.comment.isTrashed %}{{ 'removed'|trans|lower }}{% else %}{{ 'deleted'|trans|lower }}{% endif %} {% endblock post_deleted_notification %} {% block post_mentioned_notification %} {{ component('user_inline', {user: notification.post.user, showNewIcon: true}) }} {{ 'mentioned_you'|trans|lower }} - {{ notification.post.shortTitle }} {% endblock post_mentioned_notification %} {% block post_comment_created_notification %} {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'added_new_comment'|trans|lower }} - {{ notification.comment.shortTitle }} {% endblock post_comment_created_notification %} {% block post_comment_edited_notification %} {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'edited_comment'|trans|lower }} - {{ notification.comment.shortTitle }} {% endblock post_comment_edited_notification %} {% block post_comment_reply_notification %} {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'replied_to_your_comment'|trans|lower }} - {{ notification.comment.shortTitle }} {% endblock post_comment_reply_notification %} {% block post_comment_deleted_notification %} {{ 'comment'|trans }} {{ notification.comment.shortTitle }} - {% if notification.comment.isTrashed %}{{ 'removed'|trans|lower }}{% else %}{{ 'deleted'|trans|lower }}{% endif %} {% endblock post_comment_deleted_notification %} {% block post_comment_mentioned_notification %} {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'mentioned_you'|trans|lower }} - {{ notification.comment.shortTitle }} {% endblock post_comment_mentioned_notification %} {% block message_notification %} {{ component('user_inline', {user: notification.message.sender, showNewIcon: true}) }} {{ 'wrote_message'|trans|lower }} {{ notification.message.title }} {% endblock message_notification %} {% block magazine_ban_notification %} {% if notification.ban.expiredAt is not same as null -%} {{ 'you_have_been_banned_from_magazine'|trans({'%m': component('magazine_inline', {'magazine': notification.ban.magazine})})|raw }}
    {% if now() < notification.ban.expiredAt %} {{ 'ban_expires'|trans }}: {% else %} {{ 'ban_expired'|trans }}: {% endif %} {{ component('date', {date: notification.ban.expiredAt}) -}}.
    {% else -%} {{ 'you_have_been_banned_from_magazine_permanently'|trans({'%m': component('magazine_inline', {'magazine': notification.ban.magazine})})|raw }} {% endif -%}
    {{ 'reason'|trans }}: {{ notification.ban.reason }}
    {% endblock magazine_ban_notification %} {% block magazine_unban_notification %} {{ 'you_are_no_longer_banned_from_magazine'|trans({'%m': component('magazine_inline', {'magazine': notification.ban.magazine})})|raw }} {% endblock magazine_unban_notification %} {% block reportlink %} {% if notification.report.entry is defined and notification.report.entry is not same as null %} {% set entry = notification.report.entry %} {{ entry.title }} {% elseif notification.report.entryComment is defined and notification.report.entryComment is not same as null %} {% set entryComment = notification.report.entryComment %} {{ entryComment.getShortTitle() }} {% elseif notification.report.post is defined and notification.report.post is not same as null %} {% set post = notification.report.post %} {{ post.getShortTitle() }} {% elseif notification.report.postComment is defined and notification.report.postComment is not same as null %} {% set postComment = notification.report.postComment %} {{ postComment.getShortTitle() }} {% endif %} {% endblock %} {% block report_created_notification %} {{ component('user_inline', {user: notification.report.reporting, showNewIcon: true}) }} {{ 'reported'|trans|lower }} {{ component('user_inline', {user: notification.report.reported, showNewIcon: true}) }}
    {{ 'report_subject'|trans }}: {{ block('reportlink') }}
    {% if app.user.admin or app.user.moderator or notification.report.magazine.userIsModerator(app.user) %} {{ 'open_report'|trans }} {% endif %} {% endblock report_created_notification %} {% block report_rejected_notification %} {{ 'own_report_rejected'|trans }}
    {{ 'reported_user'|trans }}: {{ component('user_inline', {user: notification.report.reported, showNewIcon: true}) }}
    {{ 'report_subject'|trans }}: {{ block('reportlink') }}
    {% if app.user.admin or app.user.moderator or notification.report.magazine.userIsModerator(app.user) %} {{ 'open_report'|trans }} {% endif %} {% endblock report_rejected_notification %} {% block report_approved_notification %} {% if notification.report.reporting.id is same as app.user.id %} {{ 'own_report_accepted'|trans }}
    {% elseif notification.report.reported.id is same as app.user.id %} {{ 'own_content_reported_accepted'|trans }}
    {% else %} {{ 'report_accepted'|trans }}
    {{ 'reported_user'|trans }}: {{ component('user_inline', {user: notification.report.reported, showNewIcon: true}) }}
    {{ 'reporting_user'|trans }}: {{ component('user_inline', {user: notification.report.reported, showNewIcon: true}) }}
    {% endif %} {{ 'report_subject'|trans }}: {{ block('reportlink') }}
    {% if app.user.admin or app.user.moderator or notification.report.magazine.userIsModerator(app.user) %} {{ 'open_report'|trans }} {% endif %} {% endblock report_approved_notification %} {% block new_signup %} {{ 'notification_title_new_signup'|trans }}
    {{ component('user_inline', { user: notification.newUser, showNewIcon: true } ) }} {% if do_new_users_need_approval() and notification.newUser.applicationStatus is not same as enum('App\\Enums\\EApplicationStatus').Approved %} {% endif %} {% endblock %} ================================================ FILE: templates/notifications/front.html.twig ================================================ {% extends 'base.html.twig' %} {% use 'notifications/_blocks.html.twig' %} {%- block title -%} {{- 'notifications'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-notifications{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'notifications'|trans }}

    {% for notification in notifications %}
    {%- with { notification: notification, showMagazine: false, } only -%} {{ block(notification.type) }} {%- endwith -%}
    {{ component('date', {date: notification.createdAt}) }}
    {% endfor %} {% if(notifications.haveToPaginate is defined and notifications.haveToPaginate) %} {{ pagerfanta(notifications, null, {'pageParameter':'[p]'}) }} {% endif %} {% if not notifications|length %} {% endif %} {% endblock %} ================================================ FILE: templates/page/about.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'about_instance'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-about{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'about_instance'|trans }}

    {{ body|markdown|raw }}
    {% endblock %} ================================================ FILE: templates/page/agent.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'kbin_bot'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-bot{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'kbin_bot'|trans }}

    {{- 'bot_body_content'|trans|nl2br }}
    {% endblock %} ================================================ FILE: templates/page/contact.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'contact'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-contact page-settings{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'contact'|trans }}

    {% include 'layout/_flash.html.twig' %}
    {{ form_start(form) }} {{ form_row(form.name, {label: 'firstname'}) }} {{ form_row(form.email, {label: 'email'}) }} {{ form_row(form.message, {label: 'message'}) }} {{ form_row(form.surname, {label: false, attr: {style: 'display:none !important'}}) }} {% if kbin_captcha_enabled() %} {{ form_row(form.captcha, { label: false }) }} {% endif %}
    {{ form_row(form.submit, {label: 'send', attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }}
    {% if body %}
    {{ body|markdown|raw }}
    {% endif %} {% endblock %} ================================================ FILE: templates/page/faq.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'faq'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-faq{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'faq'|trans }}

    {{ body|markdown|raw }}
    {% endblock %} ================================================ FILE: templates/page/federation.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'federation'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-federation{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'federation'|trans }}

    {{'federation_page_allowed_description'|trans}}

    {% if allowedInstances is not empty %} {{ component('instance_list', {'instances': allowedInstances}) }} {% else %} {% endif %}

    {{'federation_page_disallowed_description'|trans}}

    {% if defederatedInstances is not empty %} {{ component('instance_list', {'instances': defederatedInstances}) }} {% else %} {% endif %}

    {{'federation_page_dead_title'|trans}}

    {{ 'federation_page_dead_description'|trans }}

    {% if deadInstances is not empty %} {{ component('instance_list', {'instances': deadInstances}) }} {% else %} {% endif %}
    {% endblock %} ================================================ FILE: templates/page/privacy_policy.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'privacy_policy'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-privacy-policy{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'privacy_policy'|trans }}

    {{ body|markdown|raw }}
    {% endblock %} ================================================ FILE: templates/page/terms.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'terms'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-terms{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'terms'|trans }}

    {{ body|markdown|raw }}
    {% endblock %} ================================================ FILE: templates/people/front.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {% if magazine is defined and magazine %} {{- 'people'|trans }} - {{ magazine.title }} - {{ parent() -}} {% else %} {{- 'people'|trans }} - {{ parent() -}} {% endif %} {%- endblock -%} {% block mainClass %}page-people{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'people'|trans }}

    {{ 'people_local'|trans }}

    {% for user in local %}
    {{ component('user_box', {user: user}) }}
    {% endfor %}
    {% if not local|length %} {% endif %}

    {{ 'people_federated'|trans }}

    {% for user in federated %}
    {{ component('user_box', {user: user}) }}
    {% endfor %}
    {% if not federated|length %} {% endif %}
    {% endblock %} ================================================ FILE: templates/post/_form_post.html.twig ================================================ {% form_theme form.lang 'form/lang_select.html.twig' %} {% set hasImage = false %} {% if post is defined and post is not null and post.image %} {% set hasImage = true %} {% endif %} {% if edit is not defined %} {% set edit = false %} {% endif %} {% if edit %} {% set title = 'edit_post'|trans %} {% set action = path('post_edit', {magazine_name: post.magazine.name, post_id: post.id}) %} {% else %} {% set title = 'add_post'|trans %} {% set action = path('post_create') %} {% endif %} {% if attributes is not defined %} {% set attributes = {} %} {% endif %} {{ form_start(form, {action: action, attr: {class: edit ? 'post-edit replace' : 'post-add'}|merge(attributes)}) }}
    {{ component('editor_toolbar', {id: 'post_body'}) }} {{ form_row(form.body, {label: false, attr: { 'data-controller': 'input-length rich-textarea autogrow', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value': constant('App\\DTO\\PostDto::MAX_BODY_LENGTH') }}) }}
    {{ form_row(form.isAdult, {label:'is_adult'}) }} {{ form_row(form.magazine, {label: false, attr: {placeholder: false}}) }}
      {% if hasImage %} {{ post.image.altText }} {% endif %}
    • {{ form_row(form.lang, {label: false}) }}
    • {{ form_row(form.submit, {label: edit ? 'edit_post' : 'add_post', attr: {class: 'btn btn__primary', 'data-action': 'subject#sendForm'}}) }}
    {{ form_end(form) }} ================================================ FILE: templates/post/_info.html.twig ================================================ ================================================ FILE: templates/post/_list.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set SHOW_COMMENT_USER_AVATARS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR'), V_TRUE) -%} {%- set SHOW_POST_USER_AVATARS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS'), V_TRUE) -%} {%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%} {%- set INFINITE_SCROLL = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL'), V_FALSE) -%}
    {% for post in posts %} {{ component('post', { post: post, showMagazineName: magazine is not defined or not magazine, showCommentsPreview: true }) }} {% endfor %} {% if(posts.haveToPaginate is defined and posts.haveToPaginate) %} {% if INFINITE_SCROLL is same as V_TRUE %}
    {{ component('loader', {'data-infinite-scroll-target': 'loader'}) }}
    {{ pagerfanta(posts, null, {'pageParameter':'[p]'}) }}
    {% else %} {{ pagerfanta(posts, null, {'pageParameter':'[p]'}) }} {% endif %} {% endif %} {% if not posts|length %} {% endif %}
    ================================================ FILE: templates/post/_menu.html.twig ================================================ ================================================ FILE: templates/post/_moderate_panel.html.twig ================================================
  • {% if is_granted('purge', post) %}
  • {% endif %} {% if is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}
  • {% endif %}
  • {{ form_start(form, {action: path('post_change_lang', {magazine_name: magazine.name, post_id: post.id})}) }} {{ form_row(form.lang, {label: false, row_attr: {class: 'checkbox'}}) }} {{ form_row(form.submit, {label: 'change_language'|trans, attr: {class: 'btn btn__secondary'}}) }} {{ form_end(form) }}
  • ================================================ FILE: templates/post/_options.html.twig ================================================ {% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %} ================================================ FILE: templates/post/_options_activity.html.twig ================================================ ================================================ FILE: templates/post/comment/_form_comment.html.twig ================================================ {% form_theme form.lang 'form/lang_select.html.twig' %} {% set hasImage = false %} {% if comment is defined and comment is not null and comment.image %} {% set hasImage = true %} {% endif %} {% if edit is not defined %} {% set edit = false %} {% endif %} {% if edit %} {% set title = 'edit_comment'|trans %} {% set action = path('post_comment_edit', {magazine_name: post.magazine.name, post_id: post.id, comment_id: comment.id}) %} {% else %} {% set title = 'add_comment'|trans %} {% set action = path('post_comment_create', {magazine_name: post.magazine.name, post_id: post.id, parent_comment_id: parent is defined and parent ? parent.id : null}) %} {% endif %} {{ form_start(form, {action: action, attr: {class: edit ? 'comment-edit replace' : 'comment-add'}}) }}
    {{ component('editor_toolbar', {id: form.body.vars.id}) }} {{ form_row(form.body, {label: false, attr: { 'data-controller': 'input-length rich-textarea autogrow', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value': constant('App\\DTO\\PostCommentDto::MAX_BODY_LENGTH') }}) }}
      {% if hasImage %} {{ comment.image.altText }} {% endif %}
    • {{ form_row(form.lang, {label: false}) }}
    • {{ form_row(form.submit, {label: edit ? 'edit_comment' : 'add_comment', attr: {class: 'btn btn__primary', 'data-action': 'subject#sendForm'}}) }}
    {{ form_end(form) }} ================================================ FILE: templates/post/comment/_list.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set V_CHAT = constant('App\\Controller\\User\\ThemeSettingsController::CHAT') -%} {%- set V_TREE = constant('App\\Controller\\User\\ThemeSettingsController::TREE') -%} {%- set SHOW_COMMENT_USER_AVATARS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR'), V_TRUE) -%} {%- set SHOW_POST_USER_AVATARS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS'), V_TRUE) -%} {%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%} {%- set VIEW_STYLE = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::POST_COMMENTS_VIEW'), V_TREE) -%} {% if showNested is not defined %} {% if VIEW_STYLE is same as V_CHAT %} {% set showNested = false %} {% else %} {% set showNested = true %} {% endif %} {% endif %} {% if level is not defined %} {% set level = 1 %} {% endif %} {% set autoAction = is_route_name('post_single') ? 'notifications:PostCommentCreatedNotification@window->subject-list#addComment' : 'notifications:PostCommentCreatedNotification@window->subject-list#addCommentOverview' %} {% set manualAction = is_route_name('post_single') ? 'notifications:PostCommentCreatedNotification@scroll-top#increaseCounter' : 'notifications:PostCommentCreatedNotification@window->scroll_top#increaseCounter' %}
    {% for comment in comments %} {{ component('post_comment', { comment: comment, showNested: showNested, dateAsUrl: dateAsUrl is defined ? dateAsUrl : true, level: level, criteria: criteria, }) }} {% endfor %} {% if(comments.haveToPaginate is defined and comments.haveToPaginate) %} {% if app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL')) is same as 'true' %}
    {{ component('loader', {'data-infinite-scroll-target': 'loader'}) }}
    {{ pagerfanta(comments, null, {'pageParameter':'[p]'}) }}
    {% else %} {{ pagerfanta(comments, null, {'pageParameter':'[p]'}) }} {% endif %} {% endif %} {% if not comments|length %} {% elseif VIEW_STYLE is same as V_TREE %}
    {% endif %}
    ================================================ FILE: templates/post/comment/_menu.html.twig ================================================ ================================================ FILE: templates/post/comment/_moderate_panel.html.twig ================================================
  • {% if is_granted('purge', comment) %}
  • {% endif %}
  • {{ form_start(form, {action: path('post_comment_change_lang', {magazine_name: magazine.name, post_id: post.id, comment_id: comment.id})}) }} {{ form_row(form.lang, {label: false, row_attr: {class: 'checkbox'}}) }} {{ form_row(form.submit, {label: 'change_language'|trans, attr: {class: 'btn btn__secondary'}}) }} {{ form_end(form) }}
  • ================================================ FILE: templates/post/comment/_no_comments.html.twig ================================================ ================================================ FILE: templates/post/comment/_options.html.twig ================================================ ================================================ FILE: templates/post/comment/_options_activity.html.twig ================================================ ================================================ FILE: templates/post/comment/_preview.html.twig ================================================
    {% for comment in comments %} {{ component('post_comment', { comment: comment, showNested: true, level: 2, criteria: criteria, }) }} {% endfor %} {% if(comments.haveToPaginate is defined and comments.haveToPaginate) %} {{ pagerfanta(comments, null, {'pageParameter':'[p]'}) }} {% endif %} {% if not comments|length %} {% endif %}
    ================================================ FILE: templates/post/comment/create.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'add_comment'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-post-comment-create{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {{ component('post', { post: post, isSingle: true, showMagazineName: false }) }}

    {{ 'browsing_one_thread'|trans }}

    {{ 'return'|trans }}

    {% if parent is defined and parent %} {{ component('post_comment', { comment: parent, showEntryTitle: false, showNested: false }) }} {% endif %} {% include 'layout/_flash.html.twig' %} {% if user.visibility is same as 'visible'%}
    {% include 'post/comment/_form_comment.html.twig' %}
    {% endif %} {% endblock %} ================================================ FILE: templates/post/comment/edit.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'edit_comment'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-post-comment-edit{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {{ component('post_comment', { comment: comment, dateAsUrl: false, }) }} {% include 'layout/_flash.html.twig' %}

    {{ 'browsing_one_thread'|trans }}

    {{ 'return'|trans }}

    {% include 'post/comment/_form_comment.html.twig' with {edit: true} %}
    {% endblock %} ================================================ FILE: templates/post/comment/favourites.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'favourites'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-post-comment-favourites{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {{ component('post_comment', { comment: comment }) }} {% include 'layout/_flash.html.twig' %} {% include 'post/comment/_options_activity.html.twig' %}
    {% include 'layout/_user_activity_list.html.twig' with {list: favourites} %}
    {% endblock %} ================================================ FILE: templates/post/comment/moderate.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'moderate'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ magazine.title }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-post-moderate{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}
    {{ component('post_comment', { comment: comment, isSingle: true, dateAsUrl: false, }) }} {% include 'layout/_flash.html.twig' %}
    {% include 'post/comment/_moderate_panel.html.twig' %}
    {% endblock %} ================================================ FILE: templates/post/comment/voters.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'activity'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-post-comment-voters{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {{ component('post_comment', { comment: comment }) }} {% include 'layout/_flash.html.twig' %} {% include 'post/comment/_options_activity.html.twig' %}
    {% include 'layout/_user_activity_list.html.twig' with {list: votes} %}
    {% endblock %} ================================================ FILE: templates/post/create.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'add_new_post'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-post-create{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% if magazine is defined and magazine %}

    {{ magazine.title }}

    {% else %}

    {{ get_active_sort_option()|trans }}

    {% endif %} {% include 'layout/_flash.html.twig' %}
    {% include 'post/_form_post.html.twig' %}
    {% endblock %} ================================================ FILE: templates/post/edit.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'edit'|trans }} - {{ get_short_sentence(post.body, 80) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-post-edit{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% if magazine is defined and magazine %}

    {{ magazine.title }}

    {% else %}

    {{ get_active_sort_option()|trans }}

    {% endif %}
    {{ component('post', { post: post, isSingle: true, dateAsUrl: false, }) }} {% include 'layout/_flash.html.twig' %}

    {{ 'browsing_one_thread'|trans }}

    {{ 'return'|trans }}

    {% include 'post/_form_post.html.twig' with {edit: true} %}
    {% endblock %} ================================================ FILE: templates/post/favourites.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'favourites'|trans }} - {{ get_short_sentence(post.body, 80) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-post-favourites{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}
    {{ component('post', { post: post, isSingle: true }) }} {% include 'layout/_flash.html.twig' %} {% include 'post/_options_activity.html.twig' %}
    {% include 'layout/_user_activity_list.html.twig' with {list: favourites} %}
    {% endblock %} ================================================ FILE: templates/post/moderate.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'moderate'|trans }} - {{ get_short_sentence(post.body, 80) }} - {{ magazine.title }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-post-moderate{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}
    {{ component('post', { post: post, isSingle: true, dateAsUrl: false, class: 'section--top', }) }} {% include 'layout/_flash.html.twig' %}
    {% include 'post/_moderate_panel.html.twig' %}
    {% endblock %} ================================================ FILE: templates/post/single.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- get_short_sentence(post.body, 80) }} - {{ magazine.title }} - {{ parent() -}} {%- endblock -%} {% block description %}{% endblock %} {% block image %} {%- if post.image -%} {{- uploaded_asset(post.image) -}} {%- elseif post.magazine.icon -%} {{- uploaded_asset(post.magazine.icon) -}} {%- else -%} {{- parent() -}} {%- endif -%} {% endblock %} {% block mainClass %}page-post-single{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}
    {{ component('post', { post: post, isSingle: true, dateAsUrl: false, class: 'section--top' }) }} {% include 'post/comment/_options.html.twig' %} {% include 'layout/_flash.html.twig' %} {% if user is defined and user and user.visibility is same as 'visible' and (user_settings.comment_reply_position == constant('App\\Controller\\User\\ThemeSettingsController::TOP')) %}
    {% include 'post/comment/_form_comment.html.twig' %}
    {% endif %} {% if post.isLocked %}

    {{ 'comments_locked'|trans }}

    {% endif %}
    {% include 'post/comment/_list.html.twig' %}
    {% if user is defined and user and user.visibility is same as 'visible' and (user_settings.comment_reply_position == constant('App\\Controller\\User\\ThemeSettingsController::BOTTOM')) %}
    {% include 'post/comment/_form_comment.html.twig' %}
    {% endif %} {% include 'post/_options_activity.html.twig' %}
    {% endblock %} ================================================ FILE: templates/post/voters.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'up_votes'|trans }} - {{ get_short_sentence(post.body, 80) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-post-voters{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}
    {{ component('post', { post: post, isSingle: true }) }} {% include 'layout/_flash.html.twig' %} {% include 'post/_options_activity.html.twig' %} {% include 'layout/_user_activity_list.html.twig' with {list: votes} %}
    {% endblock %} ================================================ FILE: templates/report/_form_report.html.twig ================================================ {% for flash in app.flashes('error') %}
    {{ flash }}
    {% endfor %} {% for flash in app.flashes('info') %}
    {{ flash }}
    {% endfor %} {{ form_start(form) }} {{ form_row(form.reason, {label: 'reason'}) }} {{ form_row(form.submit, {label: 'report', attr: {class: 'btn btn__primary', 'data-action': 'subject#sendForm'}, row_attr: {class: 'float-end'}}) }} {{ form_end(form) }} ================================================ FILE: templates/report/create.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'report'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-report{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'layout/_subject.html.twig' with {entryAttributes: {class: 'section--top'}, postAttributes: {class: 'post--single section--top'}} %}
    {% include 'report/_form_report.html.twig' %}
    {% endblock %} ================================================ FILE: templates/resend_verification_email/resend.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'resend_account_activation_email'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-resend-activation-email{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'resend_account_activation_email'|trans }}

    {{ 'resend_account_activation_email_description'|trans }}

    {{ form_start(form) }} {% for flash_error in app.flashes('error') %}
    {{ flash_error|trans }}
    {% endfor %} {% for flash_success in app.flashes('success') %}
    {{ flash_success|trans }}
    {% endfor %} {{ form_row(form.email) }} {{ form_row(form.submit, { row_attr: { class: 'button-flex-hf' } }) }} {{ form_end(form) }} {{ component('user_form_actions', {showRegister: true, showPasswordReset: true, showResendEmail: true}) }}
    {% endblock %} ================================================ FILE: templates/reset_password/check_email.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'reset_password'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-reset-password-email-sent{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'check_email'|trans }}

    {{ 'reset_check_email_desc'|trans({'%expire%': resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle')}) }}

    {{ 'reset_check_email_desc2'|trans }}

    {{ 'try_again'|trans }}

    {% endblock %} ================================================ FILE: templates/reset_password/request.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'reset_password'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-reset-password{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'reset_password'|trans }}

    {{ form_start(form) }} {% for flash_error in app.flashes() %}
    {{ flash_error }}
    {% endfor %} {{ form_row(form.email, { label: 'email' }) }}
    {{ form_end(form) }} {{ component('user_form_actions', {showLogin: true, showRegister: true, showResendEmail: true}) }}
    {% endblock %} ================================================ FILE: templates/reset_password/reset.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'reset_password'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-reset-password{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'reset_password'|trans }}

    {{ form_start(form) }} {% for flash_error in app.flashes() %}
    {{ flash_error }}
    {% endfor %} {{ form_row(form.plainPassword) }}
    {{ form_end(form) }} {{ component('user_form_actions', {showLogin: true, showRegister: true , showResendEmail: true}) }}
    {% endblock %} ================================================ FILE: templates/search/_emoji_suggestion.html.twig ================================================
    {% for emoji in emojis %}
    {{ emoji.shortCode }} {{ emoji.emoji }}
    {% endfor %}
    ================================================ FILE: templates/search/_list.html.twig ================================================
    {% include 'layout/_subject_list.html.twig' with { entryCommentAttributes: {showMagazineName: true, showEntryTitle: true}, postCommentAttributes: {withPost: false}, magazineAttributes: {showMeta: false, showRules: false, showDescription: false, showInfo: false, stretchedLink: false, showTags: false}, userAttributes: {}, } %}
    ================================================ FILE: templates/search/_user_suggestion.html.twig ================================================
    {% for user in users %}
    {{ user.username|username(true) }}
    {% endfor %}
    ================================================ FILE: templates/search/form.html.twig ================================================ {{ form_start(form, {'attr': {'class': 'search-form'}}) }}
    {{ form_widget(form.q, {label: false, 'attr': {'class': 'form-control'}}) }}
    {{ form_widget(form.magazine, {label: false, 'attr': {'class': 'form-control'}}) }} {{ form_widget(form.user, {label: false, 'attr': {'class': 'form-control'}}) }}
    {{ form_widget(form.type, {label: false, 'attr': {'class': 'form-control', 'style': 'padding: 1rem .5rem;'}}) }}
    {{ form_label(form.since, 'created_since') }} {{ form_widget(form.since, {'attr': {'class': 'form-control', 'style': 'padding: 1rem .5rem;', 'title': 'created_since'|trans}}) }}
    {{ form_end(form) }} ================================================ FILE: templates/search/front.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'search'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-search page-settings{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'search'|trans }}

    {% include 'search/form.html.twig' %}
    {% include 'layout/_flash.html.twig' %}
    {% if objects|length %} {% for object in objects %} {% if object.type is defined and object.type is same as 'user' %}
    {{ component('user_box', {user: object.object}) }}
    {% elseif object.type is defined and object.type is same as 'magazine' %} {{ component('magazine_box', {magazine: object.object, stretchedLink: false}) }} {% else %} {% include 'layout/_subject_list.html.twig' with {results: [object.object], entryCommentAttributes: {showMagazineName: true, showEntryTitle: true}, postCommentAttributes: {withPost: false}} %} {% endif %} {% endfor %} {% elseif q is defined and q %} {% include 'search/_list.html.twig' %} {% endif %}
    {% endblock %} ================================================ FILE: templates/stats/_filters.html.twig ================================================
    ================================================ FILE: templates/stats/_options.html.twig ================================================ {%- set TYPE_GENERAL = constant('App\\Repository\\StatsRepository::TYPE_GENERAL') -%} {%- set TYPE_CONTENT = constant('App\\Repository\\StatsRepository::TYPE_CONTENT') -%} {%- set TYPE_VOTES = constant('App\\Repository\\StatsRepository::TYPE_VOTES') -%} ================================================ FILE: templates/stats/_stats_count.html.twig ================================================
    {% include 'stats/_filters.html.twig' %}

    {{ 'users'|trans|upper }}

    {{ users|abbreviateNumber }}

    {{ 'magazines'|trans|upper }}

    {{ magazines|abbreviateNumber }}

    {{ 'votes'|trans|upper }}

    {{ votes|abbreviateNumber }}

    {{ 'threads'|trans|upper }}

    {{ entries|abbreviateNumber }}

    {{ 'comments'|trans|upper }}

    {{ comments|abbreviateNumber }}

    {{ 'posts'|trans|upper }}

    {{ posts|abbreviateNumber }}

    ================================================ FILE: templates/stats/front.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'stats'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-stats{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'stats/_options.html.twig' %}

    {{ 'stats'|trans }}

    {% if route_has_param('statsType', 'general'|trans|lower) or chart is null %} {% include 'stats/_stats_count.html.twig' %} {% else %}
    {% include 'stats/_filters.html.twig' %} {{ render_chart(chart) }}
    {% endif %} {% endblock %} ================================================ FILE: templates/styles/custom.css.twig ================================================ {# using |raw here should be somewhat safe since the next thing you fight should be the browser's css parser #} /* site dynamic styles */ {{ include('components/_details_label.css.twig') }} {% if not app.user or not app.user.ignoreMagazinesCustomCss %} {% if magazine is defined and magazine and magazine.customCss %} /* magazine styles */ {{ magazine.customCss|raw }} {% endif %} {% endif %} {% if app.user is defined and app.user and app.user.customCss %} /* user styles */ {{ app.user.customCss|raw }} {% endif %} ================================================ FILE: templates/tag/_list.html.twig ================================================
    {% include 'layout/_subject_list.html.twig' with {entryCommentAttributes: {showMagazineName: true, showEntryTitle: true}, postCommentAttributes: {withPost: false}} %}
    ================================================ FILE: templates/tag/_options.html.twig ================================================ ================================================ FILE: templates/tag/_panel.html.twig ================================================

    {{ 'tag'|trans }}

    {{ component('tag_actions', {tag: tag}) }} {% if false %} {{ component('magazine_sub', {magazine: magazine}) }} {% if showInfo %}
    • {{ 'subscribers'|trans }}: {{ computed.magazine.subscriptionsCount }}
    {% endif %} {% endif %}
      {{ _self.meta_item('threads'|trans, counts["entry"]) }} {{ _self.meta_item('comments'|trans, counts["entry_comment"]) }} {{ _self.meta_item('posts'|trans, counts["post"]) }} {{ _self.meta_item('replies'|trans, counts["post_comment"]) }}
    {% macro meta_item(name, count) %}
  • {{ name }}{{ count }}
  • {% endmacro %}
    ================================================ FILE: templates/tag/comments.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} #{{ tag }} - {{ 'comments'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-tag-comments{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'tag/_options.html.twig' %}
    {% include 'entry/comment/_list.html.twig' %}
    {% endblock %} ================================================ FILE: templates/tag/front.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} #{{ tag }} - {{ 'threads'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-tag-entries{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'tag/_options.html.twig' %}
    {% include 'entry/_list.html.twig' %}
    {% endblock %} ================================================ FILE: templates/tag/overview.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} #{{ tag }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-tag-overview{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% if app.user and app.user.admin %}

    {{ 'admin_panel'|trans }}

    {% endif %} {% endblock %} {% block body %} {% include 'tag/_options.html.twig' %}
    {% include 'tag/_list.html.twig' %}
    {% endblock %} ================================================ FILE: templates/tag/people.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} #{{ tag }} - {{ 'people'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-tag-people page-people{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'tag/_options.html.twig' %}

    {{ 'people_local'|trans }}

    {% for user in local %}
    {{ component('user_box', {user: user}) }}
    {% endfor %}
    {% if not local|length %} {% endif %}

    {{ 'people_federated'|trans }}

    {% for user in federated %}
    {{ component('user_box', {user: user}) }}
    {% endfor %}
    {% if not federated|length %} {% endif %}
    {% endblock %} ================================================ FILE: templates/tag/posts.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} #{{ tag }} - {{ 'posts'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-tag-posts{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'tag/_options.html.twig' %}
    {% include 'post/_list.html.twig' %}
    {% endblock %} ================================================ FILE: templates/user/2fa.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'Two factor authentication'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-2fa{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'two_factor_authentication'|trans }}

    {% if authenticationError %}
    {{ '2fa.code_invalid'|trans }}
    {% endif %}
    {% endblock %} ================================================ FILE: templates/user/_admin_panel.html.twig ================================================ {% if (is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR')) and app.user != user and not user.admin() %}

    {{ 'admin_panel'|trans }}

    {% if user.apId is same as null and not user.isVerified and is_granted('ROLE_ADMIN') %}

    {% endif %} {% if user.isTotpAuthenticationEnabled and is_granted('ROLE_ADMIN') %}

    {% endif %}

    {% if is_granted('ROLE_ADMIN') %}

    {% if user.id is not same as app.user.id and not user.admin %} {% if user.markedForDeletionAt is same as null and user.apId is same as null %}
    {% elseif user.markedForDeletionAt is not same as null and user.apId is same as null %}
    {% endif %}
    {% endif %} {% endif %}
    {% endif %} ================================================ FILE: templates/user/_boost_list.html.twig ================================================
    {% include 'layout/_subject_list.html.twig' with {entryCommentAttributes: {showMagazineName: false}} %}
    ================================================ FILE: templates/user/_federated_info.html.twig ================================================ {% if user.apId %}

    {{ 'federated_user_info'|trans }} {{ 'go_to_original_instance'|trans }}

    {% if is_instance_of_user_banned(user) %}
    {{ 'user_instance_defederated_info'|trans }}
    {% endif %} {% endif %} ================================================ FILE: templates/user/_info.html.twig ================================================ ================================================ FILE: templates/user/_list.html.twig ================================================ {%- set V_TRUE = constant('App\\Controller\\User\\ThemeSettingsController::TRUE') -%} {%- set V_FALSE = constant('App\\Controller\\User\\ThemeSettingsController::FALSE') -%} {%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%} {% if users|length %}
    {% if view is same as 'cards'|trans|lower %}
    {% for user in users %} {{ component('user', {user: user, showMeta: false, showInfo: false}) }} {% endfor %}
    {% elseif view is same as 'columns'|trans|lower %}
    {% else %}
    {% for u in users %} {% endfor %}
    {{ 'name'|trans }} {{ 'threads'|trans }} {{ 'comments'|trans }} {{ 'posts'|trans }}
    {{ component('user_inline', { user: u, stretchedLink: true, showAvatar: true, showNewIcon: true, fullName: SHOW_USER_FULLNAME is same as V_TRUE}) }} {{ u.entries|length }} {{ u.entryComments|length }} {{ u.posts|length + u.postComments|length }} {{ component('user_actions', {user: u}) }}
    {% endif %}
    {% else %} {% endif %} ================================================ FILE: templates/user/_options.html.twig ================================================ ================================================ FILE: templates/user/_user_popover.html.twig ================================================
    {% if user.avatar %} {{ component('user_avatar', { user: user, width: 100, height: 100, asLink: true }) }} {% endif %}

    {{ user.title ?? user.username|username }} {% if user.isNew() %} {% set days = constant('App\\Entity\\User::NEW_FOR_DAYS') %} {% endif %} {% if user.isCakeDay() %} {% endif %}

    {{ user.username|username(true) }} {% if user.apManuallyApprovesFollowers is same as true %} {% endif %} {% if user.apProfileId %} {% endif %}

    {{ component('user_actions', {user: user}) }} {% if app.user is defined and app.user is not same as null and app.user is not same as user %} {{ component('notification_switch', {target: user}) }} {% endif %}
    {{ form_start(form) }} {{ form_row(form.body, {label: 'note'}) }} {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary', 'data-action': ''}, row_attr: {class: 'float-end'}}) }} {{ form_end(form) }}
    ================================================ FILE: templates/user/_visibility_info.html.twig ================================================ {% if user is defined and user and user.visibility is same as 'trashed' %}

    {{ 'account_is_suspended'|trans }}

    {% endif %} ================================================ FILE: templates/user/comments.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'comments'|trans }} - {{ user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-user page-user-overview{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% include('user/_admin_panel.html.twig') %} {% endblock %} {% block body %}
    {{ component('user_box', { user: user, stretchedLink: false }) }}
    {% include('user/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include('user/_federated_info.html.twig') %} {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}
    {% include 'entry/comment/_list.html.twig' with {showNested: false} %}
    {% endif %} {% endblock %} ================================================ FILE: templates/user/consent.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'oauth.consent.title'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-login{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ app_name }} - {{ 'oauth.consent.grant_permissions'|trans }}

    {% include 'layout/_flash.html.twig' %}
    {% if image %}
    {% endif %}

    {{ app_name }} {{ 'oauth.consent.app_requesting_permissions'|trans }}:

      {% for scope in scopes %}
    • {{ scope|trans }}
    • {% endfor %}
    {% if has_existing_scopes %}

    {{ app_name }} {{ 'oauth.consent.app_has_permissions'|trans }}:

      {% for scope in existing_scopes %}
    • {{ scope|trans }}
    • {% endfor %}
    {% endif %}

    {{ 'oauth.consent.to_allow_access'|trans }}

    {% endblock %} ================================================ FILE: templates/user/entries.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'threads'|trans }} - {{ user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-user page-user-overview{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% include('user/_admin_panel.html.twig') %} {% endblock %} {% block body %}
    {{ component('user_box', { user: user, stretchedLink: false }) }}
    {% include('user/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include('user/_federated_info.html.twig') %} {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}
    {% include 'entry/_list.html.twig' %}
    {% endif %} {% endblock %} ================================================ FILE: templates/user/followers.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'followers'|trans }} - {{ user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-user page-user-overview{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% include('user/_admin_panel.html.twig') %} {% endblock %} {% block body %}
    {{ component('user_box', { user: user, stretchedLink: false }) }}
    {% include('user/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include('user/_federated_info.html.twig') %} {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}
    {% include 'layout/_user_activity_list.html.twig' with {list: users, actor: 'follower'} %}
    {% endif %} {% endblock %} ================================================ FILE: templates/user/following.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'following'|trans }} - {{ user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-user page-user-overview{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% include('user/_admin_panel.html.twig') %} {% endblock %} {% block body %}
    {{ component('user_box', { user: user, stretchedLink: false }) }}
    {% include('user/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include('user/_federated_info.html.twig') %} {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}
    {% include 'layout/_user_activity_list.html.twig' with {list: users, actor: 'following'} %}
    {% endif %} {% endblock %} ================================================ FILE: templates/user/login.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'login'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-login{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'login'|trans }}

    {% include 'layout/_flash.html.twig' %}
    {% if mbin_sso_show_first() %} {{ component('login_socials') }} {% endif %} {% if not mbin_sso_only_mode() %}
    {% if error %}
    {{ error.messageKey|trans(error.messageData, 'security')|raw }}
    {% endif %}
    {% endif %} {% if not mbin_sso_show_first() %} {{ component('login_socials') }} {% endif %} {% if not mbin_sso_only_mode() %} {{ component('user_form_actions', {showRegister: true, showPasswordReset: true, showResendEmail: true}) }} {% endif %}
    {% endblock %} ================================================ FILE: templates/user/message.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'send_message'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-user page-user-send-message{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'message'|trans }}

    {% include('user/_options.html.twig') %}
    {% include 'messages/_form_create.html.twig' %}
    {% endblock %} ================================================ FILE: templates/user/moderated.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'moderated'|trans }} - {{ user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-user page-user-overview{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% include('user/_admin_panel.html.twig') %} {% endblock %} {% block body %}
    {{ component('user_box', { user: user, stretchedLink: false }) }}
    {% include('user/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include('user/_federated_info.html.twig') %} {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}
    {% include('magazine/_list.html.twig') %}
    {% endif %} {% endblock %} ================================================ FILE: templates/user/overview.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'overview'|trans }} - {{ user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-user page-user-overview{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% include('user/_admin_panel.html.twig') %} {% endblock %} {% block body %}
    {{ component('user_box', { user: user, stretchedLink: false }) }}
    {% include('user/_options.html.twig') %} {% include('layout/_flash.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include('user/_federated_info.html.twig') %} {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}
    {% include 'layout/_subject_list.html.twig' with {entryCommentAttributes: {showMagazineName: false}} %}
    {% endif %} {% endblock %} ================================================ FILE: templates/user/posts.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'posts'|trans }} - {{ user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-user page-user-overview{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% include('user/_admin_panel.html.twig') %} {% endblock %} {% block body %}
    {{ component('user_box', { user: user, stretchedLink: false }) }}
    {% include('user/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include('user/_federated_info.html.twig') %} {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}
    {% include 'post/_list.html.twig' %}
    {% endif %} {% endblock %} ================================================ FILE: templates/user/register.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'register'|trans }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-register{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}

    {{ 'register'|trans }}

    {% if mbin_sso_registrations_enabled() and mbin_sso_show_first() %} {{ component('login_socials') }} {% endif %} {% if kbin_registrations_enabled() %} {{ form_start(form) }} {% for flash_error in app.flashes('verify_email_error') %}
    {{ flash_error }}
    {% endfor %} {{ form_row(form.username, { label: 'username', }) }} {% if do_new_users_need_approval() %} {{ form_row(form.applicationText, { label: 'application_text', }) }} {% endif %} {{ form_row(form.email, { label: 'email' }) }} {{ form_row(form.plainPassword, { label: 'password' }) }} {% if kbin_captcha_enabled() %} {{ form_row(form.captcha, { label: false }) }} {% endif %} {{ form_row(form.agreeTerms, { translation_domain: false, label: 'agree_terms'|trans({ '%terms_link_start%' : '', '%terms_link_end%' : '', '%policy_link_start%' : '', '%policy_link_end%' : '', }), attr: { 'aria-label': 'agree_terms'|trans }, row_attr: { class: 'checkbox' } }) }} {{ form_row(form.submit, { label: 'register', attr: { class: 'btn btn__primary' }, row_attr: { class: 'button-flex-hf' } }) }} {{ form_end(form) }} {% else %}

    {{ 'registration_disabled'|trans }}

    {% endif %} {% if mbin_sso_registrations_enabled() and not mbin_sso_show_first() %} {{ component('login_socials') }} {% endif %} {{ component('user_form_actions', {showLogin: true, showPasswordReset: true, showResendEmail: true}) }}
    {% endblock %} ================================================ FILE: templates/user/replies.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'replies'|trans }} - {{ user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-user page-user-replies{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% include('user/_admin_panel.html.twig') %} {% endblock %} {% block body %}
    {{ component('user_box', { user: user, stretchedLink: false }) }}
    {% include('user/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include('user/_federated_info.html.twig') %} {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}
    {% include 'layout/_subject_list.html.twig' with {'postCommentAttributes': {'showNested': false, 'withPost': true}} %}
    {% endif %} {% endblock %} ================================================ FILE: templates/user/reputation.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'reputation_points'|trans }} - {{ user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-user page-user-overview{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% include('user/_admin_panel.html.twig') %} {% endblock %} {% block body %} {%- set TYPE_ENTRY = constant('App\\Repository\\ReputationRepository::TYPE_ENTRY') -%} {%- set TYPE_ENTRY_COMMENT = constant('App\\Repository\\ReputationRepository::TYPE_ENTRY_COMMENT') -%} {%- set TYPE_POST = constant('App\\Repository\\ReputationRepository::TYPE_POST') -%} {%- set TYPE_POST_COMMENT = constant('App\\Repository\\ReputationRepository::TYPE_POST_COMMENT') -%}
    {{ component('user_box', { user: user, stretchedLink: false }) }}
    {% include('user/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}
    {% if results|length %}
    {% for subject in results %} {% endfor %}
    {{ subject.points }} {{ subject.day >= date('-2 days') ? subject.day|ago : subject.day|date('Y-m-d') }}
    {% endif %} {% if(results.haveToPaginate is defined and results.haveToPaginate) %} {{ pagerfanta(results, null, {'pageParameter':'[p]'}) }} {% endif %} {% if not results|length %} {% endif %}
    {% endif %} {% endblock %} ================================================ FILE: templates/user/settings/2fa.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'two_factor_authentication'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-2fa{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include('user/settings/_options.html.twig') %}

    {{ 'two_factor_authentication'|trans }}

    {{ '2fa.enable'|trans }}

    {{ '2fa.qr_code_img.alt'|trans }}

    {{ '2fa.available_apps' | trans({ '%google_authenticator%': 'Google Authenticator', '%aegis%': 'Aegis', '%raivo%': 'Raivo' }) | raw }}

    {{ '2fa.manual_code_hint'|trans }}:
    {% include 'user/settings/2fa_secret.html.twig' with {'secret': secret} %}

    {{ '2fa.backup'|trans }}

    {% include 'user/settings/_2fa_backup.html.twig' %}
    {{ form_start(form) }} {{ form_row(form.totpCode) }} {{ form_row(form.currentPassword) }}
    {{ form_row(form.submit, {label: '2fa.add', attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }}
    {% endblock %} ================================================ FILE: templates/user/settings/2fa_backup.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'two_factor_backup'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-2fa{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include('user/settings/_options.html.twig') %}

    {{ 'two_factor_backup_codes'|trans }}

    {{ '2fa.backup'|trans }}

    {% include 'user/settings/_2fa_backup.html.twig' %}
    {% endblock %} ================================================ FILE: templates/user/settings/2fa_secret.html.twig ================================================
    • {{ secret }}
    ================================================ FILE: templates/user/settings/_2fa_backup.html.twig ================================================
      {% for code in codes %}
    • {{ code }}
    • {% endfor %}

    {{ '2fa.backup_codes.help' | trans | raw }}

    {{ '2fa.backup_codes.recommendation' | trans }}

    ================================================ FILE: templates/user/settings/_options.html.twig ================================================ {%- set STATUS_PENDING = constant('App\\Entity\\Report::STATUS_PENDING') -%} ================================================ FILE: templates/user/settings/_stats_pills.html.twig ================================================ {%- set TYPE_CONTENT = constant('App\\Repository\\StatsRepository::TYPE_CONTENT') -%} {%- set TYPE_VOTES = constant('App\\Repository\\StatsRepository::TYPE_VOTES') -%} ================================================ FILE: templates/user/settings/account_deletion.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'account_deletion_title'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-account-deletion{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include('user/settings/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include 'layout/_flash.html.twig' %}

    {{ 'account_deletion_title'|trans }}

    {{ 'account_deletion_description'|trans }}

    {{ form_start(form) }} {{ form_row(form.currentPassword, {label: 'current_password'}) }} {{ form_row(form.instantDelete, {label: 'account_deletion_immediate', row_attr: {class: 'checkbox'}}) }}
    {{ form_row(form.submit, { label: 'account_deletion_button', attr: { class: 'btn btn__danger' } }) }}
    {{ form_end(form) }}
    {% endblock %} ================================================ FILE: templates/user/settings/block_domains.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'blocked'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-block-magazines{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'user/settings/_options.html.twig' %} {% include('user/_visibility_info.html.twig') %} {% include 'user/settings/block_pills.html.twig' %} {% include 'layout/_domain_activity_list.html.twig' with {list: domains, actor: 'domain'} %} {% endblock %} ================================================ FILE: templates/user/settings/block_magazines.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'blocked'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-block-magazines{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include('user/settings/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include 'user/settings/block_pills.html.twig' %}
    {% include 'layout/_magazine_activity_list.html.twig' with {list: magazines, actor: 'magazine'} %}
    {% endblock %} ================================================ FILE: templates/user/settings/block_pills.html.twig ================================================ ================================================ FILE: templates/user/settings/block_users.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'blocked'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-block-magazines{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'user/settings/_options.html.twig' %} {% include('user/_visibility_info.html.twig') %} {% include 'user/settings/block_pills.html.twig' %}
    {% include 'layout/_user_activity_list.html.twig' with {list: users, actor: 'blocked'} %}
    {% endblock %} ================================================ FILE: templates/user/settings/email.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'email'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-email{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include('user/settings/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include 'layout/_flash.html.twig' %}

    {{ 'profile'|trans }}

    {% if not app.user.SsoControlled() %}

    {{ 'change_email'|trans }}

    {{ form_start(form) }} {{ form_row(form.email, {label: 'old_email', value: app.user.email, attr: {disabled: 'disabled'}}) }} {{ form_row(form.newEmail, {label: 'new_email'}) }} {{ form_row(form.currentPassword, {label: 'password'}) }}
    {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }} {% else %}

    {{ 'email'|trans }}

    {{ 'old_email'|trans }}
    {{ app.user.email }}
    {% endif %}
    {% endblock %} ================================================ FILE: templates/user/settings/filter_lists.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'filter_lists'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-filter-lists{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include('user/settings/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include 'layout/_flash.html.twig' %}

    {{ 'filter_lists'|trans }}

    {% for list in app.user.filterLists %} {{ component('filter_list', {list: list}) }} {% endfor %}
    {% endblock %} ================================================ FILE: templates/user/settings/filter_lists_create.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'filter_lists'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-filter-lists{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include('user/settings/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include 'layout/_flash.html.twig' %}

    {{ 'filter_lists'|trans }}

    {{ include('user/settings/filter_lists_form.html.twig', {btn_label: 'add'|trans }) }}
    {% endblock %} ================================================ FILE: templates/user/settings/filter_lists_edit.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'filter_lists'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-filter-lists{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include('user/settings/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include 'layout/_flash.html.twig' %}

    {{ 'filter_lists'|trans }}

    {{ include('user/settings/filter_lists_form.html.twig', {btn_label: 'save'|trans }) }}
    {% endblock %} ================================================ FILE: templates/user/settings/filter_lists_form.html.twig ================================================ {{ form_start(form) }} {{ form_row(form.name, {label: 'name'}) }}
    {{ form_label(form.expirationDate, 'expiration_date') }} {{ form_widget(form.expirationDate, {'attr': {'class': 'form-control', 'style': 'padding: 1rem .5rem;', 'title': 'expiration_date'|trans}}) }}
    {{ 'filter_lists_where_to_filter'|trans }}:
    {{ form_label(form.feeds) }} {{ form_widget(form.feeds) }}
    {{ form_help(form.feeds) }}
    {{ form_label(form.comments) }} {{ form_widget(form.comments) }}
    {{ form_help(form.comments) }}
    {{ form_label(form.profile) }} {{ form_widget(form.profile) }}
    {{ form_help(form.profile) }}
    {{ form_label(form.words) }} {{ 'filter_lists_word_exact_match_help'|trans }}
    {{ form_widget(form.words) }}
    {{ form_row(form.submit, {label: btn_label, attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }} ================================================ FILE: templates/user/settings/general.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'general'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-general{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include('user/settings/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include 'layout/_flash.html.twig' %}

    {{ 'general'|trans }}

    {{ form_start(form) }}

    {{ 'appearance'|trans }}

    {{ form_row(form.homepage, {label: 'homepage'}) }} {{ form_row(form.frontDefaultContent, {label: 'front_default_content'}) }} {{ form_row(form.frontDefaultSort, {label: 'front_default_sort'}) }} {{ form_row(form.commentDefaultSort, {label: 'comment_default_sort'}) }} {{ form_row(form.preferredLanguages, {label: 'preferred_languages'}) }} {{ form_row(form.featuredMagazines, {label: 'featured_magazines'}) }} {{ form_row(form.customCss, {label: 'custom_css', row_attr: {class: 'textarea'}}) }} {{ form_row(form.ignoreMagazinesCustomCss, {label: 'ignore_magazines_custom_css', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.hideAdult, {label: 'hide_adult', row_attr: {class: 'checkbox'}}) }}
    {{ form_label(form.showFollowingBoosts, 'show_boost_following_label') }} {{ form_widget(form.showFollowingBoosts) }}
    {{ form_help(form.showFollowingBoosts) }}

    {{ 'writing'|trans }}

    {{ form_row(form.addMentionsEntries, {label: 'add_mentions_entries', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.addMentionsPosts, {label: 'add_mentions_posts', row_attr: {class: 'checkbox'}}) }}

    {{ 'privacy'|trans }}

    {{ form_row(form.directMessageSetting, {label: 'direct_message_setting_label'}) }} {{ form_row(form.showProfileSubscriptions, {label: 'show_profile_subscriptions', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.showProfileFollowings, {label: 'show_profile_followings', row_attr: {class: 'checkbox'}}) }}
    {{ form_label(form.discoverable, 'discoverable') }} {{ form_widget(form.discoverable) }}
    {{ form_help(form.discoverable) }}
    {{ form_label(form.indexable, 'indexable_by_search_engines') }} {{ form_widget(form.indexable) }}
    {{ form_help(form.indexable) }}

    {{ 'notifications'|trans }}

    {{ form_row(form.notifyOnNewEntryReply, {label: 'notify_on_new_entry_reply', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.notifyOnNewEntryCommentReply, {label: 'notify_on_new_entry_comment_reply', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.notifyOnNewPostReply, {label: 'notify_on_new_post_reply', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.notifyOnNewPostCommentReply, {label: 'notify_on_new_post_comment_reply', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.notifyOnNewEntry, {label: 'notify_on_new_entry', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.notifyOnNewPost, {label: 'notify_on_new_posts', row_attr: {class: 'checkbox'}}) }} {% if app.user.admin or app.user.moderator %} {{ form_row(form.notifyOnUserSignup, {label: 'notify_on_user_signup', row_attr: {class: 'checkbox'}}) }} {% endif %}
    {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }}
    {% endblock %} ================================================ FILE: templates/user/settings/password.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'password'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-password{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include('user/settings/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include 'layout/_flash.html.twig' %}

    {{ 'password_and_2fa'|trans }}

    {{ 'change_password'|trans }}

    {{ form_start(form) }} {{ form_row(form.currentPassword) }}
    {{ form_row(form.totpCode, {required: has2fa}) }}
    {{ form_row(form.plainPassword) }}
    {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }}

    {{ 'two_factor_authentication'|trans }}

    {% if has2fa %} {{ form_start(disable2faForm, {action: path('user_settings_2fa_disable'), attr: {'data-action': "confirmation#ask", 'data-confirmation-message-param': 'are_you_sure'|trans}}) }} {{ form_row(disable2faForm.currentPassword) }} {{ form_row(disable2faForm.totpCode, {required: has2fa}) }}
    {{ form_row(disable2faForm.submit, {label: '2fa.disable', attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(disable2faForm) }}

    {{ '2fa.backup-create.help' | trans }}

    {{ form_start(regenerateBackupCodes, {action: path('user_settings_2fa_backup'), attr: {'data-action': "confirmation#ask", 'data-confirmation-message-param': 'are_you_sure'|trans}}) }} {{ form_row(regenerateBackupCodes.currentPassword) }} {{ form_row(regenerateBackupCodes.totpCode, {required: has2fa}) }}
    {{ form_row(regenerateBackupCodes.submit, {label: '2fa.backup-create.label', attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(regenerateBackupCodes) }} {% else %} {% endif %}
    {% endblock %} ================================================ FILE: templates/user/settings/profile.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'profile'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-profile{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include('user/settings/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include 'layout/_flash.html.twig' %}
    {{ component('user_box', { user: app.user, stretchedLink: false }) }}

    {{ 'profile'|trans }}

    {{ form_start(form) }} {{ form_row(form.username, {label: 'username', attr: { 'data-controller': 'input-length autogrow', 'data-entry-link-create-target': 'user_about', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value' : constant('App\\DTO\\UserDto::MAX_USERNAME_LENGTH') }}) }} {{ form_row(form.title, {label: 'displayname', attr: { 'data-controller': 'input-length autogrow', 'data-entry-link-create-target': 'user_about', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value' : constant('App\\DTO\\UserDto::MAX_USERNAME_LENGTH') }}) }} {{ component('editor_toolbar', {id: 'user_basic_about'}) }} {{ form_row(form.about, {label: false, attr: { placeholder: 'about', 'data-controller': 'input-length rich-textarea autogrow', 'data-entry-link-create-target': 'user_about', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value' : constant('App\\DTO\\UserDto::MAX_ABOUT_LENGTH') }}) }} {{ form_row(form.avatar, {label: 'avatar'}) }} {% if app.user.avatar is not same as null %}
      {{ app.user.avatar.altText }}
    {% endif %} {{ form_row(form.cover, {label: 'cover'}) }} {% if app.user.cover is not same as null %}
      {{ app.user.cover.altText }}
    {% endif %}
    {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}
    {{ form_end(form) }}
    {% endblock %} ================================================ FILE: templates/user/settings/reports.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'reports'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-reports{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'user/settings/_options.html.twig' %} {% include('user/_visibility_info.html.twig') %}

    {{ 'reports'|trans }}

    {{ component('report_list', {reports: reports, routeName: 'user_settings_reports'}) }} {% endblock %} ================================================ FILE: templates/user/settings/stats.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'reports'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-reports{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'user/settings/_options.html.twig' %} {% include('user/_visibility_info.html.twig') %} {% include 'user/settings/_stats_pills.html.twig' %}
    {% include 'stats/_filters.html.twig' %} {{ render_chart(chart) }}
    {% endblock %} ================================================ FILE: templates/user/settings/sub_domains.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'subscriptions'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-sub-magazines{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'user/settings/_options.html.twig' %} {% include('user/_visibility_info.html.twig') %} {% include 'user/settings/sub_pills.html.twig' %} {% include 'layout/_domain_activity_list.html.twig' with {list: domains, actor: 'domain'} %} {% endblock %} ================================================ FILE: templates/user/settings/sub_magazines.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'subscriptions'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-sub-magazines{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include('user/settings/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include 'user/settings/sub_pills.html.twig' %}
    {% include 'layout/_magazine_activity_list.html.twig' with {list: magazines, actor: 'magazine'} %}
    {% endblock %} ================================================ FILE: templates/user/settings/sub_pills.html.twig ================================================ ================================================ FILE: templates/user/settings/sub_users.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'subscriptions'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-settings page-settings-sub-magazines{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %} {% include 'user/settings/_options.html.twig' %} {% include('user/_visibility_info.html.twig') %} {% include 'user/settings/sub_pills.html.twig' %}
    {% include 'layout/_user_activity_list.html.twig' with {list: users, actor: 'following'} %}
    {% endblock %} ================================================ FILE: templates/user/subscriptions.html.twig ================================================ {% extends 'base.html.twig' %} {%- block title -%} {{- 'subscriptions'|trans }} - {{ user.username|username(false) }} - {{ parent() -}} {%- endblock -%} {% block mainClass %}page-user page-user-overview{% endblock %} {% block header_nav %} {% endblock %} {% block sidebar_top %} {% endblock %} {% block body %}
    {{ component('user_box', { user: user, stretchedLink: false }) }}
    {% include('user/_options.html.twig') %} {% include('user/_visibility_info.html.twig') %} {% include('user/_federated_info.html.twig') %} {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}
    {% include 'layout/_magazine_activity_list.html.twig' with {list: magazines} %}
    {% endif %} {% endblock %} ================================================ FILE: tests/ActivityPubJsonDriver.php ================================================ scrub($data); return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n"; } public function match($expected, $actual): void { if (\is_string($actual)) { $actual = json_decode($actual, false, 512, JSON_THROW_ON_ERROR); } $actual = $this->scrub($actual); $expected = json_decode($expected, false, 512, JSON_THROW_ON_ERROR); Assert::assertJsonStringEqualsJsonString(json_encode($expected), json_encode($actual)); } protected function scrub(mixed $data): mixed { if (\is_array($data)) { return $this->scrubArray($data); } elseif (\is_object($data)) { return $this->scrubObject($data); } return $this; } protected function scrubArray(array $data): array { if (isset($data['id'])) { $data['id'] = 'SCRUBBED_ID'; } if (isset($data['type']) && 'Note' === $data['type'] && isset($data['url'])) { $data['url'] = 'SCRUBBED_ID'; } if (isset($data['inReplyTo'])) { $data['inReplyTo'] = 'SCRUBBED_ID'; } if (isset($data['published'])) { $data['published'] = 'SCRUBBED_DATE'; } if (isset($data['updated'])) { $data['updated'] = 'SCRUBBED_DATE'; } if (isset($data['publicKey'])) { $data['publicKey'] = 'SCRUBBED_KEY'; } if (isset($data['object']) && \is_string($data['object'])) { $data['object'] = 'SCRUBBED_ID'; } if (isset($data['object']) && (\is_array($data['object']) || \is_object($data['object']))) { $data['object'] = $this->scrub($data['object']); } if (isset($data['orderedItems']) && \is_array($data['orderedItems'])) { $items = []; foreach ($data['orderedItems'] as $item) { $items[] = $this->scrub($item); } $data['orderedItems'] = $items; } return $data; } protected function scrubObject(object $data): object { if (isset($data->id)) { $data->id = 'SCRUBBED_ID'; } if (isset($data->type) && 'Note' === $data->type && isset($data->url)) { $data->url = 'SCRUBBED_ID'; } if (isset($data->inReplyTo)) { $data->inReplyTo = 'SCRUBBED_ID'; } if (isset($data->published)) { $data->published = 'SCRUBBED_DATE'; } if (isset($data->updated)) { $data->updated = 'SCRUBBED_DATE'; } if (isset($data->publicKey)) { $data->publicKey = 'SCRUBBED_KEY'; } if (isset($data->object) && \is_string($data->object)) { $data->object = 'SCRUBBED_ID'; } if (isset($data->object) && (\is_array($data->object) || \is_object($data->object))) { $data->object = $this->scrub($data->object); } return $data; } } ================================================ FILE: tests/ActivityPubTestCase.php ================================================ owner = $this->getUserByUsername('owner', addImage: false); $this->magazine = $this->getMagazineByName('test', $this->owner); $this->user = $this->getUserByUsername('user', addImage: false); $this->personFactory = $this->getService(PersonFactory::class); $this->groupFactory = $this->getService(GroupFactory::class); $this->instanceFactory = $this->getService(InstanceFactory::class); $this->entryCommentNoteFactory = $this->getService(EntryCommentNoteFactory::class); $this->postNoteFactory = $this->getService(PostNoteFactory::class); $this->postCommentNoteFactory = $this->getService(PostCommentNoteFactory::class); $this->addRemoveFactory = $this->getService(AddRemoveFactory::class); $this->createWrapper = $this->getService(CreateWrapper::class); $this->updateWrapper = $this->getService(UpdateWrapper::class); $this->deleteWrapper = $this->getService(DeleteWrapper::class); $this->likeWrapper = $this->getService(LikeWrapper::class); $this->followWrapper = $this->getService(FollowWrapper::class); $this->announceWrapper = $this->getService(AnnounceWrapper::class); $this->undoWrapper = $this->getService(UndoWrapper::class); $this->followResponseWrapper = $this->getService(FollowResponseWrapper::class); $this->flagFactory = $this->getService(FlagFactory::class); $this->blockFactory = $this->getService(BlockFactory::class); $this->lockFactory = $this->getService(LockFactory::class); $this->userFollowRequestRepository = $this->getService(UserFollowRequestRepository::class); $this->apMarkdownConverter = $this->getService(MarkdownConverter::class); } /** * @template T * * @param class-string $className * * @return T */ private function getService(string $className) { return $this->getContainer()->get($className); } protected function getDefaultUuid(): Uuid { return new Uuid('00000000-0000-0000-0000-000000000000'); } protected function getSnapshotDirectory(): string { return \dirname((new \ReflectionClass($this))->getFileName()). DIRECTORY_SEPARATOR. 'JsonSnapshots'; } } ================================================ FILE: tests/FactoryTrait.php ================================================ favouriteManager; $favManager->toggle($user, $subject); } else { $voteManager = $this->voteManager; $voteManager->vote($choice, $subject, $user); } } protected function loadExampleMagazines(): void { $this->loadExampleUsers(); foreach ($this->provideMagazines() as $data) { $this->createMagazine($data['name'], $data['title'], $data['user'], $data['isAdult'], $data['description']); } } protected function loadExampleUsers(): void { foreach ($this->provideUsers() as $data) { $this->createUser($data['username'], $data['email'], $data['password']); } } private function provideUsers(): iterable { yield [ 'username' => 'adminUser', 'password' => 'adminUser123', 'email' => 'adminUser@example.com', 'type' => 'Person', ]; yield [ 'username' => 'JohnDoe', 'password' => 'JohnDoe123', 'email' => 'JohnDoe@example.com', 'type' => 'Person', ]; } private function createUser(string $username, ?string $email = null, ?string $password = null, string $type = 'Person', $active = true, $hideAdult = true, $about = null, $addImage = true): User { $user = new User($email ?: $username.'@example.com', $username, $password ?: 'secret', $type); $user->isVerified = $active; $user->notifyOnNewEntry = true; $user->notifyOnNewEntryReply = true; $user->notifyOnNewEntryCommentReply = true; $user->notifyOnNewPost = true; $user->notifyOnNewPostReply = true; $user->notifyOnNewPostCommentReply = true; $user->showProfileFollowings = true; $user->showProfileSubscriptions = true; $user->hideAdult = $hideAdult; $user->apDiscoverable = true; $user->about = $about; $user->apIndexable = true; if ($addImage) { $user->avatar = $this->createImage(bin2hex(random_bytes(20)).'.png'); } $this->entityManager->persist($user); $this->entityManager->flush(); $this->users->add($user); return $user; } public function createMessage(User $to, User $from, string $content): Message { $thread = $this->createMessageThread($to, $from, $content); /** @var Message $message */ $message = $thread->messages->get(0); return $message; } public function createMessageThread(User $to, User $from, string $content): MessageThread { $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = $content; return $messageManager->toThread($dto, $from, $to); } public static function createOAuth2AuthCodeClient(): void { /** @var ClientManagerInterface $manager */ $manager = self::getContainer()->get(ClientManagerInterface::class); $client = new Client('/kbin Test Client', 'testclient', 'testsecret'); $client->setDescription('An OAuth2 client for testing purposes'); $client->setContactEmail('test@kbin.test'); $client->setScopes(...array_map(fn (string $scope) => new Scope($scope), OAuth2ClientDto::AVAILABLE_SCOPES)); $client->setGrants(new Grant('authorization_code'), new Grant('refresh_token')); $client->setRedirectUris(new RedirectUri('https://localhost:3001')); $manager->save($client); } public static function createOAuth2PublicAuthCodeClient(): void { /** @var ClientManagerInterface $manager */ $manager = self::getContainer()->get(ClientManagerInterface::class); $client = new Client('/kbin Test Client', 'testpublicclient', null); $client->setDescription('An OAuth2 public client for testing purposes'); $client->setContactEmail('test@kbin.test'); $client->setScopes(...array_map(fn (string $scope) => new Scope($scope), OAuth2ClientDto::AVAILABLE_SCOPES)); $client->setGrants(new Grant('authorization_code'), new Grant('refresh_token')); $client->setRedirectUris(new RedirectUri('https://localhost:3001')); $manager->save($client); } public static function createOAuth2ClientCredsClient(): void { /** @var ClientManagerInterface $clientManager */ $clientManager = self::getContainer()->get(ClientManagerInterface::class); /** @var UserManager $userManager */ $userManager = self::getContainer()->get(UserManager::class); $client = new Client('/kbin Test Client', 'testclient', 'testsecret'); $userDto = new UserDto(); $userDto->username = 'test_bot'; $userDto->email = 'test@kbin.test'; $userDto->plainPassword = hash('sha512', random_bytes(32)); $userDto->isBot = true; $user = $userManager->create($userDto, false, false, true); $client->setUser($user); $client->setDescription('An OAuth2 client for testing purposes'); $client->setContactEmail('test@kbin.test'); $client->setScopes(...array_map(fn (string $scope) => new Scope($scope), OAuth2ClientDto::AVAILABLE_SCOPES)); $client->setGrants(new Grant('client_credentials')); $client->setRedirectUris(new RedirectUri('https://localhost:3001')); $clientManager->save($client); } private function provideMagazines(): iterable { yield [ 'name' => 'acme', 'title' => 'Magazyn polityczny', 'user' => $this->getUserByUsername('JohnDoe'), 'isAdult' => false, 'description' => 'Foobar', ]; yield [ 'name' => 'kbin', 'title' => 'kbin devlog', 'user' => $this->getUserByUsername('adminUser'), 'isAdult' => false, 'description' => 'development process in detail', ]; yield [ 'name' => 'adult', 'title' => 'Adult only', 'user' => $this->getUserByUsername('JohnDoe'), 'isAdult' => true, 'description' => 'Not for kids', ]; yield [ 'name' => 'starwarsmemes@republic.new', 'title' => 'starwarsmemes@republic.new', 'user' => $this->getUserByUsername('adminUser'), 'isAdult' => false, 'description' => "It's a trap", ]; } protected function getUserByUsername(string $username, bool $isAdmin = false, bool $hideAdult = true, ?string $about = null, bool $active = true, bool $isModerator = false, bool $addImage = true, ?string $email = null): User { $user = $this->users->filter(fn (User $user) => $user->getUsername() === $username)->first(); if ($user) { return $user; } $user = $this->createUser($username, email: $email, active: $active, hideAdult: $hideAdult, about: $about, addImage: $addImage); if ($isAdmin) { $user->roles = ['ROLE_ADMIN']; } elseif ($isModerator) { $user->roles = ['ROLE_MODERATOR']; } $this->entityManager->persist($user); $this->entityManager->flush(); return $user; } protected function setAdmin(User $user): void { $user->roles = ['ROLE_ADMIN']; $manager = $this->entityManager; $manager->persist($user); $manager->flush(); $manager->refresh($user); } private function createMagazine( string $name, ?string $title = null, ?User $user = null, bool $isAdult = false, ?string $description = null, ): Magazine { $dto = new MagazineDto(); $dto->name = $name; $dto->title = $title ?? 'Magazine title'; $dto->isAdult = $isAdult; $dto->description = $description; if (str_contains($name, '@')) { [$name, $host] = explode('@', $name); $dto->apId = $name; $dto->apProfileId = "https://{$host}/m/{$name}"; } $newMod = $user ?? $this->getUserByUsername('JohnDoe'); $this->entityManager->persist($newMod); $magazine = $this->magazineManager->create($dto, $newMod); $this->entityManager->persist($magazine); $this->magazines->add($magazine); return $magazine; } protected function loadNotificationsFixture() { $owner = $this->getUserByUsername('owner'); $magazine = $this->getMagazineByName('acme', $owner); $actor = $this->getUserByUsername('actor'); $regular = $this->getUserByUsername('JohnDoe'); $entry = $this->getEntryByTitle('test', null, 'test', $magazine, $actor); $comment = $this->createEntryComment('test', $entry, $regular); $this->entryCommentManager->delete($owner, $comment); $this->entryManager->delete($owner, $entry); $post = $this->createPost('test', $magazine, $actor); $comment = $this->createPostComment('test', $post, $regular); $this->postCommentManager->delete($owner, $comment); $this->postManager->delete($owner, $post); $this->magazineManager->ban( $magazine, $actor, $owner, MagazineBanDto::create('test', new \DateTimeImmutable('+1 day')) ); } protected function getMagazineByName(string $name, ?User $user = null, bool $isAdult = false): Magazine { $magazine = $this->magazines->filter(fn (Magazine $magazine) => $magazine->name === $name)->first(); return $magazine ?: $this->createMagazine($name, $name, $user, $isAdult); } protected function getMagazineByNameNoRSAKey(string $name, ?User $user = null, bool $isAdult = false): Magazine { $magazine = $this->magazines->filter(fn (Magazine $magazine) => $magazine->name === $name)->first(); if ($magazine) { return $magazine; } $dto = new MagazineDto(); $dto->name = $name; $dto->title = $title ?? 'Magazine title'; $dto->isAdult = $isAdult; if (str_contains($name, '@')) { [$name, $host] = explode('@', $name); $dto->apId = $name; $dto->apProfileId = "https://{$host}/m/{$name}"; } $factory = $this->magazineFactory; $magazine = $factory->createFromDto($dto, $user ?? $this->getUserByUsername('JohnDoe')); $magazine->apId = $dto->apId; $magazine->apProfileId = $dto->apProfileId; $magazine->apDiscoverable = true; if (!$dto->apId) { $urlGenerator = $this->urlGenerator; $magazine->publicKey = 'fakepublic'; $magazine->privateKey = 'fakeprivate'; $magazine->apProfileId = $urlGenerator->generate( 'ap_magazine', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); } $entityManager = $this->entityManager; $entityManager->persist($magazine); $entityManager->flush(); $manager = $this->magazineManager; $manager->subscribe($magazine, $user ?? $this->getUserByUsername('JohnDoe')); $this->magazines->add($magazine); return $magazine; } protected function getEntryByTitle( string $title, ?string $url = null, ?string $body = null, ?Magazine $magazine = null, ?User $user = null, ?ImageDto $image = null, string $lang = 'en', ): Entry { $entry = $this->entries->filter(fn (Entry $entry) => $entry->title === $title)->first(); if (!$entry) { $magazine = $magazine ?? $this->getMagazineByName('acme'); $user = $user ?? $this->getUserByUsername('JohnDoe'); $entry = $this->createEntry($title, $magazine, $user, $url, $body, $image, $lang); } return $entry; } protected function createEntry( string $title, Magazine $magazine, User $user, ?string $url = null, ?string $body = 'Test entry content', ?ImageDto $imageDto = null, string $lang = 'en', ): Entry { $manager = $this->entryManager; $dto = new EntryDto(); $dto->magazine = $magazine; $dto->title = $title; $dto->user = $user; $dto->url = $url; $dto->body = $body; $dto->lang = $lang; $dto->image = $imageDto; $entry = $manager->create($dto, $user); $this->entries->add($entry); return $entry; } public function createEntryComment( string $body, ?Entry $entry = null, ?User $user = null, ?EntryComment $parent = null, ?ImageDto $imageDto = null, string $lang = 'en', ): EntryComment { $manager = $this->entryCommentManager; $repository = $this->imageRepository; if ($parent) { $dto = (new EntryCommentDto())->createWithParent( $entry ?? $this->getEntryByTitle('test entry content', 'https://kbin.pub'), $parent, $imageDto ? $repository->find($imageDto->id) : null, $body ); } else { $dto = new EntryCommentDto(); $dto->entry = $entry ?? $this->getEntryByTitle('test entry content', 'https://kbin.pub'); $dto->body = $body; $dto->image = $imageDto; } $dto->lang = $lang; return $manager->create($dto, $user ?? $this->getUserByUsername('JohnDoe')); } public function createPost(string $body, ?Magazine $magazine = null, ?User $user = null, ?ImageDto $imageDto = null, string $lang = 'en'): Post { $manager = $this->postManager; $dto = new PostDto(); $dto->magazine = $magazine ?: $this->getMagazineByName('acme'); $dto->body = $body; $dto->lang = $lang; $dto->image = $imageDto; return $manager->create($dto, $user ?? $this->getUserByUsername('JohnDoe')); } public function createPostComment(string $body, ?Post $post = null, ?User $user = null, ?ImageDto $imageDto = null, ?PostComment $parent = null, string $lang = 'en'): PostComment { $manager = $this->postCommentManager; $dto = new PostCommentDto(); $dto->post = $post ?? $this->createPost('test post content'); $dto->body = $body; $dto->lang = $lang; $dto->image = $imageDto; $dto->parent = $parent; return $manager->create($dto, $user ?? $this->getUserByUsername('JohnDoe')); } public function createPostCommentReply(string $body, ?Post $post = null, ?User $user = null, ?PostComment $parent = null): PostComment { $manager = $this->postCommentManager; $dto = new PostCommentDto(); $dto->post = $post ?? $this->createPost('test post content'); $dto->body = $body; $dto->lang = 'en'; $dto->parent = $parent ?? $this->createPostComment('test parent comment', $dto->post); return $manager->create($dto, $user ?? $this->getUserByUsername('JohnDoe')); } public function createImage(string $fileName): Image { $imageRepo = $this->imageRepository; $image = $imageRepo->findOneBy(['fileName' => $fileName]); if ($image) { return $image; } $image = new Image( $fileName, '/dev/random', hash('sha256', $fileName), 100, 100, null, ); $this->entityManager->persist($image); $this->entityManager->flush(); return $image; } public function createMessageNotification(?User $to = null, ?User $from = null): Notification { $messageManager = $this->messageManager; $repository = $this->notificationRepository; $dto = new MessageDto(); $dto->body = 'test message'; $messageManager->toThread($dto, $from ?? $this->getUserByUsername('JaneDoe'), $to ?? $this->getUserByUsername('JohnDoe')); return $repository->findOneBy(['user' => $to ?? $this->getUserByUsername('JohnDoe')]); } protected function createInstancePages(): Site { $siteRepository = $this->siteRepository; $entityManager = $this->entityManager; $results = $siteRepository->findAll(); $site = null; if (!\count($results)) { $site = new Site(); } else { $site = $results[0]; } $site->about = 'about'; $site->contact = 'contact'; $site->faq = 'faq'; $site->privacyPolicy = 'privacyPolicy'; $site->terms = 'terms'; $entityManager->persist($site); $entityManager->flush(); return $site; } /** * Creates 5 modlog messages, one each of: * * log_entry_deleted * * log_entry_comment_deleted * * log_post_deleted * * log_post_comment_deleted * * log_ban. */ public function createModlogMessages(): void { $magazineManager = $this->magazineManager; $entryManager = $this->entryManager; $entryCommentManager = $this->entryCommentManager; $postManager = $this->postManager; $postCommentManager = $this->postCommentManager; $moderator = $this->getUserByUsername('moderator'); $magazine = $this->getMagazineByName('acme', $moderator); $user = $this->getUserByUsername('user'); $post = $this->createPost('test post', $magazine, $user); $entry = $this->getEntryByTitle('A title', body: 'test entry', magazine: $magazine, user: $user); $postComment = $this->createPostComment('test comment', $post, $user); $entryComment = $this->createEntryComment('test comment 2', $entry, $user); $entryCommentManager->delete($moderator, $entryComment); $entryManager->delete($moderator, $entry); $postCommentManager->delete($moderator, $postComment); $postManager->delete($moderator, $post); $magazineManager->ban($magazine, $user, $moderator, MagazineBanDto::create('test ban', new \DateTimeImmutable('+12 hours'))); } public function register($active = false): KernelBrowser { $crawler = $this->client->request('GET', '/register'); $this->client->submit( $crawler->filter('form[name=user_register]')->selectButton('Register')->form( [ 'user_register[username]' => 'JohnDoe', 'user_register[email]' => 'johndoe@kbin.pub', 'user_register[plainPassword][first]' => 'secret', 'user_register[plainPassword][second]' => 'secret', 'user_register[agreeTerms]' => true, ] ) ); if (302 === $this->client->getResponse()->getStatusCode()) { $this->client->followRedirect(); } self::assertResponseIsSuccessful(); if ($active) { $user = self::getContainer()->get('doctrine')->getRepository(User::class) ->findOneBy(['username' => 'JohnDoe']); $user->isVerified = true; self::getContainer()->get('doctrine')->getManager()->flush(); self::getContainer()->get('doctrine')->getManager()->refresh($user); } return $this->client; } public function getKibbyImageDto(): ImageDto { return $this->getKibbyImageVariantDto(''); } public function getKibbyFlippedImageDto(): ImageDto { return $this->getKibbyImageVariantDto('_flipped'); } private function getKibbyImageVariantDto(string $suffix): ImageDto { $imageRepository = $this->imageRepository; $imageFactory = $this->imageFactory; if (!file_exists(\dirname($this->kibbyPath).'/copy')) { if (!mkdir(\dirname($this->kibbyPath).'/copy')) { throw new \Exception('The copy dir could not be created'); } } // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = \dirname($this->kibbyPath).'/copy/'.bin2hex(random_bytes(32)).'.png'; $srcPath = \dirname($this->kibbyPath).'/'.basename($this->kibbyPath, '.png').$suffix.'.png'; if (!file_exists($srcPath)) { throw new \Exception('For some reason the kibby image got deleted'); } copy($srcPath, $tmpPath); /** @var Image $image */ $image = $imageRepository->findOrCreateFromUpload(new UploadedFile($tmpPath, 'kibby_emoji.png', 'image/png')); self::assertNotNull($image); $image->altText = 'kibby'; $this->entityManager->persist($image); $this->entityManager->flush(); $dto = $imageFactory->createDto($image); assertNotNull($dto->id); return $dto; } } ================================================ FILE: tests/Functional/ActivityPub/ActivityPubFunctionalTestCase.php ================================================ localDomain = $this->settingsManager->get('KBIN_DOMAIN'); $this->setupLocalActors(); $this->switchToRemoteDomain($this->remoteSubDomain); $this->setUpRemoteSubscriber(); $this->entries = new ArrayCollection(); $this->magazines = new ArrayCollection(); $this->users = new ArrayCollection(); $this->switchToLocalDomain(); $this->switchToRemoteDomain($this->remoteDomain); $this->setUpRemoteActors(); $this->setUpRemoteEntities(); $this->entries = new ArrayCollection(); $this->magazines = new ArrayCollection(); $this->users = new ArrayCollection(); $this->switchToLocalDomain(); $this->setUpLocalEntities(); $this->switchToRemoteDomain($this->remoteDomain); $this->setUpLateRemoteEntities(); $this->switchToLocalDomain(); // foreach ($this->entitiesToRemoveAfterSetup as $entity) { // $this->entityManager->remove($entity); // } for ($i = \sizeof($this->entitiesToRemoveAfterSetup) - 1; $i >= 0; --$i) { $this->entityManager->remove($this->entitiesToRemoveAfterSetup[$i]); } $this->entries = new ArrayCollection(); $this->magazines = new ArrayCollection(); $this->users = new ArrayCollection(); $this->entityManager->flush(); $this->entityManager->clear(); $this->remoteSubscriber = $this->activityPubManager->findActorOrCreate("@remoteSubscriber@$this->remoteSubDomain"); $this->remoteSubscriber->publicKey = 'some public key'; $this->remoteMagazine = $this->activityPubManager->findActorOrCreate("!remoteMagazine@$this->remoteDomain"); $this->remoteMagazine->publicKey = 'some public key'; $this->remoteUser = $this->activityPubManager->findActorOrCreate("@remoteUser@$this->remoteDomain"); $this->remoteUser->publicKey = 'some public key'; $this->localMagazine = $this->magazineRepository->findOneByName('magazine'); $this->magazineManager->subscribe($this->localMagazine, $this->remoteSubscriber); self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber)); $this->entityManager->refresh($this->localMagazine); $this->localUser = $this->userRepository->findOneByUsername('user'); } protected function setupLocalActors(): void { $this->localUser = $this->getUserByUsername('user', addImage: false); $this->localMagazine = $this->getMagazineByName('magazine', user: $this->localUser); $this->entityManager->flush(); } abstract public function setUpRemoteEntities(): void; /** * Override this method if you want to set up remote objects depending on you local entities. */ public function setUpLateRemoteEntities(): void { } /** * Override this method if you want to set up additional local entities. */ public function setUpLocalEntities(): void { } protected function setUpRemoteActors(): void { $domain = $this->remoteDomain; $username = 'remoteUser'; $this->remoteUser = $this->getUserByUsername($username, addImage: false); $magazineName = 'remoteMagazine'; $this->remoteMagazine = $this->getMagazineByName($magazineName, user: $this->remoteUser); $this->registerActor($this->remoteMagazine, $domain, true); $this->registerActor($this->remoteUser, $domain, true); } protected function setUpRemoteSubscriber(): void { $domain = $this->remoteSubDomain; $username = 'remoteSubscriber'; $this->remoteSubscriber = $this->getUserByUsername($username, addImage: false); $this->registerActor($this->remoteSubscriber, $domain, true); } protected function registerActor(ActivityPubActorInterface $actor, string $domain, bool $removeAfterSetup = false): void { if ($actor instanceof User) { $json = $this->personFactory->create($actor); } elseif ($actor instanceof Magazine) { $json = $this->groupFactory->create($actor); } else { $class = \get_class($actor); throw new \LogicException("tests do not support actors of type $class"); } $this->testingApHttpClient->actorObjects[$json['id']] = $json; $username = $json['preferredUsername']; $userEvent = new WebfingerResponseEvent(new JsonRd(), "acct:$username@$domain", ['account' => $username]); $this->eventDispatcher->dispatch($userEvent); $realDomain = \sprintf(WebFingerFactory::WEBFINGER_URL, 'https', $domain, '', "$username@$domain"); $this->testingApHttpClient->webfingerObjects[$realDomain] = $userEvent->jsonRd->toArray(); if ($removeAfterSetup) { $this->entitiesToRemoveAfterSetup[] = $actor; } } protected function switchToRemoteDomain($domain): void { $this->prev = $this->settingsManager->get('KBIN_DOMAIN'); $this->settingsManager->set('KBIN_DOMAIN', $domain); $context = $this->router->getContext(); $context->setHost($domain); } protected function switchToLocalDomain(): void { if (null === $this->prev) { return; } $context = $this->router->getContext(); $this->settingsManager->set('KBIN_DOMAIN', $this->prev); $context->setHost($this->prev); $this->prev = null; } /** * @param callable(Entry $entry):void|null $entryCreateCallback */ protected function createRemoteEntryInRemoteMagazine(Magazine $magazine, User $user, ?callable $entryCreateCallback = null): array { $entry = $this->getEntryByTitle('remote entry', magazine: $magazine, user: $user); $json = $this->pageFactory->create($entry, $this->tagLinkRepository->getTagsOfContent($entry)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; $createActivity = $this->createWrapper->build($entry); $create = $this->activityJsonBuilder->buildActivityJson($createActivity); $this->testingApHttpClient->activityObjects[$create['id']] = $create; $announceActivity = $this->announceWrapper->build($magazine, $createActivity); $announce = $this->activityJsonBuilder->buildActivityJson($announceActivity); $this->testingApHttpClient->activityObjects[$announce['id']] = $announce; if (null !== $entryCreateCallback) { $entryCreateCallback($entry); } $this->entitiesToRemoveAfterSetup[] = $announceActivity; $this->entitiesToRemoveAfterSetup[] = $createActivity; $this->entitiesToRemoveAfterSetup[] = $entry; return $announce; } /** * @param callable(EntryComment $entry):void|null $entryCommentCreateCallback */ protected function createRemoteEntryCommentInRemoteMagazine(Magazine $magazine, User $user, ?callable $entryCommentCreateCallback = null): array { $entries = array_filter($this->entitiesToRemoveAfterSetup, fn ($item) => $item instanceof Entry); $entry = $entries[array_key_first($entries)]; $comment = $this->createEntryComment('remote entry comment', $entry, $user); $json = $this->entryCommentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; $createActivity = $this->createWrapper->build($comment); $create = $this->activityJsonBuilder->buildActivityJson($createActivity); $this->testingApHttpClient->activityObjects[$create['id']] = $create; $announceActivity = $this->announceWrapper->build($magazine, $createActivity); $announce = $this->activityJsonBuilder->buildActivityJson($announceActivity); $this->testingApHttpClient->activityObjects[$announce['id']] = $announce; if (null !== $entryCommentCreateCallback) { $entryCommentCreateCallback($comment); } $this->entitiesToRemoveAfterSetup[] = $announceActivity; $this->entitiesToRemoveAfterSetup[] = $createActivity; $this->entitiesToRemoveAfterSetup[] = $comment; return $announce; } /** * @param callable(Post $entry):void|null $postCreateCallback */ protected function createRemotePostInRemoteMagazine(Magazine $magazine, User $user, ?callable $postCreateCallback = null): array { $post = $this->createPost('remote post', magazine: $magazine, user: $user); $json = $this->postNoteFactory->create($post, $this->tagLinkRepository->getTagsOfContent($post)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; $createActivity = $this->createWrapper->build($post); $create = $this->activityJsonBuilder->buildActivityJson($createActivity); $this->testingApHttpClient->activityObjects[$create['id']] = $create; $announceActivity = $this->announceWrapper->build($magazine, $createActivity); $announce = $this->activityJsonBuilder->buildActivityJson($announceActivity); $this->testingApHttpClient->activityObjects[$announce['id']] = $announce; if (null !== $postCreateCallback) { $postCreateCallback($post); } $this->entitiesToRemoveAfterSetup[] = $announceActivity; $this->entitiesToRemoveAfterSetup[] = $createActivity; $this->entitiesToRemoveAfterSetup[] = $post; return $announce; } /** * @param callable(PostComment $entry):void|null $postCommentCreateCallback */ protected function createRemotePostCommentInRemoteMagazine(Magazine $magazine, User $user, ?callable $postCommentCreateCallback = null): array { $posts = array_filter($this->entitiesToRemoveAfterSetup, fn ($item) => $item instanceof Post); $post = $posts[array_key_first($posts)]; $comment = $this->createPostComment('remote post comment', $post, $user); $json = $this->postCommentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; $createActivity = $this->createWrapper->build($comment); $create = $this->activityJsonBuilder->buildActivityJson($createActivity); $this->testingApHttpClient->activityObjects[$create['id']] = $create; $announceActivity = $this->announceWrapper->build($magazine, $createActivity); $announce = $this->activityJsonBuilder->buildActivityJson($announceActivity); $this->testingApHttpClient->activityObjects[$announce['id']] = $announce; if (null !== $postCommentCreateCallback) { $postCommentCreateCallback($comment); } $this->entitiesToRemoveAfterSetup[] = $announceActivity; $this->entitiesToRemoveAfterSetup[] = $createActivity; $this->entitiesToRemoveAfterSetup[] = $comment; return $announce; } /** * @param callable(Entry $entry):void|null $entryCreateCallback */ protected function createRemoteEntryInLocalMagazine(Magazine $magazine, User $user, ?callable $entryCreateCallback = null): array { $entry = $this->getEntryByTitle('remote entry in local', magazine: $magazine, user: $user); $json = $this->pageFactory->create($entry, $this->tagLinkRepository->getTagsOfContent($entry)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; $createActivity = $this->createWrapper->build($entry); $create = $this->activityJsonBuilder->buildActivityJson($createActivity); $this->testingApHttpClient->activityObjects[$create['id']] = $create; $create = $this->RewriteTargetFieldsToLocal($magazine, $create); if (null !== $entryCreateCallback) { $entryCreateCallback($entry); } $this->entitiesToRemoveAfterSetup[] = $createActivity; $this->entitiesToRemoveAfterSetup[] = $entry; return $create; } /** * @param callable(EntryComment $entry):void|null $entryCommentCreateCallback */ protected function createRemoteEntryCommentInLocalMagazine(Magazine $magazine, User $user, ?callable $entryCommentCreateCallback = null): array { $entries = array_filter($this->entitiesToRemoveAfterSetup, fn ($item) => $item instanceof Entry && 'remote entry in local' === $item->title); $entry = $entries[array_key_first($entries)]; $comment = $this->createEntryComment('remote entry comment', $entry, $user); $json = $this->entryCommentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; $createActivity = $this->createWrapper->build($comment); $create = $this->activityJsonBuilder->buildActivityJson($createActivity); $this->testingApHttpClient->activityObjects[$create['id']] = $create; $create = $this->RewriteTargetFieldsToLocal($magazine, $create); if (null !== $entryCommentCreateCallback) { $entryCommentCreateCallback($comment); } $this->entitiesToRemoveAfterSetup[] = $createActivity; $this->entitiesToRemoveAfterSetup[] = $comment; return $create; } /** * @param callable(Post $entry):void|null $postCreateCallback */ protected function createRemotePostInLocalMagazine(Magazine $magazine, User $user, ?callable $postCreateCallback = null): array { $post = $this->createPost('remote post in local', magazine: $magazine, user: $user); $json = $this->postNoteFactory->create($post, $this->tagLinkRepository->getTagsOfContent($post)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; $createActivity = $this->createWrapper->build($post); $create = $this->activityJsonBuilder->buildActivityJson($createActivity); $this->testingApHttpClient->activityObjects[$create['id']] = $create; $create = $this->RewriteTargetFieldsToLocal($magazine, $create); if (null !== $postCreateCallback) { $postCreateCallback($post); } $this->entitiesToRemoveAfterSetup[] = $createActivity; $this->entitiesToRemoveAfterSetup[] = $post; return $create; } /** * @param callable(PostComment $entry):void|null $postCommentCreateCallback */ protected function createRemotePostCommentInLocalMagazine(Magazine $magazine, User $user, ?callable $postCommentCreateCallback = null): array { $posts = array_filter($this->entitiesToRemoveAfterSetup, fn ($item) => $item instanceof Post && 'remote post in local' === $item->body); $post = $posts[array_key_first($posts)]; $comment = $this->createPostComment('remote post comment in local', $post, $user); $json = $this->postCommentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; $createActivity = $this->createWrapper->build($comment); $create = $this->activityJsonBuilder->buildActivityJson($createActivity); $this->testingApHttpClient->activityObjects[$create['id']] = $create; $create = $this->RewriteTargetFieldsToLocal($magazine, $create); if (null !== $postCommentCreateCallback) { $postCommentCreateCallback($comment); } $this->entitiesToRemoveAfterSetup[] = $createActivity; $this->entitiesToRemoveAfterSetup[] = $comment; return $create; } /** * @param callable(Message $entry):void|null $messageCreateCallback */ protected function createRemoteMessage(User $fromRemoteUser, User $toLocalUser, ?callable $messageCreateCallback = null): array { $dto = new MessageDto(); $dto->body = 'remote message'; $thread = $this->messageManager->toThread($dto, $fromRemoteUser, $toLocalUser); $message = $thread->getLastMessage(); $this->entitiesToRemoveAfterSetup[] = $thread; $this->entitiesToRemoveAfterSetup[] = $message; $createActivity = $this->createWrapper->build($message); $create = $this->activityJsonBuilder->buildActivityJson($createActivity); $correctUserString = "https://$this->prev/u/$toLocalUser->username"; $create['to'] = [$correctUserString]; $create['object']['to'] = [$correctUserString]; $this->testingApHttpClient->activityObjects[$create['id']] = $create; if (null !== $messageCreateCallback) { $messageCreateCallback($message); } $this->entitiesToRemoveAfterSetup[] = $createActivity; return $create; } /** * This rewrites the target fields `to` and `audience` to the @see self::$prev domain. * This is useful when remote actors create activities on local magazines. * * @return array the array with rewritten target fields */ protected function RewriteTargetFieldsToLocal(Magazine $magazine, array $activityArray): array { $magazineAddress = "https://$this->prev/m/$magazine->name"; $to = [ $magazineAddress, ActivityPubActivityInterface::PUBLIC_URL, ]; if (isset($activityArray['to'])) { $activityArray['to'] = $to; } if (isset($activityArray['audience'])) { $activityArray['audience'] = $magazineAddress; } if (isset($activityArray['object']) && \is_array($activityArray['object'])) { $activityArray['object'] = $this->RewriteTargetFieldsToLocal($magazine, $activityArray['object']); } return $activityArray; } protected function assertCountOfSentActivitiesOfType(int $expectedCount, string $type): void { $activities = $this->getSentActivitiesOfType($type); $this->assertCount($expectedCount, $activities); } protected function assertOneSentActivityOfType(string $type, ?string $activityId = null, ?string $inboxUrl = null): array { $activities = $this->getSentActivitiesOfType($type); self::assertCount(1, $activities); if (null !== $activityId) { self::assertEquals($activityId, $activities[0]['payload']['id']); } if (null !== $inboxUrl) { self::assertEquals($inboxUrl, $activities[0]['inboxUrl']); } return $activities[0]['payload']; } protected function assertOneSentAnnouncedActivityOfType(string $type, ?string $announcedActivityId = null): void { $activities = $this->getSentAnnounceActivitiesOfInnerType($type); self::assertCount(1, $activities); if (null !== $announcedActivityId) { self::assertEquals($announcedActivityId, $activities[0]['payload']['object']['id']); } } protected function assertOneSentAnnouncedActivityOfTypeGetInnerActivity(string $type, ?string $announcedActivityId = null, ?string $announceId = null, ?string $inboxUrl = null): array|string { $activities = $this->getSentAnnounceActivitiesOfInnerType($type); self::assertCount(1, $activities); if (null !== $announcedActivityId) { self::assertEquals($announcedActivityId, $activities[0]['payload']['object']['id']); } if (null !== $announceId) { self::assertEquals($announceId, $activities[0]['payload']['id']); } if (null !== $inboxUrl) { self::assertEquals($inboxUrl, $activities[0]['inboxUrl']); } return $activities[0]['payload']['object']; } /** * @return array */ protected function getSentActivitiesOfType(string $type): array { return array_values(array_filter($this->testingApHttpClient->getPostedObjects(), fn (array $item) => $type === $item['payload']['type'])); } /** * @return array */ protected function getSentAnnounceActivitiesOfInnerType(string $type): array { return array_values(array_filter($this->testingApHttpClient->getPostedObjects(), fn (array $item) => 'Announce' === $item['payload']['type'] && $type === $item['payload']['object']['type'])); } } ================================================ FILE: tests/Functional/ActivityPub/Inbox/AcceptHandlerTest.php ================================================ remoteUser->apManuallyApprovesFollowers = true; $this->userManager->follow($this->localUser, $this->remoteUser); } public function setUpLocalEntities(): void { $followActivity = $this->followWrapper->build($this->localUser, $this->remoteUser); $this->followRemoteUser = $this->activityJsonBuilder->buildActivityJson($followActivity); $this->testingApHttpClient->activityObjects[$this->followRemoteUser['id']] = $this->followRemoteUser; $this->entitiesToRemoveAfterSetup[] = $followActivity; $this->magazineManager->subscribe($this->remoteMagazine, $this->localUser); $followActivity = $this->followWrapper->build($this->localUser, $this->remoteMagazine); $this->followRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($followActivity); $this->testingApHttpClient->activityObjects[$this->followRemoteMagazine['id']] = $this->followRemoteMagazine; $this->entitiesToRemoveAfterSetup[] = $followActivity; } public function setUpRemoteEntities(): void { } public function setUpLateRemoteEntities(): void { $acceptActivity = $this->followResponseWrapper->build($this->remoteUser, $this->followRemoteUser); $this->acceptFollowRemoteUser = $this->activityJsonBuilder->buildActivityJson($acceptActivity); $this->testingApHttpClient->activityObjects[$this->acceptFollowRemoteUser['id']] = $this->acceptFollowRemoteUser; $this->entitiesToRemoveAfterSetup[] = $acceptActivity; $acceptActivity = $this->followResponseWrapper->build($this->remoteMagazine, $this->followRemoteMagazine); $this->acceptFollowRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($acceptActivity); $this->testingApHttpClient->activityObjects[$this->acceptFollowRemoteMagazine['id']] = $this->acceptFollowRemoteMagazine; $this->entitiesToRemoveAfterSetup[] = $acceptActivity; } public function testAcceptFollowMagazine(): void { // we do not have manual follower approving for magazines implemented $this->bus->dispatch(new ActivityMessage(json_encode($this->acceptFollowRemoteMagazine))); } public function testAcceptFollowUser(): void { self::assertTrue($this->remoteUser->apManuallyApprovesFollowers); $request = $this->userFollowRequestRepository->findOneby(['follower' => $this->localUser, 'following' => $this->remoteUser]); self::assertNotNull($request); $this->bus->dispatch(new ActivityMessage(json_encode($this->acceptFollowRemoteUser))); $request = $this->userFollowRequestRepository->findOneby(['follower' => $this->localUser, 'following' => $this->remoteUser]); self::assertNull($request); } } ================================================ FILE: tests/Functional/ActivityPub/Inbox/AddHandlerTest.php ================================================ magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->remoteUser, $this->remoteMagazine->getOwner())); $this->magazineManager->addModerator(new ModeratorDto($this->localMagazine, $this->remoteUser, $this->localUser)); } public function testAddModeratorInRemoteMagazine(): void { self::assertFalse($this->remoteMagazine->userIsModerator($this->remoteSubscriber)); $this->bus->dispatch(new ActivityMessage(json_encode($this->addModeratorRemoteMagazine))); self::assertTrue($this->remoteMagazine->userIsModerator($this->remoteSubscriber)); } public function testAddModeratorLocalMagazine(): void { self::assertFalse($this->localMagazine->userIsModerator($this->remoteSubscriber)); $this->bus->dispatch(new ActivityMessage(json_encode($this->addModeratorLocalMagazine))); self::assertTrue($this->localMagazine->userIsModerator($this->remoteSubscriber)); $this->assertAddSentToSubscriber($this->addModeratorLocalMagazine); } public function testAddPinnedEntryInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine))); $entry = $this->entryRepository->findOneBy(['apId' => $this->createRemoteEntryInRemoteMagazine['object']['object']['id']]); self::assertNotNull($entry); self::assertFalse($entry->sticky); $this->bus->dispatch(new ActivityMessage(json_encode($this->addPinnedEntryRemoteMagazine))); $this->entityManager->refresh($entry); self::assertTrue($entry->sticky); } public function testAddPinnedEntryLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine))); $entry = $this->entryRepository->findOneBy(['apId' => $this->createRemoteEntryInLocalMagazine['object']['id']]); self::assertNotNull($entry); self::assertFalse($entry->sticky); $this->bus->dispatch(new ActivityMessage(json_encode($this->addPinnedEntryLocalMagazine))); $this->entityManager->refresh($entry); self::assertTrue($entry->sticky); $this->assertAddSentToSubscriber($this->addPinnedEntryLocalMagazine); } public function setUpRemoteEntities(): void { $this->buildAddModeratorInRemoteMagazine(); $this->buildAddModeratorInLocalMagazine(); $this->createRemoteEntryInRemoteMagazine = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildAddPinnedPostInRemoteMagazine($entry)); $this->createRemoteEntryInLocalMagazine = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildAddPinnedPostInLocalMagazine($entry)); } private function buildAddModeratorInRemoteMagazine(): void { $addActivity = $this->addRemoveFactory->buildAddModerator($this->remoteUser, $this->remoteSubscriber, $this->remoteMagazine); $this->addModeratorRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($addActivity); $this->addModeratorRemoteMagazine['object'] = 'https://remote.sub.mbin/u/remoteSubscriber'; $this->testingApHttpClient->activityObjects[$this->addModeratorRemoteMagazine['id']] = $this->addModeratorRemoteMagazine; $this->entitiesToRemoveAfterSetup[] = $addActivity; } private function buildAddModeratorInLocalMagazine(): void { $addActivity = $this->addRemoveFactory->buildAddModerator($this->remoteUser, $this->remoteSubscriber, $this->localMagazine); $this->addModeratorLocalMagazine = $this->activityJsonBuilder->buildActivityJson($addActivity); $this->addModeratorLocalMagazine['target'] = 'https://kbin.test/m/magazine/moderators'; $this->addModeratorLocalMagazine['object'] = 'https://remote.sub.mbin/u/remoteSubscriber'; $this->testingApHttpClient->activityObjects[$this->addModeratorLocalMagazine['id']] = $this->addModeratorLocalMagazine; $this->entitiesToRemoveAfterSetup[] = $addActivity; } private function buildAddPinnedPostInRemoteMagazine(Entry $entry): void { $addActivity = $this->addRemoveFactory->buildAddPinnedPost($this->remoteUser, $entry); $this->addPinnedEntryRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($addActivity); $this->testingApHttpClient->activityObjects[$this->addPinnedEntryRemoteMagazine['id']] = $this->addPinnedEntryRemoteMagazine; $this->entitiesToRemoveAfterSetup[] = $addActivity; } private function buildAddPinnedPostInLocalMagazine(Entry $entry): void { $addActivity = $this->addRemoveFactory->buildAddPinnedPost($this->remoteUser, $entry); $this->addPinnedEntryLocalMagazine = $this->activityJsonBuilder->buildActivityJson($addActivity); $this->addPinnedEntryLocalMagazine['target'] = 'https://kbin.test/m/magazine/pinned'; $this->testingApHttpClient->activityObjects[$this->addPinnedEntryLocalMagazine['id']] = $this->addPinnedEntryLocalMagazine; $this->entitiesToRemoveAfterSetup[] = $addActivity; } private function assertAddSentToSubscriber(array $originalPayload): void { $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedAddAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Add' === $arr['payload']['object']['type']); $postedAddAnnounce = $postedAddAnnounces[array_key_first($postedAddAnnounces)]; // the id of the 'Add' activity should be wrapped in an 'Announce' activity self::assertEquals($originalPayload['id'], $postedAddAnnounce['payload']['object']['id']); self::assertEquals($originalPayload['object'], $postedAddAnnounce['payload']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedAddAnnounce['inboxUrl']); } } ================================================ FILE: tests/Functional/ActivityPub/Inbox/BlockHandlerTest.php ================================================ magazineManager->addModerator(new ModeratorDto($this->localMagazine, $this->remoteSubscriber, $this->localUser)); $this->remoteAdmin = $this->activityPubManager->findActorOrCreate("@remoteAdmin@$this->remoteDomain"); $this->magazineManager->subscribe($this->localMagazine, $this->remoteUser); } protected function setUpRemoteActors(): void { parent::setUpRemoteActors(); $user = $this->getUserByUsername('remoteAdmin', addImage: false); $this->registerActor($user, $this->remoteDomain, true); $this->remoteAdmin = $user; } public function setupLocalActors(): void { $this->localSubscriber = $this->getUserByUsername('localSubscriber', addImage: false); parent::setupLocalActors(); } public function setUpRemoteEntities(): void { $this->buildBlockLocalUserLocalMagazine(); $this->buildBlockLocalUserRemoteMagazine(); $this->buildBlockRemoteUserLocalMagazine(); $this->buildBlockRemoteUserRemoteMagazine(); $this->buildInstanceBanRemoteUser(); } public function testBlockLocalUserLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->blockLocalUserLocalMagazine))); $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->localMagazine, 'user' => $this->localSubscriber]); self::assertNotNull($ban); $this->entityManager->refresh($this->localMagazine); self::assertTrue($this->localMagazine->isBanned($this->localSubscriber)); // should not be sent to source instance, only to subscriber instance $announcedBlock = $this->assertOneSentAnnouncedActivityOfTypeGetInnerActivity('Block', announcedActivityId: $this->blockLocalUserLocalMagazine['id'], inboxUrl: $this->remoteUser->apInboxUrl); self::assertEquals($this->blockLocalUserLocalMagazine['object'], $announcedBlock['object']); } #[Depends('testBlockLocalUserLocalMagazine')] public function testUndoBlockLocalUserLocalMagazine(): void { $this->testBlockLocalUserLocalMagazine(); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoBlockLocalUserLocalMagazine))); $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->localMagazine, 'user' => $this->localSubscriber]); self::assertNotNull($ban); $this->entityManager->refresh($this->localMagazine); self::assertFalse($this->localMagazine->isBanned($this->localSubscriber)); // should not be sent to source instance, only to subscriber instance $announcedUndo = $this->assertOneSentAnnouncedActivityOfTypeGetInnerActivity('Undo', announcedActivityId: $this->undoBlockLocalUserLocalMagazine['id'], inboxUrl: $this->remoteUser->apInboxUrl); self::assertEquals($this->undoBlockLocalUserLocalMagazine['object']['object'], $announcedUndo['object']['object']); self::assertEquals($this->undoBlockLocalUserLocalMagazine['object']['id'], $announcedUndo['object']['id']); } public function testBlockRemoteUserLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->blockRemoteUserLocalMagazine))); $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->localMagazine, 'user' => $this->remoteUser]); self::assertNotNull($ban); $this->entityManager->refresh($this->localMagazine); self::assertTrue($this->localMagazine->isBanned($this->remoteUser)); // should not be sent to source instance, only to subscriber instance $blockActivity = $this->assertOneSentAnnouncedActivityOfTypeGetInnerActivity('Block', announcedActivityId: $this->blockRemoteUserLocalMagazine['id'], inboxUrl: $this->remoteUser->apInboxUrl); self::assertEquals($this->blockRemoteUserLocalMagazine['object'], $blockActivity['object']); } #[Depends('testBlockRemoteUserLocalMagazine')] public function testUndoBlockRemoteUserLocalMagazine(): void { $this->testBlockRemoteUserLocalMagazine(); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoBlockRemoteUserLocalMagazine))); $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->localMagazine, 'user' => $this->remoteUser]); self::assertNotNull($ban); $this->entityManager->refresh($this->localMagazine); self::assertFalse($this->localMagazine->isBanned($this->remoteUser)); // should not be sent to source instance, only to subscriber instance $announcedUndo = $this->assertOneSentAnnouncedActivityOfTypeGetInnerActivity('Undo', announcedActivityId: $this->undoBlockRemoteUserLocalMagazine['id'], inboxUrl: $this->remoteUser->apInboxUrl); self::assertEquals($this->undoBlockRemoteUserLocalMagazine['object']['id'], $announcedUndo['object']['id']); self::assertEquals($this->undoBlockRemoteUserLocalMagazine['object']['object'], $announcedUndo['object']['object']); } public function testBlockLocalUserRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->blockLocalUserRemoteMagazine))); $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->remoteMagazine, 'user' => $this->localSubscriber]); self::assertNotNull($ban); $this->entityManager->refresh($this->remoteMagazine); self::assertTrue($this->remoteMagazine->isBanned($this->localSubscriber)); } #[Depends('testBlockLocalUserRemoteMagazine')] public function testUndoBlockLocalUserRemoteMagazine(): void { $this->testBlockLocalUserRemoteMagazine(); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoBlockLocalUserRemoteMagazine))); $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->remoteMagazine, 'user' => $this->localSubscriber]); self::assertNotNull($ban); $this->entityManager->refresh($this->remoteMagazine); self::assertFalse($this->remoteMagazine->isBanned($this->localSubscriber)); } public function testBlockRemoteUserRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->blockRemoteUserRemoteMagazine))); $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->remoteMagazine, 'user' => $this->remoteSubscriber]); self::assertNotNull($ban); $this->entityManager->refresh($this->remoteMagazine); self::assertTrue($this->remoteMagazine->isBanned($this->remoteSubscriber)); } #[Depends('testBlockRemoteUserRemoteMagazine')] public function testUndoBlockRemoteUserRemoteMagazine(): void { $this->testBlockRemoteUserRemoteMagazine(); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoBlockRemoteUserRemoteMagazine))); $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->remoteMagazine, 'user' => $this->remoteSubscriber]); self::assertNotNull($ban); $this->entityManager->refresh($this->remoteMagazine); self::assertFalse($this->remoteMagazine->isBanned($this->remoteSubscriber)); } public function testInstanceBanRemoteUser(): void { $username = "@remoteUser@$this->remoteDomain"; $remoteUser = $this->userRepository->findOneByUsername($username); self::assertFalse($remoteUser->isBanned); $this->bus->dispatch(new ActivityMessage(json_encode($this->instanceBanRemoteUser))); $this->entityManager->refresh($remoteUser); self::assertTrue($remoteUser->isBanned); self::assertEquals('testing', $remoteUser->banReason); } #[Depends('testInstanceBanRemoteUser')] public function testUndoInstanceBanRemoteUser(): void { $this->testInstanceBanRemoteUser(); $username = "@remoteUser@$this->remoteDomain"; $remoteUser = $this->userRepository->findOneByUsername($username); self::assertTrue($remoteUser->isBanned); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoInstanceBanRemoteUser))); $this->entityManager->refresh($remoteUser); self::assertFalse($remoteUser->isBanned); } private function buildBlockLocalUserLocalMagazine(): void { $ban = $this->magazineManager->ban($this->localMagazine, $this->localSubscriber, $this->remoteSubscriber, MagazineBanDto::create('testing')); $activity = $this->blockFactory->createActivityFromMagazineBan($ban); $this->blockLocalUserLocalMagazine = $this->activityJsonBuilder->buildActivityJson($activity); $this->blockLocalUserLocalMagazine['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->blockLocalUserLocalMagazine['actor']); $this->blockLocalUserLocalMagazine['object'] = str_replace($this->remoteDomain, $this->localDomain, $this->blockLocalUserLocalMagazine['object']); $this->blockLocalUserLocalMagazine['target'] = str_replace($this->remoteDomain, $this->localDomain, $this->blockLocalUserLocalMagazine['target']); $undoActivity = $this->undoWrapper->build($activity); $this->undoBlockLocalUserLocalMagazine = $this->activityJsonBuilder->buildActivityJson($undoActivity); $this->undoBlockLocalUserLocalMagazine['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->undoBlockLocalUserLocalMagazine['actor']); $this->undoBlockLocalUserLocalMagazine['object'] = $this->blockLocalUserLocalMagazine; $this->testingApHttpClient->activityObjects[$this->blockLocalUserLocalMagazine['id']] = $this->blockLocalUserLocalMagazine; $this->testingApHttpClient->activityObjects[$this->undoBlockLocalUserLocalMagazine['id']] = $this->undoBlockLocalUserLocalMagazine; $this->entitiesToRemoveAfterSetup[] = $undoActivity; $this->entitiesToRemoveAfterSetup[] = $activity; } private function buildBlockRemoteUserLocalMagazine(): void { $ban = $this->magazineManager->ban($this->localMagazine, $this->remoteUser, $this->remoteSubscriber, MagazineBanDto::create('testing')); $activity = $this->blockFactory->createActivityFromMagazineBan($ban); $this->blockRemoteUserLocalMagazine = $this->activityJsonBuilder->buildActivityJson($activity); $this->blockRemoteUserLocalMagazine['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->blockRemoteUserLocalMagazine['actor']); $this->blockRemoteUserLocalMagazine['target'] = str_replace($this->remoteDomain, $this->localDomain, $this->blockRemoteUserLocalMagazine['target']); $undoActivity = $this->undoWrapper->build($activity); $this->undoBlockRemoteUserLocalMagazine = $this->activityJsonBuilder->buildActivityJson($undoActivity); $this->undoBlockRemoteUserLocalMagazine['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->undoBlockRemoteUserLocalMagazine['actor']); $this->undoBlockRemoteUserLocalMagazine['object'] = $this->blockRemoteUserLocalMagazine; $this->testingApHttpClient->activityObjects[$this->blockRemoteUserLocalMagazine['id']] = $this->blockRemoteUserLocalMagazine; $this->testingApHttpClient->activityObjects[$this->undoBlockRemoteUserLocalMagazine['id']] = $this->undoBlockRemoteUserLocalMagazine; $this->entitiesToRemoveAfterSetup[] = $undoActivity; $this->entitiesToRemoveAfterSetup[] = $activity; } private function buildBlockLocalUserRemoteMagazine(): void { $ban = $this->magazineManager->ban($this->remoteMagazine, $this->localSubscriber, $this->remoteSubscriber, MagazineBanDto::create('testing')); $activity = $this->blockFactory->createActivityFromMagazineBan($ban); $this->blockLocalUserRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($activity); $this->blockLocalUserRemoteMagazine['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->blockLocalUserRemoteMagazine['actor']); $this->blockLocalUserRemoteMagazine['object'] = str_replace($this->remoteDomain, $this->localDomain, $this->blockLocalUserRemoteMagazine['object']); $undoActivity = $this->undoWrapper->build($activity); $this->undoBlockLocalUserRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($undoActivity); $this->undoBlockLocalUserRemoteMagazine['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->undoBlockLocalUserRemoteMagazine['actor']); $this->undoBlockLocalUserRemoteMagazine['object'] = $this->blockLocalUserRemoteMagazine; $this->testingApHttpClient->activityObjects[$this->blockLocalUserRemoteMagazine['id']] = $this->blockLocalUserRemoteMagazine; $this->testingApHttpClient->activityObjects[$this->undoBlockLocalUserRemoteMagazine['id']] = $this->undoBlockLocalUserRemoteMagazine; $this->entitiesToRemoveAfterSetup[] = $undoActivity; $this->entitiesToRemoveAfterSetup[] = $activity; } private function buildBlockRemoteUserRemoteMagazine(): void { $ban = $this->magazineManager->ban($this->remoteMagazine, $this->remoteSubscriber, $this->remoteUser, MagazineBanDto::create('testing')); $activity = $this->blockFactory->createActivityFromMagazineBan($ban); $this->blockRemoteUserRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($activity); $this->blockRemoteUserRemoteMagazine['object'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->blockRemoteUserRemoteMagazine['object']); $undoActivity = $this->undoWrapper->build($activity); $this->undoBlockRemoteUserRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($undoActivity); $this->undoBlockRemoteUserRemoteMagazine['object'] = $this->blockRemoteUserRemoteMagazine; $this->testingApHttpClient->activityObjects[$this->blockRemoteUserRemoteMagazine['id']] = $this->blockRemoteUserRemoteMagazine; $this->testingApHttpClient->activityObjects[$this->undoBlockRemoteUserRemoteMagazine['id']] = $this->undoBlockRemoteUserRemoteMagazine; $this->entitiesToRemoveAfterSetup[] = $undoActivity; $this->entitiesToRemoveAfterSetup[] = $activity; } private function buildInstanceBanRemoteUser(): void { $this->remoteUser->banReason = 'testing'; $activity = $this->blockFactory->createActivityFromInstanceBan($this->remoteUser, $this->remoteAdmin); $this->instanceBanRemoteUser = $this->activityJsonBuilder->buildActivityJson($activity); $this->testingApHttpClient->activityObjects[$this->instanceBanRemoteUser['id']] = $this->instanceBanRemoteUser; $this->entitiesToRemoveAfterSetup[] = $activity; $activity = $this->undoWrapper->build($activity); $this->undoInstanceBanRemoteUser = $this->activityJsonBuilder->buildActivityJson($activity); $this->testingApHttpClient->activityObjects[$this->undoInstanceBanRemoteUser['id']] = $this->undoInstanceBanRemoteUser; $this->entitiesToRemoveAfterSetup[] = $activity; } } ================================================ FILE: tests/Functional/ActivityPub/Inbox/CreateHandlerTest.php ================================================ announceEntry = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser); $this->announceEntryComment = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser); $this->announcePost = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remoteUser); $this->announcePostComment = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser); $this->createEntry = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser); $this->createEntryWithUrlAndImage = $this->createRemoteEntryWithUrlAndImageInLocalMagazine($this->localMagazine, $this->remoteUser); $this->createEntryComment = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remoteUser); $this->createPost = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser); $this->createPostComment = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remoteUser); $this->createMessage = $this->createRemoteMessage($this->remoteUser, $this->localUser); $this->setupMastodonPost(); $this->setupMastodonPostWithoutTagArray(); } public function setUpLocalEntities(): void { $this->setupRemoteActor(); } public function testCreateAnnouncedEntry(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry))); $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]); self::assertNotNull($entry); } #[Depends('testCreateAnnouncedEntry')] public function testCreateAnnouncedEntryComment(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry))); $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntryComment))); $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]); self::assertNotNull($entryComment); } #[Depends('testCreateAnnouncedEntryComment')] public function testCannotCreateAnnouncedEntryCommentInLockedEntry(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry))); $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]); self::assertNotNull($entry); $entry->isLocked = true; $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntryComment))); $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]); // the comment should not be created and therefore be null self::assertNull($entryComment); } public function testCreateAnnouncedPost(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost))); $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]); self::assertNotNull($post); } #[Depends('testCreateAnnouncedPost')] public function testCreateAnnouncedPostComment(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost))); $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePostComment))); $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]); self::assertNotNull($postComment); } #[Depends('testCreateAnnouncedPostComment')] public function testCannotCreateAnnouncedPostCommentInLockedPost(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost))); $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]); self::assertNotNull($post); $post->isLocked = true; $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePostComment))); $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]); // the comment should not be created and therefore be null self::assertNull($postComment); } public function testCreateEntry(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry))); $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]); self::assertNotNull($entry); self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber)); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); // the id of the 'Create' activity should be wrapped in a 'Announce' activity self::assertEquals($this->createEntry['id'], $postedObjects[0]['payload']['object']['id']); self::assertEquals($this->createEntry['object']['id'], $postedObjects[0]['payload']['object']['object']['id']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedObjects[0]['inboxUrl']); } public function testCreateEntryWithUrlAndImage(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryWithUrlAndImage))); $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntryWithUrlAndImage['object']['id']]); self::assertNotNull($entry); self::assertNotNull($entry->image); self::assertNotNull($entry->url); self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber)); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); // the id of the 'Create' activity should be wrapped in a 'Announce' activity self::assertEquals($this->createEntryWithUrlAndImage['id'], $postedObjects[0]['payload']['object']['id']); self::assertEquals($this->createEntryWithUrlAndImage['object']['id'], $postedObjects[0]['payload']['object']['object']['id']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedObjects[0]['inboxUrl']); } #[Depends('testCreateEntry')] public function testCreateEntryComment(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry))); $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]); self::assertNotNull($entry); $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryComment))); $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]); self::assertNotNull($entryComment); self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber)); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertCount(2, $postedObjects); // the id of the 'Create' activity should be wrapped in a 'Announce' activity self::assertEquals($this->createEntryComment['id'], $postedObjects[1]['payload']['object']['id']); self::assertEquals($this->createEntryComment['object']['id'], $postedObjects[1]['payload']['object']['object']['id']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedObjects[1]['inboxUrl']); } public function testCreatePost(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost))); $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]); self::assertNotNull($post); self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber)); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); // the id of the 'Create' activity should be wrapped in a 'Announce' activity self::assertEquals($this->createPost['id'], $postedObjects[0]['payload']['object']['id']); self::assertEquals($this->createPost['object']['id'], $postedObjects[0]['payload']['object']['object']['id']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedObjects[0]['inboxUrl']); } #[Depends('testCreatePost')] public function testCreatePostComment(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost))); $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]); self::assertNotNull($post); $this->bus->dispatch(new ActivityMessage(json_encode($this->createPostComment))); $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]); self::assertNotNull($postComment); self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber)); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertCount(2, $postedObjects); // the id of the 'Create' activity should be wrapped in a 'Announce' activity self::assertEquals($this->createPostComment['id'], $postedObjects[1]['payload']['object']['id']); self::assertEquals($this->createPostComment['object']['id'], $postedObjects[1]['payload']['object']['object']['id']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedObjects[1]['inboxUrl']); } public function testCreateMessage(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createMessage))); $message = $this->messageRepository->findOneBy(['apId' => $this->createMessage['object']['id']]); self::assertNotNull($message); } public function testCreateMessageFollowersOnlyFails(): void { $this->localUser->directMessageSetting = EDirectMessageSettings::FollowersOnly->value; self::expectException(HandlerFailedException::class); $this->bus->dispatch(new ActivityMessage(json_encode($this->createMessage))); } public function testCreateMessageFollowersOnly(): void { $this->localUser->directMessageSetting = EDirectMessageSettings::FollowersOnly->value; $this->userManager->follow($this->remoteUser, $this->localUser); $this->bus->dispatch(new ActivityMessage(json_encode($this->createMessage))); $message = $this->messageRepository->findOneBy(['apId' => $this->createMessage['object']['id']]); self::assertNotNull($message); } public function testCreateMessageNobodyFails(): void { $this->localUser->directMessageSetting = EDirectMessageSettings::Nobody->value; $this->userManager->follow($this->remoteUser, $this->localUser); self::expectException(HandlerFailedException::class); $this->bus->dispatch(new ActivityMessage(json_encode($this->createMessage))); } public function testMastodonMentionInPost(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createMastodonPostWithMention))); $post = $this->postRepository->findOneBy(['apId' => $this->createMastodonPostWithMention['object']['id']]); self::assertNotNull($post); $mentions = $this->mentionManager->extract($post->body); self::assertCount(3, $mentions); self::assertEquals('@someOtherUser@some.instance.tld', $mentions[0]); self::assertEquals('@someUser@some.instance.tld', $mentions[1]); self::assertEquals('@someMagazine@some.instance.tld', $mentions[2]); } public function testMastodonMentionInPostWithoutTagArray(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createMastodonPostWithMentionWithoutTagArray))); $post = $this->postRepository->findOneBy(['apId' => $this->createMastodonPostWithMentionWithoutTagArray['object']['id']]); self::assertNotNull($post); $mentions = $this->mentionManager->extract($post->body); self::assertCount(1, $mentions); self::assertEquals('@remoteUser@remote.mbin', $mentions[0]); } private function setupRemoteActor(): void { $domain = 'some.instance.tld'; $this->switchToRemoteDomain($domain); $user = $this->getUserByUsername('someOtherUser', addImage: false, email: 'user@some.tld'); $this->registerActor($user, $domain, true); $user = $this->getUserByUsername('someUser', addImage: false, email: 'user2@some.tld'); $this->registerActor($user, $domain, true); $magazine = $this->getMagazineByName('someMagazine', user: $user); $this->registerActor($magazine, $domain, true); $this->switchToLocalDomain(); } private function setupMastodonPost(): void { $this->createMastodonPostWithMention = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser); unset($this->createMastodonPostWithMention['object']['source']); // this is what it would look like if a user created a post in Mastodon with just a single mention and nothing else $text = '

    @someOtherUser @someUser @someMagazine

    '; $this->createMastodonPostWithMention['object']['contentMap']['en'] = $text; $this->createMastodonPostWithMention['object']['content'] = $text; $this->createMastodonPostWithMention['object']['tag'] = [ [ 'type' => 'Mention', 'href' => 'https://some.instance.tld/u/someOtherUser', 'name' => '@someOtherUser', ], [ 'type' => 'Mention', 'href' => 'https://some.instance.tld/u/someUser', 'name' => '@someUser', ], [ 'type' => 'Mention', 'href' => 'https://some.instance.tld/m/someMagazine', 'name' => '@someMagazine', ], ]; } private function setupMastodonPostWithoutTagArray(): void { $this->createMastodonPostWithMentionWithoutTagArray = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser); unset($this->createMastodonPostWithMentionWithoutTagArray['object']['source']); // this is what it would look like if a user created a post in Mastodon with just a single mention and nothing else $text = '

    @remoteUser'; $this->createMastodonPostWithMentionWithoutTagArray['object']['contentMap']['en'] = $text; $this->createMastodonPostWithMentionWithoutTagArray['object']['content'] = $text; } private function createRemoteEntryWithUrlAndImageInLocalMagazine(Magazine $magazine, User $user): array { $entry = $this->getEntryByTitle('remote entry with URL and image in local', url: 'https://joinmbin.org', magazine: $magazine, user: $user, image: $this->getKibbyImageDto()); $json = $this->pageFactory->create($entry, $this->tagLinkRepository->getTagsOfContent($entry)); $this->testingApHttpClient->activityObjects[$json['id']] = $json; $createActivity = $this->createWrapper->build($entry); $create = $this->activityJsonBuilder->buildActivityJson($createActivity); $this->testingApHttpClient->activityObjects[$create['id']] = $create; $create = $this->RewriteTargetFieldsToLocal($magazine, $create); $this->entitiesToRemoveAfterSetup[] = $createActivity; $this->entitiesToRemoveAfterSetup[] = $entry; return $create; } } ================================================ FILE: tests/Functional/ActivityPub/Inbox/DeleteHandlerTest.php ================================================ createLocalEntryAndCreateDeleteActivity($this->localMagazine, $this->localUser, $this->remoteUser); $activity = $obj['activity']; $entry = $obj['content']; $this->bus->dispatch(new ActivityMessage(json_encode($activity))); $this->entityManager->refresh($entry); self::assertTrue($entry->isTrashed()); $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']); } public function testDeleteLocalEntryInRemoteMagazineByRemoteModerator(): void { $obj = $this->createLocalEntryAndCreateDeleteActivity($this->remoteMagazine, $this->localUser, $this->remoteUser); $activity = $obj['activity']; $entry = $obj['content']; $this->bus->dispatch(new ActivityMessage(json_encode($activity))); $this->entityManager->refresh($entry); self::assertTrue($entry->isTrashed()); $this->assertCountOfSentActivitiesOfType(0, 'Delete'); $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']); } public function testDeleteRemoteEntryInLocalMagazineByRemoteModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine))); $entryApId = $this->createRemoteEntryInLocalMagazine['object']['id']; $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertNotNull($entry); $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemoteEntryByRemoteModeratorInLocalMagazine))); $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertTrue($entry->isTrashed()); $this->assertOneSentAnnouncedActivityOfType('Delete', $this->deleteRemoteEntryByRemoteModeratorInLocalMagazine['id']); } public function testDeleteRemoteEntryInRemoteMagazineByRemoteModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine))); $entryApId = $this->createRemoteEntryInRemoteMagazine['object']['object']['id']; $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertNotNull($entry); $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemoteEntryByRemoteModeratorInRemoteMagazine))); $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertTrue($entry->isTrashed()); $this->assertCountOfSentActivitiesOfType(0, 'Delete'); $this->assertCountOfSentActivitiesOfType(0, 'Announce'); $deleteActivities = $this->activityRepository->findBy(['type' => 'Delete']); self::assertEmpty($deleteActivities); } public function testDeleteLocalEntryCommentInLocalMagazineByRemoteModerator(): void { $obj = $this->createLocalEntryCommentAndCreateDeleteActivity($this->localMagazine, $this->localUser, $this->remoteUser); $activity = $obj['activity']; $entryComment = $obj['content']; $this->bus->dispatch(new ActivityMessage(json_encode($activity))); $this->entityManager->refresh($entryComment); self::assertTrue($entryComment->isTrashed()); $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']); } public function testDeleteLocalEntryCommentInRemoteMagazineByRemoteModerator(): void { $obj = $this->createLocalEntryCommentAndCreateDeleteActivity($this->remoteMagazine, $this->localUser, $this->remoteUser); $activity = $obj['activity']; $entryComment = $obj['content']; $this->bus->dispatch(new ActivityMessage(json_encode($activity))); $this->entityManager->refresh($entryComment); self::assertTrue($entryComment->isTrashed()); $this->assertCountOfSentActivitiesOfType(0, 'Delete'); $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']); } public function testDeleteRemoteEntryCommentInLocalMagazineByRemoteModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine))); $entryApId = $this->createRemoteEntryInLocalMagazine['object']['id']; $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); assertNotNull($entry); $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryCommentInLocalMagazine))); $entryCommentApId = $this->createRemoteEntryCommentInLocalMagazine['object']['id']; $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]); self::assertNotNull($entryComment); $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemoteEntryCommentByRemoteModeratorInLocalMagazine))); $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]); self::assertTrue($entryComment->isTrashed()); $this->assertOneSentAnnouncedActivityOfType('Delete', $this->deleteRemoteEntryCommentByRemoteModeratorInLocalMagazine['id']); } public function testDeleteRemoteEntryCommentInRemoteMagazineByRemoteModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine))); $entryApId = $this->createRemoteEntryInRemoteMagazine['object']['object']['id']; $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertNotNull($entry); $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryCommentInRemoteMagazine))); $entryCommentApId = $this->createRemoteEntryCommentInRemoteMagazine['object']['object']['id']; $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]); self::assertNotNull($entryComment); $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemoteEntryCommentByRemoteModeratorInRemoteMagazine))); $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]); self::assertTrue($entryComment->isTrashed()); $this->assertCountOfSentActivitiesOfType(0, 'Delete'); $this->assertCountOfSentActivitiesOfType(0, 'Announce'); $deleteActivities = $this->activityRepository->findBy(['type' => 'Delete']); self::assertEmpty($deleteActivities); } public function testDeleteLocalPostInLocalMagazineByRemoteModerator(): void { $obj = $this->createLocalPostAndCreateDeleteActivity($this->localMagazine, $this->localUser, $this->remoteUser); $activity = $obj['activity']; $post = $obj['content']; $this->bus->dispatch(new ActivityMessage(json_encode($activity))); $this->entityManager->refresh($post); self::assertTrue($post->isTrashed()); $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']); } public function testDeleteLocalPostInRemoteMagazineByRemoteModerator(): void { $obj = $this->createLocalPostAndCreateDeleteActivity($this->remoteMagazine, $this->localUser, $this->remoteUser); $activity = $obj['activity']; $post = $obj['content']; $this->bus->dispatch(new ActivityMessage(json_encode($activity))); $this->entityManager->refresh($post); self::assertTrue($post->isTrashed()); $this->assertCountOfSentActivitiesOfType(0, 'Delete'); $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']); } public function testDeleteRemotePostInLocalMagazineByRemoteModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInLocalMagazine))); $postApId = $this->createRemotePostInLocalMagazine['object']['id']; $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertNotNull($post); $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemotePostByRemoteModeratorInLocalMagazine))); $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertTrue($post->isTrashed()); $this->assertOneSentAnnouncedActivityOfType('Delete', $this->deleteRemotePostByRemoteModeratorInLocalMagazine['id']); } public function testDeleteRemotePostInRemoteMagazineByRemoteModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInRemoteMagazine))); $postApId = $this->createRemotePostInRemoteMagazine['object']['object']['id']; $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertNotNull($post); $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemotePostByRemoteModeratorInRemoteMagazine))); $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertTrue($post->isTrashed()); $this->assertCountOfSentActivitiesOfType(0, 'Delete'); $this->assertCountOfSentActivitiesOfType(0, 'Announce'); $deleteActivities = $this->activityRepository->findBy(['type' => 'Delete']); self::assertEmpty($deleteActivities); } public function testDeleteLocalPostCommentInLocalMagazineByRemoteModerator(): void { $obj = $this->createLocalPostCommentAndCreateDeleteActivity($this->localMagazine, $this->localUser, $this->remoteUser); $activity = $obj['activity']; $PostComment = $obj['content']; $this->bus->dispatch(new ActivityMessage(json_encode($activity))); $this->entityManager->refresh($PostComment); self::assertTrue($PostComment->isTrashed()); $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']); } public function testDeleteLocalPostCommentInRemoteMagazineByRemoteModerator(): void { $obj = $this->createLocalPostCommentAndCreateDeleteActivity($this->remoteMagazine, $this->localUser, $this->remoteUser); $activity = $obj['activity']; $postComment = $obj['content']; $this->bus->dispatch(new ActivityMessage(json_encode($activity))); $this->entityManager->refresh($postComment); self::assertTrue($postComment->isTrashed()); $this->assertCountOfSentActivitiesOfType(0, 'Delete'); $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']); } public function testDeleteRemotePostCommentInLocalMagazineByRemoteModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInLocalMagazine))); $postApId = $this->createRemotePostInLocalMagazine['object']['id']; $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertNotNull($post); $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostCommentInLocalMagazine))); $postCommentApId = $this->createRemotePostCommentInLocalMagazine['object']['id']; $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]); self::assertNotNull($postComment); $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemotePostCommentByRemoteModeratorInLocalMagazine))); $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]); self::assertTrue($postComment->isTrashed()); $this->assertOneSentAnnouncedActivityOfType('Delete', $this->deleteRemotePostCommentByRemoteModeratorInLocalMagazine['id']); } public function testDeleteRemotePostCommentInRemoteMagazineByRemoteModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInRemoteMagazine))); $postApId = $this->createRemotePostInRemoteMagazine['object']['object']['id']; $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertNotNull($post); $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostCommentInRemoteMagazine))); $postCommentApId = $this->createRemotePostCommentInRemoteMagazine['object']['object']['id']; $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]); self::assertNotNull($postComment); $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemotePostCommentByRemoteModeratorInRemoteMagazine))); $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]); self::assertTrue($postComment->isTrashed()); $this->assertCountOfSentActivitiesOfType(0, 'Delete'); $this->assertCountOfSentActivitiesOfType(0, 'Announce'); $deleteActivities = $this->activityRepository->findBy(['type' => 'Delete']); self::assertEmpty($deleteActivities); } public function setUp(): void { parent::setUp(); $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->remoteUser)); $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->localUser)); $this->magazineManager->addModerator(new ModeratorDto($this->localMagazine, $this->remoteUser, $this->localMagazine->getOwner())); $this->magazineManager->subscribe($this->remoteMagazine, $this->remoteSubscriber); } protected function setUpRemoteActors(): void { parent::setUpRemoteActors(); $username = 'remotePoster'; $domain = $this->remoteDomain; $this->remotePoster = $this->getUserByUsername($username, addImage: false); $this->registerActor($this->remotePoster, $domain, true); } public function setUpRemoteEntities(): void { $this->createRemoteEntryInRemoteMagazine = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remotePoster, fn ($entry) => $this->createDeletesFromRemoteEntryInRemoteMagazine($entry)); $this->createRemoteEntryCommentInRemoteMagazine = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remotePoster, fn ($entryComment) => $this->createDeletesFromRemoteEntryCommentInRemoteMagazine($entryComment)); $this->createRemotePostInRemoteMagazine = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remotePoster, fn ($post) => $this->createDeletesFromRemotePostInRemoteMagazine($post)); $this->createRemotePostCommentInRemoteMagazine = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remotePoster, fn ($comment) => $this->createDeletesFromRemotePostCommentInRemoteMagazine($comment)); $this->createRemoteEntryInLocalMagazine = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remotePoster, fn ($entry) => $this->createDeletesFromRemoteEntryInLocalMagazine($entry)); $this->createRemoteEntryCommentInLocalMagazine = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remotePoster, fn ($entryComment) => $this->createDeletesFromRemoteEntryCommentInLocalMagazine($entryComment)); $this->createRemotePostInLocalMagazine = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remotePoster, fn ($post) => $this->createDeletesFromRemotePostInLocalMagazine($post)); $this->createRemotePostCommentInLocalMagazine = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remotePoster, fn ($comment) => $this->createDeletesFromRemotePostCommentInLocalMagazine($comment)); } private function createDeletesFromRemoteEntryInRemoteMagazine(Entry $createdEntry): void { $this->deleteRemoteEntryByRemoteModeratorInRemoteMagazine = $this->createDeleteForContent($createdEntry); } private function createDeletesFromRemoteEntryInLocalMagazine(Entry $createdEntry): void { $this->deleteRemoteEntryByRemoteModeratorInLocalMagazine = $this->createDeleteForContent($createdEntry); } private function createDeletesFromRemoteEntryCommentInRemoteMagazine(EntryComment $comment): void { $this->deleteRemoteEntryCommentByRemoteModeratorInRemoteMagazine = $this->createDeleteForContent($comment); } private function createDeletesFromRemoteEntryCommentInLocalMagazine(EntryComment $comment): void { $this->deleteRemoteEntryCommentByRemoteModeratorInLocalMagazine = $this->createDeleteForContent($comment); } private function createDeletesFromRemotePostInRemoteMagazine(Post $post): void { $this->deleteRemotePostByRemoteModeratorInRemoteMagazine = $this->createDeleteForContent($post); } private function createDeletesFromRemotePostInLocalMagazine(Post $ost): void { $this->deleteRemotePostByRemoteModeratorInLocalMagazine = $this->createDeleteForContent($ost); } private function createDeletesFromRemotePostCommentInRemoteMagazine(PostComment $comment): void { $this->deleteRemotePostCommentByRemoteModeratorInRemoteMagazine = $this->createDeleteForContent($comment); } private function createDeletesFromRemotePostCommentInLocalMagazine(PostComment $comment): void { $this->deleteRemotePostCommentByRemoteModeratorInLocalMagazine = $this->createDeleteForContent($comment); } private function createDeleteForContent(Entry|EntryComment|Post|PostComment $content): array { $activity = $this->deleteWrapper->build($content, $this->remoteUser); $json = $this->activityJsonBuilder->buildActivityJson($activity); $json['summary'] = ' '; $this->testingApHttpClient->activityObjects[$json['id']] = $json; $this->entitiesToRemoveAfterSetup[] = $activity; return $json; } /** * @return array{entry:Entry, activity: array} */ private function createLocalEntryAndCreateDeleteActivity(Magazine $magazine, User $author, User $deletingUser): array { $entry = $this->getEntryByTitle('localEntry', magazine: $magazine, user: $author); $entryJson = $this->pageFactory->create($entry, [], false); $this->switchToRemoteDomain($this->remoteDomain); $activity = $this->deleteWrapper->build($entry, $deletingUser); $activityJson = $this->activityJsonBuilder->buildActivityJson($activity); $activityJson['object'] = $entryJson; $this->switchToLocalDomain(); $this->entityManager->remove($activity); return [ 'activity' => $activityJson, 'content' => $entry, ]; } /** * @return array{content:EntryComment, activity: array} */ private function createLocalEntryCommentAndCreateDeleteActivity(Magazine $magazine, User $author, User $deletingUser): array { $parent = $this->getEntryByTitle('localEntry', magazine: $magazine, user: $author); $comment = $this->createEntryComment('localEntryComment', entry: $parent, user: $author); $commentJson = $this->entryCommentNoteFactory->create($comment, []); $this->switchToRemoteDomain($this->remoteDomain); $activity = $this->deleteWrapper->build($comment, $deletingUser); $activityJson = $this->activityJsonBuilder->buildActivityJson($activity); $activityJson['object'] = $commentJson; $this->switchToLocalDomain(); $this->entityManager->remove($activity); return [ 'activity' => $activityJson, 'content' => $comment, ]; } /** * @return array{content:EntryComment, activity: array} */ private function createLocalPostAndCreateDeleteActivity(Magazine $magazine, User $author, User $deletingUser): array { $post = $this->createPost('localPost', magazine: $magazine, user: $author); $postJson = $this->postNoteFactory->create($post, []); $this->switchToRemoteDomain($this->remoteDomain); $activity = $this->deleteWrapper->build($post, $deletingUser); $activityJson = $this->activityJsonBuilder->buildActivityJson($activity); $activityJson['object'] = $postJson; $this->switchToLocalDomain(); $this->entityManager->remove($activity); return [ 'activity' => $activityJson, 'content' => $post, ]; } /** * @return array{content:EntryComment, activity: array} */ private function createLocalPostCommentAndCreateDeleteActivity(Magazine $magazine, User $author, User $deletingUser): array { $parent = $this->createPost('localPost', magazine: $magazine, user: $author); $postComment = $this->createPostComment('localPost', post: $parent, user: $author); $commentJson = $this->postCommentNoteFactory->create($postComment, []); $this->switchToRemoteDomain($this->remoteDomain); $activity = $this->deleteWrapper->build($postComment, $deletingUser); $activityJson = $this->activityJsonBuilder->buildActivityJson($activity); $activityJson['object'] = $commentJson; $this->switchToLocalDomain(); $this->entityManager->remove($activity); return [ 'activity' => $activityJson, 'content' => $postComment, ]; } } ================================================ FILE: tests/Functional/ActivityPub/Inbox/DislikeHandlerTest.php ================================================ bus->dispatch(new ActivityMessage(json_encode($this->announceEntry))); $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]); self::assertSame(0, $entry->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeAnnounceEntry))); $this->entityManager->refresh($entry); self::assertNotNull($entry); self::assertSame(1, $entry->countDownVotes()); } #[Depends('testDislikeRemoteEntryInRemoteMagazine')] public function testUndoDislikeRemoteEntryInRemoteMagazine(): void { $this->testDislikeRemoteEntryInRemoteMagazine(); $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]); self::assertSame(1, $entry->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeAnnounceEntry))); $this->entityManager->refresh($entry); self::assertNotNull($entry); self::assertSame(0, $entry->countDownVotes()); } public function testDislikeRemoteEntryCommentInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry))); $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntryComment))); $comment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]); self::assertSame(0, $comment->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeAnnounceEntryComment))); $this->entityManager->refresh($comment); self::assertNotNull($comment); self::assertSame(1, $comment->countDownVotes()); } #[Depends('testDislikeRemoteEntryCommentInRemoteMagazine')] public function testUndoLikeRemoteEntryCommentInRemoteMagazine(): void { $this->testDislikeRemoteEntryCommentInRemoteMagazine(); $comment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]); self::assertSame(1, $comment->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeAnnounceEntryComment))); $this->entityManager->refresh($comment); self::assertNotNull($comment); self::assertSame(0, $comment->countDownVotes()); } public function testDislikeRemotePostInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost))); $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]); self::assertSame(0, $post->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeAnnouncePost))); $this->entityManager->refresh($post); self::assertNotNull($post); self::assertSame(1, $post->countDownVotes()); } #[Depends('testDislikeRemotePostInRemoteMagazine')] public function testUndoLikeRemotePostInRemoteMagazine(): void { $this->testDislikeRemotePostInRemoteMagazine(); $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]); self::assertSame(1, $post->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeAnnouncePost))); $this->entityManager->refresh($post); self::assertNotNull($post); self::assertSame(0, $post->countDownVotes()); } public function testDislikeRemotePostCommentInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost))); $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePostComment))); $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]); self::assertSame(0, $postComment->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeAnnouncePostComment))); $this->entityManager->refresh($postComment); self::assertNotNull($postComment); self::assertSame(1, $postComment->countDownVotes()); } #[Depends('testDislikeRemotePostCommentInRemoteMagazine')] public function testUndoLikeRemotePostCommentInRemoteMagazine(): void { $this->testDislikeRemotePostCommentInRemoteMagazine(); $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]); self::assertSame(1, $postComment->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeAnnouncePostComment))); $this->entityManager->refresh($postComment); self::assertNotNull($postComment); self::assertSame(0, $postComment->countDownVotes()); } public function testDislikeEntryInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry))); $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]); self::assertSame(0, $entry->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeCreateEntry))); $this->entityManager->refresh($entry); self::assertSame(1, $entry->countDownVotes()); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Dislike' === $arr['payload']['object']['type']); $postedLikeAnnounce = $postedLikeAnnounces[array_key_first($postedLikeAnnounces)]; // the id of the 'Dislike' activity should be wrapped in an 'Announce' activity self::assertEquals($this->dislikeCreateEntry['id'], $postedLikeAnnounce['payload']['object']['id']); // the 'Dislike' activity has the url as the object self::assertEquals($this->dislikeCreateEntry['object'], $postedLikeAnnounce['payload']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedLikeAnnounce['inboxUrl']); } #[Depends('testDislikeEntryInLocalMagazine')] public function testUndoLikeEntryInLocalMagazine(): void { $this->testDislikeEntryInLocalMagazine(); $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]); self::assertSame(1, $entry->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeCreateEntry))); $this->entityManager->refresh($entry); self::assertSame(0, $entry->countDownVotes()); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Dislike' === $arr['payload']['object']['object']['type']); $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)]; // the id of the 'Undo' activity should be wrapped in an 'Announce' activity self::assertEquals($this->undoDislikeCreateEntry['id'], $postedUndoLikeAnnounce['payload']['object']['id']); // the 'Undo' activity has the 'Dislike' activity as the object self::assertEquals($this->undoDislikeCreateEntry['object'], $postedUndoLikeAnnounce['payload']['object']['object']); // the 'Dislike' activity has the url as the object self::assertEquals($this->undoDislikeCreateEntry['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']); } public function testDislikeEntryCommentInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry))); $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryComment))); $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]); self::assertNotNull($entryComment); self::assertSame(0, $entryComment->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeCreateEntryComment))); $this->entityManager->refresh($entryComment); self::assertSame(1, $entryComment->countDownVotes()); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Dislike' === $arr['payload']['object']['type']); $postedLikeAnnounce = $postedLikeAnnounces[array_key_first($postedLikeAnnounces)]; // the id of the 'Dislike' activity should be wrapped in an 'Announce' activity self::assertEquals($this->dislikeCreateEntryComment['id'], $postedLikeAnnounce['payload']['object']['id']); // the 'Dislike' activity has the url as the object self::assertEquals($this->dislikeCreateEntryComment['object'], $postedLikeAnnounce['payload']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedLikeAnnounce['inboxUrl']); } #[Depends('testDislikeEntryCommentInLocalMagazine')] public function testUndoLikeEntryCommentInLocalMagazine(): void { $this->testDislikeEntryCommentInLocalMagazine(); $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]); self::assertNotNull($entryComment); self::assertSame(1, $entryComment->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeCreateEntryComment))); $this->entityManager->refresh($entryComment); self::assertSame(0, $entryComment->countDownVotes()); $postedObjects = $this->testingApHttpClient->getPostedObjects(); $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Dislike' === $arr['payload']['object']['object']['type']); $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)]; // the id of the 'Undo' activity should be wrapped in an 'Announce' activity self::assertEquals($this->undoDislikeCreateEntryComment['id'], $postedUndoLikeAnnounce['payload']['object']['id']); // the 'Undo' activity has the 'Dislike' activity as the object self::assertEquals($this->undoDislikeCreateEntryComment['object'], $postedUndoLikeAnnounce['payload']['object']['object']); // the 'Dislike' activity has the url as the object self::assertEquals($this->undoDislikeCreateEntryComment['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']); } public function testDislikePostInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost))); $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]); self::assertNotNull($post); self::assertSame(0, $post->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeCreatePost))); $this->entityManager->refresh($post); self::assertSame(1, $post->countDownVotes()); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedUpdateAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Dislike' === $arr['payload']['object']['type']); $postedUpdateAnnounce = $postedUpdateAnnounces[array_key_first($postedUpdateAnnounces)]; // the id of the 'Dislike' activity should be wrapped in an 'Announce' activity self::assertEquals($this->dislikeCreatePost['id'], $postedUpdateAnnounce['payload']['object']['id']); // the 'Dislike' activity has the url as the object self::assertEquals($this->dislikeCreatePost['object'], $postedUpdateAnnounce['payload']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUpdateAnnounce['inboxUrl']); } #[Depends('testDislikePostInLocalMagazine')] public function testUndoLikePostInLocalMagazine(): void { $this->testDislikePostInLocalMagazine(); $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]); self::assertNotNull($post); self::assertSame(1, $post->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeCreatePost))); $this->entityManager->refresh($post); self::assertSame(0, $post->countDownVotes()); $postedObjects = $this->testingApHttpClient->getPostedObjects(); $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Dislike' === $arr['payload']['object']['object']['type']); $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)]; // the id of the 'Undo' activity should be wrapped in an 'Announce' activity self::assertEquals($this->undoDislikeCreatePost['id'], $postedUndoLikeAnnounce['payload']['object']['id']); // the 'Undo' activity has the 'Dislike' activity as the object self::assertEquals($this->undoDislikeCreatePost['object'], $postedUndoLikeAnnounce['payload']['object']['object']); // the 'Dislike' activity has the url as the object self::assertEquals($this->undoDislikeCreatePost['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']); } public function testDislikePostCommentInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost))); $this->bus->dispatch(new ActivityMessage(json_encode($this->createPostComment))); $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]); self::assertNotNull($postComment); self::assertSame(0, $postComment->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeCreatePostComment))); $this->entityManager->refresh($postComment); self::assertSame(1, $postComment->countDownVotes()); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Dislike' === $arr['payload']['object']['type']); $postedLikeAnnounce = $postedLikeAnnounces[array_key_first($postedLikeAnnounces)]; // the id of the 'Dislike' activity should be wrapped in an 'Announce' activity self::assertEquals($this->dislikeCreatePostComment['id'], $postedLikeAnnounce['payload']['object']['id']); // the 'Dislike' activity has the url as the object self::assertEquals($this->dislikeCreatePostComment['object'], $postedLikeAnnounce['payload']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedLikeAnnounce['inboxUrl']); } #[Depends('testDislikePostCommentInLocalMagazine')] public function testUndoLikePostCommentInLocalMagazine(): void { $this->testDislikePostCommentInLocalMagazine(); $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]); self::assertNotNull($postComment); self::assertSame(1, $postComment->countDownVotes()); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeCreatePostComment))); $this->entityManager->refresh($postComment); self::assertSame(0, $postComment->countDownVotes()); $postedObjects = $this->testingApHttpClient->getPostedObjects(); $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Dislike' === $arr['payload']['object']['object']['type']); $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)]; // the id of the 'Undo' activity should be wrapped in an 'Announce' activity self::assertEquals($this->undoDislikeCreatePostComment['id'], $postedUndoLikeAnnounce['payload']['object']['id']); // the 'Undo' activity has the 'Dislike' activity as the object self::assertEquals($this->undoDislikeCreatePostComment['object'], $postedUndoLikeAnnounce['payload']['object']['object']); // the 'Dislike' activity has the url as the object self::assertEquals($this->undoDislikeCreatePostComment['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']); } public function setUpRemoteEntities(): void { $this->announceEntry = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildDislikeRemoteEntryInRemoteMagazine($entry)); $this->announceEntryComment = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildDislikeRemoteEntryCommentInRemoteMagazine($comment)); $this->announcePost = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Post $post) => $this->buildDislikeRemotePostInRemoteMagazine($post)); $this->announcePostComment = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildDislikeRemotePostCommentInRemoteMagazine($comment)); $this->createEntry = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildDislikeRemoteEntryInLocalMagazine($entry)); $this->createEntryComment = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildDislikeRemoteEntryCommentInLocalMagazine($comment)); $this->createPost = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Post $post) => $this->buildDislikeRemotePostInLocalMagazine($post)); $this->createPostComment = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildDislikeRemotePostCommentInLocalMagazine($comment)); } public function buildDislikeRemoteEntryInRemoteMagazine(Entry $entry): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $entry); $this->dislikeAnnounceEntry = $this->activityJsonBuilder->buildActivityJson($likeActivity); $undoActivity = $this->undoWrapper->build($likeActivity); $this->undoDislikeAnnounceEntry = $this->activityJsonBuilder->buildActivityJson($undoActivity); // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation $this->dislikeAnnounceEntry['type'] = 'Dislike'; $this->undoDislikeAnnounceEntry['object']['type'] = 'Dislike'; $this->testingApHttpClient->activityObjects[$this->dislikeAnnounceEntry['id']] = $this->dislikeAnnounceEntry; $this->entitiesToRemoveAfterSetup[] = $likeActivity; } public function buildDislikeRemoteEntryCommentInRemoteMagazine(EntryComment $comment): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $comment); $this->dislikeAnnounceEntryComment = $this->activityJsonBuilder->buildActivityJson($likeActivity); $undoActivity = $this->undoWrapper->build($likeActivity); $this->undoDislikeAnnounceEntryComment = $this->activityJsonBuilder->buildActivityJson($undoActivity); // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation $this->dislikeAnnounceEntryComment['type'] = 'Dislike'; $this->undoDislikeAnnounceEntryComment['object']['type'] = 'Dislike'; $this->testingApHttpClient->activityObjects[$this->dislikeAnnounceEntryComment['id']] = $this->dislikeAnnounceEntryComment; $this->entitiesToRemoveAfterSetup[] = $undoActivity; $this->entitiesToRemoveAfterSetup[] = $likeActivity; } public function buildDislikeRemotePostInRemoteMagazine(Post $post): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $post); $this->dislikeAnnouncePost = $this->activityJsonBuilder->buildActivityJson($likeActivity); $undoActivity = $this->undoWrapper->build($likeActivity); $this->undoDislikeAnnouncePost = $this->activityJsonBuilder->buildActivityJson($undoActivity); // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation $this->dislikeAnnouncePost['type'] = 'Dislike'; $this->undoDislikeAnnouncePost['object']['type'] = 'Dislike'; $this->testingApHttpClient->activityObjects[$this->dislikeAnnouncePost['id']] = $this->dislikeAnnouncePost; $this->entitiesToRemoveAfterSetup[] = $undoActivity; $this->entitiesToRemoveAfterSetup[] = $likeActivity; } public function buildDislikeRemotePostCommentInRemoteMagazine(PostComment $postComment): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $postComment); $this->dislikeAnnouncePostComment = $this->activityJsonBuilder->buildActivityJson($likeActivity); $undoActivity = $this->undoWrapper->build($likeActivity); $this->undoDislikeAnnouncePostComment = $this->activityJsonBuilder->buildActivityJson($undoActivity); // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation $this->dislikeAnnouncePostComment['type'] = 'Dislike'; $this->undoDislikeAnnouncePostComment['object']['type'] = 'Dislike'; $this->testingApHttpClient->activityObjects[$this->dislikeAnnouncePostComment['id']] = $this->dislikeAnnouncePostComment; $this->entitiesToRemoveAfterSetup[] = $undoActivity; $this->entitiesToRemoveAfterSetup[] = $likeActivity; } public function buildDislikeRemoteEntryInLocalMagazine(Entry $entry): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $entry); $this->dislikeCreateEntry = $this->RewriteTargetFieldsToLocal($entry->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity)); $undoActivity = $this->undoWrapper->build($likeActivity); $this->undoDislikeCreateEntry = $this->RewriteTargetFieldsToLocal($entry->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity)); // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation $this->dislikeCreateEntry['type'] = 'Dislike'; $this->undoDislikeCreateEntry['object']['type'] = 'Dislike'; $this->testingApHttpClient->activityObjects[$this->dislikeCreateEntry['id']] = $this->dislikeCreateEntry; $this->entitiesToRemoveAfterSetup[] = $undoActivity; $this->entitiesToRemoveAfterSetup[] = $likeActivity; } public function buildDislikeRemoteEntryCommentInLocalMagazine(EntryComment $comment): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $comment); $this->dislikeCreateEntryComment = $this->RewriteTargetFieldsToLocal($comment->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity)); $undoActivity = $this->undoWrapper->build($likeActivity); $this->undoDislikeCreateEntryComment = $this->RewriteTargetFieldsToLocal($comment->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity)); // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation $this->dislikeCreateEntryComment['type'] = 'Dislike'; $this->undoDislikeCreateEntryComment['object']['type'] = 'Dislike'; $this->testingApHttpClient->activityObjects[$this->dislikeCreateEntryComment['id']] = $this->dislikeCreateEntryComment; $this->entitiesToRemoveAfterSetup[] = $undoActivity; $this->entitiesToRemoveAfterSetup[] = $likeActivity; } public function buildDislikeRemotePostInLocalMagazine(Post $post): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $post); $this->dislikeCreatePost = $this->RewriteTargetFieldsToLocal($post->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity)); $undoActivity = $this->undoWrapper->build($likeActivity); $this->undoDislikeCreatePost = $this->RewriteTargetFieldsToLocal($post->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity)); // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation $this->dislikeCreatePost['type'] = 'Dislike'; $this->undoDislikeCreatePost['object']['type'] = 'Dislike'; $this->testingApHttpClient->activityObjects[$this->dislikeCreatePost['id']] = $this->dislikeCreatePost; $this->entitiesToRemoveAfterSetup[] = $undoActivity; $this->entitiesToRemoveAfterSetup[] = $likeActivity; } public function buildDislikeRemotePostCommentInLocalMagazine(PostComment $postComment): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $postComment); $this->dislikeCreatePostComment = $this->RewriteTargetFieldsToLocal($postComment->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity)); $undoActivity = $this->undoWrapper->build($likeActivity); $this->undoDislikeCreatePostComment = $this->RewriteTargetFieldsToLocal($postComment->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity)); // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation $this->dislikeCreatePostComment['type'] = 'Dislike'; $this->undoDislikeCreatePostComment['object']['type'] = 'Dislike'; $this->testingApHttpClient->activityObjects[$this->dislikeCreatePostComment['id']] = $this->dislikeCreatePostComment; $this->entitiesToRemoveAfterSetup[] = $undoActivity; $this->entitiesToRemoveAfterSetup[] = $likeActivity; } } ================================================ FILE: tests/Functional/ActivityPub/Inbox/FlagHandlerTest.php ================================================ bus->dispatch(new ActivityMessage(json_encode($this->announceEntry))); $subject = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]); self::assertNotNull($subject); $this->bus->dispatch(new ActivityMessage(json_encode($this->flagAnnounceEntry))); $report = $this->reportRepository->findBySubject($subject); self::assertNotNull($report); self::assertSame($this->remoteSubscriber->username, $report->reporting->username); self::assertSame(self::REASON, $report->reason); } public function testFlagRemoteEntryCommentInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry))); $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntryComment))); $subject = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]); self::assertNotNull($subject); $this->bus->dispatch(new ActivityMessage(json_encode($this->flagAnnounceEntryComment))); $report = $this->reportRepository->findBySubject($subject); self::assertNotNull($report); self::assertSame($this->remoteSubscriber->username, $report->reporting->username); self::assertSame(self::REASON, $report->reason); } public function testFlagRemotePostInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost))); $subject = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]); self::assertNotNull($subject); $this->bus->dispatch(new ActivityMessage(json_encode($this->flagAnnouncePost))); $report = $this->reportRepository->findBySubject($subject); self::assertNotNull($report); self::assertSame($this->remoteSubscriber->username, $report->reporting->username); self::assertSame(self::REASON, $report->reason); } public function testFlagRemotePostCommentInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost))); $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePostComment))); $subject = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]); self::assertNotNull($subject); $this->bus->dispatch(new ActivityMessage(json_encode($this->flagAnnouncePostComment))); $report = $this->reportRepository->findBySubject($subject); self::assertNotNull($report); self::assertSame($this->remoteSubscriber->username, $report->reporting->username); self::assertSame(self::REASON, $report->reason); } public function testFlagRemoteEntryInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry))); $subject = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]); self::assertNotNull($subject); $this->bus->dispatch(new ActivityMessage(json_encode($this->flagCreateEntry))); $report = $this->reportRepository->findBySubject($subject); self::assertNotNull($report); self::assertSame($this->remoteSubscriber->username, $report->reporting->username); self::assertSame(self::REASON, $report->reason); } public function testFlagRemoteEntryCommentInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry))); $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryComment))); $subject = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]); self::assertNotNull($subject); $this->bus->dispatch(new ActivityMessage(json_encode($this->flagCreateEntryComment))); $report = $this->reportRepository->findBySubject($subject); self::assertNotNull($report); self::assertSame($this->remoteSubscriber->username, $report->reporting->username); self::assertSame(self::REASON, $report->reason); } public function testFlagRemotePostInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost))); $subject = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]); self::assertNotNull($subject); $this->bus->dispatch(new ActivityMessage(json_encode($this->flagCreatePost))); $report = $this->reportRepository->findBySubject($subject); self::assertNotNull($report); self::assertSame($this->remoteSubscriber->username, $report->reporting->username); self::assertSame(self::REASON, $report->reason); } public function testFlagRemotePostCommentInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost))); $this->bus->dispatch(new ActivityMessage(json_encode($this->createPostComment))); $subject = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]); self::assertNotNull($subject); $this->bus->dispatch(new ActivityMessage(json_encode($this->flagCreatePostComment))); $report = $this->reportRepository->findBySubject($subject); self::assertNotNull($report); self::assertSame($this->remoteSubscriber->username, $report->reporting->username); self::assertSame(self::REASON, $report->reason); } public function setUpRemoteEntities(): void { $this->announceEntry = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildFlagRemoteEntryInRemoteMagazine($entry)); $this->announceEntryComment = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildFlagRemoteEntryCommentInRemoteMagazine($comment)); $this->announcePost = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Post $post) => $this->buildFlagRemotePostInRemoteMagazine($post)); $this->announcePostComment = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildFlagRemotePostCommentInRemoteMagazine($comment)); $this->createEntry = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildFlagRemoteEntryInLocalMagazine($entry)); $this->createEntryComment = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildFlagRemoteEntryCommentInLocalMagazine($comment)); $this->createPost = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Post $post) => $this->buildFlagRemotePostInLocalMagazine($post)); $this->createPostComment = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildFlagRemotePostCommentInLocalMagazine($comment)); } private function buildFlagRemoteEntryInRemoteMagazine(Entry $entry): void { $this->flagAnnounceEntry = $this->createFlagActivity($this->remoteSubscriber, $entry); } private function buildFlagRemoteEntryCommentInRemoteMagazine(EntryComment $comment): void { $this->flagAnnounceEntryComment = $this->createFlagActivity($this->remoteSubscriber, $comment); } private function buildFlagRemotePostInRemoteMagazine(Post $post): void { $this->flagAnnouncePost = $this->createFlagActivity($this->remoteSubscriber, $post); } private function buildFlagRemotePostCommentInRemoteMagazine(PostComment $comment): void { $this->flagAnnouncePostComment = $this->createFlagActivity($this->remoteSubscriber, $comment); } private function buildFlagRemoteEntryInLocalMagazine(Entry $entry): void { $this->flagCreateEntry = $this->createFlagActivity($this->remoteSubscriber, $entry); } private function buildFlagRemoteEntryCommentInLocalMagazine(EntryComment $comment): void { $this->flagCreateEntryComment = $this->createFlagActivity($this->remoteSubscriber, $comment); } private function buildFlagRemotePostInLocalMagazine(Post $post): void { $this->flagCreatePost = $this->createFlagActivity($this->remoteSubscriber, $post); } private function buildFlagRemotePostCommentInLocalMagazine(PostComment $comment): void { $this->flagCreatePostComment = $this->createFlagActivity($this->remoteSubscriber, $comment); } private function createFlagActivity(Magazine|User $user, ReportInterface $subject): array { $dto = new ReportDto(); $dto->subject = $subject; $dto->reason = self::REASON; $report = $this->reportManager->report($dto, $user); $flagActivity = $this->flagFactory->build($report); $flagActivityJson = $this->activityJsonBuilder->buildActivityJson($flagActivity); $flagActivityJson['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $flagActivityJson['actor']); $this->testingApHttpClient->activityObjects[$flagActivityJson['id']] = $flagActivityJson; $this->entitiesToRemoveAfterSetup[] = $flagActivity; return $flagActivityJson; } } ================================================ FILE: tests/Functional/ActivityPub/Inbox/FollowHandlerTest.php ================================================ remoteDomain; $username = 'followUser'; $followUser = $this->getUserByUsername('followUser'); $json = $this->personFactory->create($followUser); $this->testingApHttpClient->actorObjects[$json['id']] = $json; $this->followUserApId = $this->personFactory->getActivityPubId($followUser); $userEvent = new WebfingerResponseEvent(new JsonRd(), "acct:$username@$domain", ['account' => $username]); $this->eventDispatcher->dispatch($userEvent); $realDomain = \sprintf(WebFingerFactory::WEBFINGER_URL, 'https', $domain, '', "$username@$domain"); $this->testingApHttpClient->webfingerObjects[$realDomain] = $userEvent->jsonRd->toArray(); $followActivity = $this->followWrapper->build($followUser, $this->localMagazine); $this->userFollowMagazine = $this->activityJsonBuilder->buildActivityJson($followActivity); $apId = "https://$this->prev/m/{$this->localMagazine->name}"; $this->userFollowMagazine['object'] = $apId; $this->userFollowMagazine['to'] = [$apId]; $this->testingApHttpClient->activityObjects[$this->userFollowMagazine['id']] = $this->userFollowMagazine; $undoFollowActivity = $this->undoWrapper->build($followActivity); $this->undoUserFollowMagazine = $this->activityJsonBuilder->buildActivityJson($undoFollowActivity); $this->undoUserFollowMagazine['to'] = [$apId]; $this->undoUserFollowMagazine['object']['to'] = $apId; $this->undoUserFollowMagazine['object']['object'] = $apId; $this->testingApHttpClient->activityObjects[$this->undoUserFollowMagazine['id']] = $this->undoUserFollowMagazine; $followActivity2 = $this->followWrapper->build($followUser, $this->localUser); $this->userFollowUser = $this->activityJsonBuilder->buildActivityJson($followActivity2); $apId = "https://$this->prev/u/{$this->localUser->username}"; $this->userFollowUser['object'] = $apId; $this->userFollowUser['to'] = [$apId]; $this->testingApHttpClient->activityObjects[$this->userFollowUser['id']] = $this->userFollowUser; $undoFollowActivity2 = $this->undoWrapper->build($followActivity2); $this->undoUserFollowUser = $this->activityJsonBuilder->buildActivityJson($undoFollowActivity2); $this->undoUserFollowUser['to'] = [$apId]; $this->undoUserFollowUser['object']['to'] = $apId; $this->undoUserFollowUser['object']['object'] = $apId; $this->testingApHttpClient->activityObjects[$this->undoUserFollowUser['id']] = $this->undoUserFollowUser; $this->entitiesToRemoveAfterSetup[] = $undoFollowActivity2; $this->entitiesToRemoveAfterSetup[] = $followActivity2; $this->entitiesToRemoveAfterSetup[] = $undoFollowActivity; $this->entitiesToRemoveAfterSetup[] = $followActivity; $this->entitiesToRemoveAfterSetup[] = $followUser; } public function testUserFollowUser(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->userFollowUser))); $this->entityManager->refresh($this->localUser); $followUser = $this->userRepository->findOneBy(['apProfileId' => $this->followUserApId]); $this->entityManager->refresh($followUser); self::assertNotNull($followUser); self::assertTrue($followUser->isFollower($this->localUser)); self::assertTrue($followUser->isFollowing($this->localUser)); self::assertNotNull($this->userFollowRepository->findOneBy(['follower' => $followUser, 'following' => $this->localUser])); self::assertNull($this->userFollowRepository->findOneBy(['follower' => $this->localUser, 'following' => $followUser])); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertCount(1, $postedObjects); self::assertEquals('Accept', $postedObjects[0]['payload']['type']); self::assertEquals($followUser->apInboxUrl, $postedObjects[0]['inboxUrl']); self::assertEquals($this->userFollowUser['id'], $postedObjects[0]['payload']['object']['id']); } #[Depends('testUserFollowUser')] public function testUndoUserFollowUser(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->userFollowUser))); $followUser = $this->userRepository->findOneBy(['apProfileId' => $this->followUserApId]); $this->entityManager->refresh($followUser); $this->entityManager->refresh($this->localUser); $prevPostedObjects = $this->testingApHttpClient->getPostedObjects(); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoUserFollowUser))); $this->entityManager->refresh($this->localUser); $this->entityManager->refresh($followUser); self::assertNotNull($followUser); self::assertFalse($followUser->isFollower($this->localUser)); self::assertFalse($followUser->isFollowing($this->localUser)); self::assertNull($this->userFollowRepository->findOneBy(['follower' => $followUser, 'following' => $this->localUser])); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertEquals(0, \sizeof($prevPostedObjects) - \sizeof($postedObjects)); } public function testUserFollowMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->userFollowMagazine))); $this->entityManager->refresh($this->localUser); $followUser = $this->userRepository->findOneBy(['apProfileId' => $this->followUserApId]); $this->entityManager->refresh($followUser); self::assertNotNull($followUser); $sub = $this->magazineSubscriptionRepository->findOneBy(['user' => $followUser, 'magazine' => $this->localMagazine]); self::assertNotNull($sub); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertCount(1, $postedObjects); self::assertEquals('Accept', $postedObjects[0]['payload']['type']); self::assertEquals($followUser->apInboxUrl, $postedObjects[0]['inboxUrl']); self::assertEquals($this->userFollowMagazine['id'], $postedObjects[0]['payload']['object']['id']); } #[Depends('testUserFollowMagazine')] public function testUndoUserFollowMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->userFollowMagazine))); $followUser = $this->userRepository->findOneBy(['apProfileId' => $this->followUserApId]); $this->entityManager->refresh($followUser); $this->entityManager->refresh($this->localUser); $prevPostedObjects = $this->testingApHttpClient->getPostedObjects(); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoUserFollowMagazine))); $this->entityManager->refresh($this->localUser); $this->entityManager->refresh($followUser); self::assertNotNull($followUser); $sub = $this->magazineSubscriptionRepository->findOneBy(['magazine' => $this->localMagazine, 'user' => $followUser]); self::assertNull($sub); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertEquals(0, \sizeof($prevPostedObjects) - \sizeof($postedObjects)); } } ================================================ FILE: tests/Functional/ActivityPub/Inbox/LikeHandlerTest.php ================================================ bus->dispatch(new ActivityMessage(json_encode($this->announceEntry))); $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]); self::assertSame(0, $entry->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->likeAnnounceEntry))); $this->entityManager->refresh($entry); self::assertNotNull($entry); self::assertSame(1, $entry->favouriteCount); } #[Depends('testLikeRemoteEntryInRemoteMagazine')] public function testUndoLikeRemoteEntryInRemoteMagazine(): void { $this->testLikeRemoteEntryInRemoteMagazine(); $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]); self::assertSame(1, $entry->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeAnnounceEntry))); $this->entityManager->refresh($entry); self::assertNotNull($entry); self::assertSame(0, $entry->favouriteCount); } public function testLikeRemoteEntryCommentInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry))); $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntryComment))); $comment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]); self::assertSame(0, $comment->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->likeAnnounceEntryComment))); $this->entityManager->refresh($comment); self::assertNotNull($comment); self::assertSame(1, $comment->favouriteCount); } #[Depends('testLikeRemoteEntryCommentInRemoteMagazine')] public function testUndoLikeRemoteEntryCommentInRemoteMagazine(): void { $this->testLikeRemoteEntryCommentInRemoteMagazine(); $comment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]); self::assertSame(1, $comment->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeAnnounceEntryComment))); $this->entityManager->refresh($comment); self::assertNotNull($comment); self::assertSame(0, $comment->favouriteCount); } public function testLikeRemotePostInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost))); $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]); self::assertSame(0, $post->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->likeAnnouncePost))); $this->entityManager->refresh($post); self::assertNotNull($post); self::assertSame(1, $post->favouriteCount); } #[Depends('testLikeRemotePostInRemoteMagazine')] public function testUndoLikeRemotePostInRemoteMagazine(): void { $this->testLikeRemotePostInRemoteMagazine(); $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]); self::assertSame(1, $post->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeAnnouncePost))); $this->entityManager->refresh($post); self::assertNotNull($post); self::assertSame(0, $post->favouriteCount); } public function testLikeRemotePostCommentInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost))); $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePostComment))); $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]); self::assertSame(0, $postComment->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->likeAnnouncePostComment))); $this->entityManager->refresh($postComment); self::assertNotNull($postComment); self::assertSame(1, $postComment->favouriteCount); } #[Depends('testLikeRemotePostCommentInRemoteMagazine')] public function testUndoLikeRemotePostCommentInRemoteMagazine(): void { $this->testLikeRemotePostCommentInRemoteMagazine(); $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]); self::assertSame(1, $postComment->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeAnnouncePostComment))); $this->entityManager->refresh($postComment); self::assertNotNull($postComment); self::assertSame(0, $postComment->favouriteCount); } public function testLikeEntryInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry))); $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]); self::assertSame(0, $entry->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->likeCreateEntry))); $this->entityManager->refresh($entry); self::assertSame(1, $entry->favouriteCount); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Like' === $arr['payload']['object']['type']); $postedLikeAnnounce = $postedLikeAnnounces[array_key_first($postedLikeAnnounces)]; // the id of the 'Like' activity should be wrapped in an 'Announce' activity self::assertEquals($this->likeCreateEntry['id'], $postedLikeAnnounce['payload']['object']['id']); // the 'Like' activity has the url as the object self::assertEquals($this->likeCreateEntry['object'], $postedLikeAnnounce['payload']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedLikeAnnounce['inboxUrl']); } #[Depends('testLikeEntryInLocalMagazine')] public function testUndoLikeEntryInLocalMagazine(): void { $this->testLikeEntryInLocalMagazine(); $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]); self::assertSame(1, $entry->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeCreateEntry))); $this->entityManager->refresh($entry); self::assertSame(0, $entry->favouriteCount); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Like' === $arr['payload']['object']['object']['type']); $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)]; // the id of the 'Undo' activity should be wrapped in an 'Announce' activity self::assertEquals($this->undoLikeCreateEntry['id'], $postedUndoLikeAnnounce['payload']['object']['id']); // the 'Undo' activity has the 'Like' activity as the object self::assertEquals($this->undoLikeCreateEntry['object'], $postedUndoLikeAnnounce['payload']['object']['object']); // the 'Like' activity has the url as the object self::assertEquals($this->undoLikeCreateEntry['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']); } public function testLikeEntryCommentInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry))); $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryComment))); $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]); self::assertNotNull($entryComment); self::assertSame(0, $entryComment->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->likeCreateEntryComment))); $this->entityManager->refresh($entryComment); self::assertSame(1, $entryComment->favouriteCount); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Like' === $arr['payload']['object']['type']); $postedLikeAnnounce = $postedLikeAnnounces[array_key_first($postedLikeAnnounces)]; // the id of the 'Like' activity should be wrapped in an 'Announce' activity self::assertEquals($this->likeCreateEntryComment['id'], $postedLikeAnnounce['payload']['object']['id']); // the 'Like' activity has the url as the object self::assertEquals($this->likeCreateEntryComment['object'], $postedLikeAnnounce['payload']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedLikeAnnounce['inboxUrl']); } #[Depends('testLikeEntryCommentInLocalMagazine')] public function testUndoLikeEntryCommentInLocalMagazine(): void { $this->testLikeEntryCommentInLocalMagazine(); $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]); self::assertNotNull($entryComment); self::assertSame(1, $entryComment->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeCreateEntryComment))); $this->entityManager->refresh($entryComment); self::assertSame(0, $entryComment->favouriteCount); $postedObjects = $this->testingApHttpClient->getPostedObjects(); $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Like' === $arr['payload']['object']['object']['type']); $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)]; // the id of the 'Undo' activity should be wrapped in an 'Announce' activity self::assertEquals($this->undoLikeCreateEntryComment['id'], $postedUndoLikeAnnounce['payload']['object']['id']); // the 'Undo' activity has the 'Like' activity as the object self::assertEquals($this->undoLikeCreateEntryComment['object'], $postedUndoLikeAnnounce['payload']['object']['object']); // the 'Like' activity has the url as the object self::assertEquals($this->undoLikeCreateEntryComment['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']); } public function testLikePostInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost))); $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]); self::assertNotNull($post); self::assertSame(0, $post->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->likeCreatePost))); $this->entityManager->refresh($post); self::assertSame(1, $post->favouriteCount); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedUpdateAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Like' === $arr['payload']['object']['type']); $postedUpdateAnnounce = $postedUpdateAnnounces[array_key_first($postedUpdateAnnounces)]; // the id of the 'Like' activity should be wrapped in an 'Announce' activity self::assertEquals($this->likeCreatePost['id'], $postedUpdateAnnounce['payload']['object']['id']); // the 'Like' activity has the url as the object self::assertEquals($this->likeCreatePost['object'], $postedUpdateAnnounce['payload']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUpdateAnnounce['inboxUrl']); } #[Depends('testLikePostInLocalMagazine')] public function testUndoLikePostInLocalMagazine(): void { $this->testLikePostInLocalMagazine(); $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]); self::assertNotNull($post); self::assertSame(1, $post->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeCreatePost))); $this->entityManager->refresh($post); self::assertSame(0, $post->favouriteCount); $postedObjects = $this->testingApHttpClient->getPostedObjects(); $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Like' === $arr['payload']['object']['object']['type']); $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)]; // the id of the 'Undo' activity should be wrapped in an 'Announce' activity self::assertEquals($this->undoLikeCreatePost['id'], $postedUndoLikeAnnounce['payload']['object']['id']); // the 'Undo' activity has the 'Like' activity as the object self::assertEquals($this->undoLikeCreatePost['object'], $postedUndoLikeAnnounce['payload']['object']['object']); // the 'Like' activity has the url as the object self::assertEquals($this->undoLikeCreatePost['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']); } public function testLikePostCommentInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost))); $this->bus->dispatch(new ActivityMessage(json_encode($this->createPostComment))); $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]); self::assertNotNull($postComment); self::assertSame(0, $postComment->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->likeCreatePostComment))); $this->entityManager->refresh($postComment); self::assertSame(1, $postComment->favouriteCount); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Like' === $arr['payload']['object']['type']); $postedLikeAnnounce = $postedLikeAnnounces[array_key_first($postedLikeAnnounces)]; // the id of the 'Like' activity should be wrapped in an 'Announce' activity self::assertEquals($this->likeCreatePostComment['id'], $postedLikeAnnounce['payload']['object']['id']); // the 'Like' activity has the url as the object self::assertEquals($this->likeCreatePostComment['object'], $postedLikeAnnounce['payload']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedLikeAnnounce['inboxUrl']); } #[Depends('testLikePostCommentInLocalMagazine')] public function testUndoLikePostCommentInLocalMagazine(): void { $this->testLikePostCommentInLocalMagazine(); $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]); self::assertNotNull($postComment); self::assertSame(1, $postComment->favouriteCount); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeCreatePostComment))); $this->entityManager->refresh($postComment); self::assertSame(0, $postComment->favouriteCount); $postedObjects = $this->testingApHttpClient->getPostedObjects(); $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Like' === $arr['payload']['object']['object']['type']); $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)]; // the id of the 'Undo' activity should be wrapped in an 'Announce' activity self::assertEquals($this->undoLikeCreatePostComment['id'], $postedUndoLikeAnnounce['payload']['object']['id']); // the 'Undo' activity has the 'Like' activity as the object self::assertEquals($this->undoLikeCreatePostComment['object'], $postedUndoLikeAnnounce['payload']['object']['object']); // the 'Like' activity has the url as the object self::assertEquals($this->undoLikeCreatePostComment['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']); } public function setUpRemoteEntities(): void { $this->announceEntry = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildLikeRemoteEntryInRemoteMagazine($entry)); $this->announceEntryComment = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildLikeRemoteEntryCommentInRemoteMagazine($comment)); $this->announcePost = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Post $post) => $this->buildLikeRemotePostInRemoteMagazine($post)); $this->announcePostComment = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildLikeRemotePostCommentInRemoteMagazine($comment)); $this->createEntry = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildLikeRemoteEntryInLocalMagazine($entry)); $this->createEntryComment = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildLikeRemoteEntryCommentInLocalMagazine($comment)); $this->createPost = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Post $post) => $this->buildLikeRemotePostInLocalMagazine($post)); $this->createPostComment = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildLikeRemotePostCommentInLocalMagazine($comment)); } public function buildLikeRemoteEntryInRemoteMagazine(Entry $entry): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $entry); $this->likeAnnounceEntry = $this->activityJsonBuilder->buildActivityJson($likeActivity); $undoLikeActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser); $this->undoLikeAnnounceEntry = $this->activityJsonBuilder->buildActivityJson($undoLikeActivity); $this->testingApHttpClient->activityObjects[$this->likeAnnounceEntry['id']] = $this->likeAnnounceEntry; $this->testingApHttpClient->activityObjects[$this->undoLikeAnnounceEntry['id']] = $this->undoLikeAnnounceEntry; $this->entitiesToRemoveAfterSetup[] = $likeActivity; $this->entitiesToRemoveAfterSetup[] = $undoLikeActivity; } public function buildLikeRemoteEntryCommentInRemoteMagazine(EntryComment $comment): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $comment); $this->likeAnnounceEntryComment = $this->activityJsonBuilder->buildActivityJson($likeActivity); $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser); $this->undoLikeAnnounceEntryComment = $this->activityJsonBuilder->buildActivityJson($undoActivity); $this->testingApHttpClient->activityObjects[$this->likeAnnounceEntryComment['id']] = $this->likeAnnounceEntryComment; $this->testingApHttpClient->activityObjects[$this->undoLikeAnnounceEntryComment['id']] = $this->undoLikeAnnounceEntryComment; $this->entitiesToRemoveAfterSetup[] = $likeActivity; $this->entitiesToRemoveAfterSetup[] = $undoActivity; } public function buildLikeRemotePostInRemoteMagazine(Post $post): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $post); $this->likeAnnouncePost = $this->activityJsonBuilder->buildActivityJson($likeActivity); $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser); $this->undoLikeAnnouncePost = $this->activityJsonBuilder->buildActivityJson($undoActivity); $this->testingApHttpClient->activityObjects[$this->likeAnnouncePost['id']] = $this->likeAnnouncePost; $this->testingApHttpClient->activityObjects[$this->undoLikeAnnouncePost['id']] = $this->undoLikeAnnouncePost; $this->entitiesToRemoveAfterSetup[] = $likeActivity; $this->entitiesToRemoveAfterSetup[] = $undoActivity; } public function buildLikeRemotePostCommentInRemoteMagazine(PostComment $postComment): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $postComment); $this->likeAnnouncePostComment = $this->activityJsonBuilder->buildActivityJson($likeActivity); $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser); $this->undoLikeAnnouncePostComment = $this->activityJsonBuilder->buildActivityJson($undoActivity); $this->testingApHttpClient->activityObjects[$this->likeAnnouncePostComment['id']] = $this->likeAnnouncePostComment; $this->testingApHttpClient->activityObjects[$this->undoLikeAnnouncePostComment['id']] = $this->undoLikeAnnouncePostComment; $this->entitiesToRemoveAfterSetup[] = $likeActivity; $this->entitiesToRemoveAfterSetup[] = $undoActivity; } public function buildLikeRemoteEntryInLocalMagazine(Entry $entry): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $entry); $this->likeCreateEntry = $this->RewriteTargetFieldsToLocal($entry->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity)); $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser); $this->undoLikeCreateEntry = $this->RewriteTargetFieldsToLocal($entry->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity)); $this->testingApHttpClient->activityObjects[$this->likeCreateEntry['id']] = $this->likeCreateEntry; $this->testingApHttpClient->activityObjects[$this->undoLikeCreateEntry['id']] = $this->undoLikeCreateEntry; $this->entitiesToRemoveAfterSetup[] = $likeActivity; $this->entitiesToRemoveAfterSetup[] = $undoActivity; } public function buildLikeRemoteEntryCommentInLocalMagazine(EntryComment $comment): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $comment); $this->likeCreateEntryComment = $this->RewriteTargetFieldsToLocal($comment->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity)); $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser); $this->undoLikeCreateEntryComment = $this->RewriteTargetFieldsToLocal($comment->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity)); $this->testingApHttpClient->activityObjects[$this->likeCreateEntryComment['id']] = $this->likeCreateEntryComment; $this->testingApHttpClient->activityObjects[$this->undoLikeCreateEntryComment['id']] = $this->undoLikeCreateEntryComment; $this->entitiesToRemoveAfterSetup[] = $likeActivity; $this->entitiesToRemoveAfterSetup[] = $undoActivity; } public function buildLikeRemotePostInLocalMagazine(Post $post): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $post); $this->likeCreatePost = $this->RewriteTargetFieldsToLocal($post->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity)); $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser); $this->undoLikeCreatePost = $this->RewriteTargetFieldsToLocal($post->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity)); $this->testingApHttpClient->activityObjects[$this->likeCreatePost['id']] = $this->likeCreatePost; $this->testingApHttpClient->activityObjects[$this->undoLikeCreatePost['id']] = $this->undoLikeCreatePost; $this->entitiesToRemoveAfterSetup[] = $likeActivity; $this->entitiesToRemoveAfterSetup[] = $undoActivity; } public function buildLikeRemotePostCommentInLocalMagazine(PostComment $postComment): void { $likeActivity = $this->likeWrapper->build($this->remoteUser, $postComment); $this->likeCreatePostComment = $this->RewriteTargetFieldsToLocal($postComment->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity)); $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser); $this->undoLikeCreatePostComment = $this->RewriteTargetFieldsToLocal($postComment->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity)); $this->testingApHttpClient->activityObjects[$this->likeCreatePostComment['id']] = $this->likeCreatePostComment; $this->testingApHttpClient->activityObjects[$this->undoLikeCreatePostComment['id']] = $this->undoLikeCreatePostComment; $this->entitiesToRemoveAfterSetup[] = $likeActivity; $this->entitiesToRemoveAfterSetup[] = $undoActivity; } } ================================================ FILE: tests/Functional/ActivityPub/Inbox/LockHandlerTest.php ================================================ createLocalEntryAndCreateLockActivity($this->localMagazine, $this->localUser, $this->remoteUser); $activity = $obj['activity']; /** @var Entry $entry */ $entry = $obj['content']; $this->bus->dispatch(new ActivityMessage(json_encode($activity))); $this->entityManager->refresh($entry); self::assertTrue($entry->isLocked); $this->assertOneSentAnnouncedActivityOfType('Lock', $activity['id']); $this->bus->dispatch(new ActivityMessage(json_encode($obj['undo']))); $this->entityManager->refresh($entry); self::assertFalse($entry->isLocked); $this->assertOneSentAnnouncedActivityOfType('Undo', $obj['undo']['id']); } public function testLockLocalEntryInRemoteMagazineByRemoteModerator(): void { $obj = $this->createLocalEntryAndCreateLockActivity($this->remoteMagazine, $this->localUser, $this->remoteUser); $activity = $obj['activity']; /** @var Entry $entry */ $entry = $obj['content']; $this->bus->dispatch(new ActivityMessage(json_encode($activity))); $this->entityManager->refresh($entry); self::assertTrue($entry->isLocked); $this->assertCountOfSentActivitiesOfType(0, 'Lock'); $this->assertCountOfSentActivitiesOfType(0, 'Announce'); $this->bus->dispatch(new ActivityMessage(json_encode($obj['undo']))); $this->entityManager->refresh($entry); self::assertFalse($entry->isLocked); $this->assertCountOfSentActivitiesOfType(0, 'Undo'); $this->assertCountOfSentActivitiesOfType(0, 'Announce'); } public function testLockRemoteEntryInLocalMagazineByRemoteModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine))); $entryApId = $this->createRemoteEntryInLocalMagazine['object']['id']; $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertNotNull($entry); $this->bus->dispatch(new ActivityMessage(json_encode($this->lockRemoteEntryByRemoteModeratorInLocalMagazine))); $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertTrue($entry->isLocked); $this->assertOneSentAnnouncedActivityOfType('Lock', $this->lockRemoteEntryByRemoteModeratorInLocalMagazine['id']); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLockRemoteEntryByRemoteModeratorInLocalMagazine))); $this->entityManager->refresh($entry); self::assertFalse($entry->isLocked); $this->assertOneSentAnnouncedActivityOfType('Undo', $this->undoLockRemoteEntryByRemoteModeratorInLocalMagazine['id']); } public function testLockRemoteEntryInRemoteMagazineByRemoteModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine))); $entryApId = $this->createRemoteEntryInRemoteMagazine['object']['object']['id']; $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertNotNull($entry); $this->bus->dispatch(new ActivityMessage(json_encode($this->lockRemoteEntryByRemoteModeratorInRemoteMagazine))); $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertTrue($entry->isLocked); $this->assertCountOfSentActivitiesOfType(0, 'Lock'); $this->assertCountOfSentActivitiesOfType(0, 'Announce'); $lockActivities = $this->activityRepository->findBy(['type' => 'Lock']); self::assertEmpty($lockActivities); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLockRemoteEntryByRemoteModeratorInRemoteMagazine))); $this->entityManager->refresh($entry); self::assertFalse($entry->isLocked); $this->assertCountOfSentActivitiesOfType(0, 'Undo'); $this->assertCountOfSentActivitiesOfType(0, 'Announce'); } public function testLockLocalPostInLocalMagazineByRemoteModerator(): void { $obj = $this->createLocalPostAndCreateLockActivity($this->localMagazine, $this->localUser, $this->remoteUser); $activity = $obj['activity']; $post = $obj['content']; $this->bus->dispatch(new ActivityMessage(json_encode($activity))); $this->entityManager->refresh($post); self::assertTrue($post->isLocked); $this->assertOneSentAnnouncedActivityOfType('Lock', $activity['id']); $this->bus->dispatch(new ActivityMessage(json_encode($obj['undo']))); $this->entityManager->refresh($post); self::assertFalse($post->isLocked); $this->assertOneSentAnnouncedActivityOfType('Undo', $obj['undo']['id']); } public function testLockLocalPostInRemoteMagazineByRemoteModerator(): void { $obj = $this->createLocalPostAndCreateLockActivity($this->remoteMagazine, $this->localUser, $this->remoteUser); $activity = $obj['activity']; $post = $obj['content']; $this->bus->dispatch(new ActivityMessage(json_encode($activity))); $this->entityManager->refresh($post); self::assertTrue($post->isLocked); $this->assertCountOfSentActivitiesOfType(0, 'Lock'); $this->assertCountOfSentActivitiesOfType(0, 'Announce'); $this->bus->dispatch(new ActivityMessage(json_encode($obj['undo']))); $this->entityManager->refresh($post); self::assertFalse($post->isLocked); $this->assertCountOfSentActivitiesOfType(0, 'Undo'); $this->assertCountOfSentActivitiesOfType(0, 'Announce'); } public function testLockRemotePostInLocalMagazineByRemoteModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInLocalMagazine))); $postApId = $this->createRemotePostInLocalMagazine['object']['id']; $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertNotNull($post); $this->bus->dispatch(new ActivityMessage(json_encode($this->lockRemotePostByRemoteModeratorInLocalMagazine))); $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertTrue($post->isLocked); $this->assertOneSentAnnouncedActivityOfType('Lock', $this->lockRemotePostByRemoteModeratorInLocalMagazine['id']); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLockRemotePostByRemoteModeratorInLocalMagazine))); $this->entityManager->refresh($post); self::assertFalse($post->isLocked); $this->assertOneSentAnnouncedActivityOfType('Undo', $this->undoLockRemotePostByRemoteModeratorInLocalMagazine['id']); } public function testLockRemotePostInRemoteMagazineByRemoteModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInRemoteMagazine))); $postApId = $this->createRemotePostInRemoteMagazine['object']['object']['id']; $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertNotNull($post); $this->bus->dispatch(new ActivityMessage(json_encode($this->lockRemotePostByRemoteModeratorInRemoteMagazine))); $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertTrue($post->isLocked); $this->assertCountOfSentActivitiesOfType(0, 'Lock'); $this->assertCountOfSentActivitiesOfType(0, 'Announce'); $lockActivities = $this->activityRepository->findBy(['type' => 'Lock']); self::assertEmpty($lockActivities); $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLockRemotePostByRemoteModeratorInRemoteMagazine))); $this->entityManager->refresh($post); self::assertFalse($post->isLocked); $this->assertCountOfSentActivitiesOfType(0, 'Undo'); $this->assertCountOfSentActivitiesOfType(0, 'Announce'); } public function setUp(): void { parent::setUp(); $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->remoteUser)); $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->localUser)); $this->magazineManager->addModerator(new ModeratorDto($this->localMagazine, $this->remoteUser, $this->localMagazine->getOwner())); $this->magazineManager->subscribe($this->remoteMagazine, $this->remoteSubscriber); } protected function setUpRemoteActors(): void { parent::setUpRemoteActors(); $username = 'remotePoster'; $domain = $this->remoteDomain; $this->remotePoster = $this->getUserByUsername($username, addImage: false); $this->registerActor($this->remotePoster, $domain, true); } public function setUpRemoteEntities(): void { $this->createRemoteEntryInRemoteMagazine = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remotePoster, fn ($entry) => $this->createLockFromRemoteEntryInRemoteMagazine($entry)); $this->createRemotePostInRemoteMagazine = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remotePoster, fn ($post) => $this->createLockFromRemotePostInRemoteMagazine($post)); $this->createRemoteEntryInLocalMagazine = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remotePoster, fn ($entry) => $this->createLockFromRemoteEntryInLocalMagazine($entry)); $this->createRemotePostInLocalMagazine = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remotePoster, fn ($post) => $this->createLockFromRemotePostInLocalMagazine($post)); } private function createLockFromRemoteEntryInRemoteMagazine(Entry $createdEntry): void { $activities = $this->createLockAndUnlockForContent($createdEntry); $this->lockRemoteEntryByRemoteModeratorInRemoteMagazine = $activities['lock']; $this->undoLockRemoteEntryByRemoteModeratorInRemoteMagazine = $activities['unlock']; } private function createLockFromRemoteEntryInLocalMagazine(Entry $createdEntry): void { $activities = $this->createLockAndUnlockForContent($createdEntry); $this->lockRemoteEntryByRemoteModeratorInLocalMagazine = $activities['lock']; $this->undoLockRemoteEntryByRemoteModeratorInLocalMagazine = $activities['unlock']; } private function createLockFromRemotePostInRemoteMagazine(Post $post): void { $activities = $this->createLockAndUnlockForContent($post); $this->lockRemotePostByRemoteModeratorInRemoteMagazine = $activities['lock']; $this->undoLockRemotePostByRemoteModeratorInRemoteMagazine = $activities['unlock']; } private function createLockFromRemotePostInLocalMagazine(Post $ost): void { $activities = $this->createLockAndUnlockForContent($ost); $this->lockRemotePostByRemoteModeratorInLocalMagazine = $activities['lock']; $this->undoLockRemotePostByRemoteModeratorInLocalMagazine = $activities['unlock']; } /** * @return array{lock: array, unlock: array} */ private function createLockAndUnlockForContent(Entry|Post $content): array { $activity = $this->lockFactory->build($this->remoteUser, $content); $lock = $this->activityJsonBuilder->buildActivityJson($activity); $undoActivity = $this->undoWrapper->build($activity); $unlock = $this->activityJsonBuilder->buildActivityJson($undoActivity); $this->testingApHttpClient->activityObjects[$lock['id']] = $lock; $this->testingApHttpClient->activityObjects[$unlock['id']] = $unlock; $this->entitiesToRemoveAfterSetup[] = $activity; $this->entitiesToRemoveAfterSetup[] = $undoActivity; return [ 'lock' => $lock, 'unlock' => $unlock, ]; } /** * @return array{entry: Entry, activity: array, undo: array} */ private function createLocalEntryAndCreateLockActivity(Magazine $magazine, User $author, User $lockingUser): array { $entry = $this->getEntryByTitle('localEntry', magazine: $magazine, user: $author); $entryJson = $this->pageFactory->create($entry, [], false); $this->switchToRemoteDomain($this->remoteDomain); $activity = $this->lockFactory->build($lockingUser, $entry); $activityJson = $this->activityJsonBuilder->buildActivityJson($activity); $activityJson['object'] = $entryJson['id']; $undoActivity = $this->undoWrapper->build($activity); $undoJson = $this->activityJsonBuilder->buildActivityJson($undoActivity); $undoJson['object']['object'] = $entryJson['id']; $this->switchToLocalDomain(); $this->entityManager->remove($activity); $this->entityManager->remove($undoActivity); return [ 'activity' => $activityJson, 'content' => $entry, 'undo' => $undoJson, ]; } /** * @return array{content:Post, activity: array, undo: array} */ private function createLocalPostAndCreateLockActivity(Magazine $magazine, User $author, User $lockingUser): array { $post = $this->createPost('localPost', magazine: $magazine, user: $author); $postJson = $this->postNoteFactory->create($post, []); $this->switchToRemoteDomain($this->remoteDomain); $activity = $this->lockFactory->build($lockingUser, $post); $activityJson = $this->activityJsonBuilder->buildActivityJson($activity); $activityJson['object'] = $postJson['id']; $undoActivity = $this->undoWrapper->build($activity); $undoJson = $this->activityJsonBuilder->buildActivityJson($undoActivity); $undoJson['object']['object'] = $postJson['id']; $this->switchToLocalDomain(); $this->entityManager->remove($activity); $this->entityManager->remove($undoActivity); return [ 'activity' => $activityJson, 'content' => $post, 'undo' => $undoJson, ]; } } ================================================ FILE: tests/Functional/ActivityPub/Inbox/RemoveHandlerTest.php ================================================ remoteMagazine = $this->activityPubManager->findActorOrCreate('!remoteMagazine@remote.mbin'); $this->remoteUser = $this->activityPubManager->findActorOrCreate('@remoteUser@remote.mbin'); // it is important that the moderators are initialized here, as they would be removed from the db if added in `setupRemoteEntries` $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->remoteUser, $this->remoteMagazine->getOwner())); $this->magazineManager->addModerator(new ModeratorDto($this->localMagazine, $this->remoteUser, $this->localUser)); $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->remoteSubscriber, $this->remoteMagazine->getOwner())); $this->magazineManager->addModerator(new ModeratorDto($this->localMagazine, $this->remoteSubscriber, $this->localUser)); } public function testRemoveModeratorInRemoteMagazine(): void { self::assertTrue($this->remoteMagazine->userIsModerator($this->remoteSubscriber)); $this->bus->dispatch(new ActivityMessage(json_encode($this->removeModeratorRemoteMagazine))); self::assertFalse($this->remoteMagazine->userIsModerator($this->remoteSubscriber)); } public function testRemoveModeratorLocalMagazine(): void { self::assertTrue($this->localMagazine->userIsModerator($this->remoteSubscriber)); $this->bus->dispatch(new ActivityMessage(json_encode($this->removeModeratorLocalMagazine))); self::assertFalse($this->localMagazine->userIsModerator($this->remoteSubscriber)); $this->assertRemoveSentToSubscriber($this->removeModeratorLocalMagazine); } public function testRemovePinnedEntryInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine))); $entry = $this->entryRepository->findOneBy(['apId' => $this->createRemoteEntryInRemoteMagazine['object']['object']['id']]); self::assertNotNull($entry); $entry->sticky = true; $this->entityManager->flush(); $this->bus->dispatch(new ActivityMessage(json_encode($this->removePinnedEntryRemoteMagazine))); $this->entityManager->refresh($entry); self::assertFalse($entry->sticky); } public function testRemovePinnedEntryLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine))); $entry = $this->entryRepository->findOneBy(['apId' => $this->createRemoteEntryInLocalMagazine['object']['id']]); self::assertNotNull($entry); $entry->sticky = true; $this->entityManager->flush(); $this->bus->dispatch(new ActivityMessage(json_encode($this->removePinnedEntryLocalMagazine))); $this->entityManager->refresh($entry); self::assertFalse($entry->sticky); $this->assertRemoveSentToSubscriber($this->removePinnedEntryLocalMagazine); } public function setUpRemoteEntities(): void { $this->buildRemoveModeratorInRemoteMagazine(); $this->buildRemoveModeratorInLocalMagazine(); $this->createRemoteEntryInRemoteMagazine = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildRemovePinnedPostInRemoteMagazine($entry)); $this->createRemoteEntryInLocalMagazine = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildRemovePinnedPostInLocalMagazine($entry)); } private function buildRemoveModeratorInRemoteMagazine(): void { $removeActivity = $this->addRemoveFactory->buildRemoveModerator($this->remoteUser, $this->remoteSubscriber, $this->remoteMagazine); $this->removeModeratorRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($removeActivity); $this->removeModeratorRemoteMagazine['object'] = 'https://remote.sub.mbin/u/remoteSubscriber'; $this->testingApHttpClient->activityObjects[$this->removeModeratorRemoteMagazine['id']] = $this->removeModeratorRemoteMagazine; $this->entitiesToRemoveAfterSetup[] = $removeActivity; } private function buildRemoveModeratorInLocalMagazine(): void { $removeActivity = $this->addRemoveFactory->buildRemoveModerator($this->remoteUser, $this->remoteSubscriber, $this->localMagazine); $this->removeModeratorLocalMagazine = $this->activityJsonBuilder->buildActivityJson($removeActivity); $this->removeModeratorLocalMagazine['target'] = 'https://kbin.test/m/magazine/moderators'; $this->removeModeratorLocalMagazine['object'] = 'https://remote.sub.mbin/u/remoteSubscriber'; $this->testingApHttpClient->activityObjects[$this->removeModeratorLocalMagazine['id']] = $this->removeModeratorLocalMagazine; $this->entitiesToRemoveAfterSetup[] = $removeActivity; } private function buildRemovePinnedPostInRemoteMagazine(Entry $entry): void { $removeActivity = $this->addRemoveFactory->buildRemovePinnedPost($this->remoteUser, $entry); $this->removePinnedEntryRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($removeActivity); $this->testingApHttpClient->activityObjects[$this->removePinnedEntryRemoteMagazine['id']] = $this->removePinnedEntryRemoteMagazine; $this->entitiesToRemoveAfterSetup[] = $removeActivity; } private function buildRemovePinnedPostInLocalMagazine(Entry $entry): void { $removeActivity = $this->addRemoveFactory->buildRemovePinnedPost($this->remoteUser, $entry); $this->removePinnedEntryLocalMagazine = $this->activityJsonBuilder->buildActivityJson($removeActivity); $this->removePinnedEntryLocalMagazine['target'] = 'https://kbin.test/m/magazine/pinned'; $this->testingApHttpClient->activityObjects[$this->removePinnedEntryLocalMagazine['id']] = $this->removePinnedEntryLocalMagazine; $this->entitiesToRemoveAfterSetup[] = $removeActivity; } private function assertRemoveSentToSubscriber(array $originalPayload): void { $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedAddAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Remove' === $arr['payload']['object']['type']); $postedAddAnnounce = $postedAddAnnounces[array_key_first($postedAddAnnounces)]; // the id of the 'Remove' activity should be wrapped in an 'Announce' activity self::assertEquals($originalPayload['id'], $postedAddAnnounce['payload']['object']['id']); self::assertEquals($originalPayload['object'], $postedAddAnnounce['payload']['object']['object']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedAddAnnounce['inboxUrl']); } } ================================================ FILE: tests/Functional/ActivityPub/Inbox/UpdateHandlerTest.php ================================================ bus->dispatch(new ActivityMessage(json_encode($this->announceEntry))); $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]); self::assertStringNotContainsString('update', $entry->title); $this->bus->dispatch(new ActivityMessage(json_encode($this->updateAnnounceEntry))); $this->entityManager->refresh($entry); self::assertNotNull($entry); self::assertStringContainsString('update', $entry->title); self::assertStringContainsString('update', $entry->body); self::assertFalse($entry->isLocked); } public function testUpdateRemoteEntryCommentInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry))); $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntryComment))); $comment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]); self::assertStringNotContainsString('update', $comment->body); $this->bus->dispatch(new ActivityMessage(json_encode($this->updateAnnounceEntryComment))); $this->entityManager->refresh($comment); self::assertNotNull($comment); self::assertStringContainsString('update', $comment->body); } public function testUpdateRemotePostInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost))); $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]); self::assertStringNotContainsString('update', $post->body); $this->bus->dispatch(new ActivityMessage(json_encode($this->updateAnnouncePost))); $this->entityManager->refresh($post); self::assertNotNull($post); self::assertStringContainsString('update', $post->body); self::assertFalse($post->isLocked); } public function testUpdateRemotePostCommentInRemoteMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost))); $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePostComment))); $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]); self::assertStringNotContainsString('update', $postComment->body); $this->bus->dispatch(new ActivityMessage(json_encode($this->updateAnnouncePostComment))); $this->entityManager->refresh($postComment); self::assertNotNull($postComment); self::assertStringContainsString('update', $postComment->body); } public function testUpdateEntryInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry))); $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]); self::assertStringNotContainsString('update', $entry->title); $this->bus->dispatch(new ActivityMessage(json_encode($this->updateCreateEntry))); self::assertStringContainsString('update', $entry->title); // explicitly set in the build method self::assertTrue($entry->isLocked); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedUpdateAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Update' === $arr['payload']['object']['type']); $postedUpdateAnnounce = $postedUpdateAnnounces[array_key_first($postedUpdateAnnounces)]; // the id of the 'Update' activity should be wrapped in an 'Announce' activity self::assertEquals($this->updateCreateEntry['id'], $postedUpdateAnnounce['payload']['object']['id']); self::assertEquals($this->updateCreateEntry['object']['id'], $postedUpdateAnnounce['payload']['object']['object']['id']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUpdateAnnounce['inboxUrl']); } public function testUpdateEntryCommentInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry))); $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryComment))); $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]); self::assertNotNull($entryComment); self::assertStringNotContainsString('update', $entryComment->body); $this->bus->dispatch(new ActivityMessage(json_encode($this->updateCreateEntryComment))); self::assertStringContainsString('update', $entryComment->body); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedUpdateAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Update' === $arr['payload']['object']['type']); $postedUpdateAnnounce = $postedUpdateAnnounces[array_key_first($postedUpdateAnnounces)]; // the id of the 'Update' activity should be wrapped in an 'Announce' activity self::assertEquals($this->updateCreateEntryComment['id'], $postedUpdateAnnounce['payload']['object']['id']); self::assertEquals($this->updateCreateEntryComment['object']['id'], $postedUpdateAnnounce['payload']['object']['object']['id']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUpdateAnnounce['inboxUrl']); } public function testUpdatePostInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost))); $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]); self::assertStringNotContainsString('update', $post->body); $this->bus->dispatch(new ActivityMessage(json_encode($this->updateCreatePost))); self::assertStringContainsString('update', $post->body); // explicitly set in the build method self::assertTrue($post->isLocked); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedUpdateAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Update' === $arr['payload']['object']['type']); $postedUpdateAnnounce = $postedUpdateAnnounces[array_key_first($postedUpdateAnnounces)]; // the id of the 'Update' activity should be wrapped in an 'Announce' activity self::assertEquals($this->updateCreatePost['id'], $postedUpdateAnnounce['payload']['object']['id']); self::assertEquals($this->updateCreatePost['object']['id'], $postedUpdateAnnounce['payload']['object']['object']['id']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUpdateAnnounce['inboxUrl']); } public function testUpdatePostCommentInLocalMagazine(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost))); $this->bus->dispatch(new ActivityMessage(json_encode($this->createPostComment))); $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]); self::assertNotNull($postComment); self::assertStringNotContainsString('update', $postComment->body); $this->bus->dispatch(new ActivityMessage(json_encode($this->updateCreatePostComment))); self::assertStringContainsString('update', $postComment->body); $postedObjects = $this->testingApHttpClient->getPostedObjects(); self::assertNotEmpty($postedObjects); $postedUpdateAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Update' === $arr['payload']['object']['type']); $postedUpdateAnnounce = $postedUpdateAnnounces[array_key_first($postedUpdateAnnounces)]; // the id of the 'Update' activity should be wrapped in an 'Announce' activity self::assertEquals($this->updateCreatePostComment['id'], $postedUpdateAnnounce['payload']['object']['id']); self::assertEquals($this->updateCreatePostComment['object']['id'], $postedUpdateAnnounce['payload']['object']['object']['id']); self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUpdateAnnounce['inboxUrl']); } public function testUpdateRemoteUser(): void { // an update activity forces to fetch the remote object again -> rewrite the actor id to the updated object from the activity $this->testingApHttpClient->actorObjects[$this->updateUser['object']['id']] = $this->updateUser['object']; $this->bus->dispatch(new ActivityMessage(json_encode($this->updateUser))); $user = $this->userRepository->findOneBy(['apPublicUrl' => $this->updateUser['object']['id']]); self::assertNotNull($user); self::assertStringContainsString('update', $user->about); self::assertNotNull($user->publicKey); self::assertStringContainsString('new public key', $user->publicKey); self::assertNotNull($user->lastKeyRotationDate); } public function testUpdateRemoteUserTitle(): void { // an update activity forces to fetch the remote object again -> rewrite the actor id to the updated object from the activity $object = $this->updateUser['object']; $object['name'] = 'Test User'; $this->testingApHttpClient->actorObjects[$this->updateUser['object']['id']] = $object; $this->bus->dispatch(new ActivityMessage(json_encode($this->updateUser))); $user = $this->userRepository->findOneBy(['apPublicUrl' => $this->updateUser['object']['id']]); self::assertNotNull($user); self::assertEquals('Test User', $user->title); $object = $this->updateUser['object']; unset($object['name']); $this->testingApHttpClient->actorObjects[$this->updateUser['object']['id']] = $object; $this->bus->dispatch(new ActivityMessage(json_encode($this->updateUser))); $user = $this->userRepository->findOneBy(['apPublicUrl' => $this->updateUser['object']['id']]); self::assertNotNull($user); self::assertNull($user->title); } public function testUpdateRemoteMagazine(): void { // an update activity forces to fetch the remote object again -> rewrite the actor id to the updated object from the activity $this->testingApHttpClient->actorObjects[$this->updateMagazine['object']['id']] = $this->updateMagazine['object']; $this->bus->dispatch(new ActivityMessage(json_encode($this->updateMagazine))); $magazine = $this->magazineRepository->findOneBy(['apPublicUrl' => $this->updateMagazine['object']['id']]); self::assertNotNull($magazine); self::assertStringContainsString('update', $magazine->description); self::assertNotNull($magazine->publicKey); self::assertStringContainsString('new public key', $magazine->publicKey); self::assertNotNull($magazine->lastKeyRotationDate); } public function setUpRemoteEntities(): void { $this->announceEntry = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildUpdateRemoteEntryInRemoteMagazine($entry)); $this->announceEntryComment = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildUpdateRemoteEntryCommentInRemoteMagazine($comment)); $this->announcePost = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Post $post) => $this->buildUpdateRemotePostInRemoteMagazine($post)); $this->announcePostComment = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildUpdateRemotePostCommentInRemoteMagazine($comment)); $this->createEntry = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildUpdateRemoteEntryInLocalMagazine($entry)); $this->createEntryComment = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildUpdateRemoteEntryCommentInLocalMagazine($comment)); $this->createPost = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Post $post) => $this->buildUpdateRemotePostInLocalMagazine($post)); $this->createPostComment = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildUpdateRemotePostCommentInLocalMagazine($comment)); $this->buildUpdateUser(); $this->buildUpdateMagazine(); } public function buildUpdateRemoteEntryInRemoteMagazine(Entry $entry): void { $updateActivity = $this->updateWrapper->buildForActivity($entry, $this->remoteUser); $entry->title = 'Some updated title'; $entry->body = 'Some updated body'; $this->updateAnnounceEntry = $this->activityJsonBuilder->buildActivityJson($updateActivity); $this->testingApHttpClient->activityObjects[$this->updateAnnounceEntry['id']] = $this->updateAnnounceEntry; $this->entitiesToRemoveAfterSetup[] = $updateActivity; } public function buildUpdateRemoteEntryCommentInRemoteMagazine(EntryComment $comment): void { $updateActivity = $this->updateWrapper->buildForActivity($comment, $this->remoteUser); $comment->body = 'Some updated body'; $this->updateAnnounceEntryComment = $this->activityJsonBuilder->buildActivityJson($updateActivity); $this->testingApHttpClient->activityObjects[$this->updateAnnounceEntryComment['id']] = $this->updateAnnounceEntryComment; $this->entitiesToRemoveAfterSetup[] = $updateActivity; } public function buildUpdateRemotePostInRemoteMagazine(Post $post): void { $updateActivity = $this->updateWrapper->buildForActivity($post, $this->remoteUser); $post->body = 'Some updated body'; $this->updateAnnouncePost = $this->activityJsonBuilder->buildActivityJson($updateActivity); $this->testingApHttpClient->activityObjects[$this->updateAnnouncePost['id']] = $this->updateAnnouncePost; $this->entitiesToRemoveAfterSetup[] = $updateActivity; } public function buildUpdateRemotePostCommentInRemoteMagazine(PostComment $postComment): void { $updateActivity = $this->updateWrapper->buildForActivity($postComment, $this->remoteUser); $postComment->body = 'Some updated body'; $this->updateAnnouncePostComment = $this->activityJsonBuilder->buildActivityJson($updateActivity); $this->testingApHttpClient->activityObjects[$this->updateAnnouncePostComment['id']] = $this->updateAnnouncePostComment; $this->entitiesToRemoveAfterSetup[] = $updateActivity; } public function buildUpdateRemoteEntryInLocalMagazine(Entry $entry): void { $updateActivity = $this->updateWrapper->buildForActivity($entry, $this->remoteUser); $titleBefore = $entry->title; $entry->title = 'Some updated title'; $entry->body = 'Some updated body'; $entry->isLocked = true; $this->updateCreateEntry = $this->RewriteTargetFieldsToLocal($entry->magazine, $this->activityJsonBuilder->buildActivityJson($updateActivity)); $entry->title = $titleBefore; $entry->isLocked = false; $this->testingApHttpClient->activityObjects[$this->updateCreateEntry['id']] = $this->updateCreateEntry; $this->entitiesToRemoveAfterSetup[] = $updateActivity; } public function buildUpdateRemoteEntryCommentInLocalMagazine(EntryComment $comment): void { $updateActivity = $this->updateWrapper->buildForActivity($comment, $this->remoteUser); $comment->body = 'Some updated body'; $this->updateCreateEntryComment = $this->RewriteTargetFieldsToLocal($comment->magazine, $this->activityJsonBuilder->buildActivityJson($updateActivity)); $this->testingApHttpClient->activityObjects[$this->updateCreateEntryComment['id']] = $this->updateCreateEntryComment; $this->entitiesToRemoveAfterSetup[] = $updateActivity; } public function buildUpdateRemotePostInLocalMagazine(Post $post): void { $updateActivity = $this->updateWrapper->buildForActivity($post, $this->remoteUser); $bodyBefore = $post->body; $post->body = 'Some updated body'; $post->isLocked = true; $this->updateCreatePost = $this->RewriteTargetFieldsToLocal($post->magazine, $this->activityJsonBuilder->buildActivityJson($updateActivity)); $post->body = $bodyBefore; $post->isLocked = false; $this->testingApHttpClient->activityObjects[$this->updateCreatePost['id']] = $this->updateCreatePost; $this->entitiesToRemoveAfterSetup[] = $updateActivity; } public function buildUpdateRemotePostCommentInLocalMagazine(PostComment $postComment): void { $updateActivity = $this->updateWrapper->buildForActivity($postComment, $this->remoteUser); $postComment->body = 'Some updated body'; $this->updateCreatePostComment = $this->RewriteTargetFieldsToLocal($postComment->magazine, $this->activityJsonBuilder->buildActivityJson($updateActivity)); $this->testingApHttpClient->activityObjects[$this->updateCreatePostComment['id']] = $this->updateCreatePostComment; $this->entitiesToRemoveAfterSetup[] = $updateActivity; } public function buildUpdateUser(): void { $aboutBefore = $this->remoteUser->about; $this->remoteUser->about = 'Some updated user description'; $this->remoteUser->publicKey = 'Some new public key'; $this->remoteUser->privateKey = 'Some new private key'; $updateActivity = $this->updateWrapper->buildForActor($this->remoteUser, $this->remoteUser); $this->updateUser = $this->activityJsonBuilder->buildActivityJson($updateActivity); $this->remoteUser->about = $aboutBefore; $this->testingApHttpClient->activityObjects[$this->updateUser['id']] = $this->updateUser; $this->entitiesToRemoveAfterSetup[] = $updateActivity; } public function buildUpdateMagazine(): void { $descriptionBefore = $this->remoteMagazine->description; $this->remoteMagazine->description = 'Some updated magazine description'; $this->remoteMagazine->publicKey = 'Some new public key'; $this->remoteMagazine->privateKey = 'Some new private key'; $updateActivity = $this->updateWrapper->buildForActor($this->remoteMagazine, $this->remoteMagazine->getOwner()); $this->updateMagazine = $this->activityJsonBuilder->buildActivityJson($updateActivity); $this->remoteMagazine->description = $descriptionBefore; $this->testingApHttpClient->activityObjects[$this->updateMagazine['id']] = $this->updateMagazine; $this->entitiesToRemoveAfterSetup[] = $updateActivity; } } ================================================ FILE: tests/Functional/ActivityPub/MarkdownConverterTest.php ================================================ switchToRemoteDomain($domain); $this->registerActor($this->getUserByUsername('someUser', email: "someUser@$domain"), $domain, true); $this->registerActor($this->getMagazineByName('someMagazine'), $domain, true); $this->switchToLocalDomain(); } public function setUp(): void { parent::setUp(); // generate the local user 'someUser' $user = $this->getUserByUsername('someUser', email: 'someUser@kbin.test'); $this->getMagazineByName('someMagazine', $user); $mastodonUser = new User('SomeUser@mastodon.tld', 'SomeUser@mastodon.tld', '', 'Person', 'https://mastodon.tld/users/SomeAccount'); $mastodonUser->apPublicUrl = 'https://mastodon.tld/@SomeAccount'; $this->entityManager->persist($mastodonUser); } #[DataProvider('htmlMentionsProvider')] public function testMentions(string $html, array $apTags, array $expectedMentions, string $name): void { $converted = $this->apMarkdownConverter->convert($html, $apTags); $mentions = $this->mentionManager->extract($converted); assertEquals($expectedMentions, $mentions, message: "Mention test '$name'"); } public static function htmlMentionsProvider(): array { return [ [ 'html' => '

    @someUser @someUser@kbin.test

    ', 'apTags' => [ [ 'type' => 'Mention', 'href' => 'https://some.domain.tld/u/someUser', 'name' => '@someUser', ], [ 'type' => 'Mention', 'href' => 'https://kbin.test/u/someUser', 'name' => '@someUser@kbin.test', ], ], 'expectedMentions' => ['@someUser@some.domain.tld', '@someUser@kbin.test'], 'name' => 'Local and remote user', ], [ 'html' => '

    @someMagazine

    ', 'apTags' => [ [ 'type' => 'Mention', 'href' => 'https://some.domain.tld/m/someMagazine', 'name' => '@someMagazine', ], ], 'expectedMentions' => ['@someMagazine@some.domain.tld'], 'name' => 'Magazine mention', ], [ 'html' => '

    @someMagazine

    ', 'apTags' => [ [ 'type' => 'Mention', 'href' => 'https://kbin.test/m/someMagazine', 'name' => '@someMagazine', ], ], 'expectedMentions' => ['@someMagazine@kbin.test'], 'name' => 'Local magazine mention', ], [ 'html' => '@SomeAccount', 'apTags' => [ [ 'type' => 'Mention', 'href' => 'https://mastodon.tld/users/SomeAccount', 'name' => '@SomeAccount@mastodon.tld', ], ], 'expectedMentions' => ['@SomeAccount@mastodon.tld'], 'name' => 'Mastodon account mention', ], ]; } } ================================================ FILE: tests/Functional/ActivityPub/Outbox/BlockHandlerTest.php ================================================ localSubscriber = $this->getUserByUsername('localSubscriber', addImage: false); // so localSubscriber has one interaction with another instance $this->magazineManager->subscribe($this->remoteMagazine, $this->localSubscriber); } public function setUpRemoteEntities(): void { } public function testBanLocalUserLocalMagazineLocalModerator(): void { $this->magazineManager->ban($this->localMagazine, $this->localSubscriber, $this->localUser, MagazineBanDto::create(reason: 'test')); $blockActivity = $this->assertOneSentActivityOfType('Block', inboxUrl: $this->remoteSubscriber->apInboxUrl); self::assertEquals('test', $blockActivity['summary']); self::assertEquals($this->personFactory->getActivityPubId($this->localSubscriber), $blockActivity['object']); self::assertEquals($this->groupFactory->getActivityPubId($this->localMagazine), $blockActivity['target']); } public function testUndoBanLocalUserLocalMagazineLocalModerator(): void { $this->magazineManager->ban($this->localMagazine, $this->localSubscriber, $this->localUser, MagazineBanDto::create(reason: 'test')); $blockActivity = $this->assertOneSentActivityOfType('Block', inboxUrl: $this->remoteSubscriber->apInboxUrl); $this->magazineManager->unban($this->localMagazine, $this->localSubscriber); $undoActivity = $this->assertOneSentActivityOfType('Undo', inboxUrl: $this->remoteSubscriber->apInboxUrl); self::assertEquals($blockActivity['id'], $undoActivity['object']['id']); } public function testBanRemoteUserLocalMagazineLocalModerator(): void { $this->magazineManager->ban($this->localMagazine, $this->remoteSubscriber, $this->localUser, MagazineBanDto::create(reason: 'test')); $blockActivity = $this->assertOneSentActivityOfType('Block', inboxUrl: $this->remoteSubscriber->apInboxUrl); self::assertEquals('test', $blockActivity['summary']); self::assertEquals($this->remoteSubscriber->apProfileId, $blockActivity['object']); self::assertEquals($this->groupFactory->getActivityPubId($this->localMagazine), $blockActivity['target']); } public function testUndoBanRemoteUserLocalMagazineLocalModerator(): void { $this->magazineManager->ban($this->localMagazine, $this->remoteSubscriber, $this->localUser, MagazineBanDto::create(reason: 'test')); $blockActivity = $this->assertOneSentActivityOfType('Block', inboxUrl: $this->remoteSubscriber->apInboxUrl); $this->magazineManager->unban($this->localMagazine, $this->remoteSubscriber); $undoActivity = $this->assertOneSentActivityOfType('Undo', inboxUrl: $this->remoteSubscriber->apInboxUrl); self::assertEquals($blockActivity['id'], $undoActivity['object']['id']); } public function testBanLocalUserInstanceLocalModerator(): void { $this->userManager->ban($this->localSubscriber, $this->localUser, 'test'); $blockActivity = $this->assertOneSentActivityOfType('Block', inboxUrl: $this->remoteMagazine->apInboxUrl); self::assertEquals('test', $blockActivity['summary']); self::assertEquals($this->personFactory->getActivityPubId($this->localSubscriber), $blockActivity['object']); self::assertEquals($this->instanceFactory->getTargetUrl(), $blockActivity['target']); } public function testUndoBanLocalUserInstanceLocalModerator(): void { $this->userManager->ban($this->localSubscriber, $this->localUser, 'test'); $blockActivity = $this->assertOneSentActivityOfType('Block', inboxUrl: $this->remoteMagazine->apInboxUrl); $this->userManager->unban($this->localSubscriber, $this->localUser, 'test'); $undoActivity = $this->assertOneSentActivityOfType('Undo', inboxUrl: $this->remoteMagazine->apInboxUrl); self::assertEquals($blockActivity['id'], $undoActivity['object']['id']); } } ================================================ FILE: tests/Functional/ActivityPub/Outbox/DeleteHandlerTest.php ================================================ magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->localUser)); $this->localPoster = $this->getUserByUsername('localPoster', addImage: false); } public function setUpRemoteEntities(): void { $this->createRemoteEntryInRemoteMagazine = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remotePoster); $this->createRemoteEntryCommentInRemoteMagazine = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remotePoster); $this->createRemotePostInRemoteMagazine = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remotePoster); $this->createRemotePostCommentInRemoteMagazine = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remotePoster); $this->createRemoteEntryInLocalMagazine = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remotePoster); $this->createRemoteEntryCommentInLocalMagazine = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remotePoster); $this->createRemotePostInLocalMagazine = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remotePoster); $this->createRemotePostCommentInLocalMagazine = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remotePoster); } protected function setUpRemoteActors(): void { parent::setUpRemoteActors(); $username = 'remotePoster'; $domain = $this->remoteDomain; $this->remotePoster = $this->getUserByUsername($username, addImage: false); $this->registerActor($this->remotePoster, $domain, true); } public function testDeleteLocalEntryInLocalMagazineByLocalModerator(): void { $entry = $this->getEntryByTitle(title: 'test entry', magazine: $this->localMagazine, user: $this->localUser); $this->entryManager->delete($this->localUser, $entry); $this->assertOneSentActivityOfType('Delete'); } public function testDeleteLocalEntryInRemoteMagazineByLocalModerator(): void { $entry = $this->getEntryByTitle(title: 'test entry', magazine: $this->remoteMagazine, user: $this->localUser); $this->entryManager->delete($this->localUser, $entry); $this->assertOneSentActivityOfType('Delete'); } public function testDeleteRemoteEntryInLocalMagazineByLocalModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine))); $entryApId = $this->createRemoteEntryInLocalMagazine['object']['id']; $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertNotNull($entry); $this->entryManager->delete($this->localUser, $entry); $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertTrue($entry->isTrashed()); $this->assertOneSentActivityOfType('Delete'); } public function testDeleteRemoteEntryInRemoteMagazineByLocalModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine))); $entryApId = $this->createRemoteEntryInRemoteMagazine['object']['object']['id']; $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertNotNull($entry); $this->entryManager->purge($this->localUser, $entry); $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertNull($entry); self::assertNotEmpty($this->testingApHttpClient->getPostedObjects()); $deleteActivity = $this->activityRepository->findOneBy(['type' => 'Delete']); self::assertNotNull($deleteActivity); $activityId = $this->urlGenerator->generate('ap_object', ['id' => $deleteActivity->uuid], UrlGeneratorInterface::ABSOLUTE_URL); $this->assertOneSentActivityOfType('Delete', $activityId); } public function testDeleteLocalEntryCommentInLocalMagazineByLocalModerator(): void { $entry = $this->getEntryByTitle(title: 'test entry', magazine: $this->localMagazine, user: $this->localUser); $comment = $this->createEntryComment('test entry comment', entry: $entry, user: $this->localUser); $this->removeActivitiesWithObject($comment); $this->entryCommentManager->delete($this->localUser, $comment); $this->assertOneSentActivityOfType('Delete'); } public function testDeleteLocalEntryCommentInRemoteMagazineByLocalModerator(): void { $entry = $this->getEntryByTitle(title: 'test entry', magazine: $this->remoteMagazine, user: $this->localUser); $comment = $this->createEntryComment('test entry comment', entry: $entry, user: $this->localUser); $this->removeActivitiesWithObject($comment); $this->entryCommentManager->delete($this->localUser, $comment); $this->assertOneSentActivityOfType('Delete'); } public function testDeleteRemoteEntryCommentInLocalMagazineByLocalModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine))); $entryApId = $this->createRemoteEntryInLocalMagazine['object']['id']; $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertNotNull($entry); $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryCommentInLocalMagazine))); $entryCommentApId = $this->createRemoteEntryCommentInLocalMagazine['object']['id']; $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]); self::assertNotNull($entryComment); $this->entryCommentManager->delete($this->localUser, $entryComment); $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]); self::assertTrue($entryComment->isTrashed()); // 2 subs -> 2 delete activities $this->assertCountOfSentActivitiesOfType(2, 'Delete'); } public function testDeleteRemoteEntryCommentInRemoteMagazineByLocalModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine))); $entryApId = $this->createRemoteEntryInRemoteMagazine['object']['object']['id']; $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]); self::assertNotNull($entry); $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryCommentInRemoteMagazine))); $entryCommentApId = $this->createRemoteEntryCommentInRemoteMagazine['object']['object']['id']; $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]); self::assertNotNull($entryComment); $this->entryCommentManager->purge($this->localUser, $entryComment); $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]); self::assertNull($entryComment); self::assertNotEmpty($this->testingApHttpClient->getPostedObjects()); $deleteActivity = $this->activityRepository->findOneBy(['type' => 'Delete']); self::assertNotNull($deleteActivity); $activityId = $this->urlGenerator->generate('ap_object', ['id' => $deleteActivity->uuid], UrlGeneratorInterface::ABSOLUTE_URL); $this->assertOneSentActivityOfType('Delete', $activityId); } public function testDeleteLocalPostInLocalMagazineByLocalModerator(): void { $post = $this->createPost(body: 'test post', magazine: $this->localMagazine, user: $this->localUser); $this->postManager->delete($this->localUser, $post); $this->assertOneSentActivityOfType('Delete'); } public function testDeleteLocalPostInRemoteMagazineByLocalModerator(): void { $post = $this->createPost(body: 'test post', magazine: $this->remoteMagazine, user: $this->localUser); $this->postManager->delete($this->localUser, $post); $this->assertOneSentActivityOfType('Delete'); } public function testDeleteRemotePostInLocalMagazineByLocalModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInLocalMagazine))); $postApId = $this->createRemotePostInLocalMagazine['object']['id']; $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertNotNull($post); $this->postManager->delete($this->localUser, $post); $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertTrue($post->isTrashed()); $this->assertOneSentActivityOfType('Delete'); } public function testDeleteRemotePostInRemoteMagazineByLocalModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInRemoteMagazine))); $postApId = $this->createRemotePostInRemoteMagazine['object']['object']['id']; $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertNotNull($post); $this->postManager->purge($this->localUser, $post); $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertNull($post); self::assertNotEmpty($this->testingApHttpClient->getPostedObjects()); $deleteActivity = $this->activityRepository->findOneBy(['type' => 'Delete']); self::assertNotNull($deleteActivity); $activityId = $this->urlGenerator->generate('ap_object', ['id' => $deleteActivity->uuid], UrlGeneratorInterface::ABSOLUTE_URL); $this->assertOneSentActivityOfType('Delete', $activityId); } public function testDeleteLocalPostCommentInLocalMagazineByLocalModerator(): void { $post = $this->createPost(body: 'test post', magazine: $this->localMagazine, user: $this->localUser); $comment = $this->createPostComment('test post comment', post: $post, user: $this->localUser); $this->removeActivitiesWithObject($comment); $this->postCommentManager->delete($this->localUser, $comment); $this->assertOneSentActivityOfType('Delete'); } public function testDeleteLocalPostCommentInRemoteMagazineByLocalModerator(): void { $post = $this->createPost(body: 'test post', magazine: $this->remoteMagazine, user: $this->localUser); $comment = $this->createPostComment('test post comment', post: $post, user: $this->localUser); $this->removeActivitiesWithObject($comment); $this->postCommentManager->delete($this->localUser, $comment); $this->assertOneSentActivityOfType('Delete'); } public function testDeleteRemotePostCommentInLocalMagazineByLocalModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInLocalMagazine))); $postApId = $this->createRemotePostInLocalMagazine['object']['id']; $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertNotNull($post); $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostCommentInLocalMagazine))); $postCommentApId = $this->createRemotePostCommentInLocalMagazine['object']['id']; $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]); self::assertNotNull($postComment); $this->postCommentManager->delete($this->localUser, $postComment); $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]); self::assertTrue($postComment->isTrashed()); // 2 subs -> 2 delete activities $this->assertCountOfSentActivitiesOfType(2, 'Delete'); } public function testDeleteRemotePostCommentInRemoteMagazineByLocalModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInRemoteMagazine))); $postApId = $this->createRemotePostInRemoteMagazine['object']['object']['id']; $post = $this->postRepository->findOneBy(['apId' => $postApId]); self::assertNotNull($post); $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostCommentInRemoteMagazine))); $postCommentApId = $this->createRemotePostCommentInRemoteMagazine['object']['object']['id']; $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]); self::assertNotNull($postComment); $this->postCommentManager->purge($this->localUser, $postComment); $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]); self::assertNull($postComment); self::assertNotEmpty($this->testingApHttpClient->getPostedObjects()); $deleteActivity = $this->activityRepository->findOneBy(['type' => 'Delete']); self::assertNotNull($deleteActivity); $activityId = $this->urlGenerator->generate('ap_object', ['id' => $deleteActivity->uuid], UrlGeneratorInterface::ABSOLUTE_URL); $this->assertOneSentActivityOfType('Delete', $activityId); } public function testDeleteLocalEntryInRemoteMagazineByAuthor(): void { $entry = $this->createEntry('test local entry', $this->remoteMagazine, $this->localPoster); $createEntryActivity = $this->activityRepository->findOneBy(['objectEntry' => $entry]); $this->entityManager->remove($createEntryActivity); $this->entryManager->delete($this->localPoster, $entry); $this->assertOneSentActivityOfType('Delete'); } public function testDeleteLocalEntryCommentInRemoteMagazineByAuthor(): void { $entry = $this->createEntry('test local entry', $this->remoteMagazine, $this->localPoster); $entryComment = $this->createEntryComment('test local entryComment', $entry, $this->localPoster); $createEntryCommentActivity = $this->activityRepository->findOneBy(['objectEntryComment' => $entryComment]); $this->entityManager->remove($createEntryCommentActivity); $this->entryCommentManager->delete($this->localPoster, $entryComment); $this->assertOneSentActivityOfType('Delete'); } public function testDeleteLocalPostInRemoteMagazineByAuthor(): void { $post = $this->createPost('test local post', $this->remoteMagazine, $this->localPoster); $createPostActivity = $this->activityRepository->findOneBy(['objectPost' => $post]); $this->entityManager->remove($createPostActivity); $this->postManager->delete($this->localPoster, $post); $this->assertOneSentActivityOfType('Delete'); } public function testDeleteLocalPostCommentInRemoteMagazineByAuthor(): void { $post = $this->createPost('test local post', $this->remoteMagazine, $this->localPoster); $postComment = $this->createPostComment('test local post comment', $post, $this->localPoster); $createPostCommentActivity = $this->activityRepository->findOneBy(['objectPostComment' => $postComment]); $this->entityManager->remove($createPostCommentActivity); $this->postCommentManager->delete($this->localPoster, $postComment); $this->assertOneSentActivityOfType('Delete'); } public function removeActivitiesWithObject(ActivityPubActivityInterface|ActivityPubActorInterface $object): void { $activities = $this->activityRepository->findAllActivitiesByObject($object); foreach ($activities as $activity) { $this->entityManager->remove($activity); } } } ================================================ FILE: tests/Functional/ActivityPub/Outbox/LockHandlerTest.php ================================================ magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->localUser)); $this->localPoster = $this->getUserByUsername('localPoster', addImage: false); } public function setUpRemoteEntities(): void { $this->createRemoteEntryInRemoteMagazine = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remotePoster); $this->createRemotePostInRemoteMagazine = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remotePoster); $this->createRemoteEntryInLocalMagazine = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remotePoster); $this->createRemotePostInLocalMagazine = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remotePoster); } protected function setUpRemoteActors(): void { parent::setUpRemoteActors(); $username = 'remotePoster'; $domain = $this->remoteDomain; $this->remotePoster = $this->getUserByUsername($username, addImage: false); $this->registerActor($this->remotePoster, $domain, true); } public function testLockLocalEntryInLocalMagazineByLocalModerator(): void { $entry = $this->createEntry('Some local entry', $this->localMagazine, $this->localPoster); $this->entryManager->toggleLock($entry, $this->localUser); $this->assertOneSentActivityOfType('Lock'); } public function testLockLocalEntryInRemoteMagazineByLocalModerator(): void { $entry = $this->createEntry('Some local entry', $this->remoteMagazine, $this->localPoster); $this->entryManager->toggleLock($entry, $this->localUser); $this->assertOneSentActivityOfType('Lock'); } public function testLockRemoteEntryInLocalMagazineByLocalModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine))); $entry = $this->entryRepository->findOneBy(['apId' => $this->createRemoteEntryInLocalMagazine['object']['id']]); self::assertNotNull($entry); $this->entryManager->toggleLock($entry, $this->localUser); $this->assertOneSentActivityOfType('Lock'); } public function testLockRemoteEntryInRemoteMagazineByLocalModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine))); $entry = $this->entryRepository->findOneBy(['apId' => $this->createRemoteEntryInRemoteMagazine['object']['object']['id']]); self::assertNotNull($entry); $this->entryManager->toggleLock($entry, $this->localUser); $this->assertOneSentActivityOfType('Lock'); } public function testLockLocalPostInLocalMagazineByLocalModerator(): void { $post = $this->createPost('Some post', $this->localMagazine, $this->localPoster); $this->postManager->toggleLock($post, $this->localUser); $this->assertOneSentActivityOfType('Lock'); } public function testLockLocalPostInRemoteMagazineByLocalModerator(): void { $post = $this->createPost('Some post', $this->remoteMagazine, $this->localPoster); $this->postManager->toggleLock($post, $this->localUser); $this->assertOneSentActivityOfType('Lock'); } public function testLockRemotePostInLocalMagazineByLocalModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInLocalMagazine))); $post = $this->postRepository->findOneBy(['apId' => $this->createRemotePostInLocalMagazine['object']['id']]); self::assertNotNull($post); $this->postManager->toggleLock($post, $this->localUser); $this->assertOneSentActivityOfType('Lock'); } public function testLockRemotePostInRemoteMagazineByLocalModerator(): void { $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInRemoteMagazine))); $post = $this->postRepository->findOneBy(['apId' => $this->createRemotePostInRemoteMagazine['object']['object']['id']]); self::assertNotNull($post); $this->postManager->toggleLock($post, $this->localUser); $this->assertOneSentActivityOfType('Lock'); } public function testLockLocalEntryInRemoteMagazineByAuthor(): void { $entry = $this->createEntry('Some local entry', $this->remoteMagazine, $this->localPoster); $this->entryManager->toggleLock($entry, $this->localPoster); $this->assertOneSentActivityOfType('Lock'); } public function testLockLocalPostInRemoteMagazineByAuthor(): void { $post = $this->createPost('Some local post', $this->remoteMagazine, $this->localPoster); $this->postManager->toggleLock($post, $this->localPoster); $this->assertOneSentActivityOfType('Lock'); } } ================================================ FILE: tests/Functional/Command/AdminCommandTest.php ================================================ create('actor', 'contact@example.com'); $dto->plainPassword = 'secret'; $this->getContainer()->get(UserManager::class) ->create($dto, false); $this->assertFalse($this->repository->findOneByUsername('actor')->isAdmin()); $tester = new CommandTester($this->command); $tester->execute(['username' => 'actor']); $this->assertStringContainsString('Administrator privileges have been granted.', $tester->getDisplay()); $this->assertTrue($this->repository->findOneByUsername('actor')->isAdmin()); } protected function setUp(): void { $application = new Application(self::bootKernel()); $this->command = $application->find('mbin:user:admin'); $this->repository = $this->getContainer()->get(UserRepository::class); } } ================================================ FILE: tests/Functional/Command/ModeratorCommandTest.php ================================================ create('actor', 'contact@example.com'); $dto->plainPassword = 'secret'; $this->getContainer()->get(UserManager::class) ->create($dto, false); $this->assertFalse($this->repository->findOneByUsername('actor')->isModerator()); $tester = new CommandTester($this->command); $tester->execute(['username' => 'actor']); $this->assertStringContainsString('Global moderator privileges have been granted.', $tester->getDisplay()); $this->assertTrue($this->repository->findOneByUsername('actor')->isModerator()); } protected function setUp(): void { $application = new Application(self::bootKernel()); $this->command = $application->find('mbin:user:moderator'); $this->repository = $this->getContainer()->get(UserRepository::class); } } ================================================ FILE: tests/Functional/Command/UserCommandTest.php ================================================ command); $tester->execute( [ 'username' => 'actor', 'email' => 'contact@example.com', 'password' => 'secret', ] ); $this->assertStringContainsString('A user has been created.', $tester->getDisplay()); $this->assertInstanceOf(User::class, $this->repository->findOneByUsername('actor')); } public function testCreateAdminUser(): void { $tester = new CommandTester($this->command); $tester->execute( [ 'username' => 'actor', 'email' => 'contact@example.com', 'password' => 'secret', '--admin' => true, ], ); $this->assertStringContainsString('A user has been created.', $tester->getDisplay()); $actor = $this->repository->findOneByUsername('actor'); $this->assertInstanceOf(User::class, $actor); $this->assertTrue($actor->isAdmin()); } protected function setUp(): void { $application = new Application(self::bootKernel()); $this->command = $application->find('mbin:user:create'); $this->repository = $this->getContainer()->get(UserRepository::class); } } ================================================ FILE: tests/Functional/Controller/ActivityPub/GeneralAPTest.php ================================================ getUserByUsername('user'); $this->client->request('GET', '/u/user', [], [], [ 'HTTP_ACCEPT' => $acceptHeader, ]); self::assertResponseHeaderSame('Content-Type', 'application/activity+json'); } public static function provideAcceptHeaders(): array { return [ ['application/ld+json;profile=https://www.w3.org/ns/activitystreams'], ['application/ld+json;profile="https://www.w3.org/ns/activitystreams"'], ['application/ld+json ; profile="https://www.w3.org/ns/activitystreams"'], ['application/ld+json'], ['application/activity+json'], ['application/json'], ]; } } ================================================ FILE: tests/Functional/Controller/ActivityPub/UserOutboxControllerTest.php ================================================ getUserByUsername('apUser', addImage: false); $user2 = $this->getUserByUsername('apUser2', addImage: false); $magazine = $this->getMagazineByName('test-magazine'); // create a message to test that it is not part of the outbox $dto = new MessageDto(); $dto->body = 'this is a message'; $thread = $this->messageManager->toThread($dto, $user, $user2); $entry = $this->createEntry('entry', $magazine, user: $user); $entryComment = $this->createEntryComment('comment', $entry, user: $user); $post = $this->createPost('post', $magazine, user: $user); $postComment = $this->createPostComment('comment', $post, user: $user); // upvote an entry to check that it is not part of the outbox $entryToLike = $this->getEntryByTitle('test entry 2'); $this->favouriteManager->toggle($user, $entryToLike); // downvote an entry to check that it is not part of the outbox $entryToDislike = $this->getEntryByTitle('test entry 3'); $this->voteManager->vote(-1, $entryToDislike, $user); // boost an entry to check that it is part of the outbox $entryToDislike = $this->getEntryByTitle('test entry 4'); $this->voteManager->vote(1, $entryToDislike, $user); } public function testUserOutbox(): void { $this->client->request('GET', '/u/apUser/outbox', server: ['HTTP_ACCEPT' => 'application/activity+json']); self::assertResponseIsSuccessful(); $json = self::getJsonResponse($this->client); self::assertArrayKeysMatch(self::COLLECTION_KEYS, $json); self::assertEquals('OrderedCollection', $json['type']); self::assertEquals(5, $json['totalItems']); $firstPage = $json['first']; $this->client->request('GET', $firstPage, server: ['HTTP_ACCEPT' => 'application/activity+json']); self::assertResponseIsSuccessful(); } public function testUserOutboxPage1(): void { $this->client->request('GET', '/u/apUser/outbox?page=1', server: ['HTTP_ACCEPT' => 'application/activity+json']); self::assertResponseIsSuccessful(); $json = self::getJsonResponse($this->client); self::assertArrayKeysMatch(self::COLLECTION_ITEMS_KEYS, $json); self::assertEquals(5, $json['totalItems']); self::assertCount(5, $json['orderedItems']); $entries = array_filter($json['orderedItems'], fn (array $createActivity) => 'Create' === $createActivity['type'] && 'Page' === $createActivity['object']['type']); self::assertCount(1, $entries); $entryComments = array_filter($json['orderedItems'], fn (array $createActivity) => 'Create' === $createActivity['type'] && 'Note' === $createActivity['object']['type'] && str_contains($createActivity['object']['inReplyTo'] ?? '', '/t/')); self::assertCount(1, $entryComments); $posts = array_filter($json['orderedItems'], fn (array $createActivity) => 'Create' === $createActivity['type'] && 'Note' === $createActivity['object']['type'] && null === $createActivity['object']['inReplyTo']); self::assertCount(1, $posts); $postComments = array_filter($json['orderedItems'], fn (array $createActivity) => 'Create' === $createActivity['type'] && 'Note' === $createActivity['object']['type'] && str_contains($createActivity['object']['inReplyTo'] ?? '', '/p/')); self::assertCount(1, $postComments); $boosts = array_filter($json['orderedItems'], fn (array $createActivity) => 'Announce' === $createActivity['type']); self::assertCount(1, $boosts); // the outbox should not contain ChatMessages, likes or dislikes $likes = array_filter($json['orderedItems'], fn (array $createActivity) => 'Like' === $createActivity['type']); self::assertCount(0, $likes); $dislikes = array_filter($json['orderedItems'], fn (array $createActivity) => 'Dislike' === $createActivity['type']); self::assertCount(0, $dislikes); $chatMessages = array_filter($json['orderedItems'], fn (array $createActivity) => 'Create' === $createActivity['type'] && 'ChatMessage' === $createActivity['object']['type']); self::assertCount(0, $chatMessages); $ids = array_map(fn (array $createActivity) => $createActivity['id'], $json['orderedItems']); $this->client->request('GET', '/u/apUser/outbox?page=1', server: ['HTTP_ACCEPT' => 'application/activity+json']); self::assertResponseIsSuccessful(); $json = self::getJsonResponse($this->client); $ids2 = array_map(fn (array $createActivity) => $createActivity['id'], $json['orderedItems']); // check that the ids of the 'Create' activities are stable self::assertEquals($ids, $ids2); } } ================================================ FILE: tests/Functional/Controller/Admin/AdminFederationControllerTest.php ================================================ instanceRepository->getOrCreateInstance('www.example.com'); $this->instanceManager->banInstance($instance); $this->client->loginUser($this->getUserByUsername('admin', isAdmin: true)); $crawler = $this->client->request('GET', '/admin/federation'); $this->client->submit($crawler->filter('#content tr td button[type=submit]')->form()); $this->assertSame( [], $this->settingsManager->getBannedInstances(), ); } } ================================================ FILE: tests/Functional/Controller/Admin/AdminUserControllerTest.php ================================================ getUserByUsername('inactiveUser', active: false); $admin = $this->getUserByUsername('admin', isAdmin: true); $this->client->loginUser($admin); $this->client->request('GET', '/admin/users/inactive'); self::assertResponseIsSuccessful(); self::assertAnySelectorTextContains('a.user-inline', 'inactiveUser'); } } ================================================ FILE: tests/Functional/Controller/Api/Bookmark/BookmarkApiTest.php ================================================ user = $this->getUserByUsername('user'); $this->client->loginUser($this->user); self::createOAuth2PublicAuthCodeClient(); $codes = self::getPublicAuthorizationCodeTokenResponse($this->client, scopes: 'read bookmark bookmark_list'); $this->token = $codes['token_type'].' '.$codes['access_token']; // it seems that the oauth flow detaches the user object from the entity manager, so fetch it again $this->user = $this->userRepository->findOneByUsername('user'); } public function testBookmarkEntryToDefault(): void { $entry = $this->getEntryByTitle('entry'); $this->client->request('PUT', "/api/bos/{$entry->getId()}/entry", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $this->bookmarkListRepository->findOneByUserDefault($this->user)); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); } public function testBookmarkEntryCommentToDefault(): void { $entry = $this->getEntryByTitle('entry'); $comment = $this->createEntryComment('comment', $entry); $this->client->request('PUT', "/api/bos/{$comment->getId()}/entry_comment", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $this->bookmarkListRepository->findOneByUserDefault($this->user)); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); } public function testBookmarkPostToDefault(): void { $post = $this->createPost('post'); $this->client->request('PUT', "/api/bos/{$post->getId()}/post", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $this->bookmarkListRepository->findOneByUserDefault($this->user)); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); } public function testBookmarkPostCommentToDefault(): void { $post = $this->createPost('entry'); $comment = $this->createPostComment('comment', $post); $this->client->request('PUT', "/api/bos/{$comment->getId()}/post_comment", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $this->bookmarkListRepository->findOneByUserDefault($this->user)); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); } public function testRemoveBookmarkEntryFromDefault(): void { $entry = $this->getEntryByTitle('entry'); $this->client->request('PUT', "/api/bos/{$entry->getId()}/entry", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $list = $this->bookmarkListRepository->findOneByUserDefault($this->user); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); $this->client->request('DELETE', "/api/rbo/{$entry->getId()}/entry", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(0, $bookmarks); } public function testRemoveBookmarkEntryCommentFromDefault(): void { $entry = $this->getEntryByTitle('entry'); $comment = $this->createEntryComment('comment', $entry); $this->client->request('PUT', "/api/bos/{$comment->getId()}/entry_comment", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $list = $this->bookmarkListRepository->findOneByUserDefault($this->user); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); $this->client->request('DELETE', "/api/rbo/{$comment->getId()}/entry_comment", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(0, $bookmarks); } public function testRemoveBookmarkPostFromDefault(): void { $post = $this->createPost('post'); $this->client->request('PUT', "/api/bos/{$post->getId()}/post", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $list = $this->bookmarkListRepository->findOneByUserDefault($this->user); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); $this->client->request('DELETE', "/api/rbo/{$post->getId()}/post", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(0, $bookmarks); } public function testRemoveBookmarkPostCommentFromDefault(): void { $post = $this->createPost('entry'); $comment = $this->createPostComment('comment', $post); $this->client->request('PUT', "/api/bos/{$comment->getId()}/post_comment", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $list = $this->bookmarkListRepository->findOneByUserDefault($this->user); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); $this->client->request('DELETE', "/api/rbo/{$comment->getId()}/post_comment", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(0, $bookmarks); } public function testBookmarkEntryToList(): void { $this->entityManager->refresh($this->user); $list = $this->bookmarkManager->createList($this->user, 'list'); $entry = $this->getEntryByTitle('entry'); $this->client->request('PUT', "/api/bol/{$entry->getId()}/entry/$list->name", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); } public function testBookmarkEntryCommentToList(): void { $list = $this->bookmarkManager->createList($this->user, 'list'); $entry = $this->getEntryByTitle('entry'); $comment = $this->createEntryComment('comment', $entry); $this->client->request('PUT', "/api/bol/{$comment->getId()}/entry_comment/$list->name", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); } public function testBookmarkPostToList(): void { $list = $this->bookmarkManager->createList($this->user, 'list'); $post = $this->createPost('post'); $this->client->request('PUT', "/api/bol/{$post->getId()}/post/$list->name", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); } public function testBookmarkPostCommentToList(): void { $list = $this->bookmarkManager->createList($this->user, 'list'); $post = $this->createPost('entry'); $comment = $this->createPostComment('comment', $post); $this->client->request('PUT', "/api/bol/{$comment->getId()}/post_comment/$list->name", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); } public function testRemoveBookmarkEntryFromList(): void { $list = $this->bookmarkManager->createList($this->user, 'list'); $entry = $this->getEntryByTitle('entry'); $this->client->request('PUT', "/api/bol/{$entry->getId()}/entry/$list->name", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); $this->client->request('DELETE', "/api/rbol/{$entry->getId()}/entry/$list->name", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(0, $bookmarks); } public function testRemoveBookmarkEntryCommentFromList(): void { $list = $this->bookmarkManager->createList($this->user, 'list'); $entry = $this->getEntryByTitle('entry'); $comment = $this->createEntryComment('comment', $entry); $this->client->request('PUT', "/api/bol/{$comment->getId()}/entry_comment/$list->name", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); $this->client->request('DELETE', "/api/rbol/{$comment->getId()}/entry_comment/$list->name", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(0, $bookmarks); } public function testRemoveBookmarkPostFromList(): void { $list = $this->bookmarkManager->createList($this->user, 'list'); $post = $this->createPost('post'); $this->client->request('PUT', "/api/bol/{$post->getId()}/post/$list->name", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); $this->client->request('DELETE', "/api/rbol/{$post->getId()}/post/$list->name", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(0, $bookmarks); } public function testRemoveBookmarkPostCommentFromList(): void { $list = $this->bookmarkManager->createList($this->user, 'list'); $post = $this->createPost('entry'); $comment = $this->createPostComment('comment', $post); $this->client->request('PUT', "/api/bol/{$comment->getId()}/post_comment/$list->name", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(1, $bookmarks); $this->client->request('DELETE', "/api/rbol/{$comment->getId()}/post_comment/$list->name", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $bookmarks = $this->bookmarkRepository->findByList($this->user, $list); self::assertIsArray($bookmarks); self::assertCount(0, $bookmarks); } public function testBookmarkedEntryJson(): void { $entry = $this->getEntryByTitle('entry'); $list = $this->bookmarkManager->createList($this->user, 'list'); $this->bookmarkManager->addBookmarkToDefaultList($this->user, $entry); $this->bookmarkManager->addBookmark($this->user, $list, $entry); $this->client->request('GET', "/api/entry/{$entry->getId()}", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); assertIsArray($jsonData['bookmarks']); assertCount(2, $jsonData['bookmarks']); self::assertContains('list', $jsonData['bookmarks']); } public function testBookmarkedEntryCommentJson(): void { $entry = $this->getEntryByTitle('entry'); $comment = $this->createEntryComment('comment', $entry); $list = $this->bookmarkManager->createList($this->user, 'list'); $this->bookmarkManager->addBookmarkToDefaultList($this->user, $comment); $this->bookmarkManager->addBookmark($this->user, $list, $comment); $this->client->request('GET', "/api/comments/{$comment->getId()}", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); assertIsArray($jsonData['bookmarks']); assertCount(2, $jsonData['bookmarks']); self::assertContains('list', $jsonData['bookmarks']); } public function testBookmarkedPostJson(): void { $post = $this->createPost('post'); $list = $this->bookmarkManager->createList($this->user, 'list'); $this->bookmarkManager->addBookmarkToDefaultList($this->user, $post); $this->bookmarkManager->addBookmark($this->user, $list, $post); $this->client->request('GET', "/api/post/{$post->getId()}", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); assertIsArray($jsonData['bookmarks']); assertCount(2, $jsonData['bookmarks']); self::assertContains('list', $jsonData['bookmarks']); } public function testBookmarkedPostCommentJson(): void { $post = $this->createPost('post'); $comment = $this->createPostComment('comment', $post); $list = $this->bookmarkManager->createList($this->user, 'list'); $this->bookmarkManager->addBookmarkToDefaultList($this->user, $comment); $this->bookmarkManager->addBookmark($this->user, $list, $comment); $this->client->request('GET', "/api/post-comments/{$comment->getId()}", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); assertIsArray($jsonData['bookmarks']); assertCount(2, $jsonData['bookmarks']); self::assertContains('list', $jsonData['bookmarks']); } public function testBookmarkListFront(): void { $list = $this->bookmarkManager->createList($this->user, 'list'); $entry = $this->getEntryByTitle('entry'); $comment = $this->createEntryComment('comment', $entry); $comment2 = $this->createEntryComment('coment2', $entry, parent: $comment); $post = $this->createPost('post'); $postComment = $this->createPostComment('comment', $post); $postComment2 = $this->createPostComment('comment2', $post, parent: $postComment); $this->bookmarkManager->addBookmark($this->user, $list, $entry); $this->bookmarkManager->addBookmark($this->user, $list, $comment); $this->bookmarkManager->addBookmark($this->user, $list, $comment2); $this->bookmarkManager->addBookmark($this->user, $list, $post); $this->bookmarkManager->addBookmark($this->user, $list, $postComment); $this->bookmarkManager->addBookmark($this->user, $list, $postComment2); $this->client->request('GET', "/api/bookmark-lists/show?list={$list->name}", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); assertIsArray($jsonData['items']); assertCount(6, $jsonData['items']); } } ================================================ FILE: tests/Functional/Controller/Api/Bookmark/BookmarkListApiTest.php ================================================ user = $this->getUserByUsername('user'); $this->client->loginUser($this->user); self::createOAuth2PublicAuthCodeClient(); $codes = self::getPublicAuthorizationCodeTokenResponse($this->client, scopes: 'bookmark_list'); $this->token = $codes['token_type'].' '.$codes['access_token']; } public function testCreateList(): void { $this->client->request('GET', '/api/bookmark-lists', server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData['items']); self::assertCount(0, $jsonData['items']); $this->client->request('POST', '/api/bookmark-lists/test-list', server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertEquals('test-list', $jsonData['name']); self::assertEquals(0, $jsonData['count']); self::assertFalse($jsonData['isDefault']); $this->client->request('GET', '/api/bookmark-lists', server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertEquals('test-list', $jsonData['items'][0]['name']); self::assertEquals(0, $jsonData['items'][0]['count']); self::assertFalse($jsonData['items'][0]['isDefault']); } public function testRenameList(): void { $dto = new BookmarkListDto(); $dto->name = 'new-test-list'; $this->client->request('POST', '/api/bookmark-lists/test-list', server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $this->client->jsonRequest('PUT', '/api/bookmark-lists/test-list', parameters: $dto->jsonSerialize(), server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertEquals('new-test-list', $jsonData['name']); self::assertEquals(0, $jsonData['count']); self::assertFalse($jsonData['isDefault']); $dto = new BookmarkListDto(); $dto->name = 'new-test-list2'; $dto->isDefault = true; $this->client->jsonRequest('PUT', '/api/bookmark-lists/new-test-list', parameters: $dto->jsonSerialize(), server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertEquals('new-test-list2', $jsonData['name']); self::assertEquals(0, $jsonData['count']); self::assertTrue($jsonData['isDefault']); } public function testDeleteList(): void { $this->client->request('GET', '/api/bookmark-lists', server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData['items']); self::assertCount(0, $jsonData['items']); $this->client->request('POST', '/api/bookmark-lists/test-list', server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); $this->client->request('GET', '/api/bookmark-lists', server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); $this->client->request('DELETE', '/api/bookmark-lists/test-list', server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $this->client->request('GET', '/api/bookmark-lists', server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData['items']); self::assertCount(0, $jsonData['items']); } public function testMakeListDefault(): void { $this->client->request('POST', '/api/bookmark-lists/test-list', server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $this->client->jsonRequest('PUT', '/api/bookmark-lists/test-list/makeDefault', server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertEquals('test-list', $jsonData['name']); self::assertEquals(0, $jsonData['count']); self::assertTrue($jsonData['isDefault']); $this->client->request('GET', '/api/bookmark-lists', server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['items'][0]); self::assertEquals('test-list', $jsonData['items'][0]['name']); self::assertEquals(0, $jsonData['items'][0]['count']); self::assertTrue($jsonData['items'][0]['isDefault']); } } ================================================ FILE: tests/Functional/Controller/Api/Combined/CombinedRetrieveApiCursoredTest.php ================================================ magazine = $this->getMagazineByName('acme'); $this->user = $this->getUserByUsername('user'); $this->magazineManager->subscribe($this->magazine, $this->user); for ($i = 0; $i < 10; ++$i) { $entry = $this->getEntryByTitle("Test Entry $i", magazine: $this->magazine); $entry->createdAt = new \DateTimeImmutable("now - $i minutes"); $this->entityManager->persist($entry); $this->generatedEntries[] = $entry; ++$i; $post = $this->createPost("Test Post $i", magazine: $this->magazine); $post->createdAt = new \DateTimeImmutable("now - $i minutes"); $this->entityManager->persist($post); $this->generatedPosts[] = $post; } $this->entityManager->flush(); } public function testCombinedAnonymous(): void { $this->client->request('GET', '/api/combined?perPage=2&content=all&sort=newest'); self::assertResponseIsSuccessful(); $data = self::getJsonResponse($this->client); self::assertArrayKeysMatch(WebTestCase::PAGINATED_KEYS, $data); self::assertCount(2, $data['items']); self::assertArrayKeysMatch(WebTestCase::PAGINATION_KEYS, $data['pagination']); self::assertEquals(5, $data['pagination']['maxPage']); self::assertArrayKeysMatch(WebTestCase::ENTRY_RESPONSE_KEYS, $data['items'][0]['entry']); self::assertNull($data['items'][0]['post']); assertEquals($this->generatedEntries[0]->getId(), $data['items'][0]['entry']['entryId']); self::assertArrayKeysMatch(WebTestCase::POST_RESPONSE_KEYS, $data['items'][1]['post']); self::assertNull($data['items'][1]['entry']); assertEquals($this->generatedPosts[0]->getId(), $data['items'][1]['post']['postId']); } public function testCombinedCursoredAnonymous(): void { $this->client->request('GET', '/api/combined/v2?perPage=2&sort=newest'); self::assertResponseIsSuccessful(); $data = self::getJsonResponse($this->client); $this->assertCursorDataShape($data); } public function testUserCombinedCursored(): void { $this->client->loginUser($this->user); self::createOAuth2PublicAuthCodeClient(); $codes = self::getPublicAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/combined/v2/subscribed?perPage=2&sort=newest', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $data = self::getJsonResponse($this->client); $this->assertCursorDataShape($data); } public function testCombinedCursoredPagination(): void { $this->client->request('GET', '/api/combined/v2?perPage=2&sort=commented'); self::assertResponseIsSuccessful(); $data1 = self::getJsonResponse($this->client); self::assertCount(2, $data1['items']); self::assertArrayKeysMatch(WebTestCase::CURSOR_PAGINATION_KEYS, $data1['pagination']); self::assertNotNull($data1['pagination']['nextCursor']); self::assertNotNull($data1['pagination']['nextCursor2']); self::assertNotNull($data1['pagination']['currentCursor']); self::assertNotNull($data1['pagination']['currentCursor2']); self::assertNull($data1['pagination']['previousCursor']); self::assertNull($data1['pagination']['previousCursor2']); $this->client->request('GET', '/api/combined/v2?perPage=2&sort=commented&cursor='.urlencode($data1['pagination']['nextCursor']).'&cursor2='.urlencode($data1['pagination']['nextCursor2'])); self::assertResponseIsSuccessful(); $data2 = self::getJsonResponse($this->client); self::assertCount(2, $data2['items']); self::assertArrayKeysMatch(WebTestCase::CURSOR_PAGINATION_KEYS, $data2['pagination']); self::assertNotNull($data2['pagination']['nextCursor']); self::assertNotNull($data2['pagination']['nextCursor2']); self::assertNotNull($data2['pagination']['currentCursor']); self::assertNotNull($data2['pagination']['currentCursor2']); self::assertNotNull($data2['pagination']['previousCursor']); self::assertNotNull($data2['pagination']['previousCursor2']); $this->client->request('GET', '/api/combined/v2?perPage=2&sort=commented&cursor='.urlencode($data2['pagination']['previousCursor']).'&cursor2='.urlencode($data2['pagination']['previousCursor2'])); self::assertResponseIsSuccessful(); $data3 = self::getJsonResponse($this->client); self::assertCount(2, $data3['items']); self::assertArrayKeysMatch(WebTestCase::CURSOR_PAGINATION_KEYS, $data3['pagination']); self::assertNotNull($data3['pagination']['nextCursor']); self::assertNotNull($data3['pagination']['nextCursor2']); self::assertNotNull($data3['pagination']['currentCursor']); self::assertNotNull($data3['pagination']['currentCursor2']); self::assertNull($data3['pagination']['previousCursor']); self::assertNull($data3['pagination']['previousCursor2']); self::assertEquals($data1['items'][0]['entry']['entryId'], $data3['items'][0]['entry']['entryId']); self::assertEquals($data1['items'][1]['post']['postId'], $data3['items'][1]['post']['postId']); } private function assertCursorDataShape(array $data): void { self::assertArrayKeysMatch(WebTestCase::PAGINATED_KEYS, $data); self::assertCount(2, $data['items']); self::assertArrayKeysMatch(WebTestCase::CURSOR_PAGINATION_KEYS, $data['pagination']); self::assertNotNull($data['pagination']['nextCursor']); self::assertNotNull($data['pagination']['nextCursor2']); self::assertNotNull($data['pagination']['currentCursor']); self::assertNotNull($data['pagination']['currentCursor2']); self::assertNull($data['pagination']['previousCursor']); self::assertNull($data['pagination']['previousCursor2']); self::assertArrayKeysMatch(WebTestCase::ENTRY_RESPONSE_KEYS, $data['items'][0]['entry']); self::assertNull($data['items'][0]['post']); assertEquals($this->generatedEntries[0]->getId(), $data['items'][0]['entry']['entryId']); self::assertArrayKeysMatch(WebTestCase::POST_RESPONSE_KEYS, $data['items'][1]['post']); self::assertNull($data['items'][1]['entry']); assertEquals($this->generatedPosts[0]->getId(), $data['items'][1]['post']['postId']); $this->client->request('GET', '/api/combined/v2?perPage=2&sort=newest&cursor='.urlencode($data['pagination']['nextCursor'])); self::assertResponseIsSuccessful(); $data = self::getJsonResponse($this->client); self::assertCount(2, $data['items']); self::assertArrayKeysMatch(WebTestCase::CURSOR_PAGINATION_KEYS, $data['pagination']); self::assertNotNull($data['pagination']['nextCursor']); self::assertNotNull($data['pagination']['nextCursor2']); self::assertNotNull($data['pagination']['currentCursor']); self::assertNotNull($data['pagination']['currentCursor2']); self::assertNotNull($data['pagination']['previousCursor']); self::assertNotNull($data['pagination']['previousCursor2']); self::assertArrayKeysMatch(WebTestCase::ENTRY_RESPONSE_KEYS, $data['items'][0]['entry']); self::assertNull($data['items'][0]['post']); assertEquals($this->generatedEntries[1]->getId(), $data['items'][0]['entry']['entryId']); self::assertArrayKeysMatch(WebTestCase::POST_RESPONSE_KEYS, $data['items'][1]['post']); self::assertNull($data['items'][1]['entry']); assertEquals($this->generatedPosts[1]->getId(), $data['items'][1]['post']['postId']); } } ================================================ FILE: tests/Functional/Controller/Api/Combined/CombinedRetrieveApiTest.php ================================================ getUserByUsername('user'); $userFollowing = $this->getUserByUsername('user2'); $user3 = $this->getUserByUsername('user3'); $magazine = $this->getMagazineByName('abc'); $this->userManager->follow($user, $userFollowing, false); $postFollowed = $this->createPost('a post', user: $userFollowing); $postBoosted = $this->createPost('third user post', user: $user3); $this->createPost('unrelated post', user: $user3); $postCommentFollowed = $this->createPostComment('a comment', $postBoosted, $userFollowing); $postCommentBoosted = $this->createPostComment('a boosted comment', $postBoosted, $user3); $this->createPostComment('unrelated comment', $postBoosted, $user3); $entryFollowed = $this->createEntry('title', $magazine, body: 'an entry', user: $userFollowing); $entryBoosted = $this->createEntry('title', $magazine, body: 'third user post', user: $user3); $this->createEntry('title', $magazine, body: 'unrelated post', user: $user3); $entryCommentFollowed = $this->createEntryComment('a comment', $entryBoosted, $userFollowing); $entryCommentBoosted = $this->createEntryComment('a boosted comment', $entryBoosted, $user3); $this->createEntryComment('unrelated comment', $entryBoosted, $user3); $this->voteManager->upvote($postBoosted, $userFollowing); $this->voteManager->upvote($postCommentBoosted, $userFollowing); $this->voteManager->upvote($entryBoosted, $userFollowing); $this->voteManager->upvote($entryCommentBoosted, $userFollowing); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/combined/subscribed?includeBoosts=true', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(8, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(8, $jsonData['pagination']['count']); $retrievedPostIds = array_map(function ($item) { if (null !== $item['post']) { self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $item['post']); return $item['post']['postId']; } else { return null; } }, $jsonData['items']); $retrievedPostIds = array_filter($retrievedPostIds, function ($item) { return null !== $item; }); sort($retrievedPostIds); $retrievedPostCommentIds = array_map(function ($item) { if (null !== $item['postComment']) { self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $item['postComment']); return $item['postComment']['commentId']; } else { return null; } }, $jsonData['items']); $retrievedPostCommentIds = array_filter($retrievedPostCommentIds, function ($item) { return null !== $item; }); sort($retrievedPostCommentIds); $retrievedEntryIds = array_map(function ($item) { if (null !== $item['entry']) { self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $item['entry']); return $item['entry']['entryId']; } else { return null; } }, $jsonData['items']); $retrievedEntryIds = array_filter($retrievedEntryIds, function ($item) { return null !== $item; }); sort($retrievedEntryIds); $retrievedEntryCommentIds = array_map(function ($item) { if (null !== $item['entryComment']) { self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $item['entryComment']); return $item['entryComment']['commentId']; } else { return null; } }, $jsonData['items']); $retrievedEntryCommentIds = array_filter($retrievedEntryCommentIds, function ($item) { return null !== $item; }); sort($retrievedEntryCommentIds); $expectedPostIds = [$postFollowed->getId(), $postBoosted->getId()]; sort($expectedPostIds); $expectedPostCommentIds = [$postCommentFollowed->getId(), $postCommentBoosted->getId()]; sort($expectedPostCommentIds); $expectedEntryIds = [$entryFollowed->getId(), $entryBoosted->getId()]; sort($expectedEntryIds); $expectedEntryCommentIds = [$entryCommentFollowed->getId(), $entryCommentBoosted->getId()]; sort($expectedEntryCommentIds); self::assertEquals($retrievedPostIds, $expectedPostIds); self::assertEquals($expectedPostCommentIds, $expectedPostCommentIds); self::assertEquals($expectedEntryIds, $retrievedEntryIds); self::assertEquals($expectedEntryCommentIds, $retrievedEntryCommentIds); } public function testApiHonersIncludeBoostsUserSetting(): void { $user = $this->getUserByUsername('user'); $userFollowing = $this->getUserByUsername('user2'); $user3 = $this->getUserByUsername('user3'); $this->userManager->follow($user, $userFollowing, false); $this->createPost('a post', user: $userFollowing); $postBoosted = $this->createPost('third user post', user: $user3); $this->voteManager->upvote($postBoosted, $userFollowing); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/combined/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); $this->userRepository->find($user->getId())->showBoostsOfFollowing = true; $this->entityManager->flush(); $this->client->request('GET', '/api/combined/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); } } ================================================ FILE: tests/Functional/Controller/Api/Domain/DomainBlockApiTest.php ================================================ getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $this->client->request('PUT', "/api/domain/{$domain->getId()}/block"); self::assertResponseStatusCodeSame(401); } public function testApiCannotBlockDomainWithoutScope() { $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('JohnDoe')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/domain/{$domain->getId()}/block", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } #[Group(name: 'NonThreadSafe')] public function testApiCanBlockDomain() { $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('JohnDoe')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/domain/{$domain->getId()}/block", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData); self::assertEquals('example.com', $jsonData['name']); self::assertSame(1, $jsonData['entryCount']); self::assertSame(0, $jsonData['subscriptionsCount']); self::assertTrue($jsonData['isBlockedByUser']); // Scope not granted so subscribe flag not populated self::assertNull($jsonData['isUserSubscribed']); // Idempotent when called multiple times $this->client->request('PUT', "/api/domain/{$domain->getId()}/block", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData); self::assertEquals('example.com', $jsonData['name']); self::assertSame(1, $jsonData['entryCount']); self::assertSame(0, $jsonData['subscriptionsCount']); self::assertTrue($jsonData['isBlockedByUser']); // Scope not granted so subscribe flag not populated self::assertNull($jsonData['isUserSubscribed']); } public function testApiCannotUnblockDomainAnonymous() { $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $this->client->request('PUT', "/api/domain/{$domain->getId()}/unblock"); self::assertResponseStatusCodeSame(401); } public function testApiCannotUnblockDomainWithoutScope() { $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('JohnDoe')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/domain/{$domain->getId()}/unblock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } #[Group(name: 'NonThreadSafe')] public function testApiCanUnblockDomain() { $user = $this->getUserByUsername('JohnDoe'); $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $manager = $this->domainManager; $manager->block($domain, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/domain/{$domain->getId()}/unblock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData); self::assertEquals('example.com', $jsonData['name']); self::assertSame(1, $jsonData['entryCount']); self::assertSame(0, $jsonData['subscriptionsCount']); self::assertFalse($jsonData['isBlockedByUser']); // Scope not granted so subscribe flag not populated self::assertNull($jsonData['isUserSubscribed']); // Idempotent when called multiple times $this->client->request('PUT', "/api/domain/{$domain->getId()}/unblock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData); self::assertEquals('example.com', $jsonData['name']); self::assertSame(1, $jsonData['entryCount']); self::assertSame(0, $jsonData['subscriptionsCount']); self::assertFalse($jsonData['isBlockedByUser']); // Scope not granted so subscribe flag not populated self::assertNull($jsonData['isUserSubscribed']); } } ================================================ FILE: tests/Functional/Controller/Api/Domain/DomainRetrieveApiTest.php ================================================ getEntryByTitle('Test link to a domain', 'https://example.com'); $this->client->request('GET', '/api/domains'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('example.com', $jsonData['items'][0]['name']); self::assertSame(1, $jsonData['items'][0]['entryCount']); self::assertSame(0, $jsonData['items'][0]['subscriptionsCount']); self::assertNull($jsonData['items'][0]['isUserSubscribed']); self::assertNull($jsonData['items'][0]['isBlockedByUser']); } public function testApiCanRetrieveDomains() { $this->getEntryByTitle('Test link to a domain', 'https://example.com'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('JohnDoe')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/domains', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('example.com', $jsonData['items'][0]['name']); self::assertSame(1, $jsonData['items'][0]['entryCount']); self::assertSame(0, $jsonData['items'][0]['subscriptionsCount']); // Scope not granted so subscription and block flags not populated self::assertNull($jsonData['items'][0]['isUserSubscribed']); self::assertNull($jsonData['items'][0]['isBlockedByUser']); } public function testApiCanRetrieveDomainsSubscriptionAndBlockStatus() { $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $user = $this->getUserByUsername('JohnDoe'); $manager = $this->domainManager; $manager->subscribe($domain, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:subscribe domain:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/domains', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('example.com', $jsonData['items'][0]['name']); self::assertSame(1, $jsonData['items'][0]['entryCount']); self::assertSame(1, $jsonData['items'][0]['subscriptionsCount']); // Scope granted so subscription and block flags populated self::assertTrue($jsonData['items'][0]['isUserSubscribed']); self::assertFalse($jsonData['items'][0]['isBlockedByUser']); } public function testApiCannotRetrieveSubscribedDomainsAnonymous() { $this->client->request('GET', '/api/domains/subscribed'); self::assertResponseStatusCodeSame(401); } public function testApiCannotRetrieveSubscribedDomainsWithoutScope() { $this->getEntryByTitle('Test link to a second domain', 'https://example.org'); $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $user = $this->getUserByUsername('JohnDoe'); $manager = $this->domainManager; $manager->subscribe($domain, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/domains/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveSubscribedDomains() { $this->getEntryByTitle('Test link to a second domain', 'https://example.org'); $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $user = $this->getUserByUsername('JohnDoe'); $manager = $this->domainManager; $manager->subscribe($domain, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:subscribe'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/domains/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame(1, $jsonData['items'][0]['entryCount']); self::assertEquals('example.com', $jsonData['items'][0]['name']); self::assertSame(1, $jsonData['items'][0]['entryCount']); self::assertSame(1, $jsonData['items'][0]['subscriptionsCount']); // Scope granted so subscription flag populated self::assertTrue($jsonData['items'][0]['isUserSubscribed']); self::assertNull($jsonData['items'][0]['isBlockedByUser']); } public function testApiCannotRetrieveBlockedDomainsAnonymous() { $this->client->request('GET', '/api/domains/blocked'); self::assertResponseStatusCodeSame(401); } public function testApiCannotRetrieveBlockedDomainsWithoutScope() { $this->getEntryByTitle('Test link to a second domain', 'https://example.org'); $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $user = $this->getUserByUsername('JohnDoe'); $manager = $this->domainManager; $manager->block($domain, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/domains/blocked', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveBlockedDomains() { $this->getEntryByTitle('Test link to a second domain', 'https://example.org'); $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $user = $this->getUserByUsername('JohnDoe'); $manager = $this->domainManager; $manager->block($domain, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/domains/blocked', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame(1, $jsonData['items'][0]['entryCount']); self::assertEquals('example.com', $jsonData['items'][0]['name']); self::assertSame(1, $jsonData['items'][0]['entryCount']); self::assertSame(0, $jsonData['items'][0]['subscriptionsCount']); // Scope granted so block flag populated self::assertNull($jsonData['items'][0]['isUserSubscribed']); self::assertTrue($jsonData['items'][0]['isBlockedByUser']); } public function testApiCanRetrieveDomainByIdAnonymous() { $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $this->client->request('GET', "/api/domain/{$domain->getId()}"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData); self::assertEquals('example.com', $jsonData['name']); self::assertSame(1, $jsonData['entryCount']); self::assertSame(0, $jsonData['subscriptionsCount']); self::assertNull($jsonData['isUserSubscribed']); self::assertNull($jsonData['isBlockedByUser']); } public function testApiCanRetrieveDomainById() { $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $user = $this->getUserByUsername('JohnDoe'); $manager = $this->domainManager; $manager->subscribe($domain, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData); self::assertEquals('example.com', $jsonData['name']); self::assertSame(1, $jsonData['entryCount']); self::assertSame(1, $jsonData['subscriptionsCount']); // Scope not granted so subscription and block flags not populated self::assertNull($jsonData['isUserSubscribed']); self::assertNull($jsonData['isBlockedByUser']); } public function testApiCanRetrieveDomainByIdSubscriptionAndBlockStatus() { $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $user = $this->getUserByUsername('JohnDoe'); $manager = $this->domainManager; $manager->subscribe($domain, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:subscribe domain:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData); self::assertEquals('example.com', $jsonData['name']); self::assertSame(1, $jsonData['entryCount']); self::assertSame(1, $jsonData['subscriptionsCount']); // Scope granted so subscription and block flags populated self::assertTrue($jsonData['isUserSubscribed']); self::assertFalse($jsonData['isBlockedByUser']); } } ================================================ FILE: tests/Functional/Controller/Api/Domain/DomainSubscribeApiTest.php ================================================ getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $this->client->request('PUT', "/api/domain/{$domain->getId()}/subscribe"); self::assertResponseStatusCodeSame(401); } public function testApiCannotSubscribeToDomainWithoutScope() { $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('JohnDoe')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/domain/{$domain->getId()}/subscribe", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanSubscribeToDomain() { $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('JohnDoe')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:subscribe'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/domain/{$domain->getId()}/subscribe", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData); self::assertEquals('example.com', $jsonData['name']); self::assertSame(1, $jsonData['entryCount']); self::assertSame(1, $jsonData['subscriptionsCount']); self::assertTrue($jsonData['isUserSubscribed']); // Scope not granted so block flag not populated self::assertNull($jsonData['isBlockedByUser']); // Idempotent when called multiple times $this->client->request('PUT', "/api/domain/{$domain->getId()}/subscribe", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData); self::assertEquals('example.com', $jsonData['name']); self::assertSame(1, $jsonData['entryCount']); self::assertSame(1, $jsonData['subscriptionsCount']); self::assertTrue($jsonData['isUserSubscribed']); // Scope not granted so block flag not populated self::assertNull($jsonData['isBlockedByUser']); } public function testApiCannotUnsubscribeFromDomainAnonymous() { $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $this->client->request('PUT', "/api/domain/{$domain->getId()}/unsubscribe"); self::assertResponseStatusCodeSame(401); } public function testApiCannotUnsubscribeFromDomainWithoutScope() { $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('JohnDoe')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/domain/{$domain->getId()}/unsubscribe", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUnsubscribeFromDomain() { $user = $this->getUserByUsername('JohnDoe'); $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain; $manager = $this->domainManager; $manager->subscribe($domain, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:subscribe'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/domain/{$domain->getId()}/unsubscribe", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData); self::assertEquals('example.com', $jsonData['name']); self::assertSame(1, $jsonData['entryCount']); self::assertSame(0, $jsonData['subscriptionsCount']); self::assertFalse($jsonData['isUserSubscribed']); // Scope not granted so block flag not populated self::assertNull($jsonData['isBlockedByUser']); // Idempotent when called multiple times $this->client->request('PUT', "/api/domain/{$domain->getId()}/unsubscribe", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData); self::assertEquals('example.com', $jsonData['name']); self::assertSame(1, $jsonData['entryCount']); self::assertSame(0, $jsonData['subscriptionsCount']); self::assertFalse($jsonData['isUserSubscribed']); // Scope not granted so block flag not populated self::assertNull($jsonData['isBlockedByUser']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Admin/EntryChangeMagazineApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $magazine2 = $this->getMagazineByNameNoRSAKey('acme2'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/admin/entry/{$entry->getId()}/change-magazine/{$magazine2->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiNonAdminCannotChangeEntryMagazine(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $magazine2 = $this->getMagazineByNameNoRSAKey('acme2'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:magazine:move_entry'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/admin/entry/{$entry->getId()}/change-magazine/{$magazine2->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotChangeEntryMagazineWithoutScope(): void { $user = $this->getUserByUsername('user', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $magazine2 = $this->getMagazineByNameNoRSAKey('acme2'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/admin/entry/{$entry->getId()}/change-magazine/{$magazine2->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanChangeEntryMagazine(): void { $user = $this->getUserByUsername('user', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $magazine2 = $this->getMagazineByNameNoRSAKey('acme2'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:magazine:move_entry'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/admin/entry/{$entry->getId()}/change-magazine/{$magazine2->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine2->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Admin/EntryPurgeApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for deletion', magazine: $magazine); $this->client->request('DELETE', "/api/admin/entry/{$entry->getId()}/purge"); self::assertResponseStatusCodeSame(401); } public function testApiCannotPurgeArticleEntryWithoutScope(): void { $user = $this->getUserByUsername('user', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/entry/{$entry->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonAdminCannotPurgeArticleEntry(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $otherUser, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/entry/{$entry->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanPurgeArticleEntry(): void { $admin = $this->getUserByUsername('admin', isAdmin: true); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($admin); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/entry/{$entry->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); } public function testApiCannotPurgeLinkEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test link', url: 'https://google.com', magazine: $magazine); $this->client->request('DELETE', "/api/admin/entry/{$entry->getId()}/purge"); self::assertResponseStatusCodeSame(401); } public function testApiCannotPurgeLinkEntryWithoutScope(): void { $user = $this->getUserByUsername('user', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/entry/{$entry->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonAdminCannotPurgeLinkEntry(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $otherUser, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/entry/{$entry->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanPurgeLinkEntry(): void { $admin = $this->getUserByUsername('admin', isAdmin: true); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($admin); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/entry/{$entry->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); } public function testApiCannotPurgeImageEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', image: $imageDto, magazine: $magazine); $this->client->request('DELETE', "/api/admin/entry/{$entry->getId()}/purge"); self::assertResponseStatusCodeSame(401); } public function testApiCannotPurgeImageEntryWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user', isAdmin: true); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/entry/{$entry->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonAdminCannotPurgeImageEntry(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $otherUser, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/entry/{$entry->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanPurgeImageEntry(): void { $admin = $this->getUserByUsername('admin', isAdmin: true); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($admin); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/entry/{$entry->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Comment/Admin/EntryCommentPurgeApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for deletion', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry); $commentRepository = $this->entryCommentRepository; $this->client->request('DELETE', "/api/admin/comment/{$comment->getId()}/purge"); self::assertResponseStatusCodeSame(401); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); } public function testApiCannotPurgeArticleEntryWithoutScope(): void { $user = $this->getUserByUsername('user', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $user, magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry); $commentRepository = $this->entryCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/comment/{$comment->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); } public function testApiNonAdminCannotPurgeComment(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $otherUser, magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry); $commentRepository = $this->entryCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry_comment:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/comment/{$comment->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); } public function testApiCanPurgeComment(): void { $admin = $this->getUserByUsername('admin', isAdmin: true); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $user, magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry); $commentRepository = $this->entryCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($admin); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry_comment:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/comment/{$comment->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $comment = $commentRepository->find($comment->getId()); self::assertNull($comment); } public function testApiCannotPurgeImageCommentAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', body: 'test', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, imageDto: $imageDto); $commentRepository = $this->entryCommentRepository; $this->client->request('DELETE', "/api/admin/comment/{$comment->getId()}/purge"); self::assertResponseStatusCodeSame(401); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); } public function testApiCannotPurgeImageCommentWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user', isAdmin: true); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', body: 'test', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, imageDto: $imageDto); $commentRepository = $this->entryCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/comment/{$comment->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); } public function testApiNonAdminCannotPurgeImageComment(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', body: 'test', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, imageDto: $imageDto); $commentRepository = $this->entryCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry_comment:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/comment/{$comment->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); } public function testApiCanPurgeImageComment(): void { $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', body: 'test', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, imageDto: $imageDto); $commentRepository = $this->entryCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($admin); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry_comment:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/comment/{$comment->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $comment = $commentRepository->find($comment->getId()); self::assertNull($comment); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Comment/DomainEntryCommentRetrieveApiTest.php ================================================ getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry); $domain = $entry->domain; $this->client->request('GET', "/api/domain/{$domain->getId()}/comments"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('test comment', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertSame(0, $jsonData['items'][0]['childCount']); self::assertIsArray($jsonData['items'][0]['children']); self::assertEmpty($jsonData['items'][0]['children']); self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); } public function testApiCanGetDomainEntryComments(): void { $this->getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry); $domain = $entry->domain; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/comments", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('test comment', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertSame(0, $jsonData['items'][0]['childCount']); self::assertIsArray($jsonData['items'][0]['children']); self::assertEmpty($jsonData['items'][0]['children']); self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); } public function testApiCanGetDomainEntryCommentsDepth(): void { $this->getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry); $nested1 = $this->createEntryComment('test comment nested 1', $entry, parent: $comment); $nested2 = $this->createEntryComment('test comment nested 2', $entry, parent: $nested1); $nested3 = $this->createEntryComment('test comment nested 3', $entry, parent: $nested2); $domain = $entry->domain; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/comments?d=2", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('test comment', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertSame(3, $jsonData['items'][0]['childCount']); self::assertIsArray($jsonData['items'][0]['children']); self::assertCount(1, $jsonData['items'][0]['children']); $child = $jsonData['items'][0]['children'][0]; self::assertIsArray($child); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $child); self::assertSame(2, $child['childCount']); self::assertIsArray($child['children']); self::assertCount(1, $child['children']); self::assertIsArray($child['children'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $child); self::assertSame(1, $child['children'][0]['childCount']); self::assertIsArray($child['children'][0]['children']); self::assertEmpty($child['children'][0]['children']); self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); } public function testApiCanGetDomainEntryCommentsNewest(): void { $entry = $this->getEntryByTitle('entry', url: 'https://google.com'); $first = $this->createEntryComment('first', $entry); $second = $this->createEntryComment('second', $entry); $third = $this->createEntryComment('third', $entry); $domain = $entry->domain; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/comments?sort=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['commentId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['commentId']); } public function testApiCanGetDomainEntryCommentsOldest(): void { $entry = $this->getEntryByTitle('entry', url: 'https://google.com'); $first = $this->createEntryComment('first', $entry); $second = $this->createEntryComment('second', $entry); $third = $this->createEntryComment('third', $entry); $domain = $entry->domain; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/comments?sort=oldest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['commentId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['commentId']); } public function testApiCanGetDomainEntryCommentsActive(): void { $entry = $this->getEntryByTitle('entry', url: 'https://google.com'); $first = $this->createEntryComment('first', $entry); $second = $this->createEntryComment('second', $entry); $third = $this->createEntryComment('third', $entry); $domain = $entry->domain; $first->lastActive = new \DateTime('-1 hour'); $second->lastActive = new \DateTime('-1 second'); $third->lastActive = new \DateTime(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/comments?sort=active", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['commentId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['commentId']); } public function testApiCanGetDomainEntryCommentsTop(): void { $entry = $this->getEntryByTitle('entry', url: 'https://google.com'); $first = $this->createEntryComment('first', $entry); $second = $this->createEntryComment('second', $entry); $third = $this->createEntryComment('third', $entry); $domain = $entry->domain; $favouriteManager = $this->favouriteManager; $favouriteManager->toggle($this->getUserByUsername('voter1'), $first); $favouriteManager->toggle($this->getUserByUsername('voter2'), $first); $favouriteManager->toggle($this->getUserByUsername('voter1'), $second); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/comments?sort=top", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['commentId']); self::assertSame(2, $jsonData['items'][0]['favourites']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertSame(1, $jsonData['items'][1]['favourites']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['commentId']); self::assertSame(0, $jsonData['items'][2]['favourites']); } public function testApiCanGetDomainEntryCommentsHot(): void { $entry = $this->getEntryByTitle('entry', url: 'https://google.com'); $first = $this->createEntryComment('first', $entry); $second = $this->createEntryComment('second', $entry); $third = $this->createEntryComment('third', $entry); $domain = $entry->domain; $voteManager = $this->voteManager; $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false); $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false); $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/comments?sort=hot", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['commentId']); self::assertSame(2, $jsonData['items'][0]['uv']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertSame(1, $jsonData['items'][1]['uv']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['commentId']); self::assertSame(0, $jsonData['items'][2]['uv']); } public function testApiCanGetDomainEntryCommentsWithUserVoteStatus(): void { $this->getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry); $domain = $entry->domain; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/comments", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('test comment', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertSame(0, $jsonData['items'][0]['childCount']); self::assertIsArray($jsonData['items'][0]['children']); self::assertEmpty($jsonData['items'][0]['children']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertFalse($jsonData['items'][0]['isFavourited']); self::assertSame(0, $jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertNull($jsonData['items'][0]['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Comment/EntryCommentCreateApiTest.php ================================================ getEntryByTitle('an entry', body: 'test'); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; $this->client->jsonRequest( 'POST', "/api/entry/{$entry->getId()}/comments", parameters: $comment ); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateCommentWithoutScope(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest( 'POST', "/api/entry/{$entry->getId()}/comments", parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateComment(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest( 'POST', "/api/entry/{$entry->getId()}/comments", parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment['body'], $jsonData['body']); self::assertSame($comment['lang'], $jsonData['lang']); self::assertSame($comment['isAdult'], $jsonData['isAdult']); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($entry->magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['rootId']); self::assertNull($jsonData['parentId']); } public function testApiCannotCreateCommentReplyAnonymous(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $entryComment = $this->createEntryComment('a comment', $entry); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; $this->client->jsonRequest( 'POST', "/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply", parameters: $comment ); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateCommentReplyWithoutScope(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $entryComment = $this->createEntryComment('a comment', $entry); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest( 'POST', "/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply", parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateCommentReply(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $entryComment = $this->createEntryComment('a comment', $entry); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest( 'POST', "/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply", parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment['body'], $jsonData['body']); self::assertSame($comment['lang'], $jsonData['lang']); self::assertSame($comment['isAdult'], $jsonData['isAdult']); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($entry->magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertSame($entryComment->getId(), $jsonData['rootId']); self::assertSame($entryComment->getId(), $jsonData['parentId']); } public function testApiCannotCreateImageCommentAnonymous(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, 'alt' => 'It\'s Kibby!', ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/entry/{$entry->getId()}/comments/image", parameters: $comment, files: ['uploadImage' => $image] ); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateImageCommentWithoutScope(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, 'alt' => 'It\'s Kibby!', ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/entry/{$entry->getId()}/comments/image", parameters: $comment, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateImageComment(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, 'alt' => 'It\'s Kibby!', ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/entry/{$entry->getId()}/comments/image", parameters: $comment, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment['body'], $jsonData['body']); self::assertSame($comment['lang'], $jsonData['lang']); self::assertSame($comment['isAdult'], $jsonData['isAdult']); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($entry->magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['rootId']); self::assertNull($jsonData['parentId']); } public function testApiCannotCreateImageCommentReplyAnonymous(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $entryComment = $this->createEntryComment('a comment', $entry); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, 'alt' => 'It\'s Kibby!', ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply/image", parameters: $comment, files: ['uploadImage' => $image] ); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateImageCommentReplyWithoutScope(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $entryComment = $this->createEntryComment('a comment', $entry); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply/image", parameters: $comment, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateImageCommentReply(): void { $imageManager = $this->imageManager; $entry = $this->getEntryByTitle('an entry', body: 'test'); $entryComment = $this->createEntryComment('a comment', $entry); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, 'alt' => 'It\'s Kibby!', ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $resultingPath = $imageManager->getFilePath($image->getFilename()); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply/image", parameters: $comment, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment['body'], $jsonData['body']); self::assertSame($comment['lang'], $jsonData['lang']); self::assertSame($comment['isAdult'], $jsonData['isAdult']); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($entry->magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertSame($entryComment->getId(), $jsonData['rootId']); self::assertSame($entryComment->getId(), $jsonData['parentId']); self::assertIsArray($jsonData['image']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']); self::assertEquals($resultingPath, $jsonData['image']['filePath']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Comment/EntryCommentDeleteApiTest.php ================================================ getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry); $this->client->request('DELETE', "/api/comments/{$comment->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDeleteCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/comments/{$comment->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotDeleteOtherUsersComment(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('other'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user2); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/comments/{$comment->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDeleteComment(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); $commentRepository = $this->entryCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/comments/{$comment->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $comment = $commentRepository->find($comment->getId()); self::assertNull($comment); } public function testApiCanSoftDeleteComment(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); $this->createEntryComment('test comment', $entry, $user, $comment); $commentRepository = $this->entryCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/comments/{$comment->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); self::assertTrue($comment->isSoftDeleted()); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Comment/EntryCommentReportApiTest.php ================================================ getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry); $report = [ 'reason' => 'This comment breaks the rules!', ]; $this->client->jsonRequest('POST', "/api/comments/{$comment->getId()}/report", $report); self::assertResponseStatusCodeSame(401); } public function testApiCannotReportCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); $report = [ 'reason' => 'This comment breaks the rules!', ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/comments/{$comment->getId()}/report", $report, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanReportOtherUsersComment(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('other'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user2); $reportRepository = $this->reportRepository; $report = [ 'reason' => 'This comment breaks the rules!', ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:report'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/comments/{$comment->getId()}/report", $report, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $report = $reportRepository->findBySubject($comment); self::assertNotNull($report); self::assertSame('This comment breaks the rules!', $report->reason); self::assertSame($user->getId(), $report->reporting->getId()); } public function testApiCanReportOwnComment(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); $reportRepository = $this->reportRepository; $report = [ 'reason' => 'This comment breaks the rules!', ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:report'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/comments/{$comment->getId()}/report", $report, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $report = $reportRepository->findBySubject($comment); self::assertNotNull($report); self::assertSame('This comment breaks the rules!', $report->reason); self::assertSame($user->getId(), $report->reporting->getId()); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Comment/EntryCommentRetrieveApiTest.php ================================================ getEntryByTitle('test entry', body: 'test'); for ($i = 0; $i < 5; ++$i) { $this->createEntryComment("test parent comment {$i}", $entry); } $this->client->request('GET', "/api/entry/{$entry->getId()}/comments"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(5, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(5, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($entry->getId(), $comment['entryId']); self::assertStringContainsString('test parent comment', $comment['body']); self::assertSame('en', $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['dv']); self::assertSame(0, $comment['favourites']); self::assertSame(0, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertEmpty($comment['mentions']); self::assertIsArray($comment['children']); self::assertEmpty($comment['children']); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertEmpty($comment['tags']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertNull($comment['bookmarks']); } } public function testApiCannotGetEntryCommentsByPreferredLangAnonymous(): void { $entry = $this->getEntryByTitle('test entry', body: 'test'); for ($i = 0; $i < 5; ++$i) { $this->createEntryComment("test parent comment {$i}", $entry); } $this->client->request('GET', "/api/entry/{$entry->getId()}/comments?usePreferredLangs=true"); self::assertResponseStatusCodeSame(403); } public function testApiCanGetEntryCommentsByPreferredLang(): void { $entry = $this->getEntryByTitle('test entry', body: 'test'); for ($i = 0; $i < 5; ++$i) { $this->createEntryComment("test parent comment {$i}", $entry); $this->createEntryComment("test german parent comment {$i}", $entry, lang: 'de'); $this->createEntryComment("test dutch parent comment {$i}", $entry, lang: 'nl'); } self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); $user->preferredLanguages = ['en', 'de']; $entityManager = $this->entityManager; $entityManager->persist($user); $entityManager->flush(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/entry/{$entry->getId()}/comments?usePreferredLangs=true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(10, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(10, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($entry->getId(), $comment['entryId']); self::assertStringContainsString('parent comment', $comment['body']); self::assertTrue('en' === $comment['lang'] || 'de' === $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['dv']); self::assertSame(0, $comment['favourites']); self::assertSame(0, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertEmpty($comment['mentions']); self::assertIsArray($comment['children']); self::assertEmpty($comment['children']); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); // No scope granted so these should be null self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertEmpty($comment['tags']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertIsArray($comment['bookmarks']); self::assertEmpty($comment['bookmarks']); } } public function testApiCanGetEntryCommentsWithLanguageAnonymous(): void { $entry = $this->getEntryByTitle('test entry', body: 'test'); for ($i = 0; $i < 5; ++$i) { $this->createEntryComment("test parent comment {$i}", $entry); $this->createEntryComment("test german parent comment {$i}", $entry, lang: 'de'); $this->createEntryComment("test dutch comment {$i}", $entry, lang: 'nl'); } $this->client->request('GET', "/api/entry/{$entry->getId()}/comments?lang[]=en&lang[]=de"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(10, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(10, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($entry->getId(), $comment['entryId']); self::assertStringContainsString('parent comment', $comment['body']); self::assertTrue('en' === $comment['lang'] || 'de' === $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['dv']); self::assertSame(0, $comment['favourites']); self::assertSame(0, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertEmpty($comment['mentions']); self::assertIsArray($comment['children']); self::assertEmpty($comment['children']); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertEmpty($comment['tags']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertNull($comment['bookmarks']); } } public function testApiCanGetEntryCommentsWithLanguage(): void { $entry = $this->getEntryByTitle('test entry', body: 'test'); for ($i = 0; $i < 5; ++$i) { $this->createEntryComment("test parent comment {$i}", $entry); $this->createEntryComment("test german parent comment {$i}", $entry, lang: 'de'); $this->createEntryComment("test dutch parent comment {$i}", $entry, lang: 'nl'); } self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/entry/{$entry->getId()}/comments?lang[]=en&lang[]=de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(10, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(10, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($entry->getId(), $comment['entryId']); self::assertStringContainsString('parent comment', $comment['body']); self::assertTrue('en' === $comment['lang'] || 'de' === $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['dv']); self::assertSame(0, $comment['favourites']); self::assertSame(0, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertEmpty($comment['mentions']); self::assertIsArray($comment['children']); self::assertEmpty($comment['children']); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); // No scope granted so these should be null self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertEmpty($comment['tags']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertIsArray($comment['bookmarks']); self::assertEmpty($comment['bookmarks']); } } public function testApiCanGetEntryComments(): void { $entry = $this->getEntryByTitle('test entry', body: 'test'); for ($i = 0; $i < 5; ++$i) { $this->createEntryComment("test parent comment {$i}", $entry); } self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/entry/{$entry->getId()}/comments", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(5, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(5, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($entry->getId(), $comment['entryId']); self::assertStringContainsString('test parent comment', $comment['body']); self::assertSame('en', $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['dv']); self::assertSame(0, $comment['favourites']); self::assertSame(0, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertEmpty($comment['mentions']); self::assertIsArray($comment['children']); self::assertEmpty($comment['children']); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); // No scope granted so these should be null self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertEmpty($comment['tags']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertIsArray($comment['bookmarks']); self::assertEmpty($comment['bookmarks']); } } public function testApiCanGetEntryCommentsWithChildren(): void { $entry = $this->getEntryByTitle('test entry', body: 'test'); for ($i = 0; $i < 5; ++$i) { $comment = $this->createEntryComment("test parent comment {$i}", $entry); $this->createEntryComment("test child comment {$i}", $entry, parent: $comment); } self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/entry/{$entry->getId()}/comments", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(5, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(5, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($entry->getId(), $comment['entryId']); self::assertStringContainsString('test parent comment', $comment['body']); self::assertSame('en', $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['dv']); self::assertSame(0, $comment['favourites']); self::assertSame(1, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertEmpty($comment['mentions']); self::assertIsArray($comment['children']); self::assertCount(1, $comment['children']); self::assertIsArray($comment['children'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment['children'][0]); self::assertStringContainsString('test child comment', $comment['children'][0]['body']); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); // No scope granted so these should be null self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertEmpty($comment['tags']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertIsArray($comment['bookmarks']); self::assertEmpty($comment['bookmarks']); } } public function testApiCanGetEntryCommentsLimitedDepth(): void { $entry = $this->getEntryByTitle('test entry', body: 'test'); for ($i = 0; $i < 2; ++$i) { $comment = $this->createEntryComment("test parent comment {$i}", $entry); $parent = $comment; for ($j = 1; $j <= 5; ++$j) { $parent = $this->createEntryComment("test child comment {$i} depth {$j}", $entry, parent: $parent); } } self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/entry/{$entry->getId()}/comments?d=3", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($entry->getId(), $comment['entryId']); self::assertStringContainsString('test parent comment', $comment['body']); self::assertSame('en', $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['dv']); self::assertSame(0, $comment['favourites']); self::assertSame(5, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertEmpty($comment['mentions']); self::assertIsArray($comment['children']); self::assertCount(1, $comment['children']); $depth = 0; $current = $comment; while (\count($current['children']) > 0) { self::assertIsArray($current['children'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $current['children'][0]); self::assertStringContainsString('test child comment', $current['children'][0]['body']); self::assertSame(5 - ($depth + 1), $current['children'][0]['childCount']); $current = $current['children'][0]; ++$depth; } self::assertSame(3, $depth); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); // No scope granted so these should be null self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertEmpty($comment['tags']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertIsArray($comment['bookmarks']); self::assertEmpty($comment['bookmarks']); } } public function testApiCanGetEntryCommentByIdAnonymous(): void { $entry = $this->getEntryByTitle('test entry', body: 'test'); $comment = $this->createEntryComment('test parent comment', $entry); $this->client->request('GET', "/api/comments/{$comment->getId()}"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertStringContainsString('test parent comment', $jsonData['body']); self::assertSame('en', $jsonData['lang']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertSame(0, $jsonData['childCount']); self::assertSame('visible', $jsonData['visibility']); self::assertIsArray($jsonData['mentions']); self::assertEmpty($jsonData['mentions']); self::assertIsArray($jsonData['children']); self::assertEmpty($jsonData['children']); self::assertFalse($jsonData['isAdult']); self::assertNull($jsonData['image']); self::assertNull($jsonData['parentId']); self::assertNull($jsonData['rootId']); self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertNull($jsonData['apId']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertNull($jsonData['bookmarks']); } public function testApiCanGetEntryCommentById(): void { $entry = $this->getEntryByTitle('test entry', body: 'test'); $comment = $this->createEntryComment('test parent comment', $entry); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/comments/{$comment->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertStringContainsString('test parent comment', $jsonData['body']); self::assertSame('en', $jsonData['lang']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertSame(0, $jsonData['childCount']); self::assertSame('visible', $jsonData['visibility']); self::assertIsArray($jsonData['mentions']); self::assertEmpty($jsonData['mentions']); self::assertIsArray($jsonData['children']); self::assertEmpty($jsonData['children']); self::assertFalse($jsonData['isAdult']); self::assertNull($jsonData['image']); self::assertNull($jsonData['parentId']); self::assertNull($jsonData['rootId']); // No scope granted so these should be null self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertNull($jsonData['apId']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertIsArray($jsonData['bookmarks']); self::assertEmpty($jsonData['bookmarks']); } public function testApiCanGetEntryCommentByIdWithDepth(): void { $entry = $this->getEntryByTitle('test entry', body: 'test'); $comment = $this->createEntryComment('test parent comment', $entry); $parent = $comment; for ($i = 0; $i < 5; ++$i) { $parent = $this->createEntryComment('test nested reply', $entry, parent: $parent); } self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/comments/{$comment->getId()}?d=2", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertStringContainsString('test parent comment', $jsonData['body']); self::assertSame('en', $jsonData['lang']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertSame(5, $jsonData['childCount']); self::assertSame('visible', $jsonData['visibility']); self::assertIsArray($jsonData['mentions']); self::assertEmpty($jsonData['mentions']); self::assertIsArray($jsonData['children']); self::assertCount(1, $jsonData['children']); self::assertFalse($jsonData['isAdult']); self::assertNull($jsonData['image']); self::assertNull($jsonData['parentId']); self::assertNull($jsonData['rootId']); // No scope granted so these should be null self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertNull($jsonData['apId']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertIsArray($jsonData['bookmarks']); self::assertEmpty($jsonData['bookmarks']); $depth = 0; $current = $jsonData; while (\count($current['children']) > 0) { self::assertIsArray($current['children'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $current['children'][0]); ++$depth; $current = $current['children'][0]; } self::assertSame(2, $depth); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Comment/EntryCommentUpdateApiTest.php ================================================ getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry); $update = [ 'body' => 'updated body', 'lang' => 'de', 'isAdult' => true, ]; $this->client->jsonRequest('PUT', "/api/comments/{$comment->getId()}", $update); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); $update = [ 'body' => 'updated body', 'lang' => 'de', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/comments/{$comment->getId()}", $update, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateOtherUsersComment(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('other'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user2); $update = [ 'body' => 'updated body', 'lang' => 'de', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/comments/{$comment->getId()}", $update, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateComment(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); $parent = $comment; for ($i = 0; $i < 5; ++$i) { $parent = $this->createEntryComment('test reply', $entry, $user, $parent); } $update = [ 'body' => 'updated body', 'lang' => 'de', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/comments/{$comment->getId()}?d=2", $update, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment->getId(), $jsonData['commentId']); self::assertSame($update['body'], $jsonData['body']); self::assertSame($update['lang'], $jsonData['lang']); self::assertSame($update['isAdult'], $jsonData['isAdult']); self::assertSame(5, $jsonData['childCount']); $depth = 0; $current = $jsonData; while (\count($current['children']) > 0) { self::assertIsArray($current['children'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $current['children'][0]); ++$depth; $current = $current['children'][0]; } self::assertSame(2, $depth); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Comment/EntryCommentVoteApiTest.php ================================================ getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry); $this->client->request('PUT', "/api/comments/{$comment->getId()}/vote/1"); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpvoteCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/comments/{$comment->getId()}/vote/1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpvoteComment(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/comments/{$comment->getId()}/vote/1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame(1, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertSame(1, $jsonData['userVote']); self::assertFalse($jsonData['isFavourited']); } public function testApiCannotDownvoteCommentAnonymous(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry); $this->client->request('PUT', "/api/comments/{$comment->getId()}/vote/-1"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDownvoteCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/comments/{$comment->getId()}/vote/-1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDownvoteComment(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/comments/{$comment->getId()}/vote/-1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame(0, $jsonData['uv']); self::assertSame(1, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertSame(-1, $jsonData['userVote']); self::assertFalse($jsonData['isFavourited']); } public function testApiCannotRemoveVoteCommentAnonymous(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry); $voteManager = $this->voteManager; $voteManager->vote(1, $comment, $this->getUserByUsername('user'), rateLimit: false); $this->client->request('PUT', "/api/comments/{$comment->getId()}/vote/0"); self::assertResponseStatusCodeSame(401); } public function testApiCannotRemoveVoteCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); $voteManager = $this->voteManager; $voteManager->vote(1, $comment, $user, rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/comments/{$comment->getId()}/vote/0", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRemoveVoteComment(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); $voteManager = $this->voteManager; $voteManager->vote(1, $comment, $user, rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/comments/{$comment->getId()}/vote/0", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertSame(0, $jsonData['userVote']); self::assertFalse($jsonData['isFavourited']); } public function testApiCannotFavouriteCommentAnonymous(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry); $this->client->request('PUT', "/api/comments/{$comment->getId()}/favourite"); self::assertResponseStatusCodeSame(401); } public function testApiCannotFavouriteCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/comments/{$comment->getId()}/favourite", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanFavouriteComment(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/comments/{$comment->getId()}/favourite", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(1, $jsonData['favourites']); self::assertSame(0, $jsonData['userVote']); self::assertTrue($jsonData['isFavourited']); } public function testApiCannotUnfavouriteCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); $favouriteManager = $this->favouriteManager; $favouriteManager->toggle($user, $comment); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/comments/{$comment->getId()}/favourite", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUnfavouriteComment(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user); $favouriteManager = $this->favouriteManager; $favouriteManager->toggle($user, $comment); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/comments/{$comment->getId()}/favourite", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertSame(0, $jsonData['userVote']); self::assertFalse($jsonData['isFavourited']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Comment/EntryCommentsActivityApiTest.php ================================================ getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for activites', user: $user, magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, $user); $this->client->jsonRequest('GET', "/api/comments/{$comment->getId()}/activity"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData); self::assertSame([], $jsonData['boosts']); self::assertSame([], $jsonData['upvotes']); self::assertSame(null, $jsonData['downvotes']); } public function testUpvotes() { $author = $this->getUserByUsername('userA'); $user1 = $this->getUserByUsername('user1'); $user2 = $this->getUserByUsername('user2'); $this->getUserByUsername('user3'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for activites', user: $author, magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, $author); $this->favouriteManager->toggle($user1, $comment); $this->favouriteManager->toggle($user2, $comment); $this->client->jsonRequest('GET', "/api/comments/{$comment->getId()}/activity"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData); self::assertSame([], $jsonData['boosts']); self::assertSame(null, $jsonData['downvotes']); self::assertCount(2, $jsonData['upvotes']); self::assertTrue(array_all($jsonData['upvotes'], function ($u) use ($user1, $user2) { /* @var UserSmallResponseDto $u */ return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId(); }), serialize($jsonData['upvotes'])); } public function testBoosts() { $author = $this->getUserByUsername('userA'); $user1 = $this->getUserByUsername('user1'); $user2 = $this->getUserByUsername('user2'); $this->getUserByUsername('user3'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for activites', user: $author, magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, $author); $this->voteManager->upvote($comment, $user1); $this->voteManager->upvote($comment, $user2); $this->client->jsonRequest('GET', "/api/comments/{$comment->getId()}/activity"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData); self::assertSame([], $jsonData['upvotes']); self::assertSame(null, $jsonData['downvotes']); self::assertCount(2, $jsonData['boosts']); self::assertTrue(array_all($jsonData['boosts'], function ($u) use ($user1, $user2) { /* @var UserSmallResponseDto $u */ return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId(); }), serialize($jsonData['boosts'])); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Comment/Moderate/EntryCommentSetAdultApiTest.php ================================================ getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry); $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/adult/true"); self::assertResponseStatusCodeSame(401); } public function testApiCannotSetCommentAdultWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByName('acme'); $user2 = $this->getUserByUsername('user2'); $admin = $this->getUserByUsername('admin', isAdmin: true); $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/adult/true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonModCannotSetCommentAdult(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user2); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/adult/true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanSetCommentAdult(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByName('acme'); $user2 = $this->getUserByUsername('other'); $admin = $this->getUserByUsername('admin', isAdmin: true); $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/adult/true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertTrue($jsonData['isAdult']); } public function testApiCannotUnsetCommentAdultAnonymous(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry); $entityManager = $this->entityManager; $comment->isAdult = true; $entityManager->persist($comment); $entityManager->flush(); $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/adult/false"); self::assertResponseStatusCodeSame(401); } public function testApiCannotUnsetCommentAdultWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByName('acme'); $user2 = $this->getUserByUsername('user2'); $admin = $this->getUserByUsername('admin', isAdmin: true); $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); $entityManager = $this->entityManager; $comment->isAdult = true; $entityManager->persist($comment); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/adult/false", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonModCannotUnsetCommentAdult(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user2); $entityManager = $this->entityManager; $comment->isAdult = true; $entityManager->persist($comment); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/adult/false", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUnsetCommentAdult(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByName('acme'); $user2 = $this->getUserByUsername('other'); $admin = $this->getUserByUsername('admin', isAdmin: true); $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); $entityManager = $this->entityManager; $comment->isAdult = true; $entityManager->persist($comment); $entityManager->flush(); $commentRepository = $this->entryCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/adult/false", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertFalse($jsonData['isAdult']); $comment = $commentRepository->find($comment->getId()); self::assertFalse($comment->isAdult); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Comment/Moderate/EntryCommentSetLanguageApiTest.php ================================================ getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry); $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/de"); self::assertResponseStatusCodeSame(401); } public function testApiCannotSetCommentLanguageWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByName('acme'); $user2 = $this->getUserByUsername('user2'); $admin = $this->getUserByUsername('admin', isAdmin: true); $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonModCannotSetCommentLanguage(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user2); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:language'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanSetCommentLanguage(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByName('acme'); $user2 = $this->getUserByUsername('other'); $admin = $this->getUserByUsername('admin', isAdmin: true); $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:language'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment->getId(), $jsonData['commentId']); self::assertSame('test comment', $jsonData['body']); self::assertSame('de', $jsonData['lang']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Comment/Moderate/EntryCommentTrashApiTest.php ================================================ getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry); $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/trash"); self::assertResponseStatusCodeSame(401); } public function testApiCannotTrashCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByName('acme'); $user2 = $this->getUserByUsername('user2'); $admin = $this->getUserByUsername('admin', isAdmin: true); $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonModCannotTrashComment(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user2); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanTrashComment(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByName('acme'); $user2 = $this->getUserByUsername('other'); $admin = $this->getUserByUsername('admin', isAdmin: true); $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment->getId(), $jsonData['commentId']); self::assertSame('test comment', $jsonData['body']); self::assertSame('trashed', $jsonData['visibility']); } public function testApiCannotRestoreCommentAnonymous(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry); $entryCommentManager = $this->entryCommentManager; $entryCommentManager->trash($this->getUserByUsername('user'), $comment); $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/restore"); self::assertResponseStatusCodeSame(401); } public function testApiCannotRestoreCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user2); $entryCommentManager = $this->entryCommentManager; $entryCommentManager->trash($user, $comment); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/restore", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonModCannotRestoreComment(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $comment = $this->createEntryComment('test comment', $entry, $user2); $entryCommentManager = $this->entryCommentManager; $entryCommentManager->trash($user, $comment); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/restore", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRestoreComment(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByName('acme'); $user2 = $this->getUserByUsername('other'); $admin = $this->getUserByUsername('admin', isAdmin: true); $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); $entryCommentManager = $this->entryCommentManager; $entryCommentManager->trash($user, $comment); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/comment/{$comment->getId()}/restore", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment->getId(), $jsonData['commentId']); self::assertSame('test comment', $jsonData['body']); self::assertSame('visible', $jsonData['visibility']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Comment/UserEntryCommentRetrieveApiTest.php ================================================ getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry); $user = $entry->user; $this->client->request('GET', "/api/users/{$user->getId()}/comments"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('test comment', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertSame(0, $jsonData['items'][0]['childCount']); self::assertIsArray($jsonData['items'][0]['children']); self::assertEmpty($jsonData['items'][0]['children']); self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); } public function testApiCanGetUserEntryComments(): void { $this->getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry); $user = $entry->user; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/comments", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('test comment', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertSame(0, $jsonData['items'][0]['childCount']); self::assertIsArray($jsonData['items'][0]['children']); self::assertEmpty($jsonData['items'][0]['children']); self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); } public function testApiCanGetUserEntryCommentsDepth(): void { $this->getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry); $nested1 = $this->createEntryComment('test comment nested 1', $entry, parent: $comment); $nested2 = $this->createEntryComment('test comment nested 2', $entry, parent: $nested1); $nested3 = $this->createEntryComment('test comment nested 3', $entry, parent: $nested2); $user = $entry->user; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/comments?d=2", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(4, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(4, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment); self::assertTrue(\count($comment['children']) <= 1); $depth = 0; $current = $comment; while (\count($current['children']) > 0) { ++$depth; $current = $current['children'][0]; self::assertIsArray($current); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $current); } self::assertTrue($depth <= 2); } } public function testApiCanGetUserEntryCommentsNewest(): void { $entry = $this->getEntryByTitle('entry', url: 'https://google.com'); $first = $this->createEntryComment('first', $entry); $second = $this->createEntryComment('second', $entry); $third = $this->createEntryComment('third', $entry); $user = $entry->user; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/comments?sort=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['commentId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['commentId']); } public function testApiCanGetUserEntryCommentsOldest(): void { $entry = $this->getEntryByTitle('entry', url: 'https://google.com'); $first = $this->createEntryComment('first', $entry); $second = $this->createEntryComment('second', $entry); $third = $this->createEntryComment('third', $entry); $user = $entry->user; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/comments?sort=oldest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['commentId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['commentId']); } public function testApiCanGetUserEntryCommentsActive(): void { $entry = $this->getEntryByTitle('entry', url: 'https://google.com'); $first = $this->createEntryComment('first', $entry); $second = $this->createEntryComment('second', $entry); $third = $this->createEntryComment('third', $entry); $user = $entry->user; $first->lastActive = new \DateTime('-1 hour'); $second->lastActive = new \DateTime('-1 second'); $third->lastActive = new \DateTime(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/comments?sort=active", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['commentId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['commentId']); } public function testApiCanGetUserEntryCommentsTop(): void { $entry = $this->getEntryByTitle('entry', url: 'https://google.com'); $first = $this->createEntryComment('first', $entry); $second = $this->createEntryComment('second', $entry); $third = $this->createEntryComment('third', $entry); $user = $entry->user; $favouriteManager = $this->favouriteManager; $favouriteManager->toggle($this->getUserByUsername('voter1'), $first); $favouriteManager->toggle($this->getUserByUsername('voter2'), $first); $favouriteManager->toggle($this->getUserByUsername('voter1'), $second); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/comments?sort=top", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['commentId']); self::assertSame(2, $jsonData['items'][0]['favourites']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertSame(1, $jsonData['items'][1]['favourites']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['commentId']); self::assertSame(0, $jsonData['items'][2]['favourites']); } public function testApiCanGetUserEntryCommentsHot(): void { $entry = $this->getEntryByTitle('entry', url: 'https://google.com'); $first = $this->createEntryComment('first', $entry); $second = $this->createEntryComment('second', $entry); $third = $this->createEntryComment('third', $entry); $user = $entry->user; $voteManager = $this->voteManager; $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false); $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false); $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/comments?sort=hot", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['commentId']); self::assertSame(2, $jsonData['items'][0]['uv']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertSame(1, $jsonData['items'][1]['uv']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['commentId']); self::assertSame(0, $jsonData['items'][2]['uv']); } public function testApiCanGetUserEntryCommentsWithUserVoteStatus(): void { $this->getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $comment = $this->createEntryComment('test comment', $entry); $user = $entry->user; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/comments", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('test comment', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertSame(0, $jsonData['items'][0]['childCount']); self::assertIsArray($jsonData['items'][0]['children']); self::assertEmpty($jsonData['items'][0]['children']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertFalse($jsonData['items'][0]['isFavourited']); self::assertSame(0, $jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertNull($jsonData['items'][0]['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/DomainEntryRetrieveApiTest.php ================================================ getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $domain = $entry->domain; $this->client->request('GET', "/api/domain/{$domain->getId()}/entries"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('another entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertEquals('link', $jsonData['items'][0]['type']); self::assertSame(0, $jsonData['items'][0]['numComments']); self::assertNull($jsonData['items'][0]['crosspostedEntries']); } public function testApiCanGetDomainEntries(): void { $this->getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $domain = $entry->domain; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/entries", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('another entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertEquals('link', $jsonData['items'][0]['type']); self::assertSame(0, $jsonData['items'][0]['numComments']); self::assertNull($jsonData['items'][0]['crosspostedEntries']); } public function testApiCanGetDomainEntriesNewest(): void { $first = $this->getEntryByTitle('first', url: 'https://google.com'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $domain = $first->domain; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/entries?sort=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['entryId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['entryId']); } public function testApiCanGetDomainEntriesOldest(): void { $first = $this->getEntryByTitle('first', url: 'https://google.com'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $domain = $first->domain; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/entries?sort=oldest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['entryId']); } public function testApiCanGetDomainEntriesCommented(): void { $first = $this->getEntryByTitle('first', url: 'https://google.com'); $this->createEntryComment('comment 1', $first); $this->createEntryComment('comment 2', $first); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $this->createEntryComment('comment 1', $second); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $domain = $first->domain; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/entries?sort=commented", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertSame(2, $jsonData['items'][0]['numComments']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertSame(1, $jsonData['items'][1]['numComments']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['entryId']); self::assertSame(0, $jsonData['items'][2]['numComments']); } public function testApiCanGetDomainEntriesActive(): void { $first = $this->getEntryByTitle('first', url: 'https://google.com'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $domain = $first->domain; $first->lastActive = new \DateTime('-1 hour'); $second->lastActive = new \DateTime('-1 second'); $third->lastActive = new \DateTime(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/entries?sort=active", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['entryId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['entryId']); } public function testApiCanGetDomainEntriesTop(): void { $first = $this->getEntryByTitle('first', url: 'https://google.com'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $domain = $first->domain; $voteManager = $this->voteManager; $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false); $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false); $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/entries?sort=top", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertSame(2, $jsonData['items'][0]['uv']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertSame(1, $jsonData['items'][1]['uv']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['entryId']); self::assertSame(0, $jsonData['items'][2]['uv']); } public function testApiCanGetDomainEntriesWithUserVoteStatus(): void { $this->getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $domain = $entry->domain; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/domain/{$domain->getId()}/entries", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('another entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertIsArray($jsonData['items'][0]['domain']); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]['domain']); self::assertEquals('https://google.com', $jsonData['items'][0]['url']); self::assertNull($jsonData['items'][0]['body']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertSame(0, $jsonData['items'][0]['numComments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertFalse($jsonData['items'][0]['isFavourited']); self::assertSame(0, $jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isOc']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('link', $jsonData['items'][0]['type']); self::assertEquals('another-entry', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertNull($jsonData['items'][0]['crosspostedEntries']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/EntriesActivityApiTest.php ================================================ getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for activites', user: $user, magazine: $magazine); $this->client->jsonRequest('GET', "/api/entry/{$entry->getId()}/activity"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData); self::assertSame([], $jsonData['boosts']); self::assertSame([], $jsonData['upvotes']); self::assertSame(null, $jsonData['downvotes']); } public function testUpvotes() { $author = $this->getUserByUsername('userA'); $user1 = $this->getUserByUsername('user1'); $user2 = $this->getUserByUsername('user2'); $this->getUserByUsername('user3'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for activites', user: $author, magazine: $magazine); $this->favouriteManager->toggle($user1, $entry); $this->favouriteManager->toggle($user2, $entry); $this->client->jsonRequest('GET', "/api/entry/{$entry->getId()}/activity"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData); self::assertSame([], $jsonData['boosts']); self::assertSame(null, $jsonData['downvotes']); self::assertCount(2, $jsonData['upvotes']); self::assertTrue(array_all($jsonData['upvotes'], function ($u) use ($user1, $user2) { /* @var UserSmallResponseDto $u */ return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId(); }), serialize($jsonData['upvotes'])); } public function testBoosts() { $author = $this->getUserByUsername('userA'); $user1 = $this->getUserByUsername('user1'); $user2 = $this->getUserByUsername('user2'); $this->getUserByUsername('user3'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for activites', user: $author, magazine: $magazine); $this->voteManager->upvote($entry, $user1); $this->voteManager->upvote($entry, $user2); $this->client->jsonRequest('GET', "/api/entry/{$entry->getId()}/activity"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData); self::assertSame([], $jsonData['upvotes']); self::assertSame(null, $jsonData['downvotes']); self::assertCount(2, $jsonData['boosts']); self::assertTrue(array_all($jsonData['boosts'], function ($u) use ($user1, $user2) { /* @var UserSmallResponseDto $u */ return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId(); }), serialize($jsonData['boosts'])); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/EntryCreateApiNewTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Anonymous Thread', 'body' => 'This is an article', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; $this->client->request('POST', "/api/magazine/{$magazine->getId()}/entries", parameters: $entryRequest); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateArticleEntryWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'No Scope Thread', 'body' => 'This is an article', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/magazine/{$magazine->getId()}/entries", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotCreateEntryWithoutTitle(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'body' => 'This has no title', 'url' => 'https://google.com', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/magazine/{$magazine->getId()}/entries", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); } public function testApiCanCreateArticleEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Test Thread', 'body' => 'This is an article', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/magazine/{$magazine->getId()}/entries", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertNotNull($jsonData['entryId']); self::assertEquals('Test Thread', $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals('This is an article', $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals('en', $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame(['test'], $jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('Test-Thread', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotCreateLinkEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Anonymous Thread', 'url' => 'https://google.com', 'body' => 'google', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; $this->client->request('POST', "/api/magazine/{$magazine->getId()}/entries", parameters: $entryRequest); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateLinkEntryWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'No Scope Thread', 'url' => 'https://google.com', 'body' => 'google', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/magazine/{$magazine->getId()}/entries", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateLinkEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Test Thread', 'url' => 'https://google.com', 'body' => 'This is a link', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/magazine/{$magazine->getId()}/entries", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertNotNull($jsonData['entryId']); self::assertEquals('Test Thread', $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertIsArray($jsonData['domain']); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['domain']); self::assertEquals('https://google.com', $jsonData['url']); self::assertEquals('This is a link', $jsonData['body']); if (null !== $jsonData['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame(['test'], $jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('link', $jsonData['type']); self::assertEquals('Test-Thread', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCanCreateLinkWithImageEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Test Thread', 'url' => 'https://google.com', 'body' => 'This is a link', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; $expectedPath = $imageManager->getFilePath($image->getFilename()); $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/entries", parameters: $entryRequest, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token], ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertNotNull($jsonData['entryId']); self::assertEquals('Test Thread', $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertIsArray($jsonData['domain']); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['domain']); self::assertEquals('https://google.com', $jsonData['url']); self::assertEquals('This is a link', $jsonData['body']); self::assertIsArray($jsonData['image']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']); self::assertStringContainsString($expectedPath, $jsonData['image']['filePath']); self::assertEquals('en', $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame(['test'], $jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('image', $jsonData['type']); self::assertEquals('Test-Thread', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotCreateImageEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Anonymous Thread', 'alt' => 'It\'s kibby!', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload copy($this->kibbyPath, $this->kibbyPath.'.tmp'); $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/entries", parameters: $entryRequest, files: ['uploadImage' => $image], ); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateImageEntryWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'No Scope Thread', 'alt' => 'It\'s kibby!', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload copy($this->kibbyPath, $this->kibbyPath.'.tmp'); $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/entries", parameters: $entryRequest, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateImageEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Test Thread', 'alt' => 'It\'s kibby!', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; $expectedPath = $imageManager->getFilePath($image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/entries", parameters: $entryRequest, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertNotNull($jsonData['entryId']); self::assertEquals('Test Thread', $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertNull($jsonData['body']); self::assertIsArray($jsonData['image']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']); self::assertStringContainsString($expectedPath, $jsonData['image']['filePath']); self::assertEquals('It\'s kibby!', $jsonData['image']['altText']); self::assertEquals('en', $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame(['test'], $jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('image', $jsonData['type']); self::assertEquals('Test-Thread', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCanCreateImageWithBodyEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Test Thread', 'body' => 'Isn\'t it a cute picture?', 'alt' => 'It\'s kibby!', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; $expectedPath = $imageManager->getFilePath($image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/entries", parameters: $entryRequest, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertNotNull($jsonData['entryId']); self::assertEquals('Test Thread', $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals('Isn\'t it a cute picture?', $jsonData['body']); self::assertIsArray($jsonData['image']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']); self::assertStringContainsString($expectedPath, $jsonData['image']['filePath']); self::assertEquals('It\'s kibby!', $jsonData['image']['altText']); self::assertEquals('en', $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame(['test'], $jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('image', $jsonData['type']); self::assertEquals('Test-Thread', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotCreateEntryWithoutMagazine(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $invalidId = $magazine->getId() + 1; $entryRequest = [ 'title' => 'No Url/Body Thread', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/magazine/{$invalidId}/entries", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(404); } public function testApiCannotCreateEntryWithoutUrlBodyOrImage(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'No Url/Body Thread', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/magazine/{$magazine->getId()}/entries", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/EntryCreateApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Anonymous Thread', 'body' => 'This is an article', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; $this->client->jsonRequest('POST', "/api/magazine/{$magazine->getId()}/article", parameters: $entryRequest); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateArticleEntryWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'No Scope Thread', 'body' => 'This is an article', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/magazine/{$magazine->getId()}/article", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateArticleEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Test Thread', 'body' => 'This is an article', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/magazine/{$magazine->getId()}/article", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertNotNull($jsonData['entryId']); self::assertEquals('Test Thread', $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals('This is an article', $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals('en', $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame(['test'], $jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('Test-Thread', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotCreateLinkEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Anonymous Thread', 'url' => 'https://google.com', 'body' => 'google', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; $this->client->jsonRequest('POST', "/api/magazine/{$magazine->getId()}/link", parameters: $entryRequest); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateLinkEntryWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'No Scope Thread', 'url' => 'https://google.com', 'body' => 'google', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/magazine/{$magazine->getId()}/link", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateLinkEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Test Thread', 'url' => 'https://google.com', 'body' => 'This is a link', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/magazine/{$magazine->getId()}/link", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertNotNull($jsonData['entryId']); self::assertEquals('Test Thread', $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertIsArray($jsonData['domain']); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['domain']); self::assertEquals('https://google.com', $jsonData['url']); self::assertEquals('This is a link', $jsonData['body']); if (null !== $jsonData['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame(['test'], $jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('link', $jsonData['type']); self::assertEquals('Test-Thread', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotCreateImageEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Anonymous Thread', 'alt' => 'It\'s kibby!', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload copy($this->kibbyPath, $this->kibbyPath.'.tmp'); $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/image", parameters: $entryRequest, files: ['uploadImage' => $image], ); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateImageEntryWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'No Scope Thread', 'alt' => 'It\'s kibby!', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload copy($this->kibbyPath, $this->kibbyPath.'.tmp'); $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/image", parameters: $entryRequest, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateImageEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Test Thread', 'alt' => 'It\'s kibby!', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; $expectedPath = $imageManager->getFilePath($image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/image", parameters: $entryRequest, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertNotNull($jsonData['entryId']); self::assertEquals('Test Thread', $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertNull($jsonData['body']); self::assertIsArray($jsonData['image']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']); self::assertStringContainsString($expectedPath, $jsonData['image']['filePath']); self::assertEquals('It\'s kibby!', $jsonData['image']['altText']); self::assertEquals('en', $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame(['test'], $jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('image', $jsonData['type']); self::assertEquals('Test-Thread', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCanCreateImageEntryWithBody(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'Test Thread', 'alt' => 'It\'s kibby!', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, 'body' => 'body text', ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; $expectedPath = $imageManager->getFilePath($image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/image", parameters: $entryRequest, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertNotNull($jsonData['entryId']); self::assertEquals('Test Thread', $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals('body text', $jsonData['body']); self::assertIsArray($jsonData['image']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']); self::assertStringContainsString($expectedPath, $jsonData['image']['filePath']); self::assertEquals('It\'s kibby!', $jsonData['image']['altText']); self::assertEquals('en', $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame(['test'], $jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('image', $jsonData['type']); self::assertEquals('Test-Thread', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotCreateEntryWithoutMagazine(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $invalidId = $magazine->getId() + 1; $entryRequest = [ 'title' => 'No Url/Body Thread', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/magazine/{$invalidId}/article", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(404); $this->client->jsonRequest('POST', "/api/magazine/{$invalidId}/link", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(404); $this->client->request('POST', "/api/magazine/{$invalidId}/image", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(404); } public function testApiCannotCreateEntryWithoutUrlBodyOrImage(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entryRequest = [ 'title' => 'No Url/Body Thread', 'tags' => ['test'], 'isOc' => false, 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/magazine/{$magazine->getId()}/article", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); $this->client->jsonRequest('POST', "/api/magazine/{$magazine->getId()}/link", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); $this->client->request('POST', "/api/magazine/{$magazine->getId()}/image", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/EntryDeleteApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for deletion', magazine: $magazine); $this->client->request('DELETE', "/api/entry/{$entry->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDeleteArticleEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/entry/{$entry->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotDeleteOtherUsersArticleEntry(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $otherUser, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/entry/{$entry->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDeleteArticleEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/entry/{$entry->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); } public function testApiCannotDeleteLinkEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test link', url: 'https://google.com'); $this->client->request('DELETE', "/api/entry/{$entry->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDeleteLinkEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/entry/{$entry->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotDeleteOtherUsersLinkEntry(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $otherUser, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/entry/{$entry->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDeleteLinkEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/entry/{$entry->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); } public function testApiCannotDeleteImageEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', image: $imageDto, magazine: $magazine); $this->client->request('DELETE', "/api/entry/{$entry->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDeleteImageEntryWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/entry/{$entry->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDeleteOtherUsersImageEntry(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $otherUser, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/entry/{$entry->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDeleteImageEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/entry/{$entry->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/EntryFavouriteApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}/favourite"); self::assertResponseStatusCodeSame(401); } public function testApiCannotFavouriteEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}/favourite", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanFavouriteEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}/favourite", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(1, $jsonData['favourites']); // No scope for seeing votes granted self::assertTrue($jsonData['isFavourited']); self::assertSame(0, $jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/EntryReportApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for report', magazine: $magazine); $reportRequest = [ 'reason' => 'Test reporting', ]; $this->client->jsonRequest('POST', "/api/entry/{$entry->getId()}/report", $reportRequest); self::assertResponseStatusCodeSame(401); } public function testApiCannotReportEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for report', user: $user, magazine: $magazine); $reportRequest = [ 'reason' => 'Test reporting', ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/entry/{$entry->getId()}/report", $reportRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanReportEntry(): void { $user = $this->getUserByUsername('user'); $otherUser = $this->getUserByUsername('somebody'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for report', user: $otherUser, magazine: $magazine); $reportRequest = [ 'reason' => 'Test reporting', ]; $magazineRepository = $this->magazineRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:report'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/entry/{$entry->getId()}/report", $reportRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $magazine = $magazineRepository->find($magazine->getId()); $reports = $magazineRepository->findReports($magazine); self::assertSame(1, $reports->count()); /** @var Report $report */ $report = $reports->getCurrentPageResults()[0]; self::assertEquals('Test reporting', $report->reason); self::assertSame($user->getId(), $report->reporting->getId()); self::assertSame($otherUser->getId(), $report->reported->getId()); self::assertSame($entry->getId(), $report->getSubject()->getId()); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/EntryRetrieveApiTest.php ================================================ client->request('GET', '/api/entries/subscribed'); self::assertResponseStatusCodeSame(401); } public function testApiCannotGetSubscribedEntriesWithoutScope(): void { self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'write'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetSubscribedEntries(): void { $user = $this->getUserByUsername('user'); $this->getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag', $user); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('another entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertIsArray($jsonData['items'][0]['domain']); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]['domain']); self::assertEquals('https://google.com', $jsonData['items'][0]['url']); self::assertNull($jsonData['items'][0]['body']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertIsArray($jsonData['items'][0]['badges']); self::assertEmpty($jsonData['items'][0]['badges']); self::assertSame(0, $jsonData['items'][0]['numComments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isOc']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('link', $jsonData['items'][0]['type']); self::assertEquals('another-entry', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); } public function testApiCannotGetModeratedEntriesAnonymous(): void { $this->client->request('GET', '/api/entries/moderated'); self::assertResponseStatusCodeSame(401); } public function testApiCannotGetModeratedEntriesWithoutScope(): void { self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries/moderated', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetModeratedEntries(): void { $user = $this->getUserByUsername('user'); $this->getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag', $user); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries/moderated', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('another entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertIsArray($jsonData['items'][0]['domain']); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]['domain']); self::assertEquals('https://google.com', $jsonData['items'][0]['url']); self::assertNull($jsonData['items'][0]['body']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertIsArray($jsonData['items'][0]['badges']); self::assertEmpty($jsonData['items'][0]['badges']); self::assertSame(0, $jsonData['items'][0]['numComments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isOc']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('link', $jsonData['items'][0]['type']); self::assertEquals('another-entry', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); } public function testApiCannotGetFavouritedEntriesAnonymous(): void { $this->client->request('GET', '/api/entries/favourited'); self::assertResponseStatusCodeSame(401); } public function testApiCannotGetFavouritedEntriesWithoutScope(): void { self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries/favourited', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetFavouritedEntries(): void { $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('an entry', body: 'test'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $favouriteManager = $this->favouriteManager; $favouriteManager->toggle($user, $entry); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries/favourited', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('an entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['domain']); self::assertNull($jsonData['items'][0]['url']); self::assertEquals('test', $jsonData['items'][0]['body']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertIsArray($jsonData['items'][0]['badges']); self::assertEmpty($jsonData['items'][0]['badges']); self::assertSame(0, $jsonData['items'][0]['numComments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(1, $jsonData['items'][0]['favourites']); // No scope for seeing votes granted self::assertTrue($jsonData['items'][0]['isFavourited']); self::assertSame(0, $jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isOc']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['items'][0]['type']); self::assertEquals('an-entry', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); } public function testApiCanGetEntriesAnonymous(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $this->createEntryComment('up the ranking', $entry); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $second = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); // Check that pinned entries don't get pinned to the top of the instance, just the magazine $entryManager = $this->entryManager; $entryManager->pin($second, null); $this->client->request('GET', '/api/entries'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('an entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['domain']); self::assertNull($jsonData['items'][0]['url']); self::assertEquals('test', $jsonData['items'][0]['body']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertIsArray($jsonData['items'][0]['badges']); self::assertEmpty($jsonData['items'][0]['badges']); self::assertSame(1, $jsonData['items'][0]['numComments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isOc']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['items'][0]['type']); self::assertEquals('an-entry', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertNull($jsonData['items'][0]['bookmarks']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another entry', $jsonData['items'][1]['title']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertEquals('link', $jsonData['items'][1]['type']); self::assertSame(0, $jsonData['items'][1]['numComments']); self::assertNull($jsonData['items'][0]['bookmarks']); } public function testApiCanGetEntries(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $this->createEntryComment('up the ranking', $entry); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('an entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['domain']); self::assertNull($jsonData['items'][0]['url']); self::assertEquals('test', $jsonData['items'][0]['body']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertIsArray($jsonData['items'][0]['badges']); self::assertEmpty($jsonData['items'][0]['badges']); self::assertSame(1, $jsonData['items'][0]['numComments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isOc']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['items'][0]['type']); self::assertEquals('an-entry', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another entry', $jsonData['items'][1]['title']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertEquals('link', $jsonData['items'][1]['type']); self::assertSame(0, $jsonData['items'][1]['numComments']); } public function testApiCanGetEntriesWithLanguageAnonymous(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $this->createEntryComment('up the ranking', $entry); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $second = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine, lang: 'de'); $this->getEntryByTitle('a dutch entry', body: 'some body', magazine: $magazine, lang: 'nl'); // Check that pinned entries don't get pinned to the top of the instance, just the magazine $entryManager = $this->entryManager; $entryManager->pin($second, null); $this->client->request('GET', '/api/entries?lang[]=en&lang[]=de'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('an entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['domain']); self::assertNull($jsonData['items'][0]['url']); self::assertEquals('test', $jsonData['items'][0]['body']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertIsArray($jsonData['items'][0]['badges']); self::assertEmpty($jsonData['items'][0]['badges']); self::assertSame(1, $jsonData['items'][0]['numComments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isOc']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['items'][0]['type']); self::assertEquals('an-entry', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertNull($jsonData['items'][0]['bookmarks']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another entry', $jsonData['items'][1]['title']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertEquals('link', $jsonData['items'][1]['type']); self::assertEquals('de', $jsonData['items'][1]['lang']); self::assertSame(0, $jsonData['items'][1]['numComments']); } public function testApiCanGetEntriesWithLanguage(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $this->createEntryComment('up the ranking', $entry); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine, lang: 'de'); $this->getEntryByTitle('a dutch entry', body: 'some body', magazine: $magazine, lang: 'nl'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries?lang[]=en&lang[]=de', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('an entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['domain']); self::assertNull($jsonData['items'][0]['url']); self::assertEquals('test', $jsonData['items'][0]['body']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertIsArray($jsonData['items'][0]['badges']); self::assertEmpty($jsonData['items'][0]['badges']); self::assertSame(1, $jsonData['items'][0]['numComments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isOc']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['items'][0]['type']); self::assertEquals('an-entry', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another entry', $jsonData['items'][1]['title']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertEquals('link', $jsonData['items'][1]['type']); self::assertEquals('de', $jsonData['items'][1]['lang']); self::assertSame(0, $jsonData['items'][1]['numComments']); } public function testApiCannotGetEntriesByPreferredLangAnonymous(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $this->createEntryComment('up the ranking', $entry); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $second = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); // Check that pinned entries don't get pinned to the top of the instance, just the magazine $entryManager = $this->entryManager; $entryManager->pin($second, null); $this->client->request('GET', '/api/entries?usePreferredLangs=true'); self::assertResponseStatusCodeSame(403); } public function testApiCanGetEntriesByPreferredLang(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $this->createEntryComment('up the ranking', $entry); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $this->getEntryByTitle('German entry', body: 'Some body', lang: 'de'); $user = $this->getUserByUsername('user'); $user->preferredLanguages = ['en']; $entityManager = $this->entityManager; $entityManager->persist($user); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries?usePreferredLangs=true', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('an entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['domain']); self::assertNull($jsonData['items'][0]['url']); self::assertEquals('test', $jsonData['items'][0]['body']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertIsArray($jsonData['items'][0]['badges']); self::assertEmpty($jsonData['items'][0]['badges']); self::assertSame(1, $jsonData['items'][0]['numComments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isOc']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['items'][0]['type']); self::assertEquals('an-entry', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another entry', $jsonData['items'][1]['title']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertEquals('link', $jsonData['items'][1]['type']); self::assertEquals('en', $jsonData['items'][1]['lang']); self::assertSame(0, $jsonData['items'][1]['numComments']); } public function testApiCanGetEntriesNewest(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries?sort=newest', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['entryId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['entryId']); } public function testApiCanGetEntriesOldest(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries?sort=oldest', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['entryId']); } public function testApiCanGetEntriesCommented(): void { $first = $this->getEntryByTitle('first', body: 'test'); $this->createEntryComment('comment 1', $first); $this->createEntryComment('comment 2', $first); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $this->createEntryComment('comment 1', $second); $third = $this->getEntryByTitle('third', url: 'https://google.com'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries?sort=commented', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertSame(2, $jsonData['items'][0]['numComments']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertSame(1, $jsonData['items'][1]['numComments']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['entryId']); self::assertSame(0, $jsonData['items'][2]['numComments']); } public function testApiCanGetEntriesActive(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $first->lastActive = new \DateTime('-1 hour'); $second->lastActive = new \DateTime('-1 second'); $third->lastActive = new \DateTime(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries?sort=active', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['entryId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['entryId']); } public function testApiCanGetEntriesTop(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $voteManager = $this->voteManager; $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false); $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false); $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries?sort=top', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertSame(2, $jsonData['items'][0]['uv']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertSame(1, $jsonData['items'][1]['uv']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['entryId']); self::assertSame(0, $jsonData['items'][2]['uv']); } public function testApiCanGetEntriesWithUserVoteStatus(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $this->createEntryComment('up the ranking', $entry); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('an entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['domain']); self::assertNull($jsonData['items'][0]['url']); self::assertEquals('test', $jsonData['items'][0]['body']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertSame(1, $jsonData['items'][0]['numComments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertFalse($jsonData['items'][0]['isFavourited']); self::assertSame(0, $jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isOc']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['items'][0]['type']); self::assertEquals('an-entry', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another entry', $jsonData['items'][1]['title']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertEquals('link', $jsonData['items'][1]['type']); self::assertSame(0, $jsonData['items'][1]['numComments']); } public function testApiCanGetEntryByIdAnonymous(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $this->client->request('GET', "/api/entry/{$entry->getId()}"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals('an entry', $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals('test', $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals('en', $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('an-entry', $jsonData['slug']); self::assertNull($jsonData['apId']); self::assertNull($jsonData['bookmarks']); } public function testApiCanGetEntryById(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/entry/{$entry->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals('an entry', $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals('test', $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals('en', $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('an-entry', $jsonData['slug']); self::assertNull($jsonData['apId']); self::assertIsArray($jsonData['bookmarks']); self::assertEmpty($jsonData['bookmarks']); } public function testApiCanGetEntryByIdWithUserVoteStatus(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/entry/{$entry->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals('an entry', $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals('test', $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals('en', $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertFalse($jsonData['isFavourited']); self::assertSame(0, $jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('an-entry', $jsonData['slug']); self::assertNull($jsonData['apId']); self::assertIsArray($jsonData['bookmarks']); self::assertEmpty($jsonData['bookmarks']); } public function testApiCanGetEntriesLocal(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', body: 'test2'); $second->apId = 'https://some.url'; $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries?federation=local', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertTrue($jsonData['items'][0]['isAuthorModeratorInMagazine']); } public function testApiCanGetEntriesFederated(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', body: 'test2'); $second->apId = 'https://some.url'; $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries?federation=federated', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($second->getId(), $jsonData['items'][0]['entryId']); self::assertTrue($jsonData['items'][0]['isAuthorModeratorInMagazine']); } public function testApiGetAuthorNotModerator(): void { $first = $this->getEntryByTitle('first', body: 'test'); sleep(1); $second = $this->getEntryByTitle('second', body: 'test2', user: $this->getUserByUsername('Jane Doe')); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/entries?sort=oldest', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertTrue($jsonData['items'][0]['isAuthorModeratorInMagazine']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertFalse($jsonData['items'][1]['isAuthorModeratorInMagazine']); } /** * This function tests that the collection endpoint does not contain crosspost information, * but fetching a single entry does. */ public function testApiContainsCrosspostInformation(): void { $magazine1 = $this->getMagazineByName('acme'); $entry1 = $this->getEntryByTitle('first URL', url: 'https://joinmbin.org', magazine: $magazine1); sleep(1); $magazine2 = $this->getMagazineByName('acme2'); $entry2 = $this->getEntryByTitle('second URL', url: 'https://joinmbin.org', magazine: $magazine2); $this->entityManager->persist($entry1); $this->entityManager->persist($entry2); $this->entityManager->flush(); $this->client->request('GET', '/api/entries?sort=oldest'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($entry1->getId(), $jsonData['items'][0]['entryId']); self::assertNull($jsonData['items'][0]['crosspostedEntries']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($entry2->getId(), $jsonData['items'][1]['entryId']); self::assertNull($jsonData['items'][1]['crosspostedEntries']); $this->client->request('GET', '/api/entry/'.$entry1->getId()); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData['crosspostedEntries']); self::assertCount(1, $jsonData['crosspostedEntries']); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['crosspostedEntries'][0]); self::assertSame($entry2->getId(), $jsonData['crosspostedEntries'][0]['entryId']); $this->client->request('GET', '/api/entry/'.$entry2->getId()); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData['crosspostedEntries']); self::assertCount(1, $jsonData['crosspostedEntries']); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['crosspostedEntries'][0]); self::assertSame($entry1->getId(), $jsonData['crosspostedEntries'][0]['entryId']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/EntryUpdateApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for update', magazine: $magazine); $updateRequest = [ 'title' => 'Updated title', 'tags' => [ 'edit', ], 'isOc' => true, 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}", $updateRequest); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateArticleEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for update', user: $user, magazine: $magazine); $updateRequest = [ 'title' => 'Updated title', 'tags' => [ 'edit', ], 'isOc' => true, 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateOtherUsersArticleEntry(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for update', user: $otherUser, magazine: $magazine); $updateRequest = [ 'title' => 'Updated title', 'tags' => [ 'edit', ], 'isOc' => true, 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateArticleEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for update', user: $user, magazine: $magazine); $updateRequest = [ 'title' => 'Updated title', 'tags' => [ 'edit', ], 'isOc' => true, 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($updateRequest['title'], $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($updateRequest['body'], $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($updateRequest['lang'], $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame($updateRequest['tags'], $jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertTrue($jsonData['isOc']); self::assertTrue($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['editedAt'], 'editedAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('Updated-title', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotUpdateLinkEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test link', url: 'https://google.com', magazine: $magazine); $updateRequest = [ 'title' => 'Updated title', 'tags' => [ 'edit', ], 'isOc' => true, 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateLinkEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $user, magazine: $magazine); $updateRequest = [ 'title' => 'Updated title', 'tags' => [ 'edit', ], 'isOc' => true, 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateOtherUsersLinkEntry(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $otherUser, magazine: $magazine); $updateRequest = [ 'title' => 'Updated title', 'tags' => [ 'edit', ], 'isOc' => true, 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateLinkEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $user, magazine: $magazine); $updateRequest = [ 'title' => 'Updated title', 'tags' => [ 'edit', ], 'isOc' => true, 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($updateRequest['title'], $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertIsArray($jsonData['domain']); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['domain']); self::assertEquals('https://google.com', $jsonData['url']); self::assertEquals($updateRequest['body'], $jsonData['body']); if (null !== $jsonData['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals($updateRequest['lang'], $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame($updateRequest['tags'], $jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertTrue($jsonData['isOc']); self::assertTrue($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['editedAt'], 'editedAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('link', $jsonData['type']); self::assertEquals('Updated-title', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotUpdateImageEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', image: $imageDto, magazine: $magazine); $updateRequest = [ 'title' => 'Updated title', 'tags' => [ 'edit', ], 'isOc' => true, 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}", $updateRequest); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateImageEntryWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $user, magazine: $magazine); $updateRequest = [ 'title' => 'Updated title', 'tags' => [ 'edit', ], 'isOc' => true, 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateOtherUsersImageEntry(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $otherUser, magazine: $magazine); $updateRequest = [ 'title' => 'Updated title', 'tags' => [ 'edit', ], 'isOc' => true, 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } #[Group(name: 'NonThreadSafe')] public function testApiCanUpdateImageEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $user, magazine: $magazine); self::assertNotNull($imageDto->id); self::assertNotNull($entry->image); self::assertNotNull($entry->image->getId()); self::assertSame($imageDto->id, $entry->image->getId()); self::assertSame($imageDto->filePath, $entry->image->filePath); $updateRequest = [ 'title' => 'Updated title', 'tags' => [ 'edit', ], 'isOc' => true, 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($updateRequest['title'], $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($updateRequest['body'], $jsonData['body']); self::assertIsArray($jsonData['image']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']); self::assertStringContainsString($imageDto->filePath, $jsonData['image']['filePath']); self::assertEquals($updateRequest['lang'], $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame($updateRequest['tags'], $jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertTrue($jsonData['isOc']); self::assertTrue($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['editedAt'], 'editedAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('image', $jsonData['type']); self::assertEquals('Updated-title', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/EntryVoteApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for upvote', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}/vote/1"); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpvoteEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for upvote', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}/vote/1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpvoteEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for upvote', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}/vote/1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(1, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertFalse($jsonData['isFavourited']); self::assertSame(1, $jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotDownvoteEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for upvote', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}/vote/-1"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDownvoteEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for upvote', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}/vote/-1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDownvoteEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for upvote', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}/vote/-1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(1, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertFalse($jsonData['isFavourited']); self::assertSame(-1, $jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotClearVoteEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for upvote', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}/vote/0"); self::assertResponseStatusCodeSame(401); } public function testApiCannotClearVoteEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for upvote', user: $user, magazine: $magazine); $voteManager = $this->voteManager; $voteManager->vote(1, $entry, $user, rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}/vote/0", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanClearVoteEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for upvote', user: $user, magazine: $magazine); $voteManager = $this->voteManager; $voteManager->vote(1, $entry, $user, rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/entry/{$entry->getId()}/vote/0", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertFalse($jsonData['isFavourited']); self::assertSame(0, $jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/MagazineEntryRetrieveApiTest.php ================================================ getEntryByTitle('an entry', body: 'test'); $this->createEntryComment('up the ranking', $entry); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); $this->client->request('GET', "/api/magazine/{$magazine->getId()}/entries"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('another entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertEquals('link', $jsonData['items'][0]['type']); self::assertSame(0, $jsonData['items'][0]['numComments']); self::assertNull($jsonData['items'][0]['crosspostedEntries']); } public function testApiCanGetMagazineEntries(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $this->createEntryComment('up the ranking', $entry); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/entries", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('another entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertEquals('link', $jsonData['items'][0]['type']); self::assertSame(0, $jsonData['items'][0]['numComments']); self::assertNull($jsonData['items'][0]['crosspostedEntries']); } public function testApiCanGetMagazineEntriesPinnedFirst(): void { $voteManager = $this->voteManager; $entryManager = $this->entryManager; $voter = $this->getUserByUsername('voter'); $first = $this->getEntryByTitle('an entry', body: 'test'); $this->createEntryComment('up the ranking', $first); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $second = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); // Upvote and comment on $second so it should come first, but then pin $third so it actually comes first $voteManager->vote(1, $second, $voter, rateLimit: false); $this->createEntryComment('test', $second, $voter); $third = $this->getEntryByTitle('a pinned entry', url: 'https://google.com', magazine: $magazine); $entryManager->pin($third, null); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/entries", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('a pinned entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertEquals('link', $jsonData['items'][0]['type']); self::assertSame(0, $jsonData['items'][0]['numComments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertTrue($jsonData['items'][0]['isPinned']); self::assertNull($jsonData['items'][0]['crosspostedEntries']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another entry', $jsonData['items'][1]['title']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertEquals('link', $jsonData['items'][1]['type']); self::assertSame(1, $jsonData['items'][1]['numComments']); self::assertSame(1, $jsonData['items'][1]['uv']); self::assertFalse($jsonData['items'][1]['isPinned']); self::assertNull($jsonData['items'][1]['crosspostedEntries']); } public function testApiCanGetMagazineEntriesNewest(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $magazine = $first->magazine; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/entries?sort=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['entryId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['entryId']); } public function testApiCanGetMagazineEntriesOldest(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $magazine = $first->magazine; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/entries?sort=oldest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['entryId']); } public function testApiCanGetMagazineEntriesCommented(): void { $first = $this->getEntryByTitle('first', body: 'test'); $this->createEntryComment('comment 1', $first); $this->createEntryComment('comment 2', $first); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $this->createEntryComment('comment 1', $second); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $magazine = $first->magazine; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/entries?sort=commented", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertSame(2, $jsonData['items'][0]['numComments']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertSame(1, $jsonData['items'][1]['numComments']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['entryId']); self::assertSame(0, $jsonData['items'][2]['numComments']); } public function testApiCanGetMagazineEntriesActive(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $magazine = $first->magazine; $first->lastActive = new \DateTime('-1 hour'); $second->lastActive = new \DateTime('-1 second'); $third->lastActive = new \DateTime(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/entries?sort=active", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['entryId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['entryId']); } public function testApiCanGetMagazineEntriesTop(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $magazine = $first->magazine; $voteManager = $this->voteManager; $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false); $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false); $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/entries?sort=top", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertSame(2, $jsonData['items'][0]['uv']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertSame(1, $jsonData['items'][1]['uv']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['entryId']); self::assertSame(0, $jsonData['items'][2]['uv']); } public function testApiCanGetMagazineEntriesWithUserVoteStatus(): void { $first = $this->getEntryByTitle('an entry', body: 'test'); $this->createEntryComment('up the ranking', $first); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/entries", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('another entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertIsArray($jsonData['items'][0]['domain']); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]['domain']); self::assertEquals('https://google.com', $jsonData['items'][0]['url']); self::assertNull($jsonData['items'][0]['body']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertSame(0, $jsonData['items'][0]['numComments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertFalse($jsonData['items'][0]['isFavourited']); self::assertSame(0, $jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isOc']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('link', $jsonData['items'][0]['type']); self::assertEquals('another-entry', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Moderate/EntryLockApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/lock"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorNonAuthorCannotLockEntry(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user2, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:lock'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotLockEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanLockEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:lock'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertTrue($jsonData['isLocked']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiAuthorNonModeratorCanLockEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:lock'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertTrue($jsonData['isLocked']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotUnlockEntryAnonymous(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine); $entryManager = $this->entryManager; $entryManager->toggleLock($entry, $user); $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/lock"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorNonAuthorCannotUnlockEntry(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user2, magazine: $magazine); $entryManager = $this->entryManager; $entryManager->pin($entry, null); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:lock'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUnlockEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $entryManager = $this->entryManager; $entryManager->toggleLock($entry, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUnlockEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $entryManager = $this->entryManager; $entryManager->toggleLock($entry, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:lock'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertFalse($jsonData['isLocked']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiAuthorNonModeratorCanUnlockEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $entryManager = $this->entryManager; $entryManager->toggleLock($entry, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:lock'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertFalse($jsonData['isLocked']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Moderate/EntryPinApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/pin"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotPinEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:pin'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/pin", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotPinEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/pin", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanPinEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:pin'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/pin", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertTrue($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotUnpinEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine); $entryManager = $this->entryManager; $entryManager->pin($entry, null); $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/pin"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotUnpinEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $entryManager = $this->entryManager; $entryManager->pin($entry, null); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:pin'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/pin", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUnpinEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $entryManager = $this->entryManager; $entryManager->pin($entry, null); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/pin", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUnpinEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $entryManager = $this->entryManager; $entryManager->pin($entry, null); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:pin'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/pin", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Moderate/EntrySetAdultApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/adult/true"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotSetEntryAdult(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/adult/true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotSetEntryAdultWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/adult/true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanSetEntryAdult(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/adult/true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertTrue($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('visible', $jsonData['visibility']); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotSetEntryNotAdultAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine); $entityManager = $this->entityManager; $entry->isAdult = true; $entityManager->persist($entry); $entityManager->flush(); $this->client->request('PUT', "/api/moderate/entry/{$entry->getId()}/adult/false"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotSetEntryNotAdult(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $entityManager = $this->entityManager; $entry->isAdult = true; $entityManager->persist($entry); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/adult/false", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotSetEntryNotAdultWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $entityManager = $this->entityManager; $entry->isAdult = true; $entityManager->persist($entry); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/adult/false", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanSetEntryNotAdult(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); $entityManager = $this->entityManager; $entry->isAdult = true; $entityManager->persist($entry); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/adult/false", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('visible', $jsonData['visibility']); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Moderate/EntrySetLanguageApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/de"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotSetEntryLanguage(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:language'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotSetEntryLanguageWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotSetEntryLanguageInvalid(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:language'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/fake", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/ac", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/aaa", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/a", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); } public function testApiCanSetEntryLanguage(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:language'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals('de', $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('visible', $jsonData['visibility']); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCanSetEntryLanguage3Letter(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:language'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/elx", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals('elx', $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('visible', $jsonData['visibility']); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/Moderate/EntryTrashApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/trash"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotTrashEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotTrashEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanTrashEntry(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('trashed', $jsonData['visibility']); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotRestoreEntryAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine); $entryManager = $this->entryManager; $entryManager->trash($user, $entry); $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/restore"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotRestoreEntry(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $entryManager = $this->entryManager; $entryManager->trash($user, $entry); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/restore", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRestoreEntryWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $entryManager = $this->entryManager; $entryManager->trash($user, $entry); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/restore", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRestoreEntry(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); $entryManager = $this->entryManager; $entryManager->trash($user, $entry); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/restore", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData); self::assertSame($entry->getId(), $jsonData['entryId']); self::assertEquals($entry->title, $jsonData['title']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['domain']); self::assertNull($jsonData['url']); self::assertEquals($entry->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($entry->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertIsArray($jsonData['badges']); self::assertEmpty($jsonData['badges']); self::assertSame(0, $jsonData['numComments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isOc']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('visible', $jsonData['visibility']); self::assertEquals('article', $jsonData['type']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Entry/UserEntryRetrieveApiTest.php ================================================ getEntryByTitle('an entry', body: 'test'); $this->createEntryComment('up the ranking', $entry); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $otherUser = $this->getUserByUsername('somebody'); $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine, user: $otherUser); $this->client->request('GET', "/api/users/{$otherUser->getId()}/entries"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('another entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertSame($otherUser->getId(), $jsonData['items'][0]['user']['userId']); self::assertEquals('link', $jsonData['items'][0]['type']); self::assertSame(0, $jsonData['items'][0]['numComments']); self::assertNull($jsonData['items'][0]['crosspostedEntries']); } public function testApiCanGetUserEntries(): void { $entry = $this->getEntryByTitle('an entry', body: 'test'); $this->createEntryComment('up the ranking', $entry); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $otherUser = $this->getUserByUsername('somebody'); $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine, user: $otherUser); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/entries", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('another entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertSame($otherUser->getId(), $jsonData['items'][0]['user']['userId']); self::assertEquals('link', $jsonData['items'][0]['type']); self::assertSame(0, $jsonData['items'][0]['numComments']); self::assertNull($jsonData['items'][0]['crosspostedEntries']); } public function testApiCanGetUserEntriesNewest(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $otherUser = $first->user; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/entries?sort=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['entryId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['entryId']); } public function testApiCanGetUserEntriesOldest(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $otherUser = $first->user; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/entries?sort=oldest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['entryId']); } public function testApiCanGetUserEntriesCommented(): void { $first = $this->getEntryByTitle('first', body: 'test'); $this->createEntryComment('comment 1', $first); $this->createEntryComment('comment 2', $first); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $this->createEntryComment('comment 1', $second); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $otherUser = $first->user; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/entries?sort=commented", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertSame(2, $jsonData['items'][0]['numComments']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertSame(1, $jsonData['items'][1]['numComments']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['entryId']); self::assertSame(0, $jsonData['items'][2]['numComments']); } public function testApiCanGetUserEntriesActive(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $otherUser = $first->user; $first->lastActive = new \DateTime('-1 hour'); $second->lastActive = new \DateTime('-1 second'); $third->lastActive = new \DateTime(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/entries?sort=active", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['entryId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['entryId']); } public function testApiCanGetUserEntriesTop(): void { $first = $this->getEntryByTitle('first', body: 'test'); $second = $this->getEntryByTitle('second', url: 'https://google.com'); $third = $this->getEntryByTitle('third', url: 'https://google.com'); $otherUser = $first->user; $voteManager = $this->voteManager; $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false); $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false); $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/entries?sort=top", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['entryId']); self::assertSame(2, $jsonData['items'][0]['uv']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['entryId']); self::assertSame(1, $jsonData['items'][1]['uv']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['entryId']); self::assertSame(0, $jsonData['items'][2]['uv']); } public function testApiCanGetUserEntriesWithUserVoteStatus(): void { $this->getEntryByTitle('an entry', body: 'test'); $otherUser = $this->getUserByUsername('somebody'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine, user: $otherUser); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/entries", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals('another entry', $jsonData['items'][0]['title']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertSame($otherUser->getId(), $jsonData['items'][0]['user']['userId']); self::assertIsArray($jsonData['items'][0]['domain']); self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]['domain']); self::assertEquals('https://google.com', $jsonData['items'][0]['url']); self::assertNull($jsonData['items'][0]['body']); if (null !== $jsonData['items'][0]['image']) { self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST)); } self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertSame(0, $jsonData['items'][0]['numComments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertFalse($jsonData['items'][0]['isFavourited']); self::assertSame(0, $jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isOc']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('link', $jsonData['items'][0]['type']); self::assertEquals('another-entry', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertNull($jsonData['items'][0]['crosspostedEntries']); } } ================================================ FILE: tests/Functional/Controller/Api/Instance/Admin/InstanceFederationUpdateApiTest.php ================================================ client->request('PUT', '/api/defederated'); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateInstanceFederationWithoutAdmin(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/defederated', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateInstanceFederationWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/defederated', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateInstanceFederation(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:federation:update'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', '/api/defederated', ['instances' => ['bad-instance.com']], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(['instances'], $jsonData); self::assertSame(['bad-instance.com'], $jsonData['instances']); } public function testApiCanClearInstanceFederation(): void { $this->instanceManager->setBannedInstances(['defederated.social', 'evil.social']); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:federation:update'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', '/api/defederated', ['instances' => []], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(['instances'], $jsonData); self::assertEmpty($jsonData['instances']); } } ================================================ FILE: tests/Functional/Controller/Api/Instance/Admin/InstancePagesUpdateApiTest.php ================================================ client->request('PUT', '/api/instance/about'); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateInstanceAboutPageWithoutAdmin(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/instance/about', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateInstanceAboutPageWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/instance/about', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateInstanceAboutPage(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:information:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', '/api/instance/about', ['body' => 'about page'], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(InstanceDetailsApiTest::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData); self::assertEquals('about page', $jsonData['about']); } public function testApiCannotUpdateInstanceContactPageAnonymous(): void { $this->client->request('PUT', '/api/instance/contact'); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateInstanceContactPageWithoutAdmin(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/instance/contact', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateInstanceContactPageWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/instance/contact', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateInstanceContactPage(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:information:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', '/api/instance/contact', ['body' => 'contact page'], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(InstanceDetailsApiTest::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData); self::assertEquals('contact page', $jsonData['contact']); } public function testApiCannotUpdateInstanceFAQPageAnonymous(): void { $this->client->request('PUT', '/api/instance/faq'); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateInstanceFAQPageWithoutAdmin(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/instance/faq', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateInstanceFAQPageWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/instance/faq', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateInstanceFAQPage(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:information:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', '/api/instance/faq', ['body' => 'faq page'], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(InstanceDetailsApiTest::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData); self::assertEquals('faq page', $jsonData['faq']); } public function testApiCannotUpdateInstancePrivacyPolicyPageAnonymous(): void { $this->client->request('PUT', '/api/instance/privacyPolicy'); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateInstancePrivacyPolicyPageWithoutAdmin(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/instance/privacyPolicy', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateInstancePrivacyPolicyPageWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/instance/privacyPolicy', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateInstancePrivacyPolicyPage(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:information:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', '/api/instance/privacyPolicy', ['body' => 'privacyPolicy page'], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(InstanceDetailsApiTest::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData); self::assertEquals('privacyPolicy page', $jsonData['privacyPolicy']); } public function testApiCannotUpdateInstanceTermsPageAnonymous(): void { $this->client->request('PUT', '/api/instance/terms'); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateInstanceTermsPageWithoutAdmin(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/instance/terms', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateInstanceTermsPageWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/instance/terms', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateInstanceTermsPage(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:information:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', '/api/instance/terms', ['body' => 'terms page'], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(InstanceDetailsApiTest::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData); self::assertEquals('terms page', $jsonData['terms']); } } ================================================ FILE: tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsRetrieveApiTest.php ================================================ client->request('GET', '/api/instance/settings'); self::assertResponseStatusCodeSame(401); } public function testApiCannotRetrieveInstanceSettingsWithoutAdmin(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/instance/settings', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRetrieveInstanceSettingsWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/instance/settings', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveInstanceSettings(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:settings:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/instance/settings', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(self::INSTANCE_SETTINGS_RESPONSE_KEYS, $jsonData); foreach ($jsonData as $key => $value) { self::assertNotNull($value, "$key was null!"); } } } ================================================ FILE: tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsUpdateApiTest.php ================================================ client->request('PUT', '/api/instance/settings'); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateInstanceSettingsWithoutAdmin(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/instance/settings', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateInstanceSettingsWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/instance/settings', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateInstanceSettings(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:settings:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $settings = [ 'KBIN_DOMAIN' => 'kbinupdated.test', 'KBIN_TITLE' => 'updated title', 'KBIN_META_TITLE' => 'meta title', 'KBIN_META_KEYWORDS' => 'this, is, a, test', 'KBIN_META_DESCRIPTION' => 'Testing out the API', 'KBIN_DEFAULT_LANG' => 'de', 'KBIN_CONTACT_EMAIL' => 'test@kbinupdated.test', 'KBIN_SENDER_EMAIL' => 'noreply@kbinupdated.test', 'MBIN_DEFAULT_THEME' => 'dark', 'KBIN_JS_ENABLED' => true, 'KBIN_FEDERATION_ENABLED' => true, 'KBIN_REGISTRATIONS_ENABLED' => false, 'KBIN_HEADER_LOGO' => true, 'KBIN_CAPTCHA_ENABLED' => true, 'KBIN_MERCURE_ENABLED' => false, 'KBIN_FEDERATION_PAGE_ENABLED' => false, 'KBIN_ADMIN_ONLY_OAUTH_CLIENTS' => true, 'MBIN_PRIVATE_INSTANCE' => true, 'KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN' => false, 'MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY' => false, 'MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY' => false, 'MBIN_SSO_REGISTRATIONS_ENABLED' => true, 'MBIN_RESTRICT_MAGAZINE_CREATION' => false, 'MBIN_DOWNVOTES_MODE' => DownvotesMode::Enabled->value, 'MBIN_SSO_ONLY_MODE' => false, 'MBIN_SSO_SHOW_FIRST' => false, 'MBIN_NEW_USERS_NEED_APPROVAL' => false, 'MBIN_USE_FEDERATION_ALLOW_LIST' => false, ]; $this->client->jsonRequest('PUT', '/api/instance/settings', $settings, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(self::INSTANCE_SETTINGS_RESPONSE_KEYS, $jsonData); foreach ($jsonData as $key => $value) { self::assertEquals($settings[$key], $value, "$key did not match!"); } $settings = [ 'KBIN_DOMAIN' => 'kbin.test', 'KBIN_TITLE' => 'updated title', 'KBIN_META_TITLE' => 'meta title', 'KBIN_META_KEYWORDS' => 'this, is, a, test', 'KBIN_META_DESCRIPTION' => 'Testing out the API', 'KBIN_DEFAULT_LANG' => 'en', 'KBIN_CONTACT_EMAIL' => 'test@kbinupdated.test', 'KBIN_SENDER_EMAIL' => 'noreply@kbinupdated.test', 'MBIN_DEFAULT_THEME' => 'light', 'KBIN_JS_ENABLED' => false, 'KBIN_FEDERATION_ENABLED' => false, 'KBIN_REGISTRATIONS_ENABLED' => true, 'KBIN_HEADER_LOGO' => false, 'KBIN_CAPTCHA_ENABLED' => false, 'KBIN_MERCURE_ENABLED' => true, 'KBIN_FEDERATION_PAGE_ENABLED' => true, 'KBIN_ADMIN_ONLY_OAUTH_CLIENTS' => false, 'MBIN_PRIVATE_INSTANCE' => false, 'KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN' => true, 'MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY' => true, 'MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY' => true, 'MBIN_SSO_REGISTRATIONS_ENABLED' => false, 'MBIN_RESTRICT_MAGAZINE_CREATION' => true, 'MBIN_DOWNVOTES_MODE' => DownvotesMode::Hidden->value, 'MBIN_SSO_ONLY_MODE' => true, 'MBIN_SSO_SHOW_FIRST' => true, 'MBIN_NEW_USERS_NEED_APPROVAL' => false, 'MBIN_USE_FEDERATION_ALLOW_LIST' => false, ]; $this->client->jsonRequest('PUT', '/api/instance/settings', $settings, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(self::INSTANCE_SETTINGS_RESPONSE_KEYS, $jsonData); foreach ($jsonData as $key => $value) { self::assertEquals($settings[$key], $value, "$key did not match!"); } } protected function tearDown(): void { parent::tearDown(); SettingsManager::resetDto(); } } ================================================ FILE: tests/Functional/Controller/Api/Instance/InstanceDetailsApiTest.php ================================================ createInstancePages(); $this->client->request('GET', '/api/instance'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(self::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData); self::assertEquals($site->about, $jsonData['about']); self::assertEquals($site->contact, $jsonData['contact']); self::assertEquals($site->faq, $jsonData['faq']); self::assertEquals($site->privacyPolicy, $jsonData['privacyPolicy']); self::assertEquals($site->terms, $jsonData['terms']); } public function testApiCanRetrieveInstanceDetails(): void { $site = $this->createInstancePages(); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/instance', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(self::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData); self::assertEquals($site->about, $jsonData['about']); self::assertEquals($site->contact, $jsonData['contact']); self::assertEquals($site->faq, $jsonData['faq']); self::assertEquals($site->privacyPolicy, $jsonData['privacyPolicy']); self::assertEquals($site->terms, $jsonData['terms']); } } ================================================ FILE: tests/Functional/Controller/Api/Instance/InstanceFederationApiTest.php ================================================ instanceManager->setBannedInstances([]); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/defederated', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(self::INSTANCE_DEFEDERATED_RESPONSE_KEYS, $jsonData); self::assertSame([], $jsonData['instances']); } #[Group(name: 'NonThreadSafe')] public function testApiCanRetrieveInstanceDefederationAnonymous(): void { $this->instanceManager->setBannedInstances(['defederated.social']); $this->client->request('GET', '/api/defederated'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(self::INSTANCE_DEFEDERATED_RESPONSE_KEYS, $jsonData); self::assertSame(['defederated.social'], $jsonData['instances']); } #[Group(name: 'NonThreadSafe')] public function testApiCanRetrieveInstanceDefederation(): void { $this->instanceManager->setBannedInstances(['defederated.social', 'evil.social']); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/defederated', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(self::INSTANCE_DEFEDERATED_RESPONSE_KEYS, $jsonData); self::assertSame(['defederated.social', 'evil.social'], $jsonData['instances']); } } ================================================ FILE: tests/Functional/Controller/Api/Instance/InstanceModlogApiTest.php ================================================ createModlogMessages(); $this->client->request('GET', '/api/modlog'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(5, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(5, $jsonData['pagination']['count']); $magazine = $this->getMagazineByName('acme'); $moderator = $magazine->getOwner(); $this->validateModlog($jsonData, $magazine, $moderator); } public function testApiCanRetrieveModlogAnonymousWithTypeFilter(): void { $this->createModlogMessages(); $this->client->request('GET', '/api/modlog?types[]='.MagazineLog::CHOICES[0].'&types[]='.MagazineLog::CHOICES[1]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); } public function testApiCanRetrieveModlog(): void { $this->createModlogMessages(); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/modlog', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(5, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(5, $jsonData['pagination']['count']); $magazine = $this->getMagazineByName('acme'); $moderator = $magazine->getOwner(); $this->validateModlog($jsonData, $magazine, $moderator); } } ================================================ FILE: tests/Functional/Controller/Api/Instance/InstanceRetrieveInfoApiTest.php ================================================ getUserByUsername('admin', isAdmin: true); $this->getUserByUsername('moderator', isModerator: true); $this->client->request('GET', '/api/info'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(self::INFO_KEYS, $jsonData); self::assertIsString($jsonData['softwareName']); self::assertIsString($jsonData['softwareVersion']); self::assertIsString($jsonData['softwareRepository']); self::assertIsString($jsonData['websiteDomain']); self::assertIsString($jsonData['websiteContactEmail']); self::assertIsString($jsonData['websiteTitle']); self::assertIsBool($jsonData['websiteOpenRegistrations']); self::assertIsBool($jsonData['websiteFederationEnabled']); self::assertIsString($jsonData['websiteDefaultLang']); self::assertIsArray($jsonData['instanceAdmins']); self::assertIsArray($jsonData['instanceModerators']); self::assertNotEmpty($jsonData['instanceAdmins']); self::assertNotEmpty($jsonData['instanceModerators']); self::assertArrayKeysMatch(self::AP_USER_DEFAULT_KEYS, $jsonData['instanceAdmins'][0]); self::assertArrayKeysMatch(self::AP_USER_DEFAULT_KEYS, $jsonData['instanceModerators'][0]); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Admin/MagazineBadgesApiTest.php ================================================ getMagazineByName('test'); $this->client->jsonRequest('POST', "/api/moderate/magazine/{$magazine->getId()}/badge", parameters: ['name' => 'test']); self::assertResponseStatusCodeSame(401); } public function testApiCannotRemoveBadgesFromMagazineAnonymous(): void { $magazine = $this->getMagazineByName('test'); $badgeManager = $this->badgeManager; $badge = $badgeManager->create(BadgeDto::create($magazine, 'test')); $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/badge/{$badge->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotAddBadgesToMagazineWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/moderate/magazine/{$magazine->getId()}/badge", parameters: ['name' => 'test'], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRemoveBadgesFromMagazineWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $badgeManager = $this->badgeManager; $badge = $badgeManager->create(BadgeDto::create($magazine, 'test')); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/badge/{$badge->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiModCannotAddBadgesMagazine(): void { $moderator = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($moderator); $owner = $this->getUserByUsername('JaneDoe'); $admin = $this->getUserByUsername('admin', isAdmin: true); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $owner); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $moderator; $dto->addedBy = $admin; $magazineManager->addModerator($dto); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:badges'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/moderate/magazine/{$magazine->getId()}/badge", parameters: ['name' => 'test'], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiModCannotRemoveBadgesMagazine(): void { $moderator = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($moderator); $owner = $this->getUserByUsername('JaneDoe'); $admin = $this->getUserByUsername('admin', isAdmin: true); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $owner); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $moderator; $dto->addedBy = $admin; $magazineManager->addModerator($dto); $badgeManager = $this->badgeManager; $badge = $badgeManager->create(BadgeDto::create($magazine, 'test')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:badges'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/badge/{$badge->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiOwnerCanAddBadgesMagazine(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:badges'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/moderate/magazine/{$magazine->getId()}/badge", parameters: ['name' => 'test'], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['badges']); self::assertCount(1, $jsonData['badges']); self::assertArrayKeysMatch(self::BADGE_RESPONSE_KEYS, $jsonData['badges'][0]); self::assertEquals('test', $jsonData['badges'][0]['name']); } public function testApiOwnerCanRemoveBadgesMagazine(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $badgeManager = $this->badgeManager; $badge = $badgeManager->create(BadgeDto::create($magazine, 'test')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:badges'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/badge/{$badge->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['badges']); self::assertCount(0, $jsonData['badges']); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Admin/MagazineCreateApiTest.php ================================================ client->request('POST', '/api/moderate/magazine/new'); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateMagazineWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', '/api/moderate/magazine/new', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateMagazine(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:create'); $token = $codes['token_type'].' '.$codes['access_token']; $name = 'test'; $title = 'API Test Magazine'; $description = 'A description'; $this->client->jsonRequest( 'POST', '/api/moderate/magazine/new', parameters: [ 'name' => $name, 'title' => $title, 'description' => $description, 'isAdult' => false, 'discoverable' => false, 'isPostingRestrictedToMods' => true, 'indexable' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertEquals($name, $jsonData['name']); self::assertSame($user->getId(), $jsonData['owner']['userId']); self::assertEquals($description, $jsonData['description']); self::assertEquals($rules, $jsonData['rules']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['discoverable']); self::assertTrue($jsonData['isPostingRestrictedToMods']); self::assertFalse($jsonData['indexable']); } public function testApiCannotCreateInvalidMagazine(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:create'); $token = $codes['token_type'].' '.$codes['access_token']; $title = 'No name'; $description = 'A description'; $this->client->jsonRequest( 'POST', '/api/moderate/magazine/new', parameters: [ 'name' => null, 'title' => $title, 'description' => $description, 'isAdult' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(400); $name = 'a'; $title = 'Too short name'; $this->client->jsonRequest( 'POST', '/api/moderate/magazine/new', parameters: [ 'name' => $name, 'title' => $title, 'description' => $description, 'isAdult' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(400); $name = 'long_name_that_exceeds_the_limit'; $title = 'Too long name'; $this->client->jsonRequest( 'POST', '/api/moderate/magazine/new', parameters: [ 'name' => $name, 'title' => $title, 'description' => $description, 'isAdult' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(400); $name = 'invalidch@racters!'; $title = 'Invalid Characters in name'; $this->client->jsonRequest( 'POST', '/api/moderate/magazine/new', parameters: [ 'name' => $name, 'title' => $title, 'description' => $description, 'isAdult' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(400); $name = 'nulltitle'; $title = null; $this->client->jsonRequest( 'POST', '/api/moderate/magazine/new', parameters: [ 'name' => $name, 'title' => $title, 'description' => $description, 'isAdult' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(400); $name = 'shorttitle'; $title = 'as'; $this->client->jsonRequest( 'POST', '/api/moderate/magazine/new', parameters: [ 'name' => $name, 'title' => $title, 'description' => $description, 'isAdult' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(400); $name = 'longtitle'; $title = 'Way too long of a title. This can only be 50 characters!'; $this->client->jsonRequest( 'POST', '/api/moderate/magazine/new', parameters: [ 'name' => $name, 'title' => $title, 'description' => $description, 'isAdult' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(400); $name = 'rulesDeprecated'; $title = 'rules are deprecated'; $rules = 'Some rules'; $this->client->jsonRequest( 'POST', '/api/moderate/magazine/new', parameters: [ 'name' => $name, 'title' => $title, 'rules' => $rules, 'isAdult' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(400); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Admin/MagazineDeleteApiTest.php ================================================ getMagazineByName('test'); $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDeleteMagazineWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiUserCannotDeleteUnownedMagazine(): void { $moderator = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($moderator); $owner = $this->getUserByUsername('JaneDoe'); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $owner); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiModCannotDeleteUnownedMagazine(): void { $moderator = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($moderator); $owner = $this->getUserByUsername('JaneDoe'); $admin = $this->getUserByUsername('admin', isAdmin: true); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $owner); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $moderator; $dto->addedBy = $admin; $magazineManager->addModerator($dto); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDeleteMagazine(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Admin/MagazineDeleteIconApiTest.php ================================================ kibbyPath = \dirname(__FILE__, 6).'/assets/kibby_emoji.png'; } public function testApiCannotDeleteMagazineIconAnonymous(): void { $magazine = $this->getMagazineByName('test'); $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/icon"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDeleteMagazineIconWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/icon", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiModCannotDeleteMagazineIcon(): void { $moderator = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($moderator); $owner = $this->getUserByUsername('JaneDoe'); $admin = $this->getUserByUsername('admin', isAdmin: true); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $owner); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $moderator; $dto->addedBy = $admin; $magazineManager->addModerator($dto); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/icon", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } #[Group(name: 'NonThreadSafe')] public function testApiCanDeleteMagazineIcon(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $upload = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageRepository = $this->imageRepository; $image = $imageRepository->findOrCreateFromUpload($upload); self::assertNotNull($image); $magazine->icon = $image; $entityManager = $this->entityManager; $entityManager->persist($magazine); $entityManager->flush(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(WebTestCase::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['icon']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['icon']); self::assertSame(96, $jsonData['icon']['width']); self::assertSame(96, $jsonData['icon']['height']); self::assertEquals('a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png', $jsonData['icon']['filePath']); self::assertNull($jsonData['banner']); $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/icon", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineUpdateThemeApiTest::MAGAZINE_THEME_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertNull($jsonData['icon']); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Admin/MagazineModeratorsApiTest.php ================================================ getMagazineByName('test'); $user = $this->getUserByUsername('notamod'); $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/mod/{$user->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotRemoveModeratorsFromMagazineAnonymous(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('yesamod'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $user; $dto->addedBy = $admin; $magazineManager->addModerator($dto); $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/mod/{$user->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotAddModeratorsToMagazineWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('notamod'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/mod/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRemoveModeratorsFromMagazineWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('yesamod'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $user; $dto->addedBy = $admin; $magazineManager->addModerator($dto); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/mod/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiModCannotAddModeratorsMagazine(): void { $moderator = $this->getUserByUsername('JohnDoe'); $user = $this->getUserByUsername('notamod'); $this->client->loginUser($moderator); $owner = $this->getUserByUsername('JaneDoe'); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $owner); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $moderator; $dto->addedBy = $owner; $magazineManager->addModerator($dto); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:moderators'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/mod/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiModCannotRemoveModeratorsMagazine(): void { $moderator = $this->getUserByUsername('JohnDoe'); $user = $this->getUserByUsername('yesamod'); $this->client->loginUser($moderator); $owner = $this->getUserByUsername('JaneDoe'); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $owner); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $moderator; $dto->addedBy = $owner; $magazineManager->addModerator($dto); $dto = new ModeratorDto($magazine); $dto->user = $user; $dto->addedBy = $owner; $magazineManager->addModerator($dto); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:moderators'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/mod/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiOwnerCanAddModeratorsMagazine(): void { $user = $this->getUserByUsername('JohnDoe'); $moderator = $this->getUserByUsername('willbeamod'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:moderators'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/mod/{$moderator->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['moderators']); self::assertCount(2, $jsonData['moderators']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][1]); self::assertSame($moderator->getId(), $jsonData['moderators'][1]['userId']); } public function testApiOwnerCanRemoveModeratorsMagazine(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $moderator = $this->getUserByUsername('yesamod'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $moderator; $dto->addedBy = $admin; $magazineManager->addModerator($dto); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:moderators'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['moderators']); self::assertCount(2, $jsonData['moderators']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][1]); self::assertSame($moderator->getId(), $jsonData['moderators'][1]['userId']); $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/mod/{$moderator->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['moderators']); self::assertCount(1, $jsonData['moderators']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][0]); self::assertSame($user->getId(), $jsonData['moderators'][0]['userId']); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Admin/MagazinePurgeApiTest.php ================================================ getMagazineByName('test'); $this->client->request('DELETE', "/api/admin/magazine/{$magazine->getId()}/purge"); self::assertResponseStatusCodeSame(401); } public function testApiCannotPurgeMagazineWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/magazine/{$magazine->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonAdminUserCannotPurgeMagazine(): void { $moderator = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($moderator); $owner = $this->getUserByUsername('JaneDoe'); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $owner); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write admin:magazine:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/magazine/{$magazine->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiModCannotPurgeMagazine(): void { $moderator = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($moderator); $owner = $this->getUserByUsername('JaneDoe'); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $owner); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $moderator; $dto->addedBy = $owner; $magazineManager->addModerator($dto); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write admin:magazine:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/magazine/{$magazine->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiOwnerCannotPurgeMagazine(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write admin:magazine:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/magazine/{$magazine->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiAdminCanPurgeMagazine(): void { $admin = $this->getUserByUsername('JohnDoe', isAdmin: true); $owner = $this->getUserByUsername('JaneDoe'); $this->client->loginUser($admin); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $owner); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write admin:magazine:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/magazine/{$magazine->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Admin/MagazineRetrieveStatsApiTest.php ================================================ getMagazineByName('test'); $this->client->request('GET', "/api/stats/magazine/{$magazine->getId()}/votes"); self::assertResponseStatusCodeSame(401); $this->client->request('GET', "/api/stats/magazine/{$magazine->getId()}/content"); self::assertResponseStatusCodeSame(401); } public function testApiCannotRetrieveMagazineStatsWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/stats/magazine/{$magazine->getId()}/votes", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $this->client->request('GET', "/api/stats/magazine/{$magazine->getId()}/content", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRetrieveMagazineStatsIfNotOwner(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $owner = $this->getUserByUsername('JaneDoe'); $magazine = $this->getMagazineByName('test', $owner); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $this->getUserByUsername('JohnDoe'); $dto->addedBy = $owner; $magazineManager->addModerator($dto); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:stats'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/stats/magazine/{$magazine->getId()}/votes", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $this->client->request('GET', "/api/stats/magazine/{$magazine->getId()}/content", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveMagazineStats(): void { $user = $this->getUserByUsername('JohnDoe'); $user2 = $this->getUserByUsername('JohnDoe2'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $entry = $this->getEntryByTitle('Stats test', body: 'This is gonna be a statistic', magazine: $magazine, user: $user); $requestStack = $this->requestStack; $requestStack->push(Request::create('/')); $dispatcher = $this->eventDispatcher; $dispatcher->dispatch(new EntryHasBeenSeenEvent($entry)); $favouriteManager = $this->favouriteManager; $favourite = $favouriteManager->toggle($user, $entry); $voteManager = $this->voteManager; $vote = $voteManager->upvote($entry, $user); $entityManager = $this->entityManager; $entityManager->persist($favourite); $entityManager->persist($vote); $entityManager->flush(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:stats'); $token = $codes['token_type'].' '.$codes['access_token']; // Start a day ago to avoid timezone issues when testing on machines with non-UTC timezones $startString = rawurlencode($entry->getCreatedAt()->add(\DateInterval::createFromDateString('-1 minute'))->format(\DateTimeImmutable::ATOM)); $this->client->request('GET', "/api/stats/magazine/{$magazine->getId()}/votes?resolution=hour&start=$startString", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::STATS_BY_CONTENT_TYPE_KEYS, $jsonData); self::assertIsArray($jsonData['entry']); self::assertCount(1, $jsonData['entry']); self::assertIsArray($jsonData['entry_comment']); self::assertEmpty($jsonData['entry_comment']); self::assertIsArray($jsonData['post']); self::assertEmpty($jsonData['post']); self::assertIsArray($jsonData['post_comment']); self::assertEmpty($jsonData['post_comment']); self::assertArrayKeysMatch(self::VOTE_ITEM_KEYS, $jsonData['entry'][0]); self::assertSame(1, $jsonData['entry'][0]['up']); self::assertSame(0, $jsonData['entry'][0]['down']); self::assertSame(1, $jsonData['entry'][0]['boost']); $this->client->request('GET', "/api/stats/magazine/{$magazine->getId()}/content?resolution=hour&start=$startString", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::STATS_BY_CONTENT_TYPE_KEYS, $jsonData); self::assertIsInt($jsonData['entry']); self::assertIsInt($jsonData['entry_comment']); self::assertIsInt($jsonData['post']); self::assertIsInt($jsonData['post_comment']); self::assertSame(1, $jsonData['entry']); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Admin/MagazineTagsApiTest.php ================================================ getMagazineByName('test'); $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/tag/test"); self::assertResponseStatusCodeSame(401); } public function testApiCannotRemoveTagsFromMagazineAnonymous(): void { $magazine = $this->getMagazineByName('test'); $magazine->tags = ['test']; $entityManager = $this->entityManager; $entityManager->persist($magazine); $entityManager->flush(); $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/tag/test"); self::assertResponseStatusCodeSame(401); } public function testApiCannotAddTagsToMagazineWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/tag/test", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRemoveTagsFromMagazineWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $magazine->tags = ['test']; $entityManager = $this->entityManager; $entityManager->persist($magazine); $entityManager->flush(); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/tag/test", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiModCannotAddTagsMagazine(): void { $moderator = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($moderator); $owner = $this->getUserByUsername('JaneDoe'); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $owner); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $moderator; $dto->addedBy = $owner; $magazineManager->addModerator($dto); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:tags'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/tag/test", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiModCannotRemoveTagsMagazine(): void { $moderator = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($moderator); $owner = $this->getUserByUsername('JaneDoe'); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $owner); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $moderator; $dto->addedBy = $owner; $magazineManager->addModerator($dto); $magazine->tags = ['test']; $entityManager = $this->entityManager; $entityManager->persist($magazine); $entityManager->flush(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:tags'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/tag/test", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiOwnerCanAddTagsMagazine(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:tags'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/tag/test", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['tags']); self::assertCount(1, $jsonData['tags']); self::assertEquals('test', $jsonData['tags'][0]); } public function testApiOwnerCannotAddWeirdTagsMagazine(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:tags'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/tag/test%20Weird", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); } public function testApiOwnerCanRemoveTagsMagazine(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $magazine->tags = ['test']; $entityManager = $this->entityManager; $entityManager->persist($magazine); $entityManager->flush(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:tags'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['tags']); self::assertCount(1, $jsonData['tags']); self::assertEquals('test', $jsonData['tags'][0]); $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/tag/test", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertEmpty($jsonData['tags']); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Admin/MagazineUpdateApiTest.php ================================================ getMagazineByName('test'); $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateMagazineWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateMagazine(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $magazine->rules = 'Some initial rules'; $this->entityManager->persist($magazine); $this->entityManager->flush(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:update'); $token = $codes['token_type'].' '.$codes['access_token']; $name = 'test'; $title = 'API Test Magazine'; $description = 'A description'; $rules = 'Some rules'; $this->client->jsonRequest( 'PUT', "/api/moderate/magazine/{$magazine->getId()}", parameters: [ 'name' => $name, 'title' => $title, 'description' => $description, 'rules' => $rules, 'isAdult' => true, 'discoverable' => false, 'indexable' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(WebTestCase::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertEquals($name, $jsonData['name']); self::assertSame($user->getId(), $jsonData['owner']['userId']); self::assertEquals($description, $jsonData['description']); self::assertEquals($rules, $jsonData['rules']); self::assertTrue($jsonData['isAdult']); self::assertFalse($jsonData['discoverable']); self::assertFalse($jsonData['indexable']); } public function testApiCannotUpdateMagazineWithInvalidParams(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:update'); $token = $codes['token_type'].' '.$codes['access_token']; $name = 'someothername'; $title = 'Different name'; $description = 'A description'; $this->client->jsonRequest( 'PUT', "/api/moderate/magazine/{$magazine->getId()}", parameters: [ 'name' => $name, 'title' => $title, 'description' => $description, 'isAdult' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(400); $description = 'short title'; $title = 'as'; $this->client->jsonRequest( 'PUT', "/api/moderate/magazine/{$magazine->getId()}", parameters: [ 'title' => $title, 'description' => $description, 'isAdult' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(400); $description = 'long title'; $title = 'Way too long of a title. This can only be 50 characters!'; $this->client->jsonRequest( 'PUT', "/api/moderate/magazine/{$magazine->getId()}", parameters: [ 'title' => $title, 'description' => $description, 'isAdult' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(400); $rules = 'Some rules'; $description = 'Rules are deprecated'; $this->client->jsonRequest( 'PUT', "/api/moderate/magazine/{$magazine->getId()}", parameters: [ 'rules' => $rules, 'description' => $description, 'isAdult' => false, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(400); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Admin/MagazineUpdateThemeApiTest.php ================================================ kibbyPath = \dirname(__FILE__, 6).'/assets/kibby_emoji.png'; } public function testApiCannotUpdateMagazineThemeAnonymous(): void { $magazine = $this->getMagazineByName('test'); $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/theme"); self::assertResponseStatusCodeSame(401); } public function testApiModCannotUpdateMagazineTheme(): void { $moderator = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($moderator); $owner = $this->getUserByUsername('JaneDoe'); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $owner); $magazineManager = $this->magazineManager; $dto = new ModeratorDto($magazine); $dto->user = $moderator; $dto->addedBy = $owner; $magazineManager->addModerator($dto); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/theme", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateMagazineThemeWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/theme", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateMagazineThemeWithCustomCss(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme'); $token = $codes['token_type'].' '.$codes['access_token']; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.tmp'); $image = new UploadedFile($tmpPath.'.tmp', 'kibby_emoji.png', 'image/png'); $customCss = 'a {background: red;}'; $this->client->request( 'POST', "/api/moderate/magazine/{$magazine->getId()}/theme", parameters: [ 'customCss' => $customCss, ], files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MAGAZINE_THEME_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertStringContainsString($customCss, $jsonData['customCss']); self::assertIsArray($jsonData['icon']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['icon']); self::assertNull($jsonData['banner']); self::assertSame(96, $jsonData['icon']['width']); self::assertSame(96, $jsonData['icon']['height']); self::assertEquals('a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png', $jsonData['icon']['filePath']); } public function testApiCanUpdateMagazineThemeWithBackgroundImage(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme'); $token = $codes['token_type'].' '.$codes['access_token']; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; $expectedPath = $imageManager->getFilePath($image->getFilename()); $backgroundImage = 'shape1'; $this->client->request( 'POST', "/api/moderate/magazine/{$magazine->getId()}/theme", parameters: [ 'backgroundImage' => $backgroundImage, ], files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MAGAZINE_THEME_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertStringContainsString('/build/images/shape.png', $jsonData['customCss']); self::assertIsArray($jsonData['icon']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['icon']); self::assertSame(96, $jsonData['icon']['width']); self::assertSame(96, $jsonData['icon']['height']); self::assertEquals($expectedPath, $jsonData['icon']['filePath']); } public function testCanUpdateMagazineBanner(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme'); $token = $codes['token_type'].' '.$codes['access_token']; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.tmp'); $image = new UploadedFile($tmpPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'PUT', "/api/moderate/magazine/{$magazine->getId()}/banner", files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MAGAZINE_THEME_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(WebTestCase::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertNull($jsonData['icon']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['banner']); self::assertSame(96, $jsonData['banner']['width']); self::assertSame(96, $jsonData['banner']['height']); self::assertEquals('a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png', $jsonData['banner']['filePath']); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/MagazineBlockApiTest.php ================================================ getMagazineByName('test'); $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/block'); self::assertResponseStatusCodeSame(401); } public function testApiCannotBlockMagazineWithoutScope(): void { $user = $this->getUserByUsername('testuser'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/block', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanBlockMagazine(): void { $user = $this->getUserByUsername('testuser'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/block', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); // Scopes for reading subscriptions and blocklists granted, so these values should be filled self::assertNull($jsonData['isUserSubscribed']); self::assertTrue($jsonData['isBlockedByUser']); $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId(), server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); // Scopes for reading subscriptions and blocklists granted, so these values should be filled self::assertNull($jsonData['isUserSubscribed']); self::assertTrue($jsonData['isBlockedByUser']); } public function testApiCannotUnblockMagazineAnonymously(): void { $magazine = $this->getMagazineByName('test'); $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/unblock'); self::assertResponseStatusCodeSame(401); } public function testApiCannotUnblockMagazineWithoutScope(): void { $user = $this->getUserByUsername('testuser'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/unblock', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUnblockMagazine(): void { $user = $this->getUserByUsername('testuser'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $manager = $this->magazineManager; $manager->block($magazine, $user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/unblock', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); // Scopes for reading subscriptions and blocklists granted, so these values should be filled self::assertNull($jsonData['isUserSubscribed']); self::assertFalse($jsonData['isBlockedByUser']); $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId(), server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); // Scopes for reading subscriptions and blocklists granted, so these values should be filled self::assertNull($jsonData['isUserSubscribed']); self::assertFalse($jsonData['isBlockedByUser']); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/MagazineModlogApiTest.php ================================================ getMagazineByName('test'); $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId().'/log'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertEmpty($jsonData['items']); } public function testApiCanRetrieveModlogByMagazineIdAnonymouslyWithTypeFilter(): void { $magazine = $this->getMagazineByName('test'); $this->client->request('GET', '/api/magazine/'.$magazine->getId().'/log?types[]='.MagazineLog::CHOICES[0].'&types[]='.MagazineLog::CHOICES[1]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertEmpty($jsonData['items']); } public function testApiCanRetrieveMagazineById(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId().'/log', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertEmpty($jsonData['items']); } public function testApiCanRetrieveEntryPinnedLog(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $entry = $this->getEntryByTitle('Something to pin', magazine: $magazine); $this->entryManager->pin($entry, $user); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/magazine/'.$magazine->getId().'/log', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); $item = $jsonData['items'][0]; self::assertArrayKeysMatch(WebTestCase::LOG_ENTRY_KEYS, $item); self::assertEquals('log_entry_pinned', $item['type']); self::assertIsArray($item['subject']); self::assertArrayKeysMatch(WebTestCase::ENTRY_RESPONSE_KEYS, $item['subject']); self::assertEquals($entry->getId(), $item['subject']['entryId']); } public function testApiCanRetrieveUserBannedLog(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $banned = $this->getUserByUsername('troll'); $dto = new MagazineBanDto(); $dto->reason = 'because'; $ban = $this->magazineManager->ban($magazine, $banned, $user, $dto); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/magazine/'.$magazine->getId().'/log', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); $item = $jsonData['items'][0]; self::assertArrayKeysMatch(WebTestCase::LOG_ENTRY_KEYS, $item); self::assertEquals('log_ban', $item['type']); self::assertArrayKeysMatch(WebTestCase::BAN_RESPONSE_KEYS, $item['subject']); self::assertEquals($ban->getId(), $item['subject']['banId']); } public function testApiCanRetrieveModeratorAddedLog(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $mod = $this->getUserByUsername('mod'); $dto = new ModeratorDto($magazine, $mod, $user); $this->magazineManager->addModerator($dto); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/magazine/'.$magazine->getId().'/log', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); $item = $jsonData['items'][0]; self::assertArrayKeysMatch(WebTestCase::LOG_ENTRY_KEYS, $item); self::assertEquals('log_moderator_add', $item['type']); self::assertArrayKeysMatch(WebTestCase::USER_SMALL_RESPONSE_KEYS, $item['subject']); self::assertEquals($mod->getId(), $item['subject']['userId']); self::assertArrayKeysMatch(WebTestCase::USER_SMALL_RESPONSE_KEYS, $item['moderator']); self::assertEquals($user->getId(), $item['moderator']['userId']); } public function testApiModlogReflectsModerationActionsTaken(): void { $this->createModlogMessages(); $magazine = $this->getMagazineByName('acme'); $moderator = $magazine->getOwner(); $entityManager = $this->entityManager; $entityManager->refresh($magazine); $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId().'/log'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(5, $jsonData['items']); $this->validateModlog($jsonData, $magazine, $moderator); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/MagazineRetrieveApiTest.php ================================================ getMagazineByName('test'); $this->client->request('GET', "/api/magazine/{$magazine->getId()}"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertSame($magazine->getId(), $jsonData['magazineId']); self::assertIsArray($jsonData['owner']); self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['owner']); self::assertSame($magazine->getOwner()->getId(), $jsonData['owner']['userId']); self::assertNull($jsonData['icon']); self::assertNull($jsonData['banner']); self::assertEmpty($jsonData['tags']); self::assertEquals('test', $jsonData['name']); self::assertIsArray($jsonData['badges']); self::assertIsArray($jsonData['moderators']); self::assertCount(1, $jsonData['moderators']); self::assertIsArray($jsonData['moderators'][0]); self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][0]); self::assertSame($magazine->getOwner()->getId(), $jsonData['moderators'][0]['userId']); self::assertFalse($jsonData['isAdult']); // Anonymous access, so these values should be null self::assertNull($jsonData['isUserSubscribed']); self::assertNull($jsonData['isBlockedByUser']); } public function testApiCanRetrieveMagazineById(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertSame($magazine->getId(), $jsonData['magazineId']); self::assertIsArray($jsonData['owner']); self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['owner']); self::assertSame($magazine->getOwner()->getId(), $jsonData['owner']['userId']); self::assertNull($jsonData['icon']); self::assertNull($jsonData['banner']); self::assertEmpty($jsonData['tags']); self::assertEquals('test', $jsonData['name']); self::assertIsArray($jsonData['badges']); self::assertIsArray($jsonData['moderators']); self::assertCount(1, $jsonData['moderators']); self::assertIsArray($jsonData['moderators'][0]); self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][0]); self::assertSame($magazine->getOwner()->getId(), $jsonData['moderators'][0]['userId']); self::assertFalse($jsonData['isAdult']); // Scopes for reading subscriptions and blocklists not granted, so these values should be null self::assertNull($jsonData['isUserSubscribed']); self::assertNull($jsonData['isBlockedByUser']); } public function testApiCanRetrieveMagazineByNameAnonymously(): void { $magazine = $this->getMagazineByName('test'); $this->client->request('GET', '/api/magazine/name/test'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertSame($magazine->getId(), $jsonData['magazineId']); self::assertIsArray($jsonData['owner']); self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['owner']); self::assertSame($magazine->getOwner()->getId(), $jsonData['owner']['userId']); self::assertNull($jsonData['icon']); self::assertNull($jsonData['banner']); self::assertEmpty($jsonData['tags']); self::assertEquals('test', $jsonData['name']); self::assertIsArray($jsonData['badges']); self::assertIsArray($jsonData['moderators']); self::assertCount(1, $jsonData['moderators']); self::assertIsArray($jsonData['moderators'][0]); self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][0]); self::assertSame($magazine->getOwner()->getId(), $jsonData['moderators'][0]['userId']); self::assertFalse($jsonData['isAdult']); // Anonymous access, so these values should be null self::assertNull($jsonData['isUserSubscribed']); self::assertNull($jsonData['isBlockedByUser']); } public function testApiCanRetrieveMagazineByName(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/magazine/name/test', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData); self::assertSame($magazine->getId(), $jsonData['magazineId']); self::assertIsArray($jsonData['owner']); self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['owner']); self::assertSame($magazine->getOwner()->getId(), $jsonData['owner']['userId']); self::assertNull($jsonData['icon']); self::assertNull($jsonData['banner']); self::assertEmpty($jsonData['tags']); self::assertEquals('test', $jsonData['name']); self::assertIsArray($jsonData['badges']); self::assertIsArray($jsonData['moderators']); self::assertCount(1, $jsonData['moderators']); self::assertIsArray($jsonData['moderators'][0]); self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][0]); self::assertSame($magazine->getOwner()->getId(), $jsonData['moderators'][0]['userId']); self::assertFalse($jsonData['isAdult']); // Scopes for reading subscriptions and blocklists not granted, so these values should be null self::assertNull($jsonData['isUserSubscribed']); self::assertNull($jsonData['isBlockedByUser']); } public function testApiMagazineSubscribeAndBlockFlags(): void { $user = $this->getUserByUsername('testuser'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe magazine:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData); // Scopes for reading subscriptions and blocklists granted, so these values should be filled self::assertFalse($jsonData['isUserSubscribed']); self::assertFalse($jsonData['isBlockedByUser']); } // The 2 next tests exist because changing the subscription status via MagazineManager after calling the API // was causing strange doctrine exceptions. If doctrine did not throw exceptions when modifications // were made, these tests could be rolled into testApiMagazineSubscribeAndBlockFlags above public function testApiMagazineSubscribeFlagIsTrueWhenSubscribed(): void { $user = $this->getUserByUsername('testuser'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $manager = $this->magazineManager; $manager->subscribe($magazine, $user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe magazine:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData); // Scopes for reading subscriptions and blocklists granted, so these values should be filled self::assertTrue($jsonData['isUserSubscribed']); self::assertFalse($jsonData['isBlockedByUser']); } public function testApiMagazineBlockFlagIsTrueWhenBlocked(): void { $user = $this->getUserByUsername('testuser'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $manager = $this->magazineManager; $manager->block($magazine, $user); $entityManager = $this->entityManager; $entityManager->persist($user); $entityManager->flush(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe magazine:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData); // Scopes for reading subscriptions and blocklists granted, so these values should be filled self::assertFalse($jsonData['isUserSubscribed']); self::assertTrue($jsonData['isBlockedByUser']); } public function testApiCanRetrieveMagazineCollectionAnonymous(): void { $magazine = $this->getMagazineByName('test'); $this->client->request('GET', '/api/magazines'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazineId']); } public function testApiCanRetrieveMagazineCollection(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/magazines', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazineId']); // Scopes not granted self::assertNull($jsonData['items'][0]['isUserSubscribed']); self::assertNull($jsonData['items'][0]['isBlockedByUser']); } public function testApiCanRetrieveMagazineCollectionMultiplePages(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazines = []; for ($i = 0; $i < self::MAGAZINE_COUNT; ++$i) { $magazines[] = $this->getMagazineByNameNoRSAKey("test{$i}"); } $perPage = max((int) ceil(self::MAGAZINE_COUNT / 2), 1); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazines?perPage={$perPage}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(self::MAGAZINE_COUNT, $jsonData['pagination']['count']); self::assertSame($perPage, $jsonData['pagination']['perPage']); self::assertSame(1, $jsonData['pagination']['currentPage']); self::assertSame(2, $jsonData['pagination']['maxPage']); self::assertIsArray($jsonData['items']); self::assertCount($perPage, $jsonData['items']); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]); self::assertAllValuesFoundByName($magazines, $jsonData['items']); } public function testApiCannotRetrieveMagazineSubscriptionsAnonymous(): void { $this->client->request('GET', '/api/magazines/subscribed'); self::assertResponseStatusCodeSame(401); } public function testApiCannotRetrieveMagazineSubscriptionsWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/magazines/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveMagazineSubscriptions(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $notSubbedMag = $this->getMagazineByName('someother', $this->getUserByUsername('JaneDoe')); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/magazines/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazineId']); // Block scope not granted self::assertTrue($jsonData['items'][0]['isUserSubscribed']); self::assertNull($jsonData['items'][0]['isBlockedByUser']); } public function testApiCannotRetrieveUserMagazineSubscriptionsAnonymous(): void { $user = $this->getUserByUsername('testUser'); $this->client->request('GET', "/api/users/{$user->getId()}/magazines/subscriptions"); self::assertResponseStatusCodeSame(401); } public function testApiCannotRetrieveUserMagazineSubscriptionsWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $user = $this->getUserByUsername('testUser'); $this->client->request('GET', "/api/users/{$user->getId()}/magazines/subscriptions", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveUserMagazineSubscriptions(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('testUser'); $user->showProfileSubscriptions = true; $entityManager = $this->entityManager; $entityManager->persist($user); $entityManager->flush(); $notSubbedMag = $this->getMagazineByName('someother', $this->getUserByUsername('JaneDoe')); $magazine = $this->getMagazineByName('test', $user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/magazines/subscriptions", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazineId']); // Block scope not granted self::assertFalse($jsonData['items'][0]['isUserSubscribed']); self::assertNull($jsonData['items'][0]['isBlockedByUser']); } public function testApiCannotRetrieveUserMagazineSubscriptionsIfSettingTurnedOff(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('testUser'); $user->showProfileSubscriptions = false; $entityManager = $this->entityManager; $entityManager->persist($user); $entityManager->flush(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/magazines/subscriptions", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRetrieveModeratedMagazinesAnonymous(): void { $this->client->request('GET', '/api/magazines/moderated'); self::assertResponseStatusCodeSame(401); } public function testApiCannotRetrieveModeratedMagazinesWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/magazines/moderated', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveModeratedMagazines(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $notModdedMag = $this->getMagazineByName('someother', $this->getUserByUsername('JaneDoe')); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:list'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/magazines/moderated', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazineId']); // Subscribe and block scopes not granted self::assertNull($jsonData['items'][0]['isUserSubscribed']); self::assertNull($jsonData['items'][0]['isBlockedByUser']); } public function testApiCannotRetrieveBlockedMagazinesAnonymous(): void { $this->client->request('GET', '/api/magazines/blocked'); self::assertResponseStatusCodeSame(401); } public function testApiCannotRetrieveBlockedMagazinesWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/magazines/blocked', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveBlockedMagazines(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $notBlockedMag = $this->getMagazineByName('someother', $this->getUserByUsername('JaneDoe')); $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe')); $manager = $this->magazineManager; $manager->block($magazine, $this->getUserByUsername('JohnDoe')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/magazines/blocked', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazineId']); // Subscribe and block scopes not granted self::assertNull($jsonData['items'][0]['isUserSubscribed']); self::assertTrue($jsonData['items'][0]['isBlockedByUser']); } public function testApiCanRetrieveAbandonedMagazine(): void { $abandoningUser = $this->getUserByUsername('JohnDoe'); $activeUser = $this->getUserByUsername('DoeJohn'); $magazine1 = $this->getMagazineByName('test1', $abandoningUser); $magazine2 = $this->getMagazineByName('test2', $abandoningUser); $magazine3 = $this->getMagazineByName('test3', $activeUser); $abandoningUser->lastActive = new \DateTime('-6 months'); $activeUser->lastActive = new \DateTime('-2 days'); $this->userRepository->save($abandoningUser, true); $this->userRepository->save($activeUser, true); $this->client->request('GET', '/api/magazines?abandoned=true&federation=local'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($magazine1->getId(), $jsonData['items'][0]['magazineId']); self::assertSame($magazine2->getId(), $jsonData['items'][1]['magazineId']); } public function testApiCanRetrieveAbandonedMagazineSortedByOwner(): void { $abandoningUser1 = $this->getUserByUsername('user1'); $abandoningUser2 = $this->getUserByUsername('user2'); $abandoningUser3 = $this->getUserByUsername('user3'); $magazine1 = $this->getMagazineByName('test1', $abandoningUser1); $magazine2 = $this->getMagazineByName('test2', $abandoningUser2); $magazine3 = $this->getMagazineByName('test3', $abandoningUser3); $abandoningUser1->lastActive = new \DateTime('-6 months'); $abandoningUser2->lastActive = new \DateTime('-5 months'); $abandoningUser3->lastActive = new \DateTime('-7 months'); $this->userRepository->save($abandoningUser1, true); $this->userRepository->save($abandoningUser2, true); $this->userRepository->save($abandoningUser3, true); $this->client->request('GET', '/api/magazines?abandoned=true&federation=local&sort=ownerLastActive'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($magazine1->getId(), $jsonData['items'][1]['magazineId']); self::assertSame($magazine2->getId(), $jsonData['items'][2]['magazineId']); self::assertSame($magazine3->getId(), $jsonData['items'][0]['magazineId']); } public static function assertAllValuesFoundByName(array $magazines, array $values, string $message = '') { $nameMap = array_column($magazines, null, 'name'); $containsMagazine = fn (bool $result, array $item) => $result && null !== $nameMap[$item['name']]; self::assertTrue(array_reduce($values, $containsMagazine, true), $message); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/MagazineRetrieveThemeApiTest.php ================================================ getMagazineByName('test'); $magazine->customCss = '.test {}'; $entityManager = $this->entityManager; $entityManager->persist($magazine); $entityManager->flush(); $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId().'/theme'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MAGAZINE_THEME_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertEquals('.test {}', $jsonData['customCss']); } public function testApiCanRetrieveMagazineThemeById(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $magazine->customCss = '.test {}'; $entityManager = $this->entityManager; $entityManager->persist($magazine); $entityManager->flush(); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId().'/theme', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MAGAZINE_THEME_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertEquals('.test {}', $jsonData['customCss']); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/MagazineSubscribeApiTest.php ================================================ getMagazineByName('test'); $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/subscribe'); self::assertResponseStatusCodeSame(401); } public function testApiCannotSubscribeToMagazineWithoutScope(): void { $user = $this->getUserByUsername('testuser'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/subscribe', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanSubscribeToMagazine(): void { $user = $this->getUserByUsername('testuser'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe magazine:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/subscribe', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); // Scopes for reading subscriptions and blocklists granted, so these values should be filled self::assertTrue($jsonData['isUserSubscribed']); self::assertFalse($jsonData['isBlockedByUser']); $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId(), server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); // Scopes for reading subscriptions and blocklists granted, so these values should be filled self::assertTrue($jsonData['isUserSubscribed']); self::assertFalse($jsonData['isBlockedByUser']); } public function testApiCannotUnsubscribeFromMagazineAnonymously(): void { $magazine = $this->getMagazineByName('test'); $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/unsubscribe'); self::assertResponseStatusCodeSame(401); } public function testApiCannotUnsubscribeFromMagazineWithoutScope(): void { $user = $this->getUserByUsername('testuser'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:block'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/unsubscribe', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUnsubscribeFromMagazine(): void { $user = $this->getUserByUsername('testuser'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $manager = $this->magazineManager; $manager->subscribe($magazine, $user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/unsubscribe', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); // Scopes for reading subscriptions and blocklists granted, so these values should be filled self::assertFalse($jsonData['isUserSubscribed']); self::assertNull($jsonData['isBlockedByUser']); $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId(), server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData); // Scopes for reading subscriptions and blocklists granted, so these values should be filled self::assertFalse($jsonData['isUserSubscribed']); self::assertNull($jsonData['isBlockedByUser']); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Moderate/MagazineActionReportsApiTest.php ================================================ getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoe'); $reportedUser = $this->getUserByUsername('testuser'); $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser); $reportManager = $this->reportManager; $report = $reportManager->report(ReportDto::create($entry, 'I don\'t like it'), $user); $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/accept"); self::assertResponseStatusCodeSame(401); } public function testApiCannotRejectReportAnonymous(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoe'); $reportedUser = $this->getUserByUsername('testuser'); $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser); $reportManager = $this->reportManager; $report = $reportManager->report(ReportDto::create($entry, 'I don\'t like it'), $user); $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/reject"); self::assertResponseStatusCodeSame(401); } public function testApiCannotAcceptReportWithoutScope(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $reportedUser = $this->getUserByUsername('testuser'); $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser); $reportManager = $this->reportManager; $report = $reportManager->report(ReportDto::create($entry, 'I don\'t like it'), $user); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/accept", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRejectReportWithoutScope(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $reportedUser = $this->getUserByUsername('testuser'); $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser); $reportManager = $this->reportManager; $report = $reportManager->report(ReportDto::create($entry, 'I don\'t like it'), $user); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/reject", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotAcceptReportIfNotMod(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe')); $reportedUser = $this->getUserByUsername('testuser'); $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser); $reportManager = $this->reportManager; $report = $reportManager->report(ReportDto::create($entry, 'I don\'t like it'), $user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:action'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/accept", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRejectReportIfNotMod(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe')); $reportedUser = $this->getUserByUsername('testuser'); $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser); $reportManager = $this->reportManager; $report = $reportManager->report(ReportDto::create($entry, 'I don\'t like it'), $user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:action'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/reject", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } #[Group(name: 'NonThreadSafe')] public function testApiCanAcceptReport(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $reportedUser = $this->getUserByUsername('testuser'); $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser); $reportManager = $this->reportManager; $report = $reportManager->report(ReportDto::create($entry, 'I don\'t like it'), $user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:action'); $token = $codes['token_type'].' '.$codes['access_token']; $consideredAt = new \DateTimeImmutable(); $this->client->jsonRequest('POST', "/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/accept", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveReportsApiTest::REPORT_RESPONSE_KEYS, $jsonData); self::assertEquals('entry_report', $jsonData['type']); self::assertEquals($report->reason, $jsonData['reason']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['reported']); self::assertSame($reportedUser->getId(), $jsonData['reported']['userId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['reporting']); self::assertSame($user->getId(), $jsonData['reporting']['userId']); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['subject']); self::assertEquals($entry->getId(), $jsonData['subject']['entryId']); self::assertEquals('trashed', $jsonData['subject']['visibility']); self::assertEquals($entry->body, $jsonData['subject']['body']); self::assertEquals('approved', $jsonData['status']); self::assertSame(1, $jsonData['weight']); self::assertEqualsWithDelta($report->createdAt->getTimestamp(), \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp(), 10.0); self::assertEqualsWithDelta($consideredAt->getTimestamp(), \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $jsonData['consideredAt'])->getTimestamp(), 10.0); self::assertNotNull($jsonData['consideredBy']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['consideredBy']); self::assertSame($user->getId(), $jsonData['consideredBy']['userId']); } public function testApiCanRejectReport(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $reportedUser = $this->getUserByUsername('testuser'); $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser); $reportManager = $this->reportManager; $report = $reportManager->report(ReportDto::create($entry, 'I don\'t like it'), $user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:action'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/reject", server: ['HTTP_AUTHORIZATION' => $token]); $consideredAt = new \DateTimeImmutable(); $adjustedConsideredAt = floor($consideredAt->getTimestamp() / 1000); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); $adjustedReceivedConsideredAt = floor(\DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp() / 1000); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveReportsApiTest::REPORT_RESPONSE_KEYS, $jsonData); self::assertEquals('entry_report', $jsonData['type']); self::assertEquals($report->reason, $jsonData['reason']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['reported']); self::assertSame($reportedUser->getId(), $jsonData['reported']['userId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['reporting']); self::assertSame($user->getId(), $jsonData['reporting']['userId']); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['subject']); self::assertEquals($entry->getId(), $jsonData['subject']['entryId']); self::assertEquals('visible', $jsonData['subject']['visibility']); self::assertEquals($entry->body, $jsonData['subject']['body']); self::assertEquals('rejected', $jsonData['status']); self::assertSame(1, $jsonData['weight']); self::assertEqualsWithDelta($report->createdAt->getTimestamp(), \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp(), 10.0); self::assertEqualsWithDelta($adjustedConsideredAt, $adjustedReceivedConsideredAt, 10.0); self::assertNotNull($jsonData['consideredBy']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['consideredBy']); self::assertSame($user->getId(), $jsonData['consideredBy']['userId']); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Moderate/MagazineBanApiTest.php ================================================ getMagazineByName('test'); $user = $this->getUserByUsername('testuser'); $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/ban/{$user->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateMagazineBanWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('testuser'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/ban/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotCreateMagazineBanIfNotMod(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe')); $user = $this->getUserByUsername('testuser'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:ban:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('POST', "/api/moderate/magazine/{$magazine->getId()}/ban/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateMagazineBan(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $bannedUser = $this->getUserByUsername('hapless_fool'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:ban:create'); $token = $codes['token_type'].' '.$codes['access_token']; $reason = 'you got banned through the API, how does that make you feel?'; $expiredAt = (new \DateTimeImmutable('+1 hour'))->format(\DateTimeImmutable::ATOM); $this->client->jsonRequest( 'POST', "/api/moderate/magazine/{$magazine->getId()}/ban/{$bannedUser->getId()}", parameters: [ 'reason' => $reason, 'expiredAt' => $expiredAt, ], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveBansApiTest::BAN_RESPONSE_KEYS, $jsonData); self::assertEquals($reason, $jsonData['reason']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['bannedUser']); self::assertSame($bannedUser->getId(), $jsonData['bannedUser']['userId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['bannedBy']); self::assertSame($user->getId(), $jsonData['bannedBy']['userId']); self::assertEquals($expiredAt, $jsonData['expiredAt']); self::assertFalse($jsonData['expired']); } public function testApiCannotDeleteMagazineBanAnonymous(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('testuser'); $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/ban/{$user->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDeleteMagazineBanWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('testuser'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/ban/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotDeleteMagazineBanIfNotMod(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe')); $user = $this->getUserByUsername('testuser'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:ban:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/ban/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDeleteMagazineBan(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $bannedUser = $this->getUserByUsername('hapless_fool'); $magazineManager = $this->magazineManager; $ban = MagazineBanDto::create('test ban <3'); $magazineManager->ban($magazine, $bannedUser, $user, $ban); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:ban:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $expiredAt = new \DateTimeImmutable(); $this->client->request('DELETE', "/api/moderate/magazine/{$magazine->getId()}/ban/{$bannedUser->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MagazineRetrieveBansApiTest::BAN_RESPONSE_KEYS, $jsonData); self::assertEquals($ban->reason, $jsonData['reason']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['bannedUser']); self::assertSame($bannedUser->getId(), $jsonData['bannedUser']['userId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['bannedBy']); self::assertSame($user->getId(), $jsonData['bannedBy']['userId']); $actualExpiry = \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $jsonData['expiredAt']); // Hopefully the API responds fast enough that there is only a max delta of 10 second between these two timestamps self::assertEqualsWithDelta($expiredAt->getTimestamp(), $actualExpiry->getTimestamp(), 10.0); self::assertTrue($jsonData['expired']); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Moderate/MagazineModOwnerRequestApiTest.php ================================================ getMagazineByName('test'); $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/modRequest/toggle"); self::assertResponseStatusCodeSame(401); } public function testApiCannotToggleModRequestWithoutScope(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/modRequest/toggle", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanToggleModRequest(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'magazine:subscribe'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/modRequest/toggle", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(['created'], $jsonData); self::assertTrue($jsonData['created']); $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/modRequest/toggle", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(['created'], $jsonData); self::assertFalse($jsonData['created']); } public function testApiCannotAcceptModRequestAnonymously(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoe'); $this->magazineManager->toggleModeratorRequest($magazine, $user); $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/modRequest/accept/{$user->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotAcceptModRequestWithoutScope(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoe'); $this->magazineManager->toggleModeratorRequest($magazine, $user); $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/modRequest/accept/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanAcceptModRequest(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoeTheSecond'); $this->magazineManager->toggleModeratorRequest($magazine, $user); $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/modRequest/accept/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); self::assertSame('', $this->client->getResponse()->getContent()); $modRequest = $this->entityManager->getRepository(ModeratorRequest::class)->findOneBy([ 'magazine' => $magazine, 'user' => $user, ]); self::assertNull($modRequest); $magazine = $this->magazineRepository->findOneBy(['id' => $magazine->getId()]); $user = $this->userRepository->findOneBy(['id' => $user->getId()]); self::assertTrue($magazine->userIsModerator($user)); } public function testApiCanRejectModRequest(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoeTheSecond'); $this->magazineManager->toggleModeratorRequest($magazine, $user); $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/modRequest/reject/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); self::assertSame('', $this->client->getResponse()->getContent()); $modRequest = $this->entityManager->getRepository(ModeratorRequest::class)->findOneBy([ 'magazine' => $magazine, 'user' => $user, ]); self::assertNull($modRequest); $magazine = $this->magazineRepository->findOneBy(['id' => $magazine->getId()]); $user = $this->userRepository->findOneBy(['id' => $user->getId()]); self::assertFalse($magazine->userIsModerator($user)); } public function testApiCannotListModRequestsAnonymously(): void { $this->client->request('GET', '/api/moderate/modRequest/list'); self::assertResponseStatusCodeSame(401); } public function testApiCannotListModRequestsWithoutScope(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/moderate/modRequest/list', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotListModRequestsForInvalidMagazineId(): void { $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/moderate/modRequest/list?magazine=a', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(404); } public function testApiCannotListModRequestsForMissingMagazine(): void { $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/moderate/modRequest/list?magazine=99', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(404); } public function testApiCanListModRequestsForMagazine(): void { $magazine1 = $this->getMagazineByName('Magazine 1'); $magazine2 = $this->getMagazineByName('Magazine 2'); $magazine3 = $this->getMagazineByName('Magazine 3'); $user1 = $this->getUserByUsername('User 1'); $user2 = $this->getUserByUsername('User 2'); $this->magazineManager->toggleModeratorRequest($magazine1, $user1); $this->magazineManager->toggleModeratorRequest($magazine1, $user2); $this->magazineManager->toggleModeratorRequest($magazine2, $user2); $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/moderate/modRequest/list?magazine={$magazine1->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertCount(2, $jsonData); self::assertTrue(array_all($jsonData, function ($item) use ($magazine1, $user1, $user2) { return $item['magazine']['magazineId'] === $magazine1->getId() && ($item['user']['userId'] === $user1->getId() || $item['user']['userId'] === $user2->getId()); })); self::assertNotSame($jsonData[0]['user']['userId'], $jsonData[1]['user']['userId']); } public function testApiCanListModRequestsForAllMagazines(): void { $magazine1 = $this->getMagazineByName('Magazine 1'); $magazine2 = $this->getMagazineByName('Magazine 2'); $magazine3 = $this->getMagazineByName('Magazine 3'); $user1 = $this->getUserByUsername('User 1'); $user2 = $this->getUserByUsername('User 2'); $this->magazineManager->toggleModeratorRequest($magazine1, $user1); $this->magazineManager->toggleModeratorRequest($magazine1, $user2); $this->magazineManager->toggleModeratorRequest($magazine2, $user2); $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/moderate/modRequest/list', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertCount(3, $jsonData); self::assertTrue(array_all($jsonData, function ($item) use ($magazine1, $magazine2, $user1, $user2) { return ($item['magazine']['magazineId'] === $magazine1->getId() && $item['user']['userId'] === $user1->getId()) || ($item['magazine']['magazineId'] === $magazine1->getId() && $item['user']['userId'] === $user2->getId()) || ($item['magazine']['magazineId'] === $magazine2->getId() && $item['user']['userId'] === $user2->getId()); })); } public function testApiCannotToggleOwnerRequestAnonymously(): void { $magazine = $this->getMagazineByName('test'); $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/ownerRequest/toggle"); self::assertResponseStatusCodeSame(401); } public function testApiCannotToggleOwnerRequestWithoutScope(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/ownerRequest/toggle", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanToggleOwnerRequest(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'magazine:subscribe'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/ownerRequest/toggle", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(['created'], $jsonData); self::assertTrue($jsonData['created']); $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/ownerRequest/toggle", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(['created'], $jsonData); self::assertFalse($jsonData['created']); } public function testApiCannotAcceptOwnerRequestAnonymously(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoe'); $this->magazineManager->toggleModeratorRequest($magazine, $user); $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/ownerRequest/accept/{$user->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotAcceptOwnerRequestWithoutScope(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoe'); $this->magazineManager->toggleModeratorRequest($magazine, $user); $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/ownerRequest/accept/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanAcceptOwnerRequest(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoeTheSecond'); $this->magazineManager->toggleOwnershipRequest($magazine, $user); $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/ownerRequest/accept/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); self::assertSame('', $this->client->getResponse()->getContent()); $ownerRequest = $this->entityManager->getRepository(MagazineOwnershipRequest::class)->findOneBy([ 'magazine' => $magazine, 'user' => $user, ]); self::assertNull($ownerRequest); $magazine = $this->magazineRepository->findOneBy(['id' => $magazine->getId()]); $user = $this->userRepository->findOneBy(['id' => $user->getId()]); self::assertTrue($magazine->userIsOwner($user)); } public function testApiCanRejectOwnerRequest(): void { $magazine = $this->getMagazineByName('test'); $user = $this->getUserByUsername('JohnDoeTheSecond'); $this->magazineManager->toggleOwnershipRequest($magazine, $user); $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/moderate/magazine/{$magazine->getId()}/ownerRequest/reject/{$user->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); self::assertSame('', $this->client->getResponse()->getContent()); $ownerRequest = $this->entityManager->getRepository(ModeratorRequest::class)->findOneBy([ 'magazine' => $magazine, 'user' => $user, ]); self::assertNull($ownerRequest); $magazine = $this->magazineRepository->findOneBy(['id' => $magazine->getId()]); $user = $this->userRepository->findOneBy(['id' => $user->getId()]); self::assertFalse($magazine->userIsOwner($user)); self::assertFalse($magazine->userIsModerator($user)); } public function testApiCannotListOwnerRequestsAnonymously(): void { $this->client->request('GET', '/api/moderate/ownerRequest/list'); self::assertResponseStatusCodeSame(401); } public function testApiCannotListOwnerRequestsWithoutScope(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/moderate/ownerRequest/list', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotListOwnerRequestsForInvalidMagazineId(): void { $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/moderate/ownerRequest/list?magazine=a', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(404); } public function testApiCannotListOwnerRequestsForMissingMagazine(): void { $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/moderate/ownerRequest/list?magazine=99', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(404); } public function testApiCanListOwnerRequestsForMagazine(): void { $magazine1 = $this->getMagazineByName('Magazine 1'); $magazine2 = $this->getMagazineByName('Magazine 2'); $magazine3 = $this->getMagazineByName('Magazine 3'); $user1 = $this->getUserByUsername('User 1'); $user2 = $this->getUserByUsername('User 2'); $this->magazineManager->toggleOwnershipRequest($magazine1, $user1); $this->magazineManager->toggleOwnershipRequest($magazine1, $user2); $this->magazineManager->toggleOwnershipRequest($magazine2, $user2); $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/moderate/ownerRequest/list?magazine={$magazine1->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertCount(2, $jsonData); self::assertTrue(array_all($jsonData, function ($item) use ($magazine1, $user1, $user2) { return $item['magazine']['magazineId'] === $magazine1->getId() && ($item['user']['userId'] === $user1->getId() || $item['user']['userId'] === $user2->getId()); })); self::assertNotSame($jsonData[0]['user']['userId'], $jsonData[1]['user']['userId']); } public function testApiCanListOwnerRequestsForAllMagazines(): void { $magazine1 = $this->getMagazineByName('Magazine 1'); $magazine2 = $this->getMagazineByName('Magazine 2'); $magazine3 = $this->getMagazineByName('Magazine 3'); $user1 = $this->getUserByUsername('User 1'); $user2 = $this->getUserByUsername('User 2'); $this->magazineManager->toggleOwnershipRequest($magazine1, $user1); $this->magazineManager->toggleOwnershipRequest($magazine1, $user2); $this->magazineManager->toggleOwnershipRequest($magazine2, $user2); $adminUser = $this->getUserByUsername('Admin'); $this->setAdmin($adminUser); $this->client->loginUser($adminUser); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/moderate/ownerRequest/list', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertCount(3, $jsonData); self::assertTrue(array_all($jsonData, function ($item) use ($magazine1, $magazine2, $user1, $user2) { return ($item['magazine']['magazineId'] === $magazine1->getId() && $item['user']['userId'] === $user1->getId()) || ($item['magazine']['magazineId'] === $magazine1->getId() && $item['user']['userId'] === $user2->getId()) || ($item['magazine']['magazineId'] === $magazine2->getId() && $item['user']['userId'] === $user2->getId()); })); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Moderate/MagazineRetrieveBansApiTest.php ================================================ getMagazineByName('test'); $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/bans"); self::assertResponseStatusCodeSame(401); } public function testApiCannotRetrieveMagazineBansWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/bans", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRetrieveMagazineBansIfNotMod(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:ban:read'); $token = $codes['token_type'].' '.$codes['access_token']; $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe')); $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/bans", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveMagazineBans(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $bannedUser = $this->getUserByUsername('hapless_fool'); $magazineManager = $this->magazineManager; $ban = MagazineBanDto::create('test ban :)'); $magazineManager->ban($magazine, $bannedUser, $user, $ban); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:ban:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/bans", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(self::BAN_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals($ban->reason, $jsonData['items'][0]['reason']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['bannedUser']); self::assertSame($bannedUser->getId(), $jsonData['items'][0]['bannedUser']['userId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['bannedBy']); self::assertSame($user->getId(), $jsonData['items'][0]['bannedBy']['userId']); self::assertNull($jsonData['items'][0]['expiredAt']); self::assertFalse($jsonData['items'][0]['expired']); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Moderate/MagazineRetrieveReportsApiTest.php ================================================ getUserByUsername('JohnDoe'); $magazine = $this->getMagazineByName('test'); $reportedUser = $this->getUserByUsername('hapless_fool'); $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser); $reportManager = $this->reportManager; $report = $reportManager->report(ReportDto::create($entry, 'I don\'t like it'), $user); $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotRetrieveMagazineReportByIdWithoutScope(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $reportedUser = $this->getUserByUsername('hapless_fool'); $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser); $reportManager = $this->reportManager; $report = $reportManager->report(ReportDto::create($entry, 'I don\'t like it'), $user); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRetrieveMagazineReportByIdIfNotMod(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe')); $reportedUser = $this->getUserByUsername('hapless_fool'); $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser); $reportManager = $this->reportManager; $report = $reportManager->report(ReportDto::create($entry, 'I don\'t like it'), $user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveMagazineReportById(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $reportedUser = $this->getUserByUsername('hapless_fool'); $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser); $reportManager = $this->reportManager; $report = $reportManager->report(ReportDto::create($entry, 'I don\'t like it'), $user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::REPORT_RESPONSE_KEYS, $jsonData); self::assertEquals($report->reason, $jsonData['reason']); self::assertEquals('entry_report', $jsonData['type']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['reported']); self::assertSame($reportedUser->getId(), $jsonData['reported']['userId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['reporting']); self::assertSame($user->getId(), $jsonData['reporting']['userId']); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['subject']); self::assertSame($entry->getId(), $jsonData['subject']['entryId']); self::assertEquals('pending', $jsonData['status']); self::assertSame(1, $jsonData['weight']); self::assertNull($jsonData['consideredAt']); self::assertNull($jsonData['consideredBy']); self::assertEqualsWithDelta($report->createdAt->getTimestamp(), \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp(), 10.0); } public function testApiCannotRetrieveMagazineReportsAnonymous(): void { $magazine = $this->getMagazineByName('test'); $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/reports"); self::assertResponseStatusCodeSame(401); } public function testApiCannotRetrieveMagazineReportsWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/reports", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRetrieveMagazineReportsIfNotMod(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:read'); $token = $codes['token_type'].' '.$codes['access_token']; $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe')); $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/reports", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveMagazineReports(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $reportedUser = $this->getUserByUsername('hapless_fool'); $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser); $reportManager = $this->reportManager; $report = $reportManager->report(ReportDto::create($entry, 'I don\'t like it'), $user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/reports", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(self::REPORT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals($report->reason, $jsonData['items'][0]['reason']); self::assertEquals('entry_report', $jsonData['items'][0]['type']); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['reported']); self::assertSame($reportedUser->getId(), $jsonData['items'][0]['reported']['userId']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['reporting']); self::assertSame($user->getId(), $jsonData['items'][0]['reporting']['userId']); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]['subject']); self::assertSame($entry->getId(), $jsonData['items'][0]['subject']['entryId']); self::assertEquals('pending', $jsonData['items'][0]['status']); self::assertSame(1, $jsonData['items'][0]['weight']); self::assertNull($jsonData['items'][0]['consideredAt']); self::assertNull($jsonData['items'][0]['consideredBy']); self::assertEqualsWithDelta($report->createdAt->getTimestamp(), \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $jsonData['items'][0]['createdAt'])->getTimestamp(), 10.0); } } ================================================ FILE: tests/Functional/Controller/Api/Magazine/Moderate/MagazineRetrieveTrashApiTest.php ================================================ getMagazineByName('test'); $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/trash"); self::assertResponseStatusCodeSame(401); } public function testApiCannotRetrieveMagazineTrashWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $codes = self::getAuthorizationCodeTokenResponse($this->client); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRetrieveMagazineTrashIfNotMod(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:trash:read'); $token = $codes['token_type'].' '.$codes['access_token']; $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe')); $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveMagazineTrash(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); self::createOAuth2AuthCodeClient(); $magazine = $this->getMagazineByName('test'); $reportedUser = $this->getUserByUsername('hapless_fool'); $entry = $this->getEntryByTitle('Delete test', body: 'This is gonna be deleted', magazine: $magazine, user: $reportedUser); $entryManager = $this->entryManager; $entryManager->delete($user, $entry); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:trash:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/moderate/magazine/{$magazine->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); $trashedEntryResponseKeys = array_merge(self::ENTRY_RESPONSE_KEYS, ['itemType']); self::assertArrayKeysMatch($trashedEntryResponseKeys, $jsonData['items'][0]); self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); self::assertEquals($entry->body, $jsonData['items'][0]['body']); self::assertEquals(VisibilityInterface::VISIBILITY_TRASHED, $jsonData['items'][0]['visibility']); } } ================================================ FILE: tests/Functional/Controller/Api/Message/MessageReadApiTest.php ================================================ createMessage($this->getUserByUsername('JohnDoe'), $this->getUserByUsername('JaneDoe'), 'test message'); $this->client->request('PUT', "/api/messages/{$message->getId()}/read"); self::assertResponseStatusCodeSame(401); } public function testApiCannotMarkMessagesReadWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $message = $this->createMessage($this->getUserByUsername('JohnDoe'), $this->getUserByUsername('JaneDoe'), 'test message'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/messages/{$message->getId()}/read", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotMarkOtherUsersMessagesRead(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $messagedUser = $this->getUserByUsername('JamesDoe'); $message = $this->createMessage($messagedUser, $messagingUser, 'test message'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/messages/{$message->getId()}/read", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanMarkMessagesRead(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $thread = $this->createMessageThread($user, $messagingUser, 'test message'); /** @var Message $message */ $message = $thread->messages->get(0); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/messages/{$message->getId()}/read", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData); self::assertSame($message->getId(), $jsonData['messageId']); self::assertSame($thread->getId(), $jsonData['threadId']); self::assertEquals('test message', $jsonData['body']); self::assertEquals(Message::STATUS_READ, $jsonData['status']); self::assertSame($message->createdAt->getTimestamp(), \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp()); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['sender']); self::assertSame($messagingUser->getId(), $jsonData['sender']['userId']); } public function testApiCannotMarkMessagesUnreadAnonymous(): void { $message = $this->createMessage($this->getUserByUsername('JohnDoe'), $this->getUserByUsername('JaneDoe'), 'test message'); $this->client->request('PUT', "/api/messages/{$message->getId()}/unread"); self::assertResponseStatusCodeSame(401); } public function testApiCannotMarkMessagesUnreadWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $message = $this->createMessage($this->getUserByUsername('JohnDoe'), $this->getUserByUsername('JaneDoe'), 'test message'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/messages/{$message->getId()}/unread", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotMarkOtherUsersMessagesUnread(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $messagedUser = $this->getUserByUsername('JamesDoe'); $message = $this->createMessage($messagedUser, $messagingUser, 'test message'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/messages/{$message->getId()}/unread", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanMarkMessagesUnread(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $thread = $this->createMessageThread($user, $messagingUser, 'test message'); /** @var Message $message */ $message = $thread->messages->get(0); $messageManager = $this->messageManager; $messageManager->readMessage($message, $user, flush: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/messages/{$message->getId()}/unread", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData); self::assertSame($message->getId(), $jsonData['messageId']); self::assertSame($thread->getId(), $jsonData['threadId']); self::assertEquals('test message', $jsonData['body']); self::assertEquals(Message::STATUS_NEW, $jsonData['status']); self::assertSame($message->createdAt->getTimestamp(), \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp()); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['sender']); self::assertSame($messagingUser->getId(), $jsonData['sender']['userId']); } } ================================================ FILE: tests/Functional/Controller/Api/Message/MessageRetrieveApiTest.php ================================================ client->request('GET', '/api/messages'); self::assertResponseStatusCodeSame(401); } public function testApiCannotGetMessagesWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/messages', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetMessages(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = 'test message'; $thread = $messageManager->toThread($dto, $messagingUser, $user); /** @var Message $message */ $message = $thread->messages->get(0); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/messages', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::MESSAGE_THREAD_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($thread->getId(), $jsonData['items'][0]['threadId']); self::assertSame(1, $jsonData['items'][0]['messageCount']); self::assertIsArray($jsonData['items'][0]['messages']); self::assertCount(1, $jsonData['items'][0]['messages']); self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['items'][0]['messages'][0]); self::assertSame($message->getId(), $jsonData['items'][0]['messages'][0]['messageId']); self::assertSame($thread->getId(), $jsonData['items'][0]['messages'][0]['threadId']); self::assertEquals('test message', $jsonData['items'][0]['messages'][0]['body']); self::assertEquals('new', $jsonData['items'][0]['messages'][0]['status']); self::assertSame($message->createdAt->getTimestamp(), \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $jsonData['items'][0]['messages'][0]['createdAt'])->getTimestamp()); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['messages'][0]['sender']); self::assertSame($messagingUser->getId(), $jsonData['items'][0]['messages'][0]['sender']['userId']); } public function testApiCannotGetMessageByIdAnonymous(): void { $this->client->request('GET', '/api/messages/1'); self::assertResponseStatusCodeSame(401); } public function testApiCannotGetMessageByIdWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/messages/1', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotGetOtherUsersMessageById(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $messagedUser = $this->getUserByUsername('JamesDoe'); $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = 'test message'; $thread = $messageManager->toThread($dto, $messagingUser, $messagedUser); /** @var Message $message */ $message = $thread->messages->get(0); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/messages/{$message->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetMessageById(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = 'test message'; $thread = $messageManager->toThread($dto, $messagingUser, $user); /** @var Message $message */ $message = $thread->messages->get(0); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/messages/{$message->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData); self::assertSame($message->getId(), $jsonData['messageId']); self::assertSame($thread->getId(), $jsonData['threadId']); self::assertEquals('test message', $jsonData['body']); self::assertEquals('new', $jsonData['status']); self::assertSame($message->createdAt->getTimestamp(), \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp()); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['sender']); self::assertSame($messagingUser->getId(), $jsonData['sender']['userId']); } public function testApiCannotGetMessageThreadByIdAnonymous(): void { $messagingUser = $this->getUserByUsername('JaneDoe'); $messagedUser = $this->getUserByUsername('JamesDoe'); $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = 'test message'; $thread = $messageManager->toThread($dto, $messagingUser, $messagedUser); $this->client->request('GET', "/api/messages/thread/{$thread->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotGetMessageThreadByIdWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $messagedUser = $this->getUserByUsername('JamesDoe'); $this->client->loginUser($user); $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = 'test message'; $thread = $messageManager->toThread($dto, $messagingUser, $messagedUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/messages/thread/{$thread->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotGetOtherUsersMessageThreadById(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $messagedUser = $this->getUserByUsername('JamesDoe'); $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = 'test message'; $thread = $messageManager->toThread($dto, $messagingUser, $messagedUser); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/messages/thread/{$thread->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetMessageThreadById(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = 'test message'; $thread = $messageManager->toThread($dto, $messagingUser, $user); /** @var Message $message */ $message = $thread->messages->get(0); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/messages/thread/{$thread->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(array_merge(self::PAGINATED_KEYS, ['participants']), $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['participants']); self::assertCount(2, $jsonData['participants']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame($message->getId(), $jsonData['items'][0]['messageId']); self::assertSame($thread->getId(), $jsonData['items'][0]['threadId']); self::assertEquals('test message', $jsonData['items'][0]['body']); self::assertEquals('new', $jsonData['items'][0]['status']); self::assertSame($message->createdAt->getTimestamp(), \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $jsonData['items'][0]['createdAt'])->getTimestamp()); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['sender']); self::assertSame($messagingUser->getId(), $jsonData['items'][0]['sender']['userId']); } } ================================================ FILE: tests/Functional/Controller/Api/Message/MessageThreadCreateApiTest.php ================================================ getUserByUsername('JohnDoe'); $this->client->jsonRequest('POST', "/api/users/{$messagedUser->getId()}/message", parameters: ['body' => 'test message']); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateThreadWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $messagedUser = $this->getUserByUsername('JaneDoe'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/users/{$messagedUser->getId()}/message", parameters: ['body' => 'test message'], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateThread(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagedUser = $this->getUserByUsername('JaneDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/users/{$messagedUser->getId()}/message", parameters: ['body' => 'test message'], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MessageRetrieveApiTest::MESSAGE_THREAD_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['participants']); self::assertCount(2, $jsonData['participants']); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['participants'][0]); self::assertTrue($user->getId() === $jsonData['participants'][0]['userId'] || $messagedUser->getId() === $jsonData['participants'][0]['userId']); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['participants'][1]); self::assertTrue($user->getId() === $jsonData['participants'][1]['userId'] || $messagedUser->getId() === $jsonData['participants'][1]['userId']); self::assertSame(1, $jsonData['messageCount']); self::assertNotNull($jsonData['threadId']); self::assertIsArray($jsonData['messages']); self::assertCount(1, $jsonData['messages']); self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['messages'][0]); self::assertEquals('test message', $jsonData['messages'][0]['body']); self::assertEquals(Message::STATUS_NEW, $jsonData['messages'][0]['status']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['messages'][0]['sender']); self::assertSame($user->getId(), $jsonData['messages'][0]['sender']['userId']); } } ================================================ FILE: tests/Functional/Controller/Api/Message/MessageThreadReplyApiTest.php ================================================ getUserByUsername('JohnDoe'); $from = $this->getUserByUsername('JaneDoe'); $thread = $this->createMessageThread($to, $from, 'starting a thread'); $this->client->jsonRequest('POST', "/api/messages/thread/{$thread->getId()}/reply", parameters: ['body' => 'test message']); self::assertResponseStatusCodeSame(401); } public function testApiCannotReplyToThreadWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $from = $this->getUserByUsername('JaneDoe'); $thread = $this->createMessageThread($user, $from, 'starting a thread'); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/messages/thread/{$thread->getId()}/reply", parameters: ['body' => 'test message'], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanReplyToThread(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $from = $this->getUserByUsername('JaneDoe'); $thread = $this->createMessageThread($user, $from, 'starting a thread'); // Fake when the message was created at so that the newest to oldest order can be reliably determined $thread->messages->get(0)->createdAt = new \DateTimeImmutable('-5 seconds'); $entityManager = $this->entityManager; $entityManager->persist($thread); $entityManager->flush(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/messages/thread/{$thread->getId()}/reply", parameters: ['body' => 'test message'], server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(MessageRetrieveApiTest::MESSAGE_THREAD_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['participants']); self::assertCount(2, $jsonData['participants']); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['participants'][0]); self::assertTrue($user->getId() === $jsonData['participants'][0]['userId'] || $from->getId() === $jsonData['participants'][0]['userId']); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['participants'][1]); self::assertTrue($user->getId() === $jsonData['participants'][1]['userId'] || $from->getId() === $jsonData['participants'][1]['userId']); self::assertSame(2, $jsonData['messageCount']); self::assertNotNull($jsonData['threadId']); self::assertIsArray($jsonData['messages']); self::assertCount(2, $jsonData['messages']); self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['messages'][0]); // Newest first self::assertEquals('test message', $jsonData['messages'][0]['body']); self::assertEquals(Message::STATUS_NEW, $jsonData['messages'][0]['status']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['messages'][0]['sender']); self::assertSame($user->getId(), $jsonData['messages'][0]['sender']['userId']); self::assertEquals('starting a thread', $jsonData['messages'][1]['body']); self::assertEquals(Message::STATUS_NEW, $jsonData['messages'][1]['status']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['messages'][1]['sender']); self::assertSame($from->getId(), $jsonData['messages'][1]['sender']['userId']); } } ================================================ FILE: tests/Functional/Controller/Api/Notification/AdminNotificationRetrieveApiTest.php ================================================ getUserByUsername('JohnDoe', isAdmin: true); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read user:message:read'); $token = $codes['token_type'].' '.$codes['access_token']; $createdAt = new \DateTimeImmutable(); $createDto = UserDto::create('new_here', email: 'user@example.com', createdAt: $createdAt, applicationText: 'hello there'); $createDto->plainPassword = '1234'; $this->userManager->create($createDto, false, false, false); $this->client->request('GET', '/api/notifications/all', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertCount(1, $jsonData['items']); $item = $jsonData['items'][0]; self::assertArrayKeysMatch(NotificationRetrieveApiTest::NOTIFICATION_RESPONSE_KEYS, $item); self::assertEquals('new_signup', $item['type']); self::assertEquals('new', $item['status']); self::assertNull($item['reportId']); $subject = $item['subject']; self::assertIsArray($subject); self::assertArrayKeysMatch(self::USER_SIGNUP_RESPONSE_KEYS, $subject); self::assertNotEquals(0, $subject['userId']); self::assertEquals('new_here', $subject['username']); self::assertEquals('user@example.com', $subject['email']); self::assertEquals($createdAt->format(\DateTimeInterface::ATOM), $subject['createdAt']); self::assertEquals('hello there', $subject['applicationText']); } } ================================================ FILE: tests/Functional/Controller/Api/Notification/NotificationDeleteApiTest.php ================================================ createMessageNotification(); $this->client->request('DELETE', "/api/notifications/{$notification->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDeleteNotificationByIdWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $notification = $this->createMessageNotification(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/notifications/{$notification->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotDeleteOtherUsersNotificationById(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagedUser = $this->getUserByUsername('JamesDoe'); $notification = $this->createMessageNotification($messagedUser); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/notifications/{$notification->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDeleteNotificationById(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $notification = $this->createMessageNotification(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/notifications/{$notification->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $notificationRepository = $this->notificationRepository; $notification = $notificationRepository->find($notification->getId()); self::assertNull($notification); } public function testApiCannotDeleteAllNotificationsAnonymous(): void { $this->createMessageNotification(); $this->client->request('DELETE', '/api/notifications'); self::assertResponseStatusCodeSame(401); } public function testApiCannotDeleteAllNotificationsWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $this->createMessageNotification(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', '/api/notifications', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDeleteAllNotifications(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $notification = $this->createMessageNotification(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', '/api/notifications', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $notificationRepository = $this->notificationRepository; $notification = $notificationRepository->find($notification->getId()); self::assertNull($notification); } } ================================================ FILE: tests/Functional/Controller/Api/Notification/NotificationReadApiTest.php ================================================ createMessageNotification(); $this->client->request('PUT', "/api/notifications/{$notification->getId()}/read"); self::assertResponseStatusCodeSame(401); } public function testApiCannotMarkNotificationReadWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $notification = $this->createMessageNotification(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/notifications/{$notification->getId()}/read", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotMarkOtherUsersNotificationRead(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagedUser = $this->getUserByUsername('JamesDoe'); $notification = $this->createMessageNotification($messagedUser); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/notifications/{$notification->getId()}/read", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanMarkNotificationRead(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $notification = $this->createMessageNotification(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/notifications/{$notification->getId()}/read", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(NotificationRetrieveApiTest::NOTIFICATION_RESPONSE_KEYS, $jsonData); self::assertEquals('read', $jsonData['status']); self::assertEquals('message_notification', $jsonData['type']); self::assertIsArray($jsonData['subject']); self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['subject']); self::assertNull($jsonData['subject']['messageId']); self::assertNull($jsonData['subject']['threadId']); self::assertNull($jsonData['subject']['sender']); self::assertNull($jsonData['subject']['status']); self::assertNull($jsonData['subject']['createdAt']); self::assertEquals('This app has not received permission to read your messages.', $jsonData['subject']['body']); } public function testApiCannotMarkNotificationUnreadAnonymous(): void { $notification = $this->createMessageNotification(); $this->client->request('PUT', "/api/notifications/{$notification->getId()}/unread"); self::assertResponseStatusCodeSame(401); } public function testApiCannotMarkNotificationUnreadWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $notification = $this->createMessageNotification(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/notifications/{$notification->getId()}/unread", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotMarkOtherUsersNotificationUnread(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagedUser = $this->getUserByUsername('JamesDoe'); $notification = $this->createMessageNotification($messagedUser); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/notifications/{$notification->getId()}/unread", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanMarkNotificationUnread(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $notification = $this->createMessageNotification(); $notification->status = Notification::STATUS_READ; $entityManager = $this->entityManager; $entityManager->persist($notification); $entityManager->flush(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/notifications/{$notification->getId()}/unread", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(NotificationRetrieveApiTest::NOTIFICATION_RESPONSE_KEYS, $jsonData); self::assertEquals('new', $jsonData['status']); self::assertEquals('message_notification', $jsonData['type']); self::assertIsArray($jsonData['subject']); self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['subject']); self::assertNull($jsonData['subject']['messageId']); self::assertNull($jsonData['subject']['threadId']); self::assertNull($jsonData['subject']['sender']); self::assertNull($jsonData['subject']['status']); self::assertNull($jsonData['subject']['createdAt']); self::assertEquals('This app has not received permission to read your messages.', $jsonData['subject']['body']); } public function testApiCannotMarkAllNotificationsReadAnonymous(): void { $this->createMessageNotification(); $this->client->request('PUT', '/api/notifications/read'); self::assertResponseStatusCodeSame(401); } public function testApiCannotMarkAllNotificationsReadWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $this->createMessageNotification(); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/notifications/read', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanMarkAllNotificationsRead(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $notification = $this->createMessageNotification(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', '/api/notifications/read', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $notificationRepository = $this->notificationRepository; $notification = $notificationRepository->find($notification->getId()); self::assertNotNull($notification); self::assertEquals('read', $notification->status); } } ================================================ FILE: tests/Functional/Controller/Api/Notification/NotificationRetrieveApiTest.php ================================================ client->request('GET', '/api/notifications/all'); self::assertResponseStatusCodeSame(401); } public function testApiCannotGetNotificationsByStatusWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/notifications/all', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetNotificationsByStatusMessagesRedactedWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = 'test message'; $thread = $messageManager->toThread($dto, $messagingUser, $user); /** @var Message $message */ $message = $thread->messages->get(0); $notificationManager = $this->notificationManager; $notificationManager->readMessageNotification($message, $user); // Create unread notification $thread = $messageManager->toThread($dto, $messagingUser, $user); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/notifications/all', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('new', $jsonData['items'][0]['status']); self::assertEquals('message_notification', $jsonData['items'][0]['type']); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('read', $jsonData['items'][1]['status']); self::assertEquals('message_notification', $jsonData['items'][1]['type']); self::assertIsArray($jsonData['items'][0]['subject']); self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['items'][0]['subject']); self::assertNull($jsonData['items'][0]['subject']['messageId']); self::assertNull($jsonData['items'][0]['subject']['threadId']); self::assertNull($jsonData['items'][0]['subject']['sender']); self::assertNull($jsonData['items'][0]['subject']['status']); self::assertNull($jsonData['items'][0]['subject']['createdAt']); self::assertEquals('This app has not received permission to read your messages.', $jsonData['items'][0]['subject']['body']); } public function testApiCanGetNotificationsByStatusAll(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = 'test message'; $thread = $messageManager->toThread($dto, $messagingUser, $user); /** @var Message $message */ $message = $thread->messages->get(0); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read user:message:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/notifications/all', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('new', $jsonData['items'][0]['status']); self::assertEquals('message_notification', $jsonData['items'][0]['type']); self::assertIsArray($jsonData['items'][0]['subject']); self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['items'][0]['subject']); self::assertSame($message->getId(), $jsonData['items'][0]['subject']['messageId']); self::assertSame($message->thread->getId(), $jsonData['items'][0]['subject']['threadId']); self::assertIsArray($jsonData['items'][0]['subject']['sender']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['subject']['sender']); self::assertSame($messagingUser->getId(), $jsonData['items'][0]['subject']['sender']['userId']); self::assertEquals('new', $jsonData['items'][0]['subject']['status']); self::assertNotNull($jsonData['items'][0]['subject']['createdAt']); self::assertEquals($message->body, $jsonData['items'][0]['subject']['body']); } public function testApiCanGetNotificationsFromThreads(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $magazine = $this->getMagazineByName('acme'); $entry = $this->getEntryByTitle('Test notification entry', body: 'Test body', magazine: $magazine, user: $messagingUser); $userEntry = $this->getEntryByTitle('Test entry', body: 'Test body', magazine: $magazine, user: $user); $comment = $this->createEntryComment('Test notification comment', $userEntry, $messagingUser); $commentTwo = $this->createEntryComment('Test notification comment 2', $userEntry, $messagingUser, $comment); $parent = $this->createEntryComment('Test parent comment', $entry, $user); $reply = $this->createEntryComment('Test reply comment', $entry, $messagingUser, $parent); $this->createEntryComment('Test not notified comment', $entry, $messagingUser); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read user:message:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/notifications/all', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertIsArray($jsonData['items']); self::assertCount(4, $jsonData['items']); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('new', $jsonData['items'][0]['status']); self::assertEquals('entry_comment_reply_notification', $jsonData['items'][0]['type']); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('new', $jsonData['items'][1]['status']); self::assertEquals('entry_comment_created_notification', $jsonData['items'][1]['type']); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][2]); self::assertEquals('new', $jsonData['items'][2]['status']); self::assertEquals('entry_comment_created_notification', $jsonData['items'][2]['type']); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][3]); self::assertEquals('new', $jsonData['items'][3]['status']); self::assertEquals('entry_created_notification', $jsonData['items'][3]['type']); self::assertIsArray($jsonData['items'][0]['subject']); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]['subject']); self::assertSame($reply->getId(), $jsonData['items'][0]['subject']['commentId']); self::assertIsArray($jsonData['items'][1]['subject']); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]['subject']); self::assertSame($commentTwo->getId(), $jsonData['items'][1]['subject']['commentId']); self::assertIsArray($jsonData['items'][2]['subject']); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]['subject']); self::assertSame($comment->getId(), $jsonData['items'][2]['subject']['commentId']); self::assertIsArray($jsonData['items'][3]['subject']); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][3]['subject']); self::assertSame($entry->getId(), $jsonData['items'][3]['subject']['entryId']); } public function testApiCanGetNotificationsFromPosts(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $magazine = $this->getMagazineByName('acme'); $post = $this->createPost('Test notification post', magazine: $magazine, user: $messagingUser); $userPost = $this->createPost('Test not notified body', magazine: $magazine, user: $user); $comment = $this->createPostComment('Test notification comment', $userPost, $messagingUser); $commentTwo = $this->createPostCommentReply('Test notification comment 2', $userPost, $messagingUser, $comment); $parent = $this->createPostComment('Test parent comment', $post, $user); $reply = $this->createPostCommentReply('Test reply comment', $post, $messagingUser, $parent); $this->createPostComment('Test not notified comment', $post, $messagingUser); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read user:message:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/notifications/all', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertIsArray($jsonData['items']); self::assertCount(4, $jsonData['items']); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('new', $jsonData['items'][0]['status']); self::assertEquals('post_comment_reply_notification', $jsonData['items'][0]['type']); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('new', $jsonData['items'][1]['status']); self::assertEquals('post_comment_created_notification', $jsonData['items'][1]['type']); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][2]); self::assertEquals('new', $jsonData['items'][2]['status']); self::assertEquals('post_comment_created_notification', $jsonData['items'][2]['type']); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][3]); self::assertEquals('new', $jsonData['items'][3]['status']); self::assertEquals('post_created_notification', $jsonData['items'][3]['type']); self::assertIsArray($jsonData['items'][0]['subject']); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]['subject']); self::assertSame($reply->getId(), $jsonData['items'][0]['subject']['commentId']); self::assertIsArray($jsonData['items'][1]['subject']); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]['subject']); self::assertSame($commentTwo->getId(), $jsonData['items'][1]['subject']['commentId']); self::assertIsArray($jsonData['items'][2]['subject']); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]['subject']); self::assertSame($comment->getId(), $jsonData['items'][2]['subject']['commentId']); self::assertIsArray($jsonData['items'][3]['subject']); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][3]['subject']); self::assertSame($post->getId(), $jsonData['items'][3]['subject']['postId']); } public function testApiCanGetNotificationsByStatusRead(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = 'test message'; $thread = $messageManager->toThread($dto, $messagingUser, $user); /** @var Message $message */ $message = $thread->messages->get(0); $notificationManager = $this->notificationManager; $notificationManager->readMessageNotification($message, $user); // Create unread notification $thread = $messageManager->toThread($dto, $messagingUser, $user); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/notifications/read', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('read', $jsonData['items'][0]['status']); self::assertEquals('message_notification', $jsonData['items'][0]['type']); self::assertIsArray($jsonData['items'][0]['subject']); self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['items'][0]['subject']); self::assertNull($jsonData['items'][0]['subject']['messageId']); self::assertNull($jsonData['items'][0]['subject']['threadId']); self::assertNull($jsonData['items'][0]['subject']['sender']); self::assertNull($jsonData['items'][0]['subject']['status']); self::assertNull($jsonData['items'][0]['subject']['createdAt']); self::assertEquals('This app has not received permission to read your messages.', $jsonData['items'][0]['subject']['body']); } public function testApiCanGetNotificationsByStatusNew(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = 'test message'; $thread = $messageManager->toThread($dto, $messagingUser, $user); /** @var Message $message */ $message = $thread->messages->get(0); $notificationManager = $this->notificationManager; $notificationManager->readMessageNotification($message, $user); // Create unread notification $thread = $messageManager->toThread($dto, $messagingUser, $user); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/notifications/new', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('new', $jsonData['items'][0]['status']); self::assertEquals('message_notification', $jsonData['items'][0]['type']); self::assertIsArray($jsonData['items'][0]['subject']); self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['items'][0]['subject']); self::assertNull($jsonData['items'][0]['subject']['messageId']); self::assertNull($jsonData['items'][0]['subject']['threadId']); self::assertNull($jsonData['items'][0]['subject']['sender']); self::assertNull($jsonData['items'][0]['subject']['status']); self::assertNull($jsonData['items'][0]['subject']['createdAt']); self::assertEquals('This app has not received permission to read your messages.', $jsonData['items'][0]['subject']['body']); } public function testApiCannotGetNotificationsByInvalidStatus(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = 'test message'; $thread = $messageManager->toThread($dto, $messagingUser, $user); /** @var Message $message */ $message = $thread->messages->get(0); $notificationManager = $this->notificationManager; $notificationManager->readMessageNotification($message, $user); // Create unread notification $thread = $messageManager->toThread($dto, $messagingUser, $user); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/notifications/invalid', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); } public function testApiCannotGetNotificationCountAnonymous(): void { $this->client->request('GET', '/api/notifications/count'); self::assertResponseStatusCodeSame(401); } public function testApiCannotGetNotificationCountWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/notifications/count', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetNotificationCount(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagingUser = $this->getUserByUsername('JaneDoe'); $magazine = $this->getMagazineByName('acme'); $this->getEntryByTitle('Test notification entry', body: 'Test body', magazine: $magazine, user: $messagingUser); $this->createPost('Test notification post body', magazine: $magazine, user: $messagingUser); $messageManager = $this->messageManager; $dto = new MessageDto(); $dto->body = 'test message'; $thread = $messageManager->toThread($dto, $messagingUser, $user); /** @var Message $message */ $message = $thread->messages->get(0); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/notifications/count', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(['count'], $jsonData); self::assertSame(3, $jsonData['count']); } public function testApiCannotGetNotificationByIdAnonymous(): void { $notification = $this->createMessageNotification(); self::assertNotNull($notification); $this->client->request('GET', "/api/notification/{$notification->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotGetNotificationByIdWithoutScope(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $notification = $this->createMessageNotification(); self::assertNotNull($notification); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/notification/{$notification->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotGetOtherUsersNotificationById(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $messagedUser = $this->getUserByUsername('JamesDoe'); $notification = $this->createMessageNotification($messagedUser); self::assertNotNull($notification); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/notification/{$notification->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetNotificationById(): void { self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('JohnDoe'); $notification = $this->createMessageNotification(); self::assertNotNull($notification); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/notification/{$notification->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData); self::assertSame($notification->getId(), $jsonData['notificationId']); } } ================================================ FILE: tests/Functional/Controller/Api/Notification/NotificationUpdateApiTest.php ================================================ user = $this->getUserByUsername('user'); $this->client->loginUser($this->user); self::createOAuth2PublicAuthCodeClient(); $codes = self::getPublicAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:edit'); $this->token = $codes['token_type'].' '.$codes['access_token']; // it seems that the oauth flow detaches the user object from the entity manager, so fetch it again $this->user = $this->userRepository->findOneByUsername('user'); } public function testSetEntryNotificationSetting(): void { $entry = $this->getEntryByTitle('entry'); $this->testAllSettings("/api/entry/{$entry->getId()}", "/api/notification/update/entry/{$entry->getId()}"); } public function testSetPostNotificationSetting(): void { $post = $this->createPost('post'); $this->testAllSettings("/api/post/{$post->getId()}", "/api/notification/update/post/{$post->getId()}"); } public function testSetUserNotificationSetting(): void { $user2 = $this->getUserByUsername('test'); $this->testAllSettings("/api/users/{$user2->getId()}", "/api/notification/update/user/{$user2->getId()}"); } public function testSetMagazineNotificationSetting(): void { $magazine = $this->getMagazineByName('test'); $this->testAllSettings("/api/magazine/{$magazine->getId()}", "/api/notification/update/magazine/{$magazine->getId()}"); } private function testAllSettings(string $retrieveUrl, string $updateUrl): void { $this->client->request('GET', $retrieveUrl, server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertEquals('Default', $jsonData['notificationStatus']); $this->client->request('PUT', "$updateUrl/Loud", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $this->client->request('GET', $retrieveUrl, server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertEquals('Loud', $jsonData['notificationStatus']); $this->client->request('PUT', "$updateUrl/Muted", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $this->client->request('GET', $retrieveUrl, server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertEquals('Muted', $jsonData['notificationStatus']); $this->client->request('PUT', "$updateUrl/Default", server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $this->client->request('GET', $retrieveUrl, server: ['HTTP_AUTHORIZATION' => $this->token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertEquals('Default', $jsonData['notificationStatus']); } } ================================================ FILE: tests/Functional/Controller/Api/OAuth2/OAuth2ClientApiTest.php ================================================ '/kbin API Created Test Client', 'description' => 'An OAuth2 client for testing purposes, created via the API', 'contactEmail' => 'test@kbin.test', 'redirectUris' => [ 'https://localhost:3002', ], 'grants' => [ 'authorization_code', 'refresh_token', ], 'scopes' => [ 'read', 'write', 'admin:oauth_clients:read', ], ]; $this->client->jsonRequest('POST', '/api/client', $requestData); self::assertResponseIsSuccessful(); $clientData = self::getJsonResponse($this->client); self::assertIsArray($clientData); self::assertArrayKeysMatch(self::CLIENT_RESPONSE_KEYS, $clientData); self::assertNotNull($clientData['identifier']); self::assertNotNull($clientData['secret']); self::assertEquals($requestData['name'], $clientData['name']); self::assertEquals($requestData['contactEmail'], $clientData['contactEmail']); self::assertEquals($requestData['description'], $clientData['description']); self::assertNull($clientData['user']); self::assertIsArray($clientData['redirectUris']); self::assertEquals($requestData['redirectUris'], $clientData['redirectUris']); self::assertIsArray($clientData['grants']); self::assertEquals($requestData['grants'], $clientData['grants']); self::assertIsArray($clientData['scopes']); self::assertEquals($requestData['scopes'], $clientData['scopes']); self::assertNull($clientData['image']); $this->client->loginUser($this->getUserByUsername('JohnDoe')); $jsonData = self::getAuthorizationCodeTokenResponse( $this->client, clientId: $clientData['identifier'], clientSecret: $clientData['secret'], redirectUri: $clientData['redirectUris'][0], ); self::assertResponseIsSuccessful(); self::assertIsArray($jsonData); } public function testApiCanCreateWorkingPublicClient(): void { $requestData = [ 'name' => '/kbin API Created Test Client', 'description' => 'An OAuth2 client for testing purposes, created via the API', 'contactEmail' => 'test@kbin.test', 'public' => true, 'redirectUris' => [ 'https://localhost:3001', ], 'grants' => [ 'authorization_code', 'refresh_token', ], 'scopes' => [ 'read', 'write', 'admin:oauth_clients:read', ], ]; $this->client->jsonRequest('POST', '/api/client', $requestData); self::assertResponseIsSuccessful(); $clientData = self::getJsonResponse($this->client); self::assertIsArray($clientData); self::assertArrayKeysMatch(self::CLIENT_RESPONSE_KEYS, $clientData); self::assertNotNull($clientData['identifier']); self::assertNull($clientData['secret']); self::assertEquals($requestData['name'], $clientData['name']); self::assertEquals($requestData['contactEmail'], $clientData['contactEmail']); self::assertEquals($requestData['description'], $clientData['description']); self::assertNull($clientData['user']); self::assertIsArray($clientData['redirectUris']); self::assertEquals($requestData['redirectUris'], $clientData['redirectUris']); self::assertIsArray($clientData['grants']); self::assertEquals($requestData['grants'], $clientData['grants']); self::assertIsArray($clientData['scopes']); self::assertEquals($requestData['scopes'], $clientData['scopes']); self::assertNull($clientData['image']); $this->client->loginUser($this->getUserByUsername('JohnDoe')); $jsonData = self::getPublicAuthorizationCodeTokenResponse( $this->client, clientId: $clientData['identifier'], redirectUri: $clientData['redirectUris'][0], ); self::assertResponseIsSuccessful(); self::assertIsArray($jsonData); } #[Group(name: 'NonThreadSafe')] public function testApiCanCreateWorkingClientWithImage(): void { $requestData = [ 'name' => '/kbin API Created Test Client', 'description' => 'An OAuth2 client for testing purposes, created via the API', 'contactEmail' => 'test@kbin.test', 'redirectUris' => [ 'https://localhost:3002', ], 'grants' => [ 'authorization_code', 'refresh_token', ], 'scopes' => [ 'read', 'write', 'admin:oauth_clients:read', ], ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload copy($this->kibbyPath, $this->kibbyPath.'.tmp'); $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request('POST', '/api/client-with-logo', $requestData, files: ['uploadImage' => $image]); self::assertResponseIsSuccessful(); $clientData = self::getJsonResponse($this->client); self::assertIsArray($clientData); self::assertArrayKeysMatch(self::CLIENT_RESPONSE_KEYS, $clientData); self::assertNotNull($clientData['identifier']); self::assertNotNull($clientData['secret']); self::assertEquals($requestData['name'], $clientData['name']); self::assertEquals($requestData['contactEmail'], $clientData['contactEmail']); self::assertEquals($requestData['description'], $clientData['description']); self::assertNull($clientData['user']); self::assertIsArray($clientData['redirectUris']); self::assertEquals($requestData['redirectUris'], $clientData['redirectUris']); self::assertIsArray($clientData['grants']); self::assertEquals($requestData['grants'], $clientData['grants']); self::assertIsArray($clientData['scopes']); self::assertEquals($requestData['scopes'], $clientData['scopes']); self::assertisArray($clientData['image']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $clientData['image']); $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::runAuthorizationCodeFlowToConsentPage($this->client, 'read write', 'oauth2state', $clientData['identifier'], $clientData['redirectUris'][0]); self::assertSelectorExists('img.oauth-client-logo'); $logo = $this->client->getCrawler()->filter('img.oauth-client-logo')->first(); self::assertStringContainsString($clientData['image']['filePath'], $logo->attr('src')); self::runAuthorizationCodeFlowToRedirectUri($this->client, 'read write', 'yes', 'oauth2state', $clientData['identifier'], $clientData['redirectUris'][0]); $jsonData = self::runAuthorizationCodeTokenFlow($this->client, $clientData['identifier'], $clientData['secret'], $clientData['redirectUris'][0]); self::assertResponseIsSuccessful(); self::assertIsArray($jsonData); } public function testApiCanDeletePrivateClient(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); self::createOAuth2AuthCodeClient(); $query = http_build_query([ 'client_id' => 'testclient', 'client_secret' => 'testsecret', ]); $this->client->request('DELETE', '/api/client?'.$query); self::assertResponseStatusCodeSame(204); $jsonData = self::getAuthorizationCodeTokenResponse($this->client); self::assertResponseStatusCodeSame(401); self::assertIsArray($jsonData); self::assertArrayHasKey('error', $jsonData); self::assertEquals('invalid_client', $jsonData['error']); self::assertArrayHasKey('error_description', $jsonData); } public function testAdminApiCanAccessClientStats(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true)); self::createOAuth2AuthCodeClient(); $jsonData = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:oauth_clients:read'); self::assertResponseIsSuccessful(); self::assertIsArray($jsonData); self::assertArrayHasKey('access_token', $jsonData); $token = 'Bearer '.$jsonData['access_token']; $query = http_build_query([ 'resolution' => 'day', ]); $this->client->request('GET', '/api/clients/stats?'.$query, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayHasKey('data', $jsonData); self::assertIsArray($jsonData['data']); self::assertCount(1, $jsonData['data']); self::assertIsArray($jsonData['data'][0]); self::assertArrayHasKey('client', $jsonData['data'][0]); self::assertEquals('/kbin Test Client', $jsonData['data'][0]['client']); self::assertArrayHasKey('datetime', $jsonData['data'][0]); // If tests are run near midnight UTC we might get unlucky with a failure, but that // should be unlikely. $today = (new \DateTime())->setTime(0, 0)->format('Y-m-d H:i:s'); self::assertEquals($today, $jsonData['data'][0]['datetime']); self::assertArrayHasKey('count', $jsonData['data'][0]); self::assertEquals(1, $jsonData['data'][0]['count']); } public function testAdminApiCannotAccessClientStatsWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true)); self::createOAuth2AuthCodeClient(); $jsonData = self::getAuthorizationCodeTokenResponse($this->client); self::assertResponseIsSuccessful(); self::assertIsArray($jsonData); self::assertArrayHasKey('access_token', $jsonData); $token = 'Bearer '.$jsonData['access_token']; $query = http_build_query([ 'resolution' => 'day', ]); $this->client->request('GET', '/api/clients/stats?'.$query, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayHasKey('type', $jsonData); self::assertEquals('https://tools.ietf.org/html/rfc2616#section-10', $jsonData['type']); self::assertArrayHasKey('title', $jsonData); self::assertEquals('An error occurred', $jsonData['title']); self::assertArrayHasKey('status', $jsonData); self::assertEquals(403, $jsonData['status']); self::assertArrayHasKey('detail', $jsonData); } public function testAdminApiCanAccessClientList(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true)); self::createOAuth2AuthCodeClient(); $jsonData = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:oauth_clients:read'); self::assertResponseIsSuccessful(); self::assertIsArray($jsonData); self::assertArrayHasKey('access_token', $jsonData); $token = 'Bearer '.$jsonData['access_token']; $this->client->request('GET', '/api/clients', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayHasKey('items', $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['items'][0]); self::assertArrayHasKey('identifier', $jsonData['items'][0]); self::assertArrayNotHasKey('secret', $jsonData['items'][0]); self::assertEquals('testclient', $jsonData['items'][0]['identifier']); self::assertArrayHasKey('name', $jsonData['items'][0]); self::assertEquals('/kbin Test Client', $jsonData['items'][0]['name']); self::assertArrayHasKey('contactEmail', $jsonData['items'][0]); self::assertEquals('test@kbin.test', $jsonData['items'][0]['contactEmail']); self::assertArrayHasKey('description', $jsonData['items'][0]); self::assertEquals('An OAuth2 client for testing purposes', $jsonData['items'][0]['description']); self::assertArrayHasKey('user', $jsonData['items'][0]); self::assertNull($jsonData['items'][0]['user']); self::assertArrayHasKey('active', $jsonData['items'][0]); self::assertEquals(true, $jsonData['items'][0]['active']); self::assertArrayHasKey('createdAt', $jsonData['items'][0]); self::assertNotNull($jsonData['items'][0]['createdAt']); self::assertArrayHasKey('redirectUris', $jsonData['items'][0]); self::assertIsArray($jsonData['items'][0]['redirectUris']); self::assertCount(1, $jsonData['items'][0]['redirectUris']); self::assertArrayHasKey('grants', $jsonData['items'][0]); self::assertIsArray($jsonData['items'][0]['grants']); self::assertCount(2, $jsonData['items'][0]['grants']); self::assertArrayHasKey('scopes', $jsonData['items'][0]); self::assertIsArray($jsonData['items'][0]['scopes']); self::assertArrayHasKey('pagination', $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayHasKey('count', $jsonData['pagination']); self::assertEquals(1, $jsonData['pagination']['count']); self::assertArrayHasKey('currentPage', $jsonData['pagination']); self::assertEquals(1, $jsonData['pagination']['currentPage']); self::assertArrayHasKey('maxPage', $jsonData['pagination']); self::assertEquals(1, $jsonData['pagination']['maxPage']); self::assertArrayHasKey('perPage', $jsonData['pagination']); self::assertEquals(15, $jsonData['pagination']['perPage']); } public function testAdminApiCannotAccessClientListWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true)); self::createOAuth2AuthCodeClient(); $jsonData = self::getAuthorizationCodeTokenResponse($this->client); self::assertResponseIsSuccessful(); self::assertIsArray($jsonData); self::assertArrayHasKey('access_token', $jsonData); $token = 'Bearer '.$jsonData['access_token']; $this->client->request('GET', '/api/clients', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayHasKey('type', $jsonData); self::assertEquals('https://tools.ietf.org/html/rfc2616#section-10', $jsonData['type']); self::assertArrayHasKey('title', $jsonData); self::assertEquals('An error occurred', $jsonData['title']); self::assertArrayHasKey('status', $jsonData); self::assertEquals(403, $jsonData['status']); self::assertArrayHasKey('detail', $jsonData); } public function testAdminApiCanAccessClientByIdentifier(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true)); self::createOAuth2AuthCodeClient(); $jsonData = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:oauth_clients:read'); self::assertResponseIsSuccessful(); self::assertIsArray($jsonData); self::assertArrayHasKey('access_token', $jsonData); $token = 'Bearer '.$jsonData['access_token']; $this->client->request('GET', '/api/clients/testclient', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayHasKey('identifier', $jsonData); self::assertArrayNotHasKey('secret', $jsonData); self::assertEquals('testclient', $jsonData['identifier']); self::assertArrayHasKey('name', $jsonData); self::assertEquals('/kbin Test Client', $jsonData['name']); self::assertArrayHasKey('contactEmail', $jsonData); self::assertEquals('test@kbin.test', $jsonData['contactEmail']); self::assertArrayHasKey('description', $jsonData); self::assertEquals('An OAuth2 client for testing purposes', $jsonData['description']); self::assertArrayHasKey('user', $jsonData); self::assertNull($jsonData['user']); self::assertArrayHasKey('active', $jsonData); self::assertEquals(true, $jsonData['active']); self::assertArrayHasKey('createdAt', $jsonData); self::assertNotNull($jsonData['createdAt']); self::assertArrayHasKey('redirectUris', $jsonData); self::assertIsArray($jsonData['redirectUris']); self::assertCount(1, $jsonData['redirectUris']); self::assertArrayHasKey('grants', $jsonData); self::assertIsArray($jsonData['grants']); self::assertCount(2, $jsonData['grants']); self::assertArrayHasKey('scopes', $jsonData); self::assertIsArray($jsonData['scopes']); } public function testApiCanRevokeTokens(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true)); self::createOAuth2AuthCodeClient(); $tokenData = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:oauth_clients:read'); self::assertResponseIsSuccessful(); self::assertIsArray($tokenData); self::assertArrayHasKey('access_token', $tokenData); self::assertArrayHasKey('refresh_token', $tokenData); $token = 'Bearer '.$tokenData['access_token']; $this->client->request('GET', '/api/clients/testclient', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $this->client->request('POST', '/api/revoke', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $this->client->request('GET', '/api/clients/testclient', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(401); $jsonData = self::getRefreshTokenResponse($this->client, $tokenData['refresh_token']); self::assertResponseStatusCodeSame(400); self::assertIsArray($jsonData); self::assertArrayHasKey('error', $jsonData); self::assertEquals('invalid_grant', $jsonData['error']); self::assertArrayHasKey('error_description', $jsonData); self::assertEquals('The refresh token is invalid.', $jsonData['error_description']); self::assertArrayHasKey('hint', $jsonData); self::assertEquals('Token has been revoked', $jsonData['hint']); } public function testAdminApiCannotAccessClientByIdentifierWithoutScope(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true)); self::createOAuth2AuthCodeClient(); $jsonData = self::getAuthorizationCodeTokenResponse($this->client); self::assertResponseIsSuccessful(); self::assertIsArray($jsonData); self::assertArrayHasKey('access_token', $jsonData); $token = 'Bearer '.$jsonData['access_token']; $this->client->request('GET', '/api/clients/testclient', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayHasKey('type', $jsonData); self::assertEquals('https://tools.ietf.org/html/rfc2616#section-10', $jsonData['type']); self::assertArrayHasKey('title', $jsonData); self::assertEquals('An error occurred', $jsonData['title']); self::assertArrayHasKey('status', $jsonData); self::assertEquals(403, $jsonData['status']); self::assertArrayHasKey('detail', $jsonData); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Admin/PostPurgeApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', magazine: $magazine); $this->client->request('DELETE', "/api/admin/post/{$post->getId()}/purge"); self::assertResponseStatusCodeSame(401); } public function testApiCannotPurgeArticlePostWithoutScope(): void { $user = $this->getUserByUsername('user', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/post/{$post->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonAdminCannotPurgeArticlePost(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $otherUser, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/post/{$post->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanPurgeArticlePost(): void { $admin = $this->getUserByUsername('admin', isAdmin: true); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($admin); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/post/{$post->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); } public function testApiCannotPurgeImagePostAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', imageDto: $imageDto, magazine: $magazine); $this->client->request('DELETE', "/api/admin/post/{$post->getId()}/purge"); self::assertResponseStatusCodeSame(401); } public function testApiCannotPurgeImagePostWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user', isAdmin: true); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', imageDto: $imageDto, user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/post/{$post->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonAdminCannotPurgeImagePost(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', imageDto: $imageDto, user: $otherUser, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/post/{$post->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanPurgeImagePost(): void { $admin = $this->getUserByUsername('admin', isAdmin: true); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', imageDto: $imageDto, user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($admin); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/post/{$post->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Comment/Admin/PostCommentPurgeApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', magazine: $magazine); $comment = $this->createPostComment('test comment', $post); $commentRepository = $this->postCommentRepository; $this->client->request('DELETE', "/api/admin/post-comment/{$comment->getId()}/purge"); self::assertResponseStatusCodeSame(401); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); } public function testApiCannotPurgeCommentWithoutScope(): void { $user = $this->getUserByUsername('user', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user, magazine: $magazine); $comment = $this->createPostComment('test comment', $post); $commentRepository = $this->postCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/post-comment/{$comment->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); } public function testApiNonAdminCannotPurgeComment(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $otherUser, magazine: $magazine); $comment = $this->createPostComment('test comment', $post); $commentRepository = $this->postCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post_comment:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/post-comment/{$comment->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); } public function testApiCanPurgeComment(): void { $admin = $this->getUserByUsername('admin', isAdmin: true); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user, magazine: $magazine); $comment = $this->createPostComment('test comment', $post); $commentRepository = $this->postCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($admin); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post_comment:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/post-comment/{$comment->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $comment = $commentRepository->find($comment->getId()); self::assertNull($comment); } public function testApiCannotPurgeImageCommentAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', magazine: $magazine); $comment = $this->createPostComment('test comment', $post, imageDto: $imageDto); $commentRepository = $this->postCommentRepository; $this->client->request('DELETE', "/api/admin/post-comment/{$comment->getId()}/purge"); self::assertResponseStatusCodeSame(401); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); } public function testApiCannotPurgeImageCommentWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user', isAdmin: true); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', magazine: $magazine); $comment = $this->createPostComment('test comment', $post, imageDto: $imageDto); $commentRepository = $this->postCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/post-comment/{$comment->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); } public function testApiNonAdminCannotPurgeImageComment(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', magazine: $magazine); $comment = $this->createPostComment('test comment', $post, imageDto: $imageDto); $commentRepository = $this->postCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post_comment:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/post-comment/{$comment->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); } public function testApiCanPurgeImageComment(): void { $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', magazine: $magazine); $comment = $this->createPostComment('test comment', $post, imageDto: $imageDto); $commentRepository = $this->postCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($admin); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post_comment:purge'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/admin/post-comment/{$comment->getId()}/purge", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $comment = $commentRepository->find($comment->getId()); self::assertNull($comment); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Comment/Moderate/PostCommentSetAdultApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('a post', $magazine); $comment = $this->createPostComment('test comment', $post); $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/adult/true"); self::assertResponseStatusCodeSame(401); } public function testApiCannotSetCommentAdultWithoutScope(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user2 = $this->getUserByUsername('user2'); $post = $this->createPost('a post', magazine: $magazine); $comment = $this->createPostComment('test comment', $post, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/adult/true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonModCannotSetCommentAdult(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $post = $this->createPost('a post', $magazine); $comment = $this->createPostComment('test comment', $post, $user2); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/adult/true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanSetCommentAdult(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user2 = $this->getUserByUsername('other'); $post = $this->createPost('a post', magazine: $magazine); $comment = $this->createPostComment('test comment', $post, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/adult/true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertTrue($jsonData['isAdult']); } public function testApiCannotUnsetCommentAdultAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('a post', $magazine); $comment = $this->createPostComment('test comment', $post); $entityManager = $this->entityManager; $comment->isAdult = true; $entityManager->persist($comment); $entityManager->flush(); $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/adult/false"); self::assertResponseStatusCodeSame(401); } public function testApiCannotUnsetCommentAdultWithoutScope(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user2 = $this->getUserByUsername('user2'); $post = $this->createPost('a post', magazine: $magazine); $comment = $this->createPostComment('test comment', $post, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); $entityManager = $this->entityManager; $comment->isAdult = true; $entityManager->persist($comment); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/adult/false", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonModCannotUnsetCommentAdult(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $post = $this->createPost('a post', $magazine); $comment = $this->createPostComment('test comment', $post, $user2); $entityManager = $this->entityManager; $comment->isAdult = true; $entityManager->persist($comment); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/adult/false", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUnsetCommentAdult(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user2 = $this->getUserByUsername('other'); $post = $this->createPost('a post', magazine: $magazine); $comment = $this->createPostComment('test comment', $post, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); $entityManager = $this->entityManager; $comment->isAdult = true; $entityManager->persist($comment); $entityManager->flush(); $commentRepository = $this->postCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/adult/false", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertFalse($jsonData['isAdult']); $comment = $commentRepository->find($comment->getId()); self::assertFalse($comment->isAdult); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Comment/Moderate/PostCommentSetLanguageApiTest.php ================================================ createPost('a post'); $comment = $this->createPostComment('test comment', $post); $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/de"); self::assertResponseStatusCodeSame(401); } public function testApiCannotSetCommentLanguageWithoutScope(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByName('acme'); $user2 = $this->getUserByUsername('user2'); $post = $this->createPost('a post', magazine: $magazine); $comment = $this->createPostComment('test comment', $post, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonModCannotSetCommentLanguage(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user2); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:language'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanSetCommentLanguage(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByName('acme'); $user2 = $this->getUserByUsername('other'); $post = $this->createPost('a post', magazine: $magazine); $comment = $this->createPostComment('test comment', $post, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:language'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment->getId(), $jsonData['commentId']); self::assertSame('test comment', $jsonData['body']); self::assertSame('de', $jsonData['lang']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Comment/Moderate/PostCommentTrashApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('a post', $magazine); $comment = $this->createPostComment('test comment', $post); $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/trash"); self::assertResponseStatusCodeSame(401); } public function testApiCannotTrashCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user2 = $this->getUserByUsername('user2'); $post = $this->createPost('a post', magazine: $magazine); $comment = $this->createPostComment('test comment', $post, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonModCannotTrashComment(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $post = $this->createPost('a post', $magazine); $comment = $this->createPostComment('test comment', $post, $user2); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanTrashComment(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByName('acme'); $user2 = $this->getUserByUsername('other'); $post = $this->createPost('a post', magazine: $magazine); $comment = $this->createPostComment('test comment', $post, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment->getId(), $jsonData['commentId']); self::assertSame('test comment', $jsonData['body']); self::assertSame('trashed', $jsonData['visibility']); } public function testApiCannotRestoreCommentAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('a post', $magazine); $comment = $this->createPostComment('test comment', $post); $postCommentManager = $this->postCommentManager; $postCommentManager->trash($this->getUserByUsername('user'), $comment); $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/restore"); self::assertResponseStatusCodeSame(401); } public function testApiCannotRestoreCommentWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $post = $this->createPost('a post', $magazine); $comment = $this->createPostComment('test comment', $post, $user2); $postCommentManager = $this->postCommentManager; $postCommentManager->trash($user, $comment); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/restore", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiNonModCannotRestoreComment(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $post = $this->createPost('a post', $magazine); $comment = $this->createPostComment('test comment', $post, $user2); $postCommentManager = $this->postCommentManager; $postCommentManager->trash($user, $comment); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/restore", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRestoreComment(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user2 = $this->getUserByUsername('other'); $post = $this->createPost('a post', magazine: $magazine); $comment = $this->createPostComment('test comment', $post, $user2); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); $postCommentManager = $this->postCommentManager; $postCommentManager->trash($user, $comment); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post-comment/{$comment->getId()}/restore", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment->getId(), $jsonData['commentId']); self::assertSame('test comment', $jsonData['body']); self::assertSame('visible', $jsonData['visibility']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Comment/PostCommentCreateApiTest.php ================================================ createPost('a post'); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; $this->client->jsonRequest( 'POST', "/api/posts/{$post->getId()}/comments", parameters: $comment ); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateCommentWithoutScope(): void { $post = $this->createPost('a post'); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest( 'POST', "/api/posts/{$post->getId()}/comments", parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateComment(): void { $post = $this->createPost('a post'); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest( 'POST', "/api/posts/{$post->getId()}/comments", parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment['body'], $jsonData['body']); self::assertSame($comment['lang'], $jsonData['lang']); self::assertSame($comment['isAdult'], $jsonData['isAdult']); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($post->magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['rootId']); self::assertNull($jsonData['parentId']); } public function testApiCannotCreateCommentReplyAnonymous(): void { $post = $this->createPost('a post'); $postComment = $this->createPostComment('a comment', $post); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; $this->client->jsonRequest( 'POST', "/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply", parameters: $comment ); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateCommentReplyWithoutScope(): void { $post = $this->createPost('a post'); $postComment = $this->createPostComment('a comment', $post); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest( 'POST', "/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply", parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateCommentReply(): void { $post = $this->createPost('a post'); $postComment = $this->createPostComment('a comment', $post); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest( 'POST', "/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply", parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment['body'], $jsonData['body']); self::assertSame($comment['lang'], $jsonData['lang']); self::assertSame($comment['isAdult'], $jsonData['isAdult']); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($post->magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertSame($postComment->getId(), $jsonData['rootId']); self::assertSame($postComment->getId(), $jsonData['parentId']); } public function testApiCannotCreateImageCommentAnonymous(): void { $post = $this->createPost('a post'); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, 'alt' => 'It\'s Kibby!', ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload copy($this->kibbyPath, $this->kibbyPath.'.tmp'); $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/posts/{$post->getId()}/comments/image", parameters: $comment, files: ['uploadImage' => $image] ); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateImageCommentWithoutScope(): void { $post = $this->createPost('a post'); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, 'alt' => 'It\'s Kibby!', ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/posts/{$post->getId()}/comments/image", parameters: $comment, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateImageComment(): void { $post = $this->createPost('a post'); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, 'alt' => 'It\'s Kibby!', ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload copy($this->kibbyPath, $this->kibbyPath.'.tmp'); $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/posts/{$post->getId()}/comments/image", parameters: $comment, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment['body'], $jsonData['body']); self::assertSame($comment['lang'], $jsonData['lang']); self::assertSame($comment['isAdult'], $jsonData['isAdult']); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($post->magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertNull($jsonData['rootId']); self::assertNull($jsonData['parentId']); } public function testApiCannotCreateImageCommentReplyAnonymous(): void { $post = $this->createPost('a post'); $postComment = $this->createPostComment('a comment', $post); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, 'alt' => 'It\'s Kibby!', ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload copy($this->kibbyPath, $this->kibbyPath.'.tmp'); $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply/image", parameters: $comment, files: ['uploadImage' => $image] ); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateImageCommentReplyWithoutScope(): void { $post = $this->createPost('a post'); $postComment = $this->createPostComment('a comment', $post); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.tmp'); $image = new UploadedFile($tmpPath.'.tmp', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply/image", parameters: $comment, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateImageCommentReply(): void { $post = $this->createPost('a post'); $postComment = $this->createPostComment('a comment', $post); $comment = [ 'body' => 'Test comment', 'lang' => 'en', 'isAdult' => false, 'alt' => 'It\'s Kibby!', ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; $expectedPath = $imageManager->getFilePath($image->getFilename()); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply/image", parameters: $comment, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment['body'], $jsonData['body']); self::assertSame($comment['lang'], $jsonData['lang']); self::assertSame($comment['isAdult'], $jsonData['isAdult']); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($post->magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertSame($postComment->getId(), $jsonData['rootId']); self::assertSame($postComment->getId(), $jsonData['parentId']); self::assertIsArray($jsonData['image']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']); self::assertEquals($expectedPath, $jsonData['image']['filePath']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Comment/PostCommentDeleteApiTest.php ================================================ createPost('a post'); $comment = $this->createPostComment('test comment', $post); $this->client->request('DELETE', "/api/post-comments/{$comment->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDeleteCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/post-comments/{$comment->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotDeleteOtherUsersComment(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('other'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user2); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/post-comments/{$comment->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDeleteComment(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); $commentRepository = $this->postCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/post-comments/{$comment->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $comment = $commentRepository->find($comment->getId()); self::assertNull($comment); } public function testApiCanSoftDeleteComment(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); $this->createPostComment('test comment', $post, $user, parent: $comment); $commentRepository = $this->postCommentRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/post-comments/{$comment->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $comment = $commentRepository->find($comment->getId()); self::assertNotNull($comment); self::assertTrue($comment->isSoftDeleted()); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Comment/PostCommentReportApiTest.php ================================================ createPost('a post'); $comment = $this->createPostComment('test comment', $post); $report = [ 'reason' => 'This comment breaks the rules!', ]; $this->client->jsonRequest('POST', "/api/post-comments/{$comment->getId()}/report", $report); self::assertResponseStatusCodeSame(401); } public function testApiCannotReportCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); $report = [ 'reason' => 'This comment breaks the rules!', ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/post-comments/{$comment->getId()}/report", $report, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanReportOtherUsersComment(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('other'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user2); $reportRepository = $this->reportRepository; $report = [ 'reason' => 'This comment breaks the rules!', ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:report'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/post-comments/{$comment->getId()}/report", $report, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $report = $reportRepository->findBySubject($comment); self::assertNotNull($report); self::assertSame('This comment breaks the rules!', $report->reason); self::assertSame($user->getId(), $report->reporting->getId()); } public function testApiCanReportOwnComment(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); $reportRepository = $this->reportRepository; $report = [ 'reason' => 'This comment breaks the rules!', ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:report'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/post-comments/{$comment->getId()}/report", $report, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $report = $reportRepository->findBySubject($comment); self::assertNotNull($report); self::assertSame('This comment breaks the rules!', $report->reason); self::assertSame($user->getId(), $report->reporting->getId()); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Comment/PostCommentRetrieveApiTest.php ================================================ createPost('test post'); for ($i = 0; $i < 5; ++$i) { $this->createPostComment("test parent comment {$i}", $post); } $this->client->request('GET', "/api/posts/{$post->getId()}/comments"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(5, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(5, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($post->getId(), $comment['postId']); self::assertStringContainsString('test parent comment', $comment['body']); self::assertSame('en', $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['favourites']); self::assertSame(0, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertEmpty($comment['mentions']); self::assertIsArray($comment['children']); self::assertEmpty($comment['children']); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertEmpty($comment['tags']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertNull($comment['bookmarks']); } } public function testApiCannotGetPostCommentsByPreferredLangAnonymous(): void { $post = $this->createPost('test post'); for ($i = 0; $i < 5; ++$i) { $this->createPostComment("test parent comment {$i}", $post); } $this->client->request('GET', "/api/posts/{$post->getId()}/comments?usePreferredLangs=true"); self::assertResponseStatusCodeSame(403); } public function testApiCanGetPostCommentsByPreferredLang(): void { $post = $this->createPost('test post'); for ($i = 0; $i < 5; ++$i) { $this->createPostComment("test parent comment {$i}", $post); $this->createPostComment("test german parent comment {$i}", $post, lang: 'de'); $this->createPostComment("test dutch parent comment {$i}", $post, lang: 'nl'); } self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); $user->preferredLanguages = ['en', 'de']; $entityManager = $this->entityManager; $entityManager->persist($user); $entityManager->flush(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/posts/{$post->getId()}/comments?usePreferredLangs=true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(10, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(10, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($post->getId(), $comment['postId']); self::assertStringContainsString('parent comment', $comment['body']); self::assertTrue('en' === $comment['lang'] || 'de' === $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['favourites']); self::assertSame(0, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertEmpty($comment['mentions']); self::assertIsArray($comment['children']); self::assertEmpty($comment['children']); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); // No scope granted so these should be null self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertEmpty($comment['tags']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertIsArray($comment['bookmarks']); self::assertEmpty($comment['bookmarks']); } } public function testApiCanGetPostCommentsWithLanguageAnonymous(): void { $post = $this->createPost('test post'); for ($i = 0; $i < 5; ++$i) { $this->createPostComment("test parent comment {$i}", $post); $this->createPostComment("test german parent comment {$i}", $post, lang: 'de'); $this->createPostComment("test dutch comment {$i}", $post, lang: 'nl'); } $this->client->request('GET', "/api/posts/{$post->getId()}/comments?lang[]=en&lang[]=de"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(10, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(10, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($post->getId(), $comment['postId']); self::assertStringContainsString('parent comment', $comment['body']); self::assertTrue('en' === $comment['lang'] || 'de' === $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['favourites']); self::assertSame(0, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertEmpty($comment['mentions']); self::assertIsArray($comment['children']); self::assertEmpty($comment['children']); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertEmpty($comment['tags']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertNull($comment['bookmarks']); } } public function testApiCanGetPostCommentsWithLanguage(): void { $post = $this->createPost('test post'); for ($i = 0; $i < 5; ++$i) { $this->createPostComment("test parent comment {$i}", $post); $this->createPostComment("test german parent comment {$i}", $post, lang: 'de'); $this->createPostComment("test dutch parent comment {$i}", $post, lang: 'nl'); } self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/posts/{$post->getId()}/comments?lang[]=en&lang[]=de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(10, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(10, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($post->getId(), $comment['postId']); self::assertStringContainsString('parent comment', $comment['body']); self::assertTrue('en' === $comment['lang'] || 'de' === $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['favourites']); self::assertSame(0, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertEmpty($comment['mentions']); self::assertIsArray($comment['children']); self::assertEmpty($comment['children']); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); // No scope granted so these should be null self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertEmpty($comment['tags']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertIsArray($comment['bookmarks']); self::assertEmpty($comment['bookmarks']); } } public function testApiCanGetPostComments(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('test post'); for ($i = 0; $i < 5; ++$i) { $this->createPostComment("test parent comment {$i} #tag @user", $post); } self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/posts/{$post->getId()}/comments", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(5, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(5, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($post->getId(), $comment['postId']); self::assertStringContainsString('test parent comment', $comment['body']); self::assertSame('en', $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['favourites']); self::assertSame(0, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertSame(['@user'], $comment['mentions']); self::assertIsArray($comment['tags']); self::assertSame(['tag'], $comment['tags']); self::assertIsArray($comment['children']); self::assertEmpty($comment['children']); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); // No scope granted so these should be null self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertIsArray($comment['bookmarks']); self::assertEmpty($comment['bookmarks']); } } public function testApiCanGetPostCommentsWithChildren(): void { $post = $this->createPost('test post'); for ($i = 0; $i < 5; ++$i) { $comment = $this->createPostComment("test parent comment {$i}", $post); $this->createPostComment("test child comment {$i}", $post, parent: $comment); } self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/posts/{$post->getId()}/comments", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(5, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(5, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($post->getId(), $comment['postId']); self::assertStringContainsString('test parent comment', $comment['body']); self::assertSame('en', $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['favourites']); self::assertSame(1, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertEmpty($comment['mentions']); self::assertIsArray($comment['children']); self::assertCount(1, $comment['children']); self::assertIsArray($comment['children'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment['children'][0]); self::assertStringContainsString('test child comment', $comment['children'][0]['body']); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); // No scope granted so these should be null self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertEmpty($comment['tags']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertIsArray($comment['bookmarks']); self::assertEmpty($comment['bookmarks']); } } public function testApiCanGetPostCommentsLimitedDepth(): void { $post = $this->createPost('test post'); for ($i = 0; $i < 2; ++$i) { $comment = $this->createPostComment("test parent comment {$i}", $post); $parent = $comment; for ($j = 1; $j <= 5; ++$j) { $parent = $this->createPostComment("test child comment {$i} depth {$j}", $post, parent: $parent); } } self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/posts/{$post->getId()}/comments?d=3", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment); self::assertIsArray($comment['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']); self::assertIsArray($comment['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']); self::assertSame($post->getId(), $comment['postId']); self::assertStringContainsString('test parent comment', $comment['body']); self::assertSame('en', $comment['lang']); self::assertSame(0, $comment['uv']); self::assertSame(0, $comment['favourites']); self::assertSame(5, $comment['childCount']); self::assertSame('visible', $comment['visibility']); self::assertIsArray($comment['mentions']); self::assertEmpty($comment['mentions']); self::assertIsArray($comment['children']); self::assertCount(1, $comment['children']); $depth = 0; $current = $comment; while (\count($current['children']) > 0) { self::assertIsArray($current['children'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $current['children'][0]); self::assertStringContainsString('test child comment', $current['children'][0]['body']); self::assertSame(5 - ($depth + 1), $current['children'][0]['childCount']); $current = $current['children'][0]; ++$depth; } self::assertSame(3, $depth); self::assertFalse($comment['isAdult']); self::assertNull($comment['image']); self::assertNull($comment['parentId']); self::assertNull($comment['rootId']); // No scope granted so these should be null self::assertNull($comment['isFavourited']); self::assertNull($comment['userVote']); self::assertNull($comment['apId']); self::assertEmpty($comment['tags']); self::assertNull($comment['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid'); self::assertIsArray($comment['bookmarks']); self::assertEmpty($comment['bookmarks']); } } public function testApiCanGetPostCommentsNewest(): void { $post = $this->createPost('post'); $first = $this->createPostComment('first', $post); $second = $this->createPostComment('second', $post); $third = $this->createPostComment('third', $post); $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/posts/{$post->getId()}/comments?sort=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['commentId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['commentId']); } public function testApiCanGetPostCommentsOldest(): void { $post = $this->createPost('post'); $first = $this->createPostComment('first', $post); $second = $this->createPostComment('second', $post); $third = $this->createPostComment('third', $post); $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/posts/{$post->getId()}/comments?sort=oldest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['commentId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['commentId']); } public function testApiCanGetPostCommentsActive(): void { $post = $this->createPost('post'); $first = $this->createPostComment('first', $post); $second = $this->createPostComment('second', $post); $third = $this->createPostComment('third', $post); $first->lastActive = new \DateTime('-1 hour'); $second->lastActive = new \DateTime('-1 second'); $third->lastActive = new \DateTime(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/posts/{$post->getId()}/comments?sort=active", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['commentId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['commentId']); } public function testApiCanGetPostCommentsHot(): void { $post = $this->createPost('post'); $first = $this->createPostComment('first', $post); $second = $this->createPostComment('second', $post); $third = $this->createPostComment('third', $post); $voteManager = $this->voteManager; $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false); $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false); $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/posts/{$post->getId()}/comments?sort=hot", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['commentId']); self::assertSame(2, $jsonData['items'][0]['uv']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertSame(1, $jsonData['items'][1]['uv']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['commentId']); self::assertSame(0, $jsonData['items'][2]['uv']); } public function testApiCanGetPostCommentByIdAnonymous(): void { $post = $this->createPost('test post'); $comment = $this->createPostComment('test parent comment', $post); $this->client->request('GET', "/api/post-comments/{$comment->getId()}"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($post->getId(), $jsonData['postId']); self::assertStringContainsString('test parent comment', $jsonData['body']); self::assertSame('en', $jsonData['lang']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['favourites']); self::assertSame(0, $jsonData['childCount']); self::assertSame('visible', $jsonData['visibility']); self::assertIsArray($jsonData['mentions']); self::assertEmpty($jsonData['mentions']); self::assertIsArray($jsonData['children']); self::assertEmpty($jsonData['children']); self::assertFalse($jsonData['isAdult']); self::assertNull($jsonData['image']); self::assertNull($jsonData['parentId']); self::assertNull($jsonData['rootId']); self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertNull($jsonData['apId']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertNull($jsonData['bookmarks']); } public function testApiCanGetPostCommentById(): void { $post = $this->createPost('test post'); $comment = $this->createPostComment('test parent comment', $post); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/post-comments/{$comment->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($post->getId(), $jsonData['postId']); self::assertStringContainsString('test parent comment', $jsonData['body']); self::assertSame('en', $jsonData['lang']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['favourites']); self::assertSame(0, $jsonData['childCount']); self::assertSame('visible', $jsonData['visibility']); self::assertIsArray($jsonData['mentions']); self::assertEmpty($jsonData['mentions']); self::assertIsArray($jsonData['children']); self::assertEmpty($jsonData['children']); self::assertFalse($jsonData['isAdult']); self::assertNull($jsonData['image']); self::assertNull($jsonData['parentId']); self::assertNull($jsonData['rootId']); // No scope granted so these should be null self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertNull($jsonData['apId']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertIsArray($jsonData['bookmarks']); self::assertEmpty($jsonData['bookmarks']); } public function testApiCanGetPostCommentByIdWithDepth(): void { $post = $this->createPost('test post'); $comment = $this->createPostComment('test parent comment', $post); $parent = $comment; for ($i = 0; $i < 5; ++$i) { $parent = $this->createPostComment('test nested reply', $post, parent: $parent); } self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/post-comments/{$comment->getId()}?d=2", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($post->getId(), $jsonData['postId']); self::assertStringContainsString('test parent comment', $jsonData['body']); self::assertSame('en', $jsonData['lang']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['favourites']); self::assertSame(5, $jsonData['childCount']); self::assertSame('visible', $jsonData['visibility']); self::assertIsArray($jsonData['mentions']); self::assertEmpty($jsonData['mentions']); self::assertIsArray($jsonData['children']); self::assertCount(1, $jsonData['children']); self::assertFalse($jsonData['isAdult']); self::assertNull($jsonData['image']); self::assertNull($jsonData['parentId']); self::assertNull($jsonData['rootId']); // No scope granted so these should be null self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertNull($jsonData['apId']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertIsArray($jsonData['bookmarks']); self::assertEmpty($jsonData['bookmarks']); $depth = 0; $current = $jsonData; while (\count($current['children']) > 0) { self::assertIsArray($current['children'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $current['children'][0]); ++$depth; $current = $current['children'][0]; } self::assertSame(2, $depth); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Comment/PostCommentUpdateApiTest.php ================================================ createPost('a post'); $comment = $this->createPostComment('test comment', $post); $update = [ 'body' => 'updated body', 'lang' => 'de', 'isAdult' => true, ]; $this->client->jsonRequest('PUT', "/api/post-comments/{$comment->getId()}", $update); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); $update = [ 'body' => 'updated body', 'lang' => 'de', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post-comments/{$comment->getId()}", $update, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateOtherUsersComment(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('other'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user2); $update = [ 'body' => 'updated body', 'lang' => 'de', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post-comments/{$comment->getId()}", $update, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateComment(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); $parent = $comment; for ($i = 0; $i < 5; ++$i) { $parent = $this->createPostComment('test reply', $post, $user, parent: $parent); } $update = [ 'body' => 'updated body', 'lang' => 'de', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post-comments/{$comment->getId()}?d=2", $update, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame($comment->getId(), $jsonData['commentId']); self::assertSame($update['body'], $jsonData['body']); self::assertSame($update['lang'], $jsonData['lang']); self::assertSame($update['isAdult'], $jsonData['isAdult']); self::assertSame(5, $jsonData['childCount']); $depth = 0; $current = $jsonData; while (\count($current['children']) > 0) { self::assertIsArray($current['children'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $current['children'][0]); ++$depth; $current = $current['children'][0]; } self::assertSame(2, $depth); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Comment/PostCommentVoteApiTest.php ================================================ createPost('a post'); $comment = $this->createPostComment('test comment', $post); $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/vote/1"); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpvoteCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/vote/1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpvoteComment(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/vote/1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame(1, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertSame(1, $jsonData['userVote']); self::assertFalse($jsonData['isFavourited']); } public function testApiCannotDownvoteCommentAnonymous(): void { $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post); $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/vote/-1"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDownvoteCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/vote/-1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotDownvoteComment(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/vote/-1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); } public function testApiCannotRemoveVoteCommentAnonymous(): void { $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post); $voteManager = $this->voteManager; $voteManager->vote(1, $comment, $this->getUserByUsername('user'), rateLimit: false); $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/vote/0"); self::assertResponseStatusCodeSame(401); } public function testApiCannotRemoveVoteCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); $voteManager = $this->voteManager; $voteManager->vote(1, $comment, $user, rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/vote/0", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRemoveVoteComment(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); $voteManager = $this->voteManager; $voteManager->vote(1, $comment, $user, rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/vote/0", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertSame(0, $jsonData['userVote']); self::assertFalse($jsonData['isFavourited']); } public function testApiCannotFavouriteCommentAnonymous(): void { $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post); $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/favourite"); self::assertResponseStatusCodeSame(401); } public function testApiCannotFavouriteCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/favourite", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanFavouriteComment(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/favourite", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(1, $jsonData['favourites']); self::assertSame(0, $jsonData['userVote']); self::assertTrue($jsonData['isFavourited']); } public function testApiCannotUnfavouriteCommentWithoutScope(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); $favouriteManager = $this->favouriteManager; $favouriteManager->toggle($user, $comment); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/favourite", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUnfavouriteComment(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $comment = $this->createPostComment('test comment', $post, $user); $favouriteManager = $this->favouriteManager; $favouriteManager->toggle($user, $comment); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('PUT', "/api/post-comments/{$comment->getId()}/favourite", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertSame(0, $jsonData['userVote']); self::assertFalse($jsonData['isFavourited']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Comment/PostCommentsActivityApiTest.php ================================================ getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); $comment = $this->createPostComment('test comment', $post, $user); $this->client->jsonRequest('GET', "/api/post-comments/{$comment->getId()}/activity"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData); self::assertSame([], $jsonData['boosts']); self::assertSame([], $jsonData['upvotes']); self::assertSame(null, $jsonData['downvotes']); } public function testUpvotes() { $author = $this->getUserByUsername('userA'); $user1 = $this->getUserByUsername('user1'); $user2 = $this->getUserByUsername('user2'); $this->getUserByUsername('user3'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $author, magazine: $magazine); $comment = $this->createPostComment('test comment', $post, $author); $this->favouriteManager->toggle($user1, $comment); $this->favouriteManager->toggle($user2, $comment); $this->client->jsonRequest('GET', "/api/post-comments/{$comment->getId()}/activity"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData); self::assertSame([], $jsonData['boosts']); self::assertSame(null, $jsonData['downvotes']); self::assertCount(2, $jsonData['upvotes']); self::assertTrue(array_all($jsonData['upvotes'], function ($u) use ($user1, $user2) { /* @var UserSmallResponseDto $u */ return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId(); }), serialize($jsonData['upvotes'])); } public function testBoosts() { $author = $this->getUserByUsername('userA'); $user1 = $this->getUserByUsername('user1'); $user2 = $this->getUserByUsername('user2'); $this->getUserByUsername('user3'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $author, magazine: $magazine); $comment = $this->createPostComment('test comment', $post, $author); $this->voteManager->upvote($comment, $user1); $this->voteManager->upvote($comment, $user2); $this->client->jsonRequest('GET', "/api/post-comments/{$comment->getId()}/activity"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData); self::assertSame([], $jsonData['upvotes']); self::assertSame(null, $jsonData['downvotes']); self::assertCount(2, $jsonData['boosts']); self::assertTrue(array_all($jsonData['boosts'], function ($u) use ($user1, $user2) { /* @var UserSmallResponseDto $u */ return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId(); }), serialize($jsonData['boosts'])); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Comment/UserPostCommentRetrieveApiTest.php ================================================ createPost('a post'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $post = $this->createPost('another post', magazine: $magazine); $comment = $this->createPostComment('test comment', $post); $user = $post->user; $this->client->request('GET', "/api/users/{$user->getId()}/post-comments"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('test comment', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertSame(0, $jsonData['items'][0]['childCount']); self::assertIsArray($jsonData['items'][0]['children']); self::assertEmpty($jsonData['items'][0]['children']); self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); } public function testApiCanGetUserPostComments(): void { $this->createPost('a post'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $post = $this->createPost('another post', magazine: $magazine); $comment = $this->createPostComment('test comment', $post); $user = $post->user; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/post-comments", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('test comment', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertSame(0, $jsonData['items'][0]['childCount']); self::assertIsArray($jsonData['items'][0]['children']); self::assertEmpty($jsonData['items'][0]['children']); self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); } public function testApiCanGetUserPostCommentsDepth(): void { $this->createPost('a post'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $post = $this->createPost('another post', magazine: $magazine); $comment = $this->createPostComment('test comment', $post); $nested1 = $this->createPostComment('test comment nested 1', $post, parent: $comment); $nested2 = $this->createPostComment('test comment nested 2', $post, parent: $nested1); $nested3 = $this->createPostComment('test comment nested 3', $post, parent: $nested2); $user = $post->user; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/post-comments?d=2", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(4, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(4, $jsonData['pagination']['count']); foreach ($jsonData['items'] as $comment) { self::assertIsArray($comment); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment); self::assertTrue(\count($comment['children']) <= 1); $depth = 0; $current = $comment; while (\count($current['children']) > 0) { ++$depth; $current = $current['children'][0]; self::assertIsArray($current); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $current); } self::assertTrue($depth <= 2); } } public function testApiCanGetUserPostCommentsNewest(): void { $post = $this->createPost('post'); $first = $this->createPostComment('first', $post); $second = $this->createPostComment('second', $post); $third = $this->createPostComment('third', $post); $user = $post->user; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/post-comments?sort=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['commentId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['commentId']); } public function testApiCanGetUserPostCommentsOldest(): void { $post = $this->createPost('post'); $first = $this->createPostComment('first', $post); $second = $this->createPostComment('second', $post); $third = $this->createPostComment('third', $post); $user = $post->user; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/post-comments?sort=oldest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['commentId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['commentId']); } public function testApiCanGetUserPostCommentsActive(): void { $post = $this->createPost('post'); $first = $this->createPostComment('first', $post); $second = $this->createPostComment('second', $post); $third = $this->createPostComment('third', $post); $user = $post->user; $first->lastActive = new \DateTime('-1 hour'); $second->lastActive = new \DateTime('-1 second'); $third->lastActive = new \DateTime(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/post-comments?sort=active", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['commentId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['commentId']); } public function testApiCanGetUserPostCommentsHot(): void { $post = $this->createPost('post'); $first = $this->createPostComment('first', $post); $second = $this->createPostComment('second', $post); $third = $this->createPostComment('third', $post); $user = $post->user; $voteManager = $this->voteManager; $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false); $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false); $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/post-comments?sort=hot", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['commentId']); self::assertSame(2, $jsonData['items'][0]['uv']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['commentId']); self::assertSame(1, $jsonData['items'][1]['uv']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['commentId']); self::assertSame(0, $jsonData['items'][2]['uv']); } public function testApiCanGetUserPostCommentsWithUserVoteStatus(): void { $this->createPost('a post'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $post = $this->createPost('another post', magazine: $magazine); $comment = $this->createPostComment('test comment', $post); $user = $post->user; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$user->getId()}/post-comments", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); self::assertEquals('test comment', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['image']); self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertSame(0, $jsonData['items'][0]['childCount']); self::assertIsArray($jsonData['items'][0]['children']); self::assertEmpty($jsonData['items'][0]['children']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertFalse($jsonData['items'][0]['isFavourited']); self::assertSame(0, $jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertNull($jsonData['items'][0]['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/MagazinePostRetrieveApiTest.php ================================================ createPost('a post'); $this->createPostComment('up the ranking', $post); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->createPost('another post', magazine: $magazine); $this->client->request('GET', "/api/magazine/{$magazine->getId()}/posts"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('another post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertSame(0, $jsonData['items'][0]['comments']); } public function testApiCanGetMagazinePosts(): void { $post = $this->createPost('a post'); $this->createPostComment('up the ranking', $post); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->createPost('another post', magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/posts", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('another post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertSame(0, $jsonData['items'][0]['comments']); } public function testApiCanGetMagazinePostsPinnedFirst(): void { $voteManager = $this->voteManager; $postManager = $this->postManager; $voter = $this->getUserByUsername('voter'); $first = $this->createPost('a post'); $this->createPostComment('up the ranking', $first); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $second = $this->createPost('another post', magazine: $magazine); // Upvote and comment on $second so it should come first, but then pin $third so it actually comes first $voteManager->vote(1, $second, $voter, rateLimit: false); $this->createPostComment('test', $second, $voter); $third = $this->createPost('a pinned post', magazine: $magazine); $postManager->pin($third); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/posts", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('a pinned post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertSame(0, $jsonData['items'][0]['comments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertTrue($jsonData['items'][0]['isPinned']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another post', $jsonData['items'][1]['body']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertSame(1, $jsonData['items'][1]['comments']); self::assertSame(1, $jsonData['items'][1]['uv']); self::assertFalse($jsonData['items'][1]['isPinned']); } public function testApiCanGetMagazinePostsNewest(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $third = $this->createPost('third'); $magazine = $first->magazine; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/posts?sort=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['postId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['postId']); } public function testApiCanGetMagazinePostsOldest(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $third = $this->createPost('third'); $magazine = $first->magazine; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/posts?sort=oldest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['postId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['postId']); } public function testApiCanGetMagazinePostsCommented(): void { $first = $this->createPost('first'); $this->createPostComment('comment 1', $first); $this->createPostComment('comment 2', $first); $second = $this->createPost('second'); $this->createPostComment('comment 1', $second); $third = $this->createPost('third'); $magazine = $first->magazine; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/posts?sort=commented", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['postId']); self::assertSame(2, $jsonData['items'][0]['comments']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertSame(1, $jsonData['items'][1]['comments']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['postId']); self::assertSame(0, $jsonData['items'][2]['comments']); } public function testApiCanGetMagazinePostsActive(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $third = $this->createPost('third'); $magazine = $first->magazine; $first->lastActive = new \DateTime('-1 hour'); $second->lastActive = new \DateTime('-1 second'); $third->lastActive = new \DateTime(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/posts?sort=active", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['postId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['postId']); } public function testApiCanGetMagazinePostsTop(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $third = $this->createPost('third'); $magazine = $first->magazine; $voteManager = $this->voteManager; $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false); $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false); $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/posts?sort=top", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['postId']); self::assertSame(2, $jsonData['items'][0]['uv']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertSame(1, $jsonData['items'][1]['uv']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['postId']); self::assertSame(0, $jsonData['items'][2]['uv']); } public function testApiCanGetMagazinePostsWithUserVoteStatus(): void { $first = $this->createPost('an post'); $this->createPostComment('up the ranking', $first); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $post = $this->createPost('another post', magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/magazine/{$magazine->getId()}/posts", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); self::assertEquals('another post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['image']); self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertNull($jsonData['items'][0]['mentions']); self::assertSame(0, $jsonData['items'][0]['comments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertFalse($jsonData['items'][0]['isFavourited']); self::assertSame(0, $jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('another-post', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Moderate/PostLockApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/lock"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotLockPost(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user2, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:lock'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotLockPostWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $post = $this->createPost('test article', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanLockPost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $post = $this->createPost('test article', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:lock'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($post->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertTrue($jsonData['isLocked']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiAuthorNonModeratorCanLockPost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:lock'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($post->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertTrue($jsonData['isLocked']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotUnlockPostAnonymous(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', magazine: $magazine); $postManager = $this->postManager; $postManager->toggleLock($post, $user); $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/lock"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotUnpinPost(): void { $user = $this->getUserByUsername('user'); $user2 = $this->getUserByUsername('user2'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user2, magazine: $magazine); $postManager = $this->postManager; $postManager->toggleLock($post, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:lock'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUnpinPostWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $post = $this->createPost('test article', user: $user, magazine: $magazine); $postManager = $this->postManager; $postManager->toggleLock($post, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUnpinPost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $post = $this->createPost('test article', user: $user, magazine: $magazine); $postManager = $this->postManager; $postManager->toggleLock($post, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:lock'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($post->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertFalse($jsonData['isLocked']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiAuthorNonModeratorCanUnpinPost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user, magazine: $magazine); $postManager = $this->postManager; $postManager->toggleLock($post, $user); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:lock'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/lock", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($post->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertFalse($jsonData['isLocked']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Moderate/PostPinApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/pin"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotPinPost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:pin'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/pin", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotPinPostWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $post = $this->createPost('test article', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/pin", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanPinPost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $post = $this->createPost('test article', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:pin'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/pin", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($post->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertTrue($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotUnpinPostAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', magazine: $magazine); $postManager = $this->postManager; $postManager->pin($post); $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/pin"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotUnpinPost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user, magazine: $magazine); $postManager = $this->postManager; $postManager->pin($post); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:pin'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/pin", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUnpinPostWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $post = $this->createPost('test article', user: $user, magazine: $magazine); $postManager = $this->postManager; $postManager->pin($post); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/pin", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUnpinPost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $post = $this->createPost('test article', user: $user, magazine: $magazine); $postManager = $this->postManager; $postManager->pin($post); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:pin'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/pin", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($post->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Moderate/PostSetAdultApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/adult/true"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotSetPostAdult(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/adult/true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotSetPostAdultWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $post = $this->createPost('test article', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/adult/true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanSetPostAdult(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/adult/true", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($post->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertTrue($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('visible', $jsonData['visibility']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotSetPostNotAdultAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', magazine: $magazine); $entityManager = $this->entityManager; $post->isAdult = true; $entityManager->persist($post); $entityManager->flush(); $this->client->request('PUT', "/api/moderate/post/{$post->getId()}/adult/false"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotSetPostNotAdult(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user, magazine: $magazine); $entityManager = $this->entityManager; $post->isAdult = true; $entityManager->persist($post); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/adult/false", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotSetPostNotAdultWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $post = $this->createPost('test article', user: $user, magazine: $magazine); $entityManager = $this->entityManager; $post->isAdult = true; $entityManager->persist($post); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/adult/false", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanSetPostNotAdult(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); $entityManager = $this->entityManager; $post->isAdult = true; $entityManager->persist($post); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:set_adult'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/adult/false", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($post->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('visible', $jsonData['visibility']); self::assertEquals('test-article', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Moderate/PostSetLanguageApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/de"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotSetPostLanguage(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:language'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotSetPostLanguageWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $post = $this->createPost('test post', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotSetPostLanguageInvalid(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:language'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/fake", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/ac", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/aaa", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/a", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); } public function testApiCanSetPostLanguage(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:language'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/de", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals('de', $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('visible', $jsonData['visibility']); self::assertEquals('test-post', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCanSetPostLanguage3Letter(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:language'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/elx", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals('elx', $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('visible', $jsonData['visibility']); self::assertEquals('test-post', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/Moderate/PostTrashApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/trash"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotTrashPost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotTrashPostWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $post = $this->createPost('test post', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanTrashPost(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/trash", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($post->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('trashed', $jsonData['visibility']); self::assertEquals('test-post', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotRestorePostAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user'); $post = $this->createPost('test post', magazine: $magazine); $postManager = $this->postManager; $postManager->trash($user, $post); $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/restore"); self::assertResponseStatusCodeSame(401); } public function testApiNonModeratorCannotRestorePost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); $postManager = $this->postManager; $postManager->trash($user, $post); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/restore", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRestorePostWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme', $user); $post = $this->createPost('test post', user: $user, magazine: $magazine); $postManager = $this->postManager; $postManager->trash($user, $post); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/restore", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanRestorePost(): void { $user = $this->getUserByUsername('user'); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); $magazineManager = $this->magazineManager; $moderator = new ModeratorDto($magazine); $moderator->user = $user; $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); $postManager = $this->postManager; $postManager->trash($user, $post); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:trash'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/moderate/post/{$post->getId()}/restore", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($post->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('visible', $jsonData['visibility']); self::assertEquals('test-post', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/PostCreateApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $postRequest = [ 'body' => 'This is a microblog', 'lang' => 'en', 'isAdult' => false, ]; $this->client->jsonRequest('POST', "/api/magazine/{$magazine->getId()}/posts", parameters: $postRequest); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreatePostWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $postRequest = [ 'body' => 'No scope post', 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/magazine/{$magazine->getId()}/posts", parameters: $postRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanCreatePost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $postRequest = [ 'body' => 'This is a microblog #test @user', 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/magazine/{$magazine->getId()}/posts", parameters: $postRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertNotNull($jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals('This is a microblog #test @user', $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals('en', $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame(['test'], $jsonData['tags']); self::assertIsArray($jsonData['mentions']); self::assertSame(['@user'], $jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertNull($jsonData['apId']); self::assertEquals('This-is-a-microblog-test-at-user', $jsonData['slug']); } public function testApiCannotCreateImagePostAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $postRequest = [ 'alt' => 'It\'s kibby!', 'lang' => 'en', 'isAdult' => false, ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload copy($this->kibbyPath, $this->kibbyPath.'.tmp'); $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/posts/image", parameters: $postRequest, files: ['uploadImage' => $image], ); self::assertResponseStatusCodeSame(401); } public function testApiCannotCreateImagePostWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $postRequest = [ 'alt' => 'It\'s kibby!', 'lang' => 'en', 'isAdult' => false, ]; // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/posts/image", parameters: $postRequest, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(403); } public function testApiCanCreateImagePost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $postRequest = [ 'alt' => 'It\'s kibby!', 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; $expectedPath = $imageManager->getFilePath($image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/posts/image", parameters: $postRequest, files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $token] ); self::assertResponseStatusCodeSame(201); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertNotNull($jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals('', $jsonData['body']); self::assertIsArray($jsonData['image']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']); self::assertStringContainsString($expectedPath, $jsonData['image']['filePath']); self::assertEquals('It\'s kibby!', $jsonData['image']['altText']); self::assertEquals('en', $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertNull($jsonData['apId']); self::assertEquals('acme-It-s-kibby', $jsonData['slug']); } public function testApiCannotCreatePostWithoutMagazine(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $invalidId = $magazine->getId() + 1; $postRequest = [ 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/magazine/{$invalidId}/posts", parameters: $postRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(404); $this->client->request('POST', "/api/magazine/{$invalidId}/posts/image", parameters: $postRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(404); } public function testApiCannotCreatePostWithoutBodyOrImage(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $postRequest = [ 'lang' => 'en', 'isAdult' => false, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:create'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/magazine/{$magazine->getId()}/posts", parameters: $postRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); $this->client->request('POST', "/api/magazine/{$magazine->getId()}/posts/image", parameters: $postRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); } } ================================================ FILE: tests/Functional/Controller/Api/Post/PostDeleteApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost(body: 'test for deletion', magazine: $magazine); $this->client->request('DELETE', "/api/post/{$post->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDeletePostWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost(body: 'test for deletion', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/post/{$post->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotDeleteOtherUsersPost(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost(body: 'test for deletion', user: $otherUser, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/post/{$post->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDeletePost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost(body: 'test for deletion', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/post/{$post->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); } public function testApiCannotDeleteImagePostAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', imageDto: $imageDto, magazine: $magazine); $this->client->request('DELETE', "/api/post/{$post->getId()}"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDeleteImagePostWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', imageDto: $imageDto, user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/post/{$post->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotDeleteOtherUsersImagePost(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', imageDto: $imageDto, user: $otherUser, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/post/{$post->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanDeleteImagePost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', imageDto: $imageDto, user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:delete'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('DELETE', "/api/post/{$post->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); } } ================================================ FILE: tests/Functional/Controller/Api/Post/PostFavouriteApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test for favourite', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}/favourite"); self::assertResponseStatusCodeSame(401); } public function testApiCannotFavouritePostWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}/favourite", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanFavouritePost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test for favourite', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}/favourite", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($post->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(1, $jsonData['favourites']); self::assertTrue($jsonData['isFavourited']); self::assertSame(0, $jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('test-for-favourite', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/PostReportApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test for report', magazine: $magazine); $reportRequest = [ 'reason' => 'Test reporting', ]; $this->client->jsonRequest('POST', "/api/post/{$post->getId()}/report", $reportRequest); self::assertResponseStatusCodeSame(401); } public function testApiCannotReportPostWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test for report', user: $user, magazine: $magazine); $reportRequest = [ 'reason' => 'Test reporting', ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/post/{$post->getId()}/report", $reportRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanReportPost(): void { $user = $this->getUserByUsername('user'); $otherUser = $this->getUserByUsername('somebody'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test for report', user: $otherUser, magazine: $magazine); $reportRequest = [ 'reason' => 'Test reporting', ]; $magazineRepository = $this->magazineRepository; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:report'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('POST', "/api/post/{$post->getId()}/report", $reportRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(204); $magazine = $magazineRepository->find($magazine->getId()); $reports = $magazineRepository->findReports($magazine); self::assertSame(1, $reports->count()); /** @var Report $report */ $report = $reports->getCurrentPageResults()[0]; self::assertEquals('Test reporting', $report->reason); self::assertSame($user->getId(), $report->reporting->getId()); self::assertSame($otherUser->getId(), $report->reported->getId()); self::assertSame($post->getId(), $report->getSubject()->getId()); } } ================================================ FILE: tests/Functional/Controller/Api/Post/PostRetrieveApiTest.php ================================================ client->request('GET', '/api/posts/subscribed'); self::assertResponseStatusCodeSame(401); } public function testApiCannotGetSubscribedPostsWithoutScope(): void { self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'write'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetSubscribedPosts(): void { $user = $this->getUserByUsername('user'); $this->createPost('a post'); $magazine = $this->getMagazineByNameNoRSAKey('somemag', $user); $post = $this->createPost('another post', magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); self::assertEquals('another post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['image']); self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertNull($jsonData['items'][0]['mentions']); self::assertSame(0, $jsonData['items'][0]['comments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('another-post', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); } public function testApiCanGetSubscribedPostsWithBoosts(): void { $user = $this->getUserByUsername('user'); $userFollowing = $this->getUserByUsername('user2'); $user3 = $this->getUserByUsername('user3'); $this->userManager->follow($user, $userFollowing, false); $postFollowed = $this->createPost('a post', user: $userFollowing); $postBoosted = $this->createPost('third user post', user: $user3); $this->createPost('unrelated post', user: $user3); $commentFollowed = $this->createPostComment('a comment', $postBoosted, $userFollowing); $commentBoosted = $this->createPostComment('a boosted comment', $postBoosted, $user3); $this->createPostComment('unrelated comment', $postBoosted, $user3); $this->voteManager->upvote($postBoosted, $userFollowing); $this->voteManager->upvote($commentBoosted, $userFollowing); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts/subscribedWithBoosts', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(4, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(4, $jsonData['pagination']['count']); $retrievedPostIds = array_map(function ($item) { if (null !== $item['post']) { self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $item['post']); return $item['post']['postId']; } else { return null; } }, $jsonData['items']); $retrievedPostIds = array_filter($retrievedPostIds, function ($item) { return null !== $item; }); sort($retrievedPostIds); $retrievedPostCommentIds = array_map(function ($item) { if (null !== $item['postComment']) { self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $item['postComment']); return $item['postComment']['commentId']; } else { return null; } }, $jsonData['items']); $retrievedPostCommentIds = array_filter($retrievedPostCommentIds, function ($item) { return null !== $item; }); sort($retrievedPostCommentIds); $expectedPostIds = [$postFollowed->getId(), $postBoosted->getId()]; sort($expectedPostIds); $expectedPostCommentIds = [$commentFollowed->getId(), $commentBoosted->getId()]; sort($expectedPostCommentIds); self::assertEquals($retrievedPostIds, $expectedPostIds); self::assertEquals($expectedPostCommentIds, $expectedPostCommentIds); } public function testApiCannotGetModeratedPostsAnonymous(): void { $this->client->request('GET', '/api/posts/moderated'); self::assertResponseStatusCodeSame(401); } public function testApiCannotGetModeratedPostsWithoutScope(): void { self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts/moderated', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetModeratedPosts(): void { $user = $this->getUserByUsername('user'); $this->createPost('a post'); $magazine = $this->getMagazineByNameNoRSAKey('somemag', $user); $post = $this->createPost('another post', magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts/moderated', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertEquals('another post', $jsonData['items'][0]['body']); self::assertNull($jsonData['items'][0]['image']); self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertNull($jsonData['items'][0]['mentions']); self::assertSame(0, $jsonData['items'][0]['comments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('another-post', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); } public function testApiCannotGetFavouritedPostsAnonymous(): void { $this->client->request('GET', '/api/posts/favourited'); self::assertResponseStatusCodeSame(401); } public function testApiCannotGetFavouritedPostsWithoutScope(): void { self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts/favourited', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetFavouritedPosts(): void { $user = $this->getUserByUsername('user'); $post = $this->createPost('a post'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->createPost('another post', magazine: $magazine); $favouriteManager = $this->favouriteManager; $favouriteManager->toggle($user, $post); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts/favourited', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); self::assertEquals('a post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['image']); self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertNull($jsonData['items'][0]['mentions']); self::assertSame(0, $jsonData['items'][0]['comments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(1, $jsonData['items'][0]['favourites']); // No scope for seeing votes granted self::assertTrue($jsonData['items'][0]['isFavourited']); self::assertSame(0, $jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('a-post', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); } public function testApiCanGetPostsAnonymous(): void { $post = $this->createPost('a post'); $this->createPostComment('up the ranking', $post); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $second = $this->createPost('another post', magazine: $magazine); // Check that pinned posts don't get pinned to the top of the instance, just the magazine $postManager = $this->postManager; $postManager->pin($second); $this->client->request('GET', '/api/posts'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); self::assertEquals('a post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['image']); self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertNull($jsonData['items'][0]['mentions']); self::assertSame(1, $jsonData['items'][0]['comments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('a-post', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertNull($jsonData['items'][0]['bookmarks']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another post', $jsonData['items'][1]['body']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertSame(0, $jsonData['items'][1]['comments']); } public function testApiCanGetPosts(): void { $post = $this->createPost('a post'); $this->createPostComment('up the ranking', $post); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->createPost('another post', magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); self::assertEquals('a post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['image']); self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertNull($jsonData['items'][0]['mentions']); self::assertSame(1, $jsonData['items'][0]['comments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('a-post', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another post', $jsonData['items'][1]['body']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertSame(0, $jsonData['items'][1]['comments']); } public function testApiCanGetPostsWithLanguageAnonymous(): void { $post = $this->createPost('a post'); $this->createPostComment('up the ranking', $post); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $second = $this->createPost('another post', magazine: $magazine, lang: 'de'); $this->createPost('a dutch post', magazine: $magazine, lang: 'nl'); // Check that pinned posts don't get pinned to the top of the instance, just the magazine $postManager = $this->postManager; $postManager->pin($second); $this->client->request('GET', '/api/posts?lang[]=en&lang[]=de'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); self::assertEquals('a post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['image']); self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertNull($jsonData['items'][0]['mentions']); self::assertSame(1, $jsonData['items'][0]['comments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('a-post', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertNull($jsonData['items'][0]['bookmarks']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another post', $jsonData['items'][1]['body']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertEquals('de', $jsonData['items'][1]['lang']); self::assertSame(0, $jsonData['items'][1]['comments']); } public function testApiCanGetPostsWithLanguage(): void { $post = $this->createPost('a post'); $this->createPostComment('up the ranking', $post); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->createPost('another post', magazine: $magazine, lang: 'de'); $this->createPost('a dutch post', magazine: $magazine, lang: 'nl'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts?lang[]=en&lang[]=de', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); self::assertEquals('a post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['image']); self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertNull($jsonData['items'][0]['mentions']); self::assertSame(1, $jsonData['items'][0]['comments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('a-post', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another post', $jsonData['items'][1]['body']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertEquals('de', $jsonData['items'][1]['lang']); self::assertSame(0, $jsonData['items'][1]['comments']); } public function testApiCannotGetPostsByPreferredLangAnonymous(): void { $post = $this->createPost('a post'); $this->createPostComment('up the ranking', $post); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $second = $this->createPost('another post', magazine: $magazine); // Check that pinned posts don't get pinned to the top of the instance, just the magazine $postManager = $this->postManager; $postManager->pin($second); $this->client->request('GET', '/api/posts?usePreferredLangs=true'); self::assertResponseStatusCodeSame(403); } public function testApiCanGetPostsByPreferredLang(): void { $post = $this->createPost('a post'); $this->createPostComment('up the ranking', $post); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->createPost('another post', magazine: $magazine); $this->createPost('German post', lang: 'de'); $user = $this->getUserByUsername('user'); $user->preferredLanguages = ['en']; $entityManager = $this->entityManager; $entityManager->persist($user); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts?usePreferredLangs=true', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); self::assertEquals('a post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['image']); self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertNull($jsonData['items'][0]['mentions']); self::assertSame(1, $jsonData['items'][0]['comments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['items'][0]['isFavourited']); self::assertNull($jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('a-post', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another post', $jsonData['items'][1]['body']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertEquals('en', $jsonData['items'][1]['lang']); self::assertSame(0, $jsonData['items'][1]['comments']); } public function testApiCanGetPostsNewest(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $third = $this->createPost('third'); $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts?sort=newest', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['postId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['postId']); } public function testApiCanGetPostsOldest(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $third = $this->createPost('third'); $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts?sort=oldest', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['postId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['postId']); } public function testApiCanGetPostsCommented(): void { $first = $this->createPost('first'); $this->createPostComment('comment 1', $first); $this->createPostComment('comment 2', $first); $second = $this->createPost('second'); $this->createPostComment('comment 1', $second); $third = $this->createPost('third'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts?sort=commented', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['postId']); self::assertSame(2, $jsonData['items'][0]['comments']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertSame(1, $jsonData['items'][1]['comments']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['postId']); self::assertSame(0, $jsonData['items'][2]['comments']); } public function testApiCanGetPostsActive(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $third = $this->createPost('third'); $first->lastActive = new \DateTime('-1 hour'); $second->lastActive = new \DateTime('-1 second'); $third->lastActive = new \DateTime(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts?sort=active', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['postId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['postId']); } public function testApiCanGetPostsTop(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $third = $this->createPost('third'); $voteManager = $this->voteManager; $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false); $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false); $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts?sort=top', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['postId']); self::assertSame(2, $jsonData['items'][0]['uv']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertSame(1, $jsonData['items'][1]['uv']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['postId']); self::assertSame(0, $jsonData['items'][2]['uv']); } public function testApiCanGetPostsWithUserVoteStatus(): void { $post = $this->createPost('a post'); $this->createPostComment('up the ranking', $post); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $this->createPost('another post', magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); self::assertEquals('a post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertNull($jsonData['items'][0]['image']); self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertSame(1, $jsonData['items'][0]['comments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertFalse($jsonData['items'][0]['isFavourited']); self::assertSame(0, $jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('a-post', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); self::assertIsArray($jsonData['items'][0]['bookmarks']); self::assertEmpty($jsonData['items'][0]['bookmarks']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertEquals('another post', $jsonData['items'][1]['body']); self::assertIsArray($jsonData['items'][1]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']); self::assertSame(0, $jsonData['items'][1]['comments']); } public function testApiCanGetPostByIdAnonymous(): void { $post = $this->createPost('a post'); $this->client->request('GET', "/api/post/{$post->getId()}"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertEquals('a post', $jsonData['body']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertNull($jsonData['image']); self::assertEquals('en', $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('a-post', $jsonData['slug']); self::assertNull($jsonData['apId']); self::assertNull($jsonData['bookmarks']); } public function testApiCanGetPostById(): void { $post = $this->createPost('a post'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/post/{$post->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertEquals('a post', $jsonData['body']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertNull($jsonData['image']); self::assertEquals('en', $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('a-post', $jsonData['slug']); self::assertNull($jsonData['apId']); self::assertIsArray($jsonData['bookmarks']); self::assertEmpty($jsonData['bookmarks']); } public function testApiCanGetPostByIdWithUserVoteStatus(): void { $post = $this->createPost('a post'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/post/{$post->getId()}", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertEquals('a post', $jsonData['body']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertNull($jsonData['image']); self::assertEquals('en', $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); self::assertFalse($jsonData['isFavourited']); self::assertSame(0, $jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); // This API creates a view when used self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('a-post', $jsonData['slug']); self::assertNull($jsonData['apId']); self::assertIsArray($jsonData['bookmarks']); self::assertEmpty($jsonData['bookmarks']); } public function testApiCanGetPostsLocal(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $second->apId = 'https://some.url'; $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts?federation=local', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['postId']); } public function testApiCanGetPostsFederated(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $second->apId = 'https://some.url'; $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', '/api/posts?federation=federated', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($second->getId(), $jsonData['items'][0]['postId']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/PostUpdateApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', magazine: $magazine); $updateRequest = [ 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}", $updateRequest); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdatePostWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user, magazine: $magazine); $updateRequest = [ 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateOtherUsersPost(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $otherUser, magazine: $magazine); $updateRequest = [ 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdatePost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test article', user: $user, magazine: $magazine); $updateRequest = [ 'body' => 'Updated #body @user', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($updateRequest['body'], $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($updateRequest['lang'], $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame(['body'], $jsonData['tags']); self::assertIsArray($jsonData['mentions']); self::assertSame(['@user'], $jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertTrue($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['editedAt'], 'editedAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('Updated-body-at-user', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotUpdateImagePostAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', imageDto: $imageDto, magazine: $magazine); $updateRequest = [ 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}", $updateRequest); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpdateImagePostWithoutScope(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $user = $this->getUserByUsername('user'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', imageDto: $imageDto, user: $user, magazine: $magazine); $updateRequest = [ 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateOtherUsersImagePost(): void { $otherUser = $this->getUserByUsername('somebody'); $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', imageDto: $imageDto, user: $otherUser, magazine: $magazine); $updateRequest = [ 'body' => 'Updated body', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } #[Group(name: 'NonThreadSafe')] public function testApiCanUpdateImagePost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $imageDto = $this->getKibbyImageDto(); $post = $this->createPost('test image', imageDto: $imageDto, user: $user, magazine: $magazine); $updateRequest = [ 'body' => 'Updated #body @user', 'lang' => 'nl', 'isAdult' => true, ]; self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($updateRequest['body'], $jsonData['body']); self::assertIsArray($jsonData['image']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']); self::assertStringContainsString($imageDto->filePath, $jsonData['image']['filePath']); self::assertEquals($updateRequest['lang'], $jsonData['lang']); self::assertIsArray($jsonData['tags']); self::assertSame(['body'], $jsonData['tags']); self::assertIsArray($jsonData['mentions']); self::assertSame(['@user'], $jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertNull($jsonData['isFavourited']); self::assertNull($jsonData['userVote']); self::assertTrue($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['editedAt'], 'editedAt date format invalid'); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('Updated-body-at-user', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/PostVoteApiTest.php ================================================ getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}/vote/1"); self::assertResponseStatusCodeSame(401); } public function testApiCannotUpvotePostWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}/vote/1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpvotePost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}/vote/1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($post->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(1, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertFalse($jsonData['isFavourited']); self::assertSame(1, $jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('test-post', $jsonData['slug']); self::assertNull($jsonData['apId']); } public function testApiCannotDownvotePostAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}/vote/-1"); self::assertResponseStatusCodeSame(401); } public function testApiCannotDownvotePostWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}/vote/-1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCannotDownvotePost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}/vote/-1", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(400); } public function testApiCannotClearVotePostAnonymous(): void { $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', magazine: $magazine); $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}/vote/0"); self::assertResponseStatusCodeSame(401); } public function testApiCannotClearVotePostWithoutScope(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); $voteManager = $this->voteManager; $voteManager->vote(1, $post, $user, rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}/vote/0", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testApiCanClearVotePost(): void { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); $voteManager = $this->voteManager; $voteManager->vote(1, $post, $user, rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('PUT', "/api/post/{$post->getId()}/vote/0", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData); self::assertSame($post->getId(), $jsonData['postId']); self::assertIsArray($jsonData['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']); self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']); self::assertIsArray($jsonData['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']); self::assertSame($user->getId(), $jsonData['user']['userId']); self::assertEquals($post->body, $jsonData['body']); self::assertNull($jsonData['image']); self::assertEquals($post->lang, $jsonData['lang']); self::assertEmpty($jsonData['tags']); self::assertNull($jsonData['mentions']); self::assertSame(0, $jsonData['comments']); self::assertSame(0, $jsonData['uv']); self::assertSame(0, $jsonData['dv']); self::assertSame(0, $jsonData['favourites']); // No scope for seeing votes granted self::assertFalse($jsonData['isFavourited']); self::assertSame(0, $jsonData['userVote']); self::assertFalse($jsonData['isAdult']); self::assertFalse($jsonData['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid'); self::assertEquals('test-post', $jsonData['slug']); self::assertNull($jsonData['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Post/PostsActivityApiTest.php ================================================ getUserByUsername('user'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $user, magazine: $magazine); $this->client->jsonRequest('GET', "/api/post/{$post->getId()}/activity"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData); self::assertSame([], $jsonData['boosts']); self::assertSame([], $jsonData['upvotes']); self::assertSame(null, $jsonData['downvotes']); } public function testUpvotes() { $author = $this->getUserByUsername('userA'); $user1 = $this->getUserByUsername('user1'); $user2 = $this->getUserByUsername('user2'); $this->getUserByUsername('user3'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $author, magazine: $magazine); $this->favouriteManager->toggle($user1, $post); $this->favouriteManager->toggle($user2, $post); $this->client->jsonRequest('GET', "/api/post/{$post->getId()}/activity"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData); self::assertSame([], $jsonData['boosts']); self::assertSame(null, $jsonData['downvotes']); self::assertCount(2, $jsonData['upvotes']); self::assertTrue(array_all($jsonData['upvotes'], function ($u) use ($user1, $user2) { /* @var UserSmallResponseDto $u */ return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId(); }), serialize($jsonData['upvotes'])); } public function testBoosts() { $author = $this->getUserByUsername('userA'); $user1 = $this->getUserByUsername('user1'); $user2 = $this->getUserByUsername('user2'); $this->getUserByUsername('user3'); $magazine = $this->getMagazineByNameNoRSAKey('acme'); $post = $this->createPost('test post', user: $author, magazine: $magazine); $this->voteManager->upvote($post, $user1); $this->voteManager->upvote($post, $user2); $this->client->jsonRequest('GET', "/api/post/{$post->getId()}/activity"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData); self::assertSame([], $jsonData['upvotes']); self::assertSame(null, $jsonData['downvotes']); self::assertCount(2, $jsonData['boosts']); self::assertTrue(array_all($jsonData['boosts'], function ($u) use ($user1, $user2) { /* @var UserSmallResponseDto $u */ return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId(); }), serialize($jsonData['boosts'])); } } ================================================ FILE: tests/Functional/Controller/Api/Post/UserPostRetrieveApiTest.php ================================================ createPost('a post'); $this->createPostComment('up the ranking', $post); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $otherUser = $this->getUserByUsername('somebody'); $this->createPost('another post', magazine: $magazine, user: $otherUser); $this->client->request('GET', "/api/users/{$otherUser->getId()}/posts"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('another post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertSame($otherUser->getId(), $jsonData['items'][0]['user']['userId']); self::assertSame(0, $jsonData['items'][0]['comments']); } public function testApiCanGetUserEntries(): void { $post = $this->createPost('a post'); $this->createPostComment('up the ranking', $post); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $otherUser = $this->getUserByUsername('somebody'); $this->createPost('another post', magazine: $magazine, user: $otherUser); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/posts", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals('another post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertSame($otherUser->getId(), $jsonData['items'][0]['user']['userId']); self::assertSame(0, $jsonData['items'][0]['comments']); } public function testApiCanGetUserEntriesNewest(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $third = $this->createPost('third'); $otherUser = $first->user; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/posts?sort=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['postId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['postId']); } public function testApiCanGetUserEntriesOldest(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $third = $this->createPost('third'); $otherUser = $first->user; $first->createdAt = new \DateTimeImmutable('-1 hour'); $second->createdAt = new \DateTimeImmutable('-1 second'); $third->createdAt = new \DateTimeImmutable(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/posts?sort=oldest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['postId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['postId']); } public function testApiCanGetUserEntriesCommented(): void { $first = $this->createPost('first'); $this->createPostComment('comment 1', $first); $this->createPostComment('comment 2', $first); $second = $this->createPost('second'); $this->createPostComment('comment 1', $second); $third = $this->createPost('third'); $otherUser = $first->user; self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/posts?sort=commented", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['postId']); self::assertSame(2, $jsonData['items'][0]['comments']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertSame(1, $jsonData['items'][1]['comments']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['postId']); self::assertSame(0, $jsonData['items'][2]['comments']); } public function testApiCanGetUserEntriesActive(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $third = $this->createPost('third'); $otherUser = $first->user; $first->lastActive = new \DateTime('-1 hour'); $second->lastActive = new \DateTime('-1 second'); $third->lastActive = new \DateTime(); $entityManager = $this->entityManager; $entityManager->persist($first); $entityManager->persist($second); $entityManager->persist($third); $entityManager->flush(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/posts?sort=active", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($third->getId(), $jsonData['items'][0]['postId']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($first->getId(), $jsonData['items'][2]['postId']); } public function testApiCanGetUserEntriesTop(): void { $first = $this->createPost('first'); $second = $this->createPost('second'); $third = $this->createPost('third'); $otherUser = $first->user; $voteManager = $this->voteManager; $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false); $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false); $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/posts?sort=top", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(3, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($first->getId(), $jsonData['items'][0]['postId']); self::assertSame(2, $jsonData['items'][0]['uv']); self::assertIsArray($jsonData['items'][1]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]); self::assertSame($second->getId(), $jsonData['items'][1]['postId']); self::assertSame(1, $jsonData['items'][1]['uv']); self::assertIsArray($jsonData['items'][2]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]); self::assertSame($third->getId(), $jsonData['items'][2]['postId']); self::assertSame(0, $jsonData['items'][2]['uv']); } public function testApiCanGetUserEntriesWithUserVoteStatus(): void { $this->createPost('a post'); $otherUser = $this->getUserByUsername('somebody'); $magazine = $this->getMagazineByNameNoRSAKey('somemag'); $post = $this->createPost('another post', magazine: $magazine, user: $otherUser); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->request('GET', "/api/users/{$otherUser->getId()}/posts", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($post->getId(), $jsonData['items'][0]['postId']); self::assertEquals('another post', $jsonData['items'][0]['body']); self::assertIsArray($jsonData['items'][0]['magazine']); self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']); self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']); self::assertIsArray($jsonData['items'][0]['user']); self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']); self::assertSame($otherUser->getId(), $jsonData['items'][0]['user']['userId']); self::assertNull($jsonData['items'][0]['image']); self::assertEquals('en', $jsonData['items'][0]['lang']); self::assertEmpty($jsonData['items'][0]['tags']); self::assertNull($jsonData['items'][0]['mentions']); self::assertSame(0, $jsonData['items'][0]['comments']); self::assertSame(0, $jsonData['items'][0]['uv']); self::assertSame(0, $jsonData['items'][0]['dv']); self::assertSame(0, $jsonData['items'][0]['favourites']); self::assertFalse($jsonData['items'][0]['isFavourited']); self::assertSame(0, $jsonData['items'][0]['userVote']); self::assertFalse($jsonData['items'][0]['isAdult']); self::assertFalse($jsonData['items'][0]['isPinned']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid'); self::assertNull($jsonData['items'][0]['editedAt']); self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid'); self::assertEquals('another-post', $jsonData['items'][0]['slug']); self::assertNull($jsonData['items'][0]['apId']); } } ================================================ FILE: tests/Functional/Controller/Api/Search/SearchApiTest.php ================================================ someUser = $this->getUserByUsername('JohnDoe2', email: 'jd@test.tld'); $this->someMagazine = $this->getMagazineByName('acme2', $this->someUser); } public function setUpRemoteEntities(): void { $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, function (Entry $entry) { $this->testEntryUrl = 'https://remote.mbin/m/someremotemagazine/t/'.$entry->getId(); }); } protected function setUpRemoteActors(): void { parent::setUpRemoteActors(); $this->remoteUser = $this->getUserByUsername(self::TEST_USER_NAME, addImage: false); $this->registerActor($this->remoteUser, $this->remoteDomain, true); $this->remoteMagazine = $this->getMagazineByName(self::TEST_MAGAZINE_NAME); $this->registerActor($this->remoteMagazine, $this->remoteDomain, true); } public function testApiCannotSearchWithNoQuery(): void { $this->client->request('GET', '/api/search/v2'); self::assertResponseStatusCodeSame(400); } public function testApiCanFindEntryByTitleAnonymous(): void { $entry = $this->getEntryByTitle('A test title to search for', magazine: $this->someMagazine, user: $this->someUser); $this->getEntryByTitle('Cannot find this', magazine: $this->someMagazine, user: $this->someUser); $this->client->request('GET', '/api/search/v2?q=title'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::validateResponseOuterData($jsonData, 1, 0); self::validateResponseItemData($jsonData['items'][0], 'entry', $entry->getId()); } public function testApiCanFindContentByBodyAnonymous(): void { $entry = $this->getEntryByTitle('A test title to search for', body: 'This is the body we\'re finding', magazine: $this->someMagazine, user: $this->someUser); $this->getEntryByTitle('Cannot find this', body: 'No keywords here!', magazine: $this->someMagazine, user: $this->someUser); $post = $this->createPost('Lets get a post with its body in there too!', magazine: $this->someMagazine, user: $this->someUser); $this->createPost('But not this one.', magazine: $this->someMagazine, user: $this->someUser); $this->client->request('GET', '/api/search/v2?q=body'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::validateResponseOuterData($jsonData, 2, 0); foreach ($jsonData['items'] as $item) { if (null !== $item['entry']) { $type = 'entry'; $id = $entry->getId(); } else { $type = 'post'; $id = $post->getId(); } self::validateResponseItemData($item, $type, $id); } } public function testApiCanFindCommentsByBodyAnonymous(): void { $entry = $this->getEntryByTitle('Cannot find this', body: 'No keywords here!', magazine: $this->someMagazine, user: $this->someUser); $post = $this->createPost('But not this one.', magazine: $this->someMagazine, user: $this->someUser); $entryComment = $this->createEntryComment('Some comment on a thread', $entry, user: $this->someUser); $postComment = $this->createPostComment('Some comment on a post', $post, user: $this->someUser); $this->client->request('GET', '/api/search/v2?q=comment'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::validateResponseOuterData($jsonData, 2, 0); foreach ($jsonData['items'] as $item) { if (null !== $item['entryComment']) { $type = 'entryComment'; $id = $entryComment->getId(); } else { $type = 'postComment'; $id = $postComment->getId(); } self::validateResponseItemData($item, $type, $id); } } public function testApiCannotFindRemoteUserAnonymousWhenOptionSet(): void { $settingsManager = $this->settingsManager; $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN'); $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', true); $this->client->request('GET', '/api/search/v2?q='.self::TEST_USER_HANDLE); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::validateResponseOuterData($jsonData, 0, 0); // Seems like settings can persist in the test environment? Might only be for bare metal setups $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); } public function testApiCannotFindRemoteMagazineAnonymousWhenOptionSet(): void { $settingsManager = $this->settingsManager; $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN'); $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', true); $this->client->request('GET', '/api/search/v2?q='.self::TEST_MAGAZINE_HANDLE); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::validateResponseOuterData($jsonData, 0, 0); // Seems like settings can persist in the test environment? Might only be for bare metal setups $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); } public function testApiCanFindRemoteUserByHandleAnonymous(): void { $settingsManager = $this->settingsManager; $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN'); $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', false); $this->getUserByUsername('test'); $this->client->request('GET', '/api/search/v2?q=@'.self::TEST_USER_HANDLE); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::validateResponseOuterData($jsonData, 0, 1); self::validateResponseItemData($jsonData['apResults'][0], 'user', null, self::TEST_USER_HANDLE, self::TEST_USER_URL); $this->client->request('GET', '/api/search/v2?q='.self::TEST_USER_HANDLE); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::validateResponseOuterData($jsonData, 1, 1); self::assertSame(self::TEST_USER_URL, $jsonData['items'][0]['user']['apProfileId']); self::validateResponseItemData($jsonData['apResults'][0], 'user', null, self::TEST_USER_HANDLE, self::TEST_USER_URL); // Seems like settings can persist in the test environment? Might only be for bare metal setups. $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); } public function testApiCanFindRemoteMagazineByHandleAnonymous(): void { // Admin user must exist to retrieve a remote magazine since remote mods aren't federated (yet) $this->getUserByUsername('admin', isAdmin: true); $settingsManager = $this->settingsManager; $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN'); $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', false); $this->getMagazineByName('testMag', user: $this->someUser); $this->client->request('GET', '/api/search/v2?q=!'.self::TEST_MAGAZINE_HANDLE); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::validateResponseOuterData($jsonData, 0, 1); self::validateResponseItemData($jsonData['apResults'][0], 'magazine', null, self::TEST_MAGAZINE_HANDLE, self::TEST_MAGAZINE_URL); // Seems like settings can persist in the test environment? Might only be for bare metal setups $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); } public function testApiCanFindRemoteUserByUrl(): void { $settingsManager = $this->settingsManager; $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN'); $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', true); $this->getUserByUsername('test'); $this->client->loginUser($this->localUser); $this->client->request('GET', '/api/search/v2?q='.urlencode(self::TEST_USER_URL)); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::validateResponseOuterData($jsonData, 0, 1); self::validateResponseItemData($jsonData['apResults'][0], 'user', null, self::TEST_USER_HANDLE, self::TEST_USER_URL); // Seems like settings can persist in the test environment? Might only be for bare metal setups $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); } public function testApiCanFindRemoteMagazineByUrl(): void { $this->getUserByUsername('admin', isAdmin: true); $settingsManager = $this->settingsManager; $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN'); $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', true); $this->client->loginUser($this->localUser); $this->getMagazineByName('testMag', user: $this->someUser); $this->client->request('GET', '/api/search/v2?q='.urlencode(self::TEST_MAGAZINE_URL)); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::validateResponseOuterData($jsonData, 0, 1); self::validateResponseItemData($jsonData['apResults'][0], 'magazine', null, self::TEST_MAGAZINE_HANDLE, self::TEST_MAGAZINE_URL); // Seems like settings can persist in the test environment? Might only be for bare metal setups $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); } public function testApiCanFindRemotePostByUrl(): void { $this->getUserByUsername('admin', isAdmin: true); $settingsManager = $this->settingsManager; $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN'); $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', true); $this->client->loginUser($this->localUser); $this->getMagazineByName('testMag', user: $this->someUser); $this->client->request('GET', '/api/search/v2?q='.urlencode($this->testEntryUrl)); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::validateResponseOuterData($jsonData, 0, 1); self::validateResponseItemData($jsonData['apResults'][0], 'entry', null, $this->testEntryUrl); // Seems like settings can persist in the test environment? Might only be for bare metal setups $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); } private static function validateResponseOuterData(array $data, int $expectedLength, int $expectedApLength): void { self::assertIsArray($data); self::assertArrayKeysMatch(self::SEARCH_PAGINATED_KEYS, $data); self::assertIsArray($data['items']); self::assertCount($expectedLength, $data['items']); self::assertIsArray($data['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $data['pagination']); self::assertSame($expectedLength, $data['pagination']['count']); self::assertIsArray($data['apResults']); self::assertCount($expectedApLength, $data['apResults']); } private static function validateResponseItemData(array $data, string $expectedType, ?int $expectedId = null, ?string $expectedApId = null, ?string $apProfileId = null): void { self::assertIsArray($data); self::assertArrayKeysMatch(self::SEARCH_ITEM_KEYS, $data); switch ($expectedType) { case 'entry': self::assertNotNull($data['entry']); self::assertNull($data['entryComment']); self::assertNull($data['post']); self::assertNull($data['postComment']); self::assertNull($data['magazine']); self::assertNull($data['user']); self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $data['entry']); if (null !== $expectedId) { self::assertSame($expectedId, $data['entry']['entryId']); } else { self::assertSame($expectedApId, $data['entry']['apId']); } break; case 'entryComment': self::assertNotNull($data['entryComment']); self::assertNull($data['entry']); self::assertNull($data['post']); self::assertNull($data['postComment']); self::assertNull($data['magazine']); self::assertNull($data['user']); self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $data['entryComment']); if (null !== $expectedId) { self::assertSame($expectedId, $data['entryComment']['commentId']); } else { self::assertSame($expectedApId, $data['entryComment']['apId']); } break; case 'post': self::assertNotNull($data['post']); self::assertNull($data['entry']); self::assertNull($data['entryComment']); self::assertNull($data['postComment']); self::assertNull($data['magazine']); self::assertNull($data['user']); self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $data['post']); if (null !== $expectedId) { self::assertSame($expectedId, $data['post']['postId']); } else { self::assertSame($expectedApId, $data['post']['apId']); } break; case 'postComment': self::assertNotNull($data['postComment']); self::assertNull($data['entry']); self::assertNull($data['entryComment']); self::assertNull($data['post']); self::assertNull($data['magazine']); self::assertNull($data['user']); self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $data['postComment']); if (null !== $expectedId) { self::assertSame($expectedId, $data['postComment']['commentId']); } else { self::assertSame($expectedApId, $data['postComment']['apId']); } break; case 'magazine': self::assertNotNull($data['magazine']); self::assertNull($data['entry']); self::assertNull($data['entryComment']); self::assertNull($data['post']); self::assertNull($data['postComment']); self::assertNull($data['user']); self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $data['magazine']); if (null !== $expectedId) { self::assertSame($expectedId, $data['magazine']['magazineId']); } else { self::assertSame($expectedApId, $data['magazine']['apId']); } if (null !== $apProfileId) { self::assertSame($apProfileId, $data['magazine']['apProfileId']); } break; case 'user': self::assertNotNull($data['user']); self::assertNull($data['entry']); self::assertNull($data['entryComment']); self::assertNull($data['post']); self::assertNull($data['postComment']); self::assertNull($data['magazine']); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $data['user']); if (null !== $expectedId) { self::assertSame($expectedId, $data['user']['userId']); } else { self::assertSame($expectedApId, $data['user']['apId']); } if (null !== $apProfileId) { self::assertSame($apProfileId, $data['user']['apProfileId']); } break; default: throw new \AssertionError(); } } } ================================================ FILE: tests/Functional/Controller/Api/User/Admin/UserBanApiTest.php ================================================ getUserByUsername('UserWithoutAbout', isAdmin: true); $bannedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/ban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); $repository = $this->userRepository; $bannedUser = $repository->find($bannedUser->getId()); self::assertFalse($bannedUser->isBanned); } public function testApiCannotUnbanUserWithoutScope(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $bannedUser = $this->getUserByUsername('JohnDoe'); $this->userManager->ban($bannedUser, $testUser, null); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/unban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); $repository = $this->userRepository; $bannedUser = $repository->find($bannedUser->getId()); self::assertTrue($bannedUser->isBanned); } public function testApiCannotBanUserWithoutAdminAccount(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: false); $bannedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban'); $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/ban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); $repository = $this->userRepository; $bannedUser = $repository->find($bannedUser->getId()); self::assertFalse($bannedUser->isBanned); } public function testApiCannotUnbanUserWithoutAdminAccount(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: false); $bannedUser = $this->getUserByUsername('JohnDoe'); $this->userManager->ban($bannedUser, $testUser, null); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban'); $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/unban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); $repository = $this->userRepository; $bannedUser = $repository->find($bannedUser->getId()); self::assertTrue($bannedUser->isBanned); } public function testApiCanBanUser(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $bannedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban'); $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/ban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(array_merge(self::USER_RESPONSE_KEYS, ['isBanned']), $jsonData); self::assertTrue($jsonData['isBanned']); $repository = $this->userRepository; $bannedUser = $repository->find($bannedUser->getId()); self::assertTrue($bannedUser->isBanned); } public function testApiCanUnbanUser(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $bannedUser = $this->getUserByUsername('JohnDoe'); $this->userManager->ban($bannedUser, $testUser, null); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban'); $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/unban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(array_merge(self::USER_RESPONSE_KEYS, ['isBanned']), $jsonData); self::assertFalse($jsonData['isBanned']); $repository = $this->userRepository; $bannedUser = $repository->find($bannedUser->getId()); self::assertFalse($bannedUser->isBanned); } public function testBanApiReturns404IfUserNotFound(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $bannedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban'); $this->client->request('POST', '/api/admin/users/'.(string) ($bannedUser->getId() * 10).'/ban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(404); $repository = $this->userRepository; $bannedUser = $repository->find($bannedUser->getId()); self::assertFalse($bannedUser->isBanned); } public function testUnbanApiReturns404IfUserNotFound(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $bannedUser = $this->getUserByUsername('JohnDoe'); $this->userManager->ban($bannedUser, $testUser, null); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban'); $this->client->request('POST', '/api/admin/users/'.(string) ($bannedUser->getId() * 10).'/unban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(404); $repository = $this->userRepository; $bannedUser = $repository->find($bannedUser->getId()); self::assertTrue($bannedUser->isBanned); } public function testBanApiReturns401IfTokenNotProvided(): void { $bannedUser = $this->getUserByUsername('JohnDoe'); $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/ban'); self::assertResponseStatusCodeSame(401); } public function testUnbanApiReturns401IfTokenNotProvided(): void { $bannedUser = $this->getUserByUsername('JohnDoe'); $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/unban'); self::assertResponseStatusCodeSame(401); } public function testBanApiIsIdempotent(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $bannedUser = $this->getUserByUsername('JohnDoe'); $this->userManager->ban($bannedUser, $testUser, null); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban'); // Ban user a second time with the API $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/ban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(array_merge(self::USER_RESPONSE_KEYS, ['isBanned']), $jsonData); self::assertTrue($jsonData['isBanned']); $repository = $this->userRepository; $bannedUser = $repository->find($bannedUser->getId()); self::assertTrue($bannedUser->isBanned); } public function testUnbanApiIsIdempotent(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $bannedUser = $this->getUserByUsername('JohnDoe'); // Do not ban user $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban'); $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/unban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(array_merge(self::USER_RESPONSE_KEYS, ['isBanned']), $jsonData); self::assertFalse($jsonData['isBanned']); $repository = $this->userRepository; $bannedUser = $repository->find($bannedUser->getId()); self::assertFalse($bannedUser->isBanned); } } ================================================ FILE: tests/Functional/Controller/Api/User/Admin/UserDeleteApiTest.php ================================================ getUserByUsername('UserWithoutAbout', isAdmin: true); $deletedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request( 'DELETE', '/api/admin/users/'.(string) $deletedUser->getId().'/delete_account', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseStatusCodeSame(403); $repository = $this->userRepository; $deletedUser = $repository->find($deletedUser->getId()); self::assertFalse($deletedUser->isAccountDeleted()); } public function testApiCannotDeleteUserWithoutAdminAccount(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: false); $deletedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:delete'); $this->client->request( 'DELETE', '/api/admin/users/'.(string) $deletedUser->getId().'/delete_account', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseStatusCodeSame(403); $repository = $this->userRepository; $deletedUser = $repository->find($deletedUser->getId()); self::assertFalse($deletedUser->isAccountDeleted()); } public function testApiCanDeleteUser(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $deletedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:delete'); $this->client->request( 'DELETE', '/api/admin/users/'.(string) $deletedUser->getId().'/delete_account', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); $repository = $this->userRepository; $deletedUser = $repository->find($deletedUser->getId()); self::assertTrue($deletedUser->isAccountDeleted()); } public function testDeleteApiReturns404IfUserNotFound(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $deletedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:delete'); $this->client->request( 'DELETE', '/api/admin/users/'.(string) ($deletedUser->getId() * 10).'/delete_account', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseStatusCodeSame(404); $repository = $this->userRepository; $deletedUser = $repository->find($deletedUser->getId()); self::assertFalse($deletedUser->isBanned); } public function testDeleteApiReturns401IfTokenNotProvided(): void { $deletedUser = $this->getUserByUsername('JohnDoe'); $this->client->request('DELETE', '/api/admin/users/'.(string) $deletedUser->getId().'/delete_account'); self::assertResponseStatusCodeSame(401); } public function testDeleteApiIsNotIdempotent(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $deletedUser = $this->getUserByUsername('JohnDoe'); $deleteId = $deletedUser->getId(); $this->userManager->delete($deletedUser); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:delete'); // Ban user a second time with the API $this->client->request( 'DELETE', '/api/admin/users/'.(string) $deleteId.'/delete_account', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseStatusCodeSame(404); $repository = $this->userRepository; $deletedUser = $repository->find($deleteId); self::assertNull($deletedUser); } } ================================================ FILE: tests/Functional/Controller/Api/User/Admin/UserPurgeApiTest.php ================================================ getUserByUsername('UserWithoutAbout', isAdmin: true); $purgedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('DELETE', '/api/admin/users/'.(string) $purgedUser->getId().'/purge_account', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); $repository = $this->userRepository; $purgedUser = $repository->find($purgedUser->getId()); self::assertNotNull($purgedUser); } public function testApiCannotPurgeUserWithoutAdminAccount(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: false); $purgedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:purge'); $this->client->request('DELETE', '/api/admin/users/'.(string) $purgedUser->getId().'/purge_account', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); $repository = $this->userRepository; $purgedUser = $repository->find($purgedUser->getId()); self::assertNotNull($purgedUser); } public function testApiCanPurgeUser(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $purgedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:purge'); $this->client->request('DELETE', '/api/admin/users/'.(string) $purgedUser->getId().'/purge_account', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(204); $repository = $this->userRepository; $purgedUser = $repository->find($purgedUser->getId()); self::assertNull($purgedUser); } public function testPurgeApiReturns404IfUserNotFound(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $purgedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:purge'); $this->client->request('DELETE', '/api/admin/users/'.(string) ($purgedUser->getId() * 10).'/purge_account', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(404); $repository = $this->userRepository; $purgedUser = $repository->find($purgedUser->getId()); self::assertNotNull($purgedUser); } public function testPurgeApiReturns401IfTokenNotProvided(): void { $purgedUser = $this->getUserByUsername('JohnDoe'); $this->client->request('DELETE', '/api/admin/users/'.(string) $purgedUser->getId().'/purge_account'); self::assertResponseStatusCodeSame(401); } } ================================================ FILE: tests/Functional/Controller/Api/User/Admin/UserRetrieveBannedApiTest.php ================================================ getUserByUsername('UserWithoutAbout', isAdmin: true); $bannedUser = $this->getUserByUsername('JohnDoe'); $this->userManager->ban($bannedUser, $testUser, null); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('GET', '/api/admin/users/banned', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } public function testApiCannotRetrieveBannedUsersWithoutAdminAccount(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: false); $bannedUser = $this->getUserByUsername('JohnDoe'); $this->userManager->ban($bannedUser, $testUser, null); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban'); $this->client->request('GET', '/api/admin/users/banned', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveBannedUsers(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $bannedUser = $this->getUserByUsername('JohnDoe'); $this->userManager->ban($bannedUser, $testUser, null); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban'); $this->client->request('GET', '/api/admin/users/banned', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(array_merge(self::USER_RESPONSE_KEYS, ['isBanned']), $jsonData['items'][0]); self::assertSame($bannedUser->getId(), $jsonData['items'][0]['userId']); } } ================================================ FILE: tests/Functional/Controller/Api/User/Admin/UserVerifyApiTest.php ================================================ getUserByUsername('UserWithoutAbout', isAdmin: true); $unverifiedUser = $this->getUserByUsername('JohnDoe', active: false); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('PUT', '/api/admin/users/'.(string) $unverifiedUser->getId().'/verify', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); $repository = $this->userRepository; $unverifiedUser = $repository->find($unverifiedUser->getId()); self::assertFalse($unverifiedUser->isVerified); } public function testApiCannotVerifyUserWithoutAdminAccount(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: false); $unverifiedUser = $this->getUserByUsername('JohnDoe', active: false); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:verify'); $this->client->request('PUT', '/api/admin/users/'.(string) $unverifiedUser->getId().'/verify', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); $repository = $this->userRepository; $unverifiedUser = $repository->find($unverifiedUser->getId()); self::assertFalse($unverifiedUser->isVerified); } public function testApiCanVerifyUser(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $unverifiedUser = $this->getUserByUsername('JohnDoe', active: false); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:verify'); $this->client->request('PUT', '/api/admin/users/'.(string) $unverifiedUser->getId().'/verify', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(200); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(array_merge(self::USER_RESPONSE_KEYS, ['isVerified']), $jsonData); self::assertTrue($jsonData['isVerified']); $repository = $this->userRepository; $unverifiedUser = $repository->find($unverifiedUser->getId()); self::assertTrue($unverifiedUser->isVerified); } public function testVerifyApiReturns404IfUserNotFound(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true); $unverifiedUser = $this->getUserByUsername('JohnDoe', active: false); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:verify'); $this->client->request('PUT', '/api/admin/users/'.(string) ($unverifiedUser->getId() * 10).'/verify', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(404); } public function testVerifyApiReturns401IfTokenNotProvided(): void { $unverifiedUser = $this->getUserByUsername('JohnDoe', active: false); $this->client->request('PUT', '/api/admin/users/'.(string) $unverifiedUser->getId().'/verify'); self::assertResponseStatusCodeSame(401); } } ================================================ FILE: tests/Functional/Controller/Api/User/UserBlockApiTest.php ================================================ getUserByUsername('UserWithoutAbout'); $blockedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('PUT', '/api/users/'.(string) $blockedUser->getId().'/block', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUnblockUserWithoutScope(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $blockedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('PUT', '/api/users/'.(string) $blockedUser->getId().'/unblock', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } #[Group(name: 'NonThreadSafe')] public function testApiCanBlockUser(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $followedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block'); $this->client->request('PUT', '/api/users/'.(string) $followedUser->getId().'/block', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayHasKey('userId', $jsonData); self::assertArrayHasKey('username', $jsonData); self::assertArrayHasKey('about', $jsonData); self::assertArrayHasKey('avatar', $jsonData); self::assertArrayHasKey('cover', $jsonData); self::assertArrayNotHasKey('lastActive', $jsonData); self::assertArrayHasKey('createdAt', $jsonData); self::assertArrayHasKey('followersCount', $jsonData); self::assertArrayHasKey('apId', $jsonData); self::assertArrayHasKey('apProfileId', $jsonData); self::assertArrayHasKey('isBot', $jsonData); self::assertArrayHasKey('isFollowedByUser', $jsonData); self::assertArrayHasKey('isFollowerOfUser', $jsonData); self::assertArrayHasKey('isBlockedByUser', $jsonData); self::assertSame(0, $jsonData['followersCount']); self::assertFalse($jsonData['isFollowedByUser']); self::assertFalse($jsonData['isFollowerOfUser']); self::assertTrue($jsonData['isBlockedByUser']); } #[Group(name: 'NonThreadSafe')] public function testApiCanUnblockUser(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $blockedUser = $this->getUserByUsername('JohnDoe'); $testUser->block($blockedUser); $manager = $this->entityManager; $manager->persist($testUser); $manager->flush(); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block'); $this->client->request('PUT', '/api/users/'.(string) $blockedUser->getId().'/unblock', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayHasKey('userId', $jsonData); self::assertArrayHasKey('username', $jsonData); self::assertArrayHasKey('about', $jsonData); self::assertArrayHasKey('avatar', $jsonData); self::assertArrayHasKey('cover', $jsonData); self::assertArrayNotHasKey('lastActive', $jsonData); self::assertArrayHasKey('createdAt', $jsonData); self::assertArrayHasKey('followersCount', $jsonData); self::assertArrayHasKey('apId', $jsonData); self::assertArrayHasKey('apProfileId', $jsonData); self::assertArrayHasKey('isBot', $jsonData); self::assertArrayHasKey('isFollowedByUser', $jsonData); self::assertArrayHasKey('isFollowerOfUser', $jsonData); self::assertArrayHasKey('isBlockedByUser', $jsonData); self::assertSame(0, $jsonData['followersCount']); self::assertFalse($jsonData['isFollowedByUser']); self::assertFalse($jsonData['isFollowerOfUser']); self::assertFalse($jsonData['isBlockedByUser']); } } ================================================ FILE: tests/Functional/Controller/Api/User/UserContentApiTest.php ================================================ getUserByUsername('JohnDoe'); $dummyUser = $this->getUserByUsername('dummy'); $magazine = $this->getMagazineByName('test'); $entry1 = $this->createEntry('e 1', $magazine, $user); $entry2 = $this->createEntry('e 2', $magazine, $user); $entryDummy = $this->createEntry('dummy', $magazine, $dummyUser); $post1 = $this->createPost('p 1', $magazine, $user); $post2 = $this->createPost('p 2', $magazine, $user); $this->createPost('dummy', $magazine, $dummyUser); $comment1 = $this->createEntryComment('c 1', $entryDummy, $user); $comment2 = $this->createEntryComment('c 2', $entryDummy, $user); $this->createEntryComment('dummy', $entryDummy, $dummyUser); $reply1 = $this->createPostComment('r 1', $post1, $user); $reply2 = $this->createPostComment('r 2', $post1, $user); $this->createPostComment('dummy', $post1, $dummyUser); $this->client->request('GET', "/api/users/{$user->getId()}/content"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(8, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(8, $jsonData['pagination']['count']); self::assertTrue(array_all($jsonData['items'], function ($item) use ($entry1, $entry2, $post1, $post2, $comment1, $comment2, $reply1, $reply2) { return (null !== $item['entry'] && ($item['entry']['entryId'] === $entry1->getId() || $item['entry']['entryId'] === $entry2->getId())) || (null !== $item['post'] && ($item['post']['postId'] === $post1->getId() || $item['post']['postId'] === $post2->getId())) || (null !== $item['entryComment'] && ($item['entryComment']['commentId'] === $comment1->getId() || $item['entryComment']['commentId'] === $comment2->getId())) || (null !== $item['postComment'] && ($item['postComment']['commentId'] === $reply1->getId() || $item['postComment']['commentId'] === $reply2->getId())) ; })); } public function testCanGetUserContentHideAdult() { $user = $this->getUserByUsername('JohnDoe'); $dummyUser = $this->getUserByUsername('dummy'); $magazine = $this->getMagazineByName('test'); $entry1 = $this->createEntry('e 1', $magazine, $user); $entry2 = $this->createEntry('e 2', $magazine, $user); $entryDummy = $this->createEntry('dummy', $magazine, $dummyUser); $post1 = $this->createPost('p 1', $magazine, $user); $post2 = $this->createPost('p 2', $magazine, $user); $this->createPost('dummy', $magazine, $dummyUser); $comment1 = $this->createEntryComment('c 1', $entryDummy, $user); $comment2 = $this->createEntryComment('c 2', $entryDummy, $user); $this->createEntryComment('dummy', $entryDummy, $dummyUser); $reply1 = $this->createPostComment('r 1', $post1, $user); $reply2 = $this->createPostComment('r 2', $post1, $user); $this->createPostComment('dummy', $post1, $dummyUser); $entry2->isAdult = true; $post2->isAdult = true; $comment2->isAdult = true; $reply2->isAdult = true; $this->entityManager->persist($entry2); $this->entityManager->persist($post2); $this->entityManager->persist($comment2); $this->entityManager->persist($reply2); $this->entityManager->flush(); $this->client->request('GET', "/api/users/{$user->getId()}/content?hideAdult=true"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(4, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(4, $jsonData['pagination']['count']); self::assertTrue(array_all($jsonData['items'], function ($item) use ($entry1, $post1, $comment1, $reply1) { return (null !== $item['entry'] && $item['entry']['entryId'] === $entry1->getId()) || (null !== $item['post'] && $item['post']['postId'] === $post1->getId()) || (null !== $item['entryComment'] && $item['entryComment']['commentId'] === $comment1->getId()) || (null !== $item['postComment'] && $item['postComment']['commentId'] === $reply1->getId()) ; })); } public function testCanGetUserBoosts() { $user = $this->getUserByUsername('JohnDoe'); $dummyUser = $this->getUserByUsername('dummy'); $magazine = $this->getMagazineByName('test'); $entry1 = $this->createEntry('e 1', $magazine, $dummyUser); $entry2 = $this->createEntry('e 2', $magazine, $dummyUser); $entryDummy = $this->createEntry('dummy', $magazine, $dummyUser); $post1 = $this->createPost('p 1', $magazine, $dummyUser); $post2 = $this->createPost('p 2', $magazine, $dummyUser); $this->createPost('dummy', $magazine, $dummyUser); $comment1 = $this->createEntryComment('c 1', $entryDummy, $dummyUser); $comment2 = $this->createEntryComment('c 2', $entryDummy, $dummyUser); $this->createEntryComment('dummy', $entryDummy, $dummyUser); $reply1 = $this->createPostComment('r 1', $post1, $dummyUser); $reply2 = $this->createPostComment('r 2', $post1, $dummyUser); $this->createPostComment('dummy', $post1, $dummyUser); $this->voteManager->upvote($entry1, $user); $this->voteManager->upvote($entry2, $user); $this->voteManager->upvote($post1, $user); $this->voteManager->upvote($post2, $user); $this->voteManager->upvote($comment1, $user); $this->voteManager->upvote($comment2, $user); $this->voteManager->upvote($reply1, $user); $this->voteManager->upvote($reply2, $user); $this->client->request('GET', "/api/users/{$user->getId()}/boosts"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(8, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(8, $jsonData['pagination']['count']); self::assertTrue(array_all($jsonData['items'], function ($item) use ($entry1, $entry2, $post1, $post2, $comment1, $comment2, $reply1, $reply2) { return (null !== $item['entry'] && ($item['entry']['entryId'] === $entry1->getId() || $item['entry']['entryId'] === $entry2->getId())) || (null !== $item['post'] && ($item['post']['postId'] === $post1->getId() || $item['post']['postId'] === $post2->getId())) || (null !== $item['entryComment'] && ($item['entryComment']['commentId'] === $comment1->getId() || $item['entryComment']['commentId'] === $comment2->getId())) || (null !== $item['postComment'] && ($item['postComment']['commentId'] === $reply1->getId() || $item['postComment']['commentId'] === $reply2->getId())) ; })); } } ================================================ FILE: tests/Functional/Controller/Api/User/UserFilterListApiTest.php ================================================ getListUserToken(); $this->client->request('GET', '/api/users/filterLists', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $data = self::getJsonResponse($this->client); self::assertArrayHasKey('items', $data); self::assertCount(1, $data['items']); $list = $data['items'][0]; self::assertArrayKeysMatch(self::USER_FILTER_LIST_KEYS, $list); } public function testAnonymousCannotRetrieve(): void { $this->client->request('GET', '/api/users/filterLists'); self::assertResponseStatusCodeSame(401); } public function testUserCanEditList(): void { $token = $this->getListUserToken(); $requestParams = [ 'name' => 'Some new Name', 'expirationDate' => (new \DateTimeImmutable('now - 5 days'))->format(DATE_ATOM), 'feeds' => false, 'profile' => false, 'comments' => false, 'words' => [ [ 'exactMatch' => true, 'word' => 'newWord', ], [ 'exactMatch' => false, 'word' => 'sOmEnEwWoRd', ], ], ]; $this->client->jsonRequest('PUT', '/api/users/filterLists/'.$this->list->getId(), $requestParams, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $data = self::getJsonResponse($this->client); self::assertArrayIsEqualToArrayIgnoringListOfKeys($requestParams, $data, ['id']); } public function testOtherUserCannotEditList(): void { self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->otherUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'user:profile:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $requestParams = [ 'name' => 'Some new Name', 'expirationDate' => null, 'feeds' => false, 'profile' => false, 'comments' => false, 'words' => $this->list->words, ]; $this->client->jsonRequest('PUT', '/api/users/filterLists/'.$this->list->getId(), $requestParams, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } public function testUserCanDeleteList(): void { $token = $this->getListUserToken(); $this->client->jsonRequest('DELETE', '/api/users/filterLists/'.$this->list->getId(), server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $freshList = $this->entityManager->getRepository(UserFilterList::class)->find($this->list->getId()); self::assertNull($freshList); } public function testOtherUserCannotDeleteList(): void { self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->otherUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'user:profile:edit'); $token = $codes['token_type'].' '.$codes['access_token']; $this->client->jsonRequest('DELETE', '/api/users/filterLists/'.$this->list->getId(), server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); $freshList = $this->entityManager->getRepository(UserFilterList::class)->find($this->list->getId()); self::assertNotNull($freshList); } public function testFilteredHomePage(): void { $token = $this->getListUserToken(); $this->deactivateFilterList($token); $entry = $this->getEntryByTitle('Cringe entry', body: 'some entry'); $entry2 = $this->getEntryByTitle('Some entry', body: 'some entry'); $entry2->createdAt = new \DateTimeImmutable('now - 1 minutes'); $entry3 = $this->getEntryByTitle('Some other entry', body: 'some entry with a cringe body'); $entry3->createdAt = new \DateTimeImmutable('now - 2 minutes'); $post = $this->createPost('Cringe body'); $post->createdAt = new \DateTimeImmutable('now - 3 minutes'); $post2 = $this->createPost('Body with a cringe text'); $post2->createdAt = new \DateTimeImmutable('now - 4 minutes'); $post3 = $this->createPost('Some post'); $post3->createdAt = new \DateTimeImmutable('now - 5 minutes'); $this->entityManager->flush(); $this->client->jsonRequest('GET', '/api/combined?sort=newest', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $data = self::getJsonResponse($this->client); self::assertIsArray($data); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $data); self::assertIsArray($data['items']); self::assertCount(6, $data['items']); self::assertEquals($entry->getId(), $data['items'][0]['entry']['entryId']); self::assertEquals($entry2->getId(), $data['items'][1]['entry']['entryId']); self::assertEquals($entry3->getId(), $data['items'][2]['entry']['entryId']); self::assertEquals($post->getId(), $data['items'][3]['post']['postId']); self::assertEquals($post2->getId(), $data['items'][4]['post']['postId']); self::assertEquals($post3->getId(), $data['items'][5]['post']['postId']); // activate list $this->activateFilterList($token); $this->client->jsonRequest('GET', '/api/combined?sortBy=newest', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $data = self::getJsonResponse($this->client); self::assertIsArray($data); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $data); self::assertIsArray($data['items']); self::assertCount(2, $data['items']); self::assertEquals($entry2->getId(), $data['items'][0]['entry']['entryId']); self::assertEquals($post3->getId(), $data['items'][1]['post']['postId']); } public function testFilteredHomePageExact(): void { $token = $this->getListUserToken(); $this->deactivateFilterList($token); $entry = $this->getEntryByTitle('TEST entry', body: 'some entry'); $entry2 = $this->getEntryByTitle('Some entry', body: 'some test entry'); $entry2->createdAt = new \DateTimeImmutable('now - 1 minutes'); $entry3 = $this->getEntryByTitle('Some other entry', body: 'some entry with a TEST body'); $entry3->createdAt = new \DateTimeImmutable('now - 2 minutes'); $post = $this->createPost('TEST body'); $post->createdAt = new \DateTimeImmutable('now - 3 minutes'); $post2 = $this->createPost('Body with a TEST text'); $post2->createdAt = new \DateTimeImmutable('now - 4 minutes'); $post3 = $this->createPost('Some test post'); $post3->createdAt = new \DateTimeImmutable('now - 5 minutes'); $this->entityManager->flush(); $this->client->jsonRequest('GET', '/api/combined?sort=newest', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $data = self::getJsonResponse($this->client); self::assertIsArray($data); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $data); self::assertIsArray($data['items']); self::assertCount(6, $data['items']); self::assertEquals($entry->getId(), $data['items'][0]['entry']['entryId']); self::assertEquals($entry2->getId(), $data['items'][1]['entry']['entryId']); self::assertEquals($entry3->getId(), $data['items'][2]['entry']['entryId']); self::assertEquals($post->getId(), $data['items'][3]['post']['postId']); self::assertEquals($post2->getId(), $data['items'][4]['post']['postId']); self::assertEquals($post3->getId(), $data['items'][5]['post']['postId']); $this->activateFilterList($token); $this->client->jsonRequest('GET', '/api/combined?sortBy=newest', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $data = self::getJsonResponse($this->client); self::assertIsArray($data); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $data); self::assertIsArray($data['items']); self::assertCount(2, $data['items']); self::assertEquals($entry2->getId(), $data['items'][0]['entry']['entryId']); self::assertEquals($post3->getId(), $data['items'][1]['post']['postId']); } public function testFilteredEntryComments(): void { $token = $this->getListUserToken(); $entry = $this->getEntryByTitle('Some Entry'); $comment1 = $this->createEntryComment('Some normal comment', $entry); $comment1->createdAt = new \DateTimeImmutable('now - 1 minutes'); $subComment1 = $this->createEntryComment('Some sub comment', $entry, parent: $comment1); $subComment1->createdAt = new \DateTimeImmutable('now - 1 minutes'); $subComment2 = $this->createEntryComment('Some Cringe sub comment', $entry, parent: $comment1); $subComment2->createdAt = new \DateTimeImmutable('now - 2 minutes'); $subComment3 = $this->createEntryComment('Some other Cringe sub comment', $entry, parent: $comment1); $subComment3->createdAt = new \DateTimeImmutable('now - 3 minutes'); $comment2 = $this->createEntryComment('Some cringe comment', $entry); $comment2->createdAt = new \DateTimeImmutable('now - 2 minutes'); $comment3 = $this->createEntryComment('Some other Cringe comment', $entry); $comment3->createdAt = new \DateTimeImmutable('now - 3 minutes'); $this->entityManager->flush(); $this->deactivateFilterList($token); $this->client->request('GET', "/api/entry/{$entry->getId()}/comments?sortBy=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']); self::assertEquals($comment2->getId(), $jsonData['items'][1]['commentId']); self::assertEquals($comment3->getId(), $jsonData['items'][2]['commentId']); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items'][0]['children']); self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']); self::assertEquals($subComment2->getId(), $jsonData['items'][0]['children'][1]['commentId']); self::assertEquals($subComment3->getId(), $jsonData['items'][0]['children'][2]['commentId']); $this->activateFilterList($token); $this->client->request('GET', "/api/entry/{$entry->getId()}/comments?sortBy=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']); self::assertCount(1, $jsonData['items'][0]['children']); self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']); } public function testFilteredEntryCommentsExact(): void { $token = $this->getListUserToken(); $entry = $this->getEntryByTitle('Some Entry'); $comment1 = $this->createEntryComment('Some normal comment', $entry); $comment1->createdAt = new \DateTimeImmutable('now - 1 minutes'); $subComment1 = $this->createEntryComment('Some sub comment', $entry, parent: $comment1); $subComment1->createdAt = new \DateTimeImmutable('now - 1 minutes'); $subComment2 = $this->createEntryComment('Some TEST sub comment', $entry, parent: $comment1); $subComment2->createdAt = new \DateTimeImmutable('now - 2 minutes'); $subComment3 = $this->createEntryComment('Some other test sub comment', $entry, parent: $comment1); $subComment3->createdAt = new \DateTimeImmutable('now - 3 minutes'); $comment2 = $this->createEntryComment('Some TEST comment', $entry); $comment2->createdAt = new \DateTimeImmutable('now - 2 minutes'); $comment3 = $this->createEntryComment('Some other test comment', $entry); $comment3->createdAt = new \DateTimeImmutable('now - 3 minutes'); $this->entityManager->flush(); $this->deactivateFilterList($token); $this->client->request('GET', "/api/entry/{$entry->getId()}/comments?sortBy=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']); self::assertEquals($comment2->getId(), $jsonData['items'][1]['commentId']); self::assertEquals($comment3->getId(), $jsonData['items'][2]['commentId']); self::assertCount(3, $jsonData['items'][0]['children']); self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']); self::assertEquals($subComment2->getId(), $jsonData['items'][0]['children'][1]['commentId']); self::assertEquals($subComment3->getId(), $jsonData['items'][0]['children'][2]['commentId']); $this->activateFilterList($token); $this->client->request('GET', "/api/entry/{$entry->getId()}/comments?sortBy=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']); self::assertCount(2, $jsonData['items'][0]['children']); self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']); self::assertEquals($subComment3->getId(), $jsonData['items'][0]['children'][1]['commentId']); } public function testFilteredPostComments(): void { $token = $this->getListUserToken(); $post = $this->createPost('Some Post'); $comment1 = $this->createPostComment('Some normal comment', $post); $comment1->createdAt = new \DateTimeImmutable('now - 1 minutes'); $subComment1 = $this->createPostComment('Some sub comment', $post, parent: $comment1); $subComment1->createdAt = new \DateTimeImmutable('now - 1 minutes'); $subComment2 = $this->createPostComment('Some Cringe sub comment', $post, parent: $comment1); $subComment2->createdAt = new \DateTimeImmutable('now - 2 minutes'); $subComment3 = $this->createPostComment('Some other Cringe sub comment', $post, parent: $comment1); $subComment3->createdAt = new \DateTimeImmutable('now - 3 minutes'); $comment2 = $this->createPostComment('Some cringe comment', $post); $comment2->createdAt = new \DateTimeImmutable('now - 2 minutes'); $comment3 = $this->createPostComment('Some other Cringe comment', $post); $comment3->createdAt = new \DateTimeImmutable('now - 3 minutes'); $this->entityManager->flush(); $this->deactivateFilterList($token); $this->client->request('GET', "/api/posts/{$post->getId()}/comments?sortBy=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']); self::assertEquals($comment2->getId(), $jsonData['items'][1]['commentId']); self::assertEquals($comment3->getId(), $jsonData['items'][2]['commentId']); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items'][0]['children']); self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']); self::assertEquals($subComment2->getId(), $jsonData['items'][0]['children'][1]['commentId']); self::assertEquals($subComment3->getId(), $jsonData['items'][0]['children'][2]['commentId']); $this->activateFilterList($token); $this->client->request('GET', "/api/posts/{$post->getId()}/comments?sortBy=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(1, $jsonData['items']); self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']); self::assertCount(1, $jsonData['items'][0]['children']); self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']); } public function testFilteredPostCommentsExact(): void { $token = $this->getListUserToken(); $post = $this->createPost('Some Post'); $comment1 = $this->createPostComment('Some normal comment', $post); $comment1->createdAt = new \DateTimeImmutable('now - 1 minutes'); $subComment1 = $this->createPostComment('Some sub comment', $post, parent: $comment1); $subComment1->createdAt = new \DateTimeImmutable('now - 1 minutes'); $subComment2 = $this->createPostComment('Some TEST sub comment', $post, parent: $comment1); $subComment2->createdAt = new \DateTimeImmutable('now - 2 minutes'); $subComment3 = $this->createPostComment('Some other test sub comment', $post, parent: $comment1); $subComment3->createdAt = new \DateTimeImmutable('now - 3 minutes'); $comment2 = $this->createPostComment('Some TEST comment', $post); $comment2->createdAt = new \DateTimeImmutable('now - 2 minutes'); $comment3 = $this->createPostComment('Some other test comment', $post); $comment3->createdAt = new \DateTimeImmutable('now - 3 minutes'); $this->entityManager->flush(); $this->deactivateFilterList($token); $this->client->request('GET', "/api/posts/{$post->getId()}/comments?sortBy=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(3, $jsonData['items']); self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']); self::assertEquals($comment2->getId(), $jsonData['items'][1]['commentId']); self::assertEquals($comment3->getId(), $jsonData['items'][2]['commentId']); self::assertCount(3, $jsonData['items'][0]['children']); self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']); self::assertEquals($subComment2->getId(), $jsonData['items'][0]['children'][1]['commentId']); self::assertEquals($subComment3->getId(), $jsonData['items'][0]['children'][2]['commentId']); $this->activateFilterList($token); $this->client->request('GET', "/api/posts/{$post->getId()}/comments?sortBy=newest", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']); self::assertCount(2, $jsonData['items'][0]['children']); self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']); self::assertEquals($subComment3->getId(), $jsonData['items'][0]['children'][1]['commentId']); } public function testFilteredProfile(): void { $token = $this->getListUserToken(); $otherUser = $this->userRepository->findOneByUsername('otherUser'); $magazine = $this->getMagazineByName('someMag'); $entry = $this->createEntry('Some Entry', $magazine, $otherUser); $entry->createdAt = new \DateTimeImmutable('now - 10 minutes'); $entryComment1 = $this->createEntryComment('Some comment', $entry, user: $otherUser); $entryComment1->createdAt = new \DateTimeImmutable('now - 9 minutes'); $entryComment2 = $this->createEntryComment('Some cringe comment', $entry, user: $otherUser); $entryComment2->createdAt = new \DateTimeImmutable('now - 8 minutes'); $entryComment3 = $this->createEntryComment('Some Cringe comment', $entry, user: $otherUser); $entryComment3->createdAt = new \DateTimeImmutable('now - 7 minutes'); $entry2 = $this->getEntryByTitle('Some cringe Entry', user: $otherUser); $entry2->createdAt = new \DateTimeImmutable('now - 6 minutes'); $post = $this->createPost('Some Post', user: $otherUser); $post->createdAt = new \DateTimeImmutable('now - 5 minutes'); $postComment1 = $this->createPostComment('Some comment', $post, user: $otherUser); $postComment1->createdAt = new \DateTimeImmutable('now - 4 minutes'); $postComment2 = $this->createPostComment('Some cringe comment', $post, user: $otherUser); $postComment2->createdAt = new \DateTimeImmutable('now - 3 minutes'); $postComment3 = $this->createPostComment('Some Cringe comment', $post, user: $otherUser); $postComment3->createdAt = new \DateTimeImmutable('now - 2 minutes'); $post2 = $this->createPost('Some Cringe Post', user: $otherUser); $post2->createdAt = new \DateTimeImmutable('now - 1 minutes'); $this->entityManager->flush(); $this->deactivateFilterList($token); $this->client->jsonRequest('GET', "/api/users/{$otherUser->getId()}/content", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData['items']); self::assertCount(10, $jsonData['items']); self::assertEquals($entry->getId(), $jsonData['items'][9]['entry']['entryId']); self::assertEquals($entryComment1->getId(), $jsonData['items'][8]['entryComment']['commentId']); self::assertEquals($entryComment2->getId(), $jsonData['items'][7]['entryComment']['commentId']); self::assertEquals($entryComment3->getId(), $jsonData['items'][6]['entryComment']['commentId']); self::assertEquals($entry2->getId(), $jsonData['items'][5]['entry']['entryId']); self::assertEquals($post->getId(), $jsonData['items'][4]['post']['postId']); self::assertEquals($postComment1->getId(), $jsonData['items'][3]['postComment']['commentId']); self::assertEquals($postComment2->getId(), $jsonData['items'][2]['postComment']['commentId']); self::assertEquals($postComment3->getId(), $jsonData['items'][1]['postComment']['commentId']); self::assertEquals($post2->getId(), $jsonData['items'][0]['post']['postId']); $this->activateFilterList($token); $this->client->jsonRequest('GET', "/api/users/{$otherUser->getId()}/content", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData['items']); self::assertCount(4, $jsonData['items']); self::assertEquals($entry->getId(), $jsonData['items'][3]['entry']['entryId']); self::assertEquals($entryComment1->getId(), $jsonData['items'][2]['entryComment']['commentId']); self::assertEquals($post->getId(), $jsonData['items'][1]['post']['postId']); self::assertEquals($postComment1->getId(), $jsonData['items'][0]['postComment']['commentId']); } public function testFilteredProfileExact(): void { $token = $this->getListUserToken(); $otherUser = $this->userRepository->findOneByUsername('otherUser'); $magazine = $this->getMagazineByName('someMag'); $entry = $this->createEntry('Some Entry', $magazine, $otherUser); $entry->createdAt = new \DateTimeImmutable('now - 10 minutes'); $entryComment1 = $this->createEntryComment('Some comment', $entry, user: $otherUser); $entryComment1->createdAt = new \DateTimeImmutable('now - 9 minutes'); $entryComment2 = $this->createEntryComment('Some TEST comment', $entry, user: $otherUser); $entryComment2->createdAt = new \DateTimeImmutable('now - 8 minutes'); $entryComment3 = $this->createEntryComment('Some test comment', $entry, user: $otherUser); $entryComment3->createdAt = new \DateTimeImmutable('now - 7 minutes'); $entry2 = $this->getEntryByTitle('Some TEST Entry', user: $otherUser); $entry2->createdAt = new \DateTimeImmutable('now - 6 minutes'); $post = $this->createPost('Some Post', user: $otherUser); $post->createdAt = new \DateTimeImmutable('now - 5 minutes'); $postComment1 = $this->createPostComment('Some comment', $post, user: $otherUser); $postComment1->createdAt = new \DateTimeImmutable('now - 4 minutes'); $postComment2 = $this->createPostComment('Some TEST comment', $post, user: $otherUser); $postComment2->createdAt = new \DateTimeImmutable('now - 3 minutes'); $postComment3 = $this->createPostComment('Some test comment', $post, user: $otherUser); $postComment3->createdAt = new \DateTimeImmutable('now - 2 minutes'); $post2 = $this->createPost('Some TEST Post', user: $otherUser); $post2->createdAt = new \DateTimeImmutable('now - 1 minutes'); $this->entityManager->flush(); $this->deactivateFilterList($token); $this->client->jsonRequest('GET', "/api/users/{$otherUser->getId()}/content", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData['items']); self::assertCount(10, $jsonData['items']); self::assertEquals($entry->getId(), $jsonData['items'][9]['entry']['entryId']); self::assertEquals($entryComment1->getId(), $jsonData['items'][8]['entryComment']['commentId']); self::assertEquals($entryComment2->getId(), $jsonData['items'][7]['entryComment']['commentId']); self::assertEquals($entryComment3->getId(), $jsonData['items'][6]['entryComment']['commentId']); self::assertEquals($entry2->getId(), $jsonData['items'][5]['entry']['entryId']); self::assertEquals($post->getId(), $jsonData['items'][4]['post']['postId']); self::assertEquals($postComment1->getId(), $jsonData['items'][3]['postComment']['commentId']); self::assertEquals($postComment2->getId(), $jsonData['items'][2]['postComment']['commentId']); self::assertEquals($postComment3->getId(), $jsonData['items'][1]['postComment']['commentId']); self::assertEquals($post2->getId(), $jsonData['items'][0]['post']['postId']); $this->activateFilterList($token); $this->client->jsonRequest('GET', "/api/users/{$otherUser->getId()}/content", server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData['items']); self::assertCount(6, $jsonData['items']); self::assertEquals($entry->getId(), $jsonData['items'][5]['entry']['entryId']); self::assertEquals($entryComment1->getId(), $jsonData['items'][4]['entryComment']['commentId']); self::assertEquals($entryComment3->getId(), $jsonData['items'][3]['entryComment']['commentId']); self::assertEquals($post->getId(), $jsonData['items'][2]['post']['postId']); self::assertEquals($postComment1->getId(), $jsonData['items'][1]['postComment']['commentId']); self::assertEquals($postComment3->getId(), $jsonData['items'][0]['postComment']['commentId']); } public function setUp(): void { parent::setUp(); $this->listUser = $this->getUserByUsername('listOwner'); $this->otherUser = $this->getUserByUsername('otherUser'); $this->list = new UserFilterList(); $this->list->name = 'Test List'; $this->list->user = $this->listUser; $this->list->expirationDate = null; $this->list->feeds = true; $this->list->profile = true; $this->list->comments = true; $this->list->words = [ [ 'exactMatch' => true, 'word' => 'TEST', ], [ 'exactMatch' => false, 'word' => 'Cringe', ], ]; $this->entityManager->persist($this->list); $this->entityManager->flush(); } private function deactivateFilterList(string $token): void { $dto = $this->getFilterListDto(); $this->client->jsonRequest('PUT', '/api/users/filterLists/'.$this->list->getId(), $dto, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); } private function activateFilterList(string $token): void { $dto = $this->getFilterListDto(); $dto['expirationDate'] = null; $this->client->jsonRequest('PUT', '/api/users/filterLists/'.$this->list->getId(), $dto, server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); } private function getFilterListDto(): array { return [ 'name' => $this->list->name, 'expirationDate' => (new \DateTimeImmutable('now - 1 day'))->format(DATE_ATOM), 'feeds' => true, 'profile' => true, 'comments' => true, 'words' => $this->list->words, ]; } private function getListUserToken(): string { self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->listUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'user:profile:edit'); $token = $codes['token_type'].' '.$codes['access_token']; return $token; } } ================================================ FILE: tests/Functional/Controller/Api/User/UserFollowApiTest.php ================================================ getUserByUsername('UserWithoutAbout'); $followedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('PUT', '/api/users/'.(string) $followedUser->getId().'/follow', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } public function testApiCannotUnfollowUserWithoutScope(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $followedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('PUT', '/api/users/'.(string) $followedUser->getId().'/unfollow', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } #[Group(name: 'NonThreadSafe')] public function testApiCanFollowUser(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $followedUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block'); $this->client->request('PUT', '/api/users/'.(string) $followedUser->getId().'/follow', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayHasKey('userId', $jsonData); self::assertArrayHasKey('username', $jsonData); self::assertArrayHasKey('about', $jsonData); self::assertArrayHasKey('avatar', $jsonData); self::assertArrayHasKey('cover', $jsonData); self::assertArrayNotHasKey('lastActive', $jsonData); self::assertArrayHasKey('createdAt', $jsonData); self::assertArrayHasKey('followersCount', $jsonData); self::assertArrayHasKey('apId', $jsonData); self::assertArrayHasKey('apProfileId', $jsonData); self::assertArrayHasKey('isBot', $jsonData); self::assertArrayHasKey('isFollowedByUser', $jsonData); self::assertArrayHasKey('isFollowerOfUser', $jsonData); self::assertArrayHasKey('isBlockedByUser', $jsonData); self::assertSame(1, $jsonData['followersCount']); self::assertTrue($jsonData['isFollowedByUser']); self::assertFalse($jsonData['isFollowerOfUser']); self::assertFalse($jsonData['isBlockedByUser']); } public function testApiCanUnfollowUser(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $followedUser = $this->getUserByUsername('JohnDoe'); $testUser->follow($followedUser); $manager = $this->entityManager; $manager->persist($testUser); $manager->flush(); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block'); $this->client->request('PUT', '/api/users/'.(string) $followedUser->getId().'/unfollow', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayHasKey('userId', $jsonData); self::assertArrayHasKey('username', $jsonData); self::assertArrayHasKey('about', $jsonData); self::assertArrayHasKey('avatar', $jsonData); self::assertArrayHasKey('cover', $jsonData); self::assertArrayNotHasKey('lastActive', $jsonData); self::assertArrayHasKey('createdAt', $jsonData); self::assertArrayHasKey('followersCount', $jsonData); self::assertArrayHasKey('apId', $jsonData); self::assertArrayHasKey('apProfileId', $jsonData); self::assertArrayHasKey('isBot', $jsonData); self::assertArrayHasKey('isFollowedByUser', $jsonData); self::assertArrayHasKey('isFollowerOfUser', $jsonData); self::assertArrayHasKey('isBlockedByUser', $jsonData); self::assertSame(0, $jsonData['followersCount']); self::assertFalse($jsonData['isFollowedByUser']); self::assertFalse($jsonData['isFollowerOfUser']); self::assertFalse($jsonData['isBlockedByUser']); } } ================================================ FILE: tests/Functional/Controller/Api/User/UserModeratesApiTest.php ================================================ getUserByUsername('JohnDoe'); $user = $this->getUserByUsername('user'); $magazine1 = $this->getMagazineByName('m 1'); $magazine2 = $this->getMagazineByName('m 2'); $this->getMagazineByName('dummy'); $this->magazineManager->addModerator(new ModeratorDto($magazine1, $user, $owner)); $this->magazineManager->addModerator(new ModeratorDto($magazine2, $user, $owner)); $this->client->request('GET', "/api/users/{$user->getId()}/moderatedMagazines"); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertCount(2, $jsonData['items']); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(2, $jsonData['pagination']['count']); self::assertTrue(array_all($jsonData['items'], function ($item) use ($magazine1, $magazine2) { return $item['magazineId'] === $magazine1->getId() || $item['magazineId'] === $magazine2->getId(); })); } } ================================================ FILE: tests/Functional/Controller/Api/User/UserRetrieveApiTest.php ================================================ getUserByUsername('user'.(string) ($i + 1), about: 'Test user '.(string) ($i + 1)); } $this->getUserByUsername('userWithoutAbout'); $this->client->request('GET', '/api/users?withAbout=1'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(self::NUM_USERS, $jsonData['pagination']['count']); self::assertSame(1, $jsonData['pagination']['currentPage']); self::assertSame(1, $jsonData['pagination']['maxPage']); // Default perPage count should be used since no perPage value was specified self::assertSame(UserRepository::PER_PAGE, $jsonData['pagination']['perPage']); self::assertIsArray($jsonData['items']); self::assertSame(self::NUM_USERS, \count($jsonData['items'])); } public function testApiCanRetrieveAdminsAnonymous(): void { $users = []; for ($i = 0; $i < self::NUM_USERS; ++$i) { $users[] = $this->getUserByUsername('admin'.(string) ($i + 1), isAdmin: true); } $this->client->request('GET', '/api/users/admins'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertIsArray($jsonData['items']); self::assertSame(self::NUM_USERS, \count($jsonData['items'])); } public function testApiCanRetrieveModeratorsAnonymous(): void { $users = []; for ($i = 0; $i < self::NUM_USERS; ++$i) { $users[] = $this->getUserByUsername('moderator'.(string) ($i + 1), isModerator: true); } $this->client->request('GET', '/api/users/moderators'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertIsArray($jsonData['items']); self::assertSame(self::NUM_USERS, \count($jsonData['items'])); } public function testApiCanRetrieveUsersWithAbout(): void { self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('UserWithoutAbout')); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $users = []; for ($i = 0; $i < self::NUM_USERS; ++$i) { $users[] = $this->getUserByUsername('user'.(string) ($i + 1), about: 'Test user '.(string) ($i + 1)); } $this->client->request('GET', '/api/users?withAbout=1', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(self::NUM_USERS, $jsonData['pagination']['count']); } public function testApiCanRetrieveUserByIdAnonymous(): void { $testUser = $this->getUserByUsername('UserWithoutAbout'); $this->client->request('GET', '/api/users/'.(string) $testUser->getId()); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertSame($testUser->getId(), $jsonData['userId']); self::assertSame('UserWithoutAbout', $jsonData['username']); self::assertNull($jsonData['about']); self::assertNotNull($jsonData['createdAt']); self::assertFalse($jsonData['isBot']); self::assertNull($jsonData['apId']); // Follow and block scopes not assigned, so these flags should be null self::assertNull($jsonData['isFollowedByUser']); self::assertNull($jsonData['isFollowerOfUser']); self::assertNull($jsonData['isBlockedByUser']); } public function testApiCanRetrieveUserById(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('GET', '/api/users/'.(string) $testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertSame($testUser->getId(), $jsonData['userId']); self::assertSame('UserWithoutAbout', $jsonData['username']); self::assertNull($jsonData['about']); self::assertNotNull($jsonData['createdAt']); self::assertFalse($jsonData['isBot']); self::assertNull($jsonData['apId']); // Follow and block scopes not assigned, so these flags should be null self::assertNull($jsonData['isFollowedByUser']); self::assertNull($jsonData['isFollowerOfUser']); self::assertNull($jsonData['isBlockedByUser']); } public function testApiCanRetrieveUserByNameAnonymous(): void { $testUser = $this->getUserByUsername('UserWithoutAbout'); $this->client->request('GET', '/api/users/name/'.$testUser->getUsername()); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertSame($testUser->getId(), $jsonData['userId']); self::assertSame('UserWithoutAbout', $jsonData['username']); self::assertNull($jsonData['about']); self::assertNotNull($jsonData['createdAt']); self::assertFalse($jsonData['isBot']); self::assertNull($jsonData['apId']); // Follow and block scopes not assigned, so these flags should be null self::assertNull($jsonData['isFollowedByUser']); self::assertNull($jsonData['isFollowerOfUser']); self::assertNull($jsonData['isBlockedByUser']); } public function testApiCanRetrieveUserByName(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('GET', '/api/users/name/'.$testUser->getUsername(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertSame($testUser->getId(), $jsonData['userId']); self::assertSame('UserWithoutAbout', $jsonData['username']); self::assertNull($jsonData['about']); self::assertNotNull($jsonData['createdAt']); self::assertFalse($jsonData['isBot']); self::assertNull($jsonData['apId']); // Follow and block scopes not assigned, so these flags should be null self::assertNull($jsonData['isFollowedByUser']); self::assertNull($jsonData['isFollowerOfUser']); self::assertNull($jsonData['isBlockedByUser']); } public function testApiCannotRetrieveCurrentUserWithoutScope(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('GET', '/api/users/me', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } public function testApiCanRetrieveCurrentUser(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read'); $this->client->request('GET', '/api/users/me', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertSame($testUser->getId(), $jsonData['userId']); self::assertSame('UserWithoutAbout', $jsonData['username']); self::assertNull($jsonData['about']); self::assertNotNull($jsonData['createdAt']); self::assertFalse($jsonData['isBot']); self::assertNull($jsonData['apId']); // Follow and block scopes not assigned, so these flags should be null self::assertNull($jsonData['isFollowedByUser']); self::assertNull($jsonData['isFollowerOfUser']); self::assertNull($jsonData['isBlockedByUser']); } public function testApiCanRetrieveUserFlagsWithScopes(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $follower = $this->getUserByUsername('follower'); $follower->follow($testUser); $manager = $this->entityManager; $manager->persist($follower); $manager->flush(); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block'); $this->client->request('GET', '/api/users/'.(string) $follower->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); // Follow and block scopes assigned, so these flags should not be null self::assertFalse($jsonData['isFollowedByUser']); self::assertTrue($jsonData['isFollowerOfUser']); self::assertFalse($jsonData['isBlockedByUser']); } public function testApiCanGetBlockedUsers(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $blockedUser = $this->getUserByUsername('JohnDoe'); $testUser->block($blockedUser); $manager = $this->entityManager; $manager->persist($testUser); $manager->flush(); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block'); $this->client->request('GET', '/api/users/blocked', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertSame(1, \count($jsonData['items'])); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($blockedUser->getId(), $jsonData['items'][0]['userId']); } public function testApiCannotGetFollowedUsersWithoutScope(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('GET', '/api/users/followed', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } public function testApiCannotGetFollowersWithoutScope(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('GET', '/api/users/followers', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetFollowedUsers(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $followedUser = $this->getUserByUsername('JohnDoe'); $testUser->follow($followedUser); $manager = $this->entityManager; $manager->persist($testUser); $manager->flush(); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block'); $this->client->request('GET', '/api/users/followed', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertSame(1, \count($jsonData['items'])); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($followedUser->getId(), $jsonData['items'][0]['userId']); } public function testApiCanGetFollowers(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $followingUser = $this->getUserByUsername('JohnDoe'); $followingUser->follow($testUser); $manager = $this->entityManager; $manager->persist($testUser); $manager->flush(); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block'); $this->client->request('GET', '/api/users/followers', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertSame(1, \count($jsonData['items'])); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($followingUser->getId(), $jsonData['items'][0]['userId']); } public function testApiCannotGetFollowedUsersByIdIfNotShared(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $followedUser = $this->getUserByUsername('JohnDoe'); $testUser->follow($followedUser); $testUser->showProfileFollowings = false; $manager = $this->entityManager; $manager->persist($testUser); $manager->flush(); $this->client->loginUser($followedUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block'); $this->client->request('GET', '/api/users/'.(string) $testUser->getId().'/followed', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetFollowedUsersById(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $followedUser = $this->getUserByUsername('JohnDoe'); $testUser->follow($followedUser); $manager = $this->entityManager; $manager->persist($testUser); $manager->flush(); $this->client->loginUser($followedUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block'); $this->client->request('GET', '/api/users/'.(string) $testUser->getId().'/followed', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertSame(1, \count($jsonData['items'])); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($followedUser->getId(), $jsonData['items'][0]['userId']); } public function testApiCanGetFollowersById(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('UserWithoutAbout'); $followingUser = $this->getUserByUsername('JohnDoe'); $followingUser->follow($testUser); $manager = $this->entityManager; $manager->persist($testUser); $manager->flush(); $this->client->loginUser($followingUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block'); $this->client->request('GET', '/api/users/'.(string) $testUser->getId().'/followers', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertSame(1, \count($jsonData['items'])); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['items'][0]); self::assertSame($followingUser->getId(), $jsonData['items'][0]['userId']); } public function testApiCannotGetSettingsWithoutScope(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $this->client->request('GET', '/api/users/settings', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetSettings(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read'); $this->client->request('GET', '/api/users/settings', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_SETTINGS_KEYS, $jsonData); } } ================================================ FILE: tests/Functional/Controller/Api/User/UserRetrieveOAuthConsentsApiTest.php ================================================ getUserByUsername('someuser'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block'); $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } public function testApiCanGetConsents(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('someuser'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:follow user:block'); $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['pagination']); self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(1, $jsonData['pagination']['count']); self::assertIsArray($jsonData['items']); self::assertSame(1, \count($jsonData['items'])); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::CONSENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals( ['read', 'user:oauth_clients:read', 'user:follow', 'user:block'], $jsonData['items'][0]['scopesGranted'] ); self::assertEquals( OAuth2ClientDto::AVAILABLE_SCOPES, $jsonData['items'][0]['scopesAvailable'] ); self::assertEquals('/kbin Test Client', $jsonData['items'][0]['client']); self::assertEquals('An OAuth2 client for testing purposes', $jsonData['items'][0]['description']); self::assertNull($jsonData['items'][0]['clientLogo']); } public function testApiCannotGetOtherUsersConsentsById(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('someuser'); $testUser2 = $this->getUserByUsername('someuser2'); $this->client->loginUser($testUser); $codes1 = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:follow user:block'); $this->client->loginUser($testUser2); $codes2 = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:follow user:block'); $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes1['token_type'].' '.$codes1['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertSame(1, \count($jsonData['items'])); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::CONSENT_RESPONSE_KEYS, $jsonData['items'][0]); $this->client->request( 'GET', '/api/users/consents/'.(string) $jsonData['items'][0]['consentId'], server: ['HTTP_AUTHORIZATION' => $codes2['token_type'].' '.$codes2['access_token']] ); self::assertResponseStatusCodeSame(403); } public function testApiCanGetConsentsById(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('someuser'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:follow user:block'); $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertIsArray($jsonData['items']); self::assertSame(1, \count($jsonData['items'])); self::assertIsArray($jsonData['items'][0]); self::assertArrayKeysMatch(self::CONSENT_RESPONSE_KEYS, $jsonData['items'][0]); $consent = $jsonData['items'][0]; $this->client->request( 'GET', '/api/users/consents/'.(string) $consent['consentId'], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertEquals($consent, $jsonData); } } ================================================ FILE: tests/Functional/Controller/Api/User/UserUpdateApiTest.php ================================================ getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read'); $this->client->jsonRequest( 'PUT', '/api/users/profile', parameters: [ 'about' => 'Updated during test', ], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateCurrentUserProfile(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read'); $this->client->request('GET', '/api/users/'.(string) $testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertSame($testUser->getId(), $jsonData['userId']); self::assertNull($jsonData['about']); $this->client->jsonRequest( 'PUT', '/api/users/profile', parameters: [ 'about' => 'Updated during test', ], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertSame($testUser->getId(), $jsonData['userId']); self::assertEquals('Updated during test', $jsonData['about']); $this->client->request('GET', '/api/users/'.(string) $testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertSame($testUser->getId(), $jsonData['userId']); self::assertEquals('Updated during test', $jsonData['about']); } public function testApiCanUpdateCurrentUserTitle(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read'); $this->client->request('GET', '/api/users/'.$testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertSame($testUser->getId(), $jsonData['userId']); self::assertNull($jsonData['title']); // region set title $this->client->jsonRequest( 'PUT', '/api/users/profile', parameters: [ 'title' => 'Custom user-name', ], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertSame($testUser->getId(), $jsonData['userId']); self::assertEquals('Custom user-name', $jsonData['title']); $this->client->request('GET', '/api/users/'.$testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertSame($testUser->getId(), $jsonData['userId']); self::assertEquals('Custom user-name', $jsonData['title']); // endregion // region reset title $this->client->jsonRequest( 'PUT', '/api/users/profile', parameters: [], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertSame($testUser->getId(), $jsonData['userId']); self::assertNull($jsonData['title']); $this->client->request('GET', '/api/users/'.$testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertSame($testUser->getId(), $jsonData['userId']); self::assertNull($jsonData['title']); // endregion } public function testApiCannotUpdateCurrentUserTitleWithWhitespaces() { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read'); $this->client->request('GET', '/api/users/'.$testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $this->client->jsonRequest( 'PUT', '/api/users/profile', parameters: [ 'title' => " \t", ], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseStatusCodeSame(400); $this->client->jsonRequest( 'PUT', '/api/users/profile', parameters: [ 'title' => '', ], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseStatusCodeSame(400); $this->client->jsonRequest( 'PUT', '/api/users/profile', parameters: [ 'title' => ' . ', ], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseStatusCodeSame(400); } public function testApiCannotUpdateCurrentUserSettingsWithoutScope(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read'); $settings = (new UserSettingsDto( false, false, false, false, false, false, false, false, false, false, false, User::HOMEPAGE_MOD, Criteria::SORT_HOT, Criteria::SORT_HOT, false, ['test'], ['en'], directMessageSetting: EDirectMessageSettings::Everyone->value, frontDefaultContent: EFrontContentOptions::Combined->value, ))->jsonSerialize(); $this->client->jsonRequest( 'PUT', '/api/users/settings', parameters: $settings, server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateCurrentUserSettings(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read'); $settings = (new UserSettingsDto( false, false, false, false, false, false, false, false, false, false, false, User::HOMEPAGE_MOD, Criteria::SORT_NEW, Criteria::SORT_TOP, false, ['test'], ['en'], directMessageSetting: EDirectMessageSettings::FollowersOnly->value, frontDefaultContent: EFrontContentOptions::Threads->value, discoverable: false, indexable: false, ))->jsonSerialize(); $this->client->jsonRequest( 'PUT', '/api/users/settings', parameters: $settings, server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(UserRetrieveApiTest::USER_SETTINGS_KEYS, $jsonData); self::assertFalse($jsonData['notifyOnNewEntry']); self::assertFalse($jsonData['notifyOnNewEntryReply']); self::assertFalse($jsonData['notifyOnNewEntryCommentReply']); self::assertFalse($jsonData['notifyOnNewPost']); self::assertFalse($jsonData['notifyOnNewPostReply']); self::assertFalse($jsonData['notifyOnNewPostCommentReply']); self::assertFalse($jsonData['hideAdult']); self::assertFalse($jsonData['showProfileSubscriptions']); self::assertFalse($jsonData['showProfileFollowings']); self::assertFalse($jsonData['addMentionsEntries']); self::assertFalse($jsonData['addMentionsPosts']); self::assertFalse($jsonData['discoverable']); self::assertEquals(User::HOMEPAGE_MOD, $jsonData['homepage']); self::assertEquals(Criteria::SORT_NEW, $jsonData['frontDefaultSort']); self::assertEquals(Criteria::SORT_TOP, $jsonData['commentDefaultSort']); self::assertEquals(['test'], $jsonData['featuredMagazines']); self::assertEquals(['en'], $jsonData['preferredLanguages']); self::assertEquals(EDirectMessageSettings::FollowersOnly->value, $jsonData['directMessageSetting']); self::assertEquals(EFrontContentOptions::Threads->value, $jsonData['frontDefaultContent']); self::assertFalse($jsonData['indexable']); $this->client->request('GET', '/api/users/settings', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(UserRetrieveApiTest::USER_SETTINGS_KEYS, $jsonData); self::assertFalse($jsonData['notifyOnNewEntry']); self::assertFalse($jsonData['notifyOnNewEntryReply']); self::assertFalse($jsonData['notifyOnNewEntryCommentReply']); self::assertFalse($jsonData['notifyOnNewPost']); self::assertFalse($jsonData['notifyOnNewPostReply']); self::assertFalse($jsonData['notifyOnNewPostCommentReply']); self::assertFalse($jsonData['hideAdult']); self::assertFalse($jsonData['showProfileSubscriptions']); self::assertFalse($jsonData['showProfileFollowings']); self::assertFalse($jsonData['addMentionsEntries']); self::assertFalse($jsonData['addMentionsPosts']); self::assertFalse($jsonData['discoverable']); self::assertEquals(User::HOMEPAGE_MOD, $jsonData['homepage']); self::assertEquals(Criteria::SORT_NEW, $jsonData['frontDefaultSort']); self::assertEquals(Criteria::SORT_TOP, $jsonData['commentDefaultSort']); self::assertEquals(['test'], $jsonData['featuredMagazines']); self::assertEquals(['en'], $jsonData['preferredLanguages']); self::assertEquals(EDirectMessageSettings::FollowersOnly->value, $jsonData['directMessageSetting']); self::assertEquals(EFrontContentOptions::Threads->value, $jsonData['frontDefaultContent']); self::assertFalse($jsonData['indexable']); } } ================================================ FILE: tests/Functional/Controller/Api/User/UserUpdateImagesApiTest.php ================================================ kibbyPath = \dirname(__FILE__, 5).'/assets/kibby_emoji.png'; } public function testApiCannotUpdateCurrentUserAvatarWithoutScope(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read'); // Uploading a file appears to delete the file at the given path, so make a copy before upload copy($this->kibbyPath, $this->kibbyPath.'.tmp'); $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', '/api/users/avatar', files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseStatusCodeSame(403); } public function testApiCannotUpdateCurrentUserCoverWithoutScope(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read'); // Uploading a file appears to delete the file at the given path, so make a copy before upload copy($this->kibbyPath, $this->kibbyPath.'.tmp'); $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', '/api/users/cover', files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseStatusCodeSame(403); } public function testApiCannotDeleteCurrentUserAvatarWithoutScope(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read'); $this->client->request('DELETE', '/api/users/avatar', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } public function testApiCannotDeleteCurrentUserCoverWithoutScope(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read'); $this->client->request('DELETE', '/api/users/cover', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateAndDeleteCurrentUserAvatar(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read'); // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; $expectedPath = $imageManager->getFilePath($image->getFilename()); $this->client->request( 'POST', '/api/users/avatar', files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['avatar']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['avatar']); self::assertSame(96, $jsonData['avatar']['width']); self::assertSame(96, $jsonData['avatar']['height']); self::assertEquals($expectedPath, $jsonData['avatar']['filePath']); // Clean up test data as well as checking that DELETE works // This isn't great, but since people could have their media directory // pretty much anywhere, its difficult to reliably clean up uploaded files // otherwise. This is certainly something that could be improved. $this->client->request('DELETE', '/api/users/avatar', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertNull($jsonData['avatar']); } public function testApiCanUpdateAndDeleteCurrentUserCover(): void { $imageManager = $this->imageManager; self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read'); // Uploading a file appears to delete the file at the given path, so make a copy before upload $tmpPath = bin2hex(random_bytes(32)); copy($this->kibbyPath, $tmpPath.'.png'); $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $expectedPath = $imageManager->getFilePath($image->getFilename()); $this->client->request( 'POST', '/api/users/cover', files: ['uploadImage' => $image], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertIsArray($jsonData['cover']); self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['cover']); self::assertSame(96, $jsonData['cover']['width']); self::assertSame(96, $jsonData['cover']['height']); self::assertEquals($expectedPath, $jsonData['cover']['filePath']); // Clean up test data as well as checking that DELETE works // This isn't great, but since people could have their media directory // pretty much anywhere, its difficult to reliably clean up uploaded files // otherwise. This is certainly something that could be improved. $this->client->request('DELETE', '/api/users/cover', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData); self::assertNull($jsonData['cover']); } } ================================================ FILE: tests/Functional/Controller/Api/User/UserUpdateOAuthConsentsApiTest.php ================================================ getUserByUsername('someuser'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read'); $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(UserRetrieveOAuthConsentsApiTest::CONSENT_RESPONSE_KEYS, $jsonData['items'][0]); $this->client->jsonRequest( 'PUT', '/api/users/consents/'.(string) $jsonData['items'][0]['consentId'], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseStatusCodeSame(403); } public function testApiCanUpdateConsents(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('someuser'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:oauth_clients:edit user:follow'); $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(UserRetrieveOAuthConsentsApiTest::CONSENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals([ 'read', 'user:oauth_clients:read', 'user:oauth_clients:edit', 'user:follow', ], $jsonData['items'][0]['scopesGranted']); $this->client->jsonRequest( 'PUT', '/api/users/consents/'.(string) $jsonData['items'][0]['consentId'], parameters: ['scopes' => [ 'read', 'user:oauth_clients:read', 'user:oauth_clients:edit', ]], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(UserRetrieveOAuthConsentsApiTest::CONSENT_RESPONSE_KEYS, $jsonData); self::assertEquals([ 'read', 'user:oauth_clients:read', 'user:oauth_clients:edit', ], $jsonData['scopesGranted']); } public function testApiUpdatingConsentsDoesNotAffectExistingKeys(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('someuser'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:oauth_clients:edit user:follow'); $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); $this->client->jsonRequest( 'PUT', '/api/users/consents/'.(string) $jsonData['items'][0]['consentId'], parameters: ['scopes' => [ 'read', 'user:oauth_clients:edit', ]], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); // Existing token still has permission to read oauth consents despite client consent being revoked. $this->client->jsonRequest( 'GET', '/api/users/consents/'.(string) $jsonData['consentId'], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(UserRetrieveOAuthConsentsApiTest::CONSENT_RESPONSE_KEYS, $jsonData); self::assertEquals([ 'read', 'user:oauth_clients:edit', ], $jsonData['scopesGranted']); } public function testApiCannotAddConsents(): void { self::createOAuth2AuthCodeClient(); $testUser = $this->getUserByUsername('someuser'); $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:oauth_clients:edit user:follow'); $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); self::assertIsArray($jsonData); self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); self::assertCount(1, $jsonData['items']); self::assertArrayKeysMatch(UserRetrieveOAuthConsentsApiTest::CONSENT_RESPONSE_KEYS, $jsonData['items'][0]); self::assertEquals([ 'read', 'user:oauth_clients:read', 'user:oauth_clients:edit', 'user:follow', ], $jsonData['items'][0]['scopesGranted']); $this->client->jsonRequest( 'PUT', '/api/users/consents/'.(string) $jsonData['items'][0]['consentId'], parameters: ['scopes' => [ 'read', 'user:oauth_clients:read', 'user:oauth_clients:edit', 'user:block', ]], server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']] ); self::assertResponseStatusCodeSame(403); } } ================================================ FILE: tests/Functional/Controller/Domain/DomainBlockControllerTest.php ================================================ createEntry( 'test entry 1', $this->getMagazineByName('acme'), $this->getUserByUsername('JohnDoe'), 'http://kbin.pub/instances' ); $this->client->loginUser($this->getUserByUsername('JaneDoe')); $crawler = $this->client->request('GET', '/d/kbin.pub'); // Block $this->client->submit($crawler->filter('#sidebar form[name=domain_block] button')->form()); $crawler = $this->client->followRedirect(); $this->assertSelectorExists('#sidebar form[name=domain_block] .active'); // Unblock $this->client->submit($crawler->filter('#sidebar form[name=domain_block] button')->form()); $this->client->followRedirect(); $this->assertSelectorNotExists('#sidebar form[name=domain_block] .active'); } #[Group(name: 'NonThreadSafe')] public function testXmlUserCanBlockDomain(): void { $entry = $this->createEntry( 'test entry 1', $this->getMagazineByName('acme'), $this->getUserByUsername('JohnDoe'), 'http://kbin.pub/instances' ); $this->client->loginUser($this->getUserByUsername('JaneDoe')); $crawler = $this->client->request('GET', '/d/kbin.pub'); $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest'); $this->client->submit($crawler->filter('#sidebar form[name=domain_block] button')->form()); $this->assertStringContainsString('{"html":', $this->client->getResponse()->getContent()); $this->assertStringContainsString('active', $this->client->getResponse()->getContent()); } #[Group(name: 'NonThreadSafe')] public function testXmlUserCanUnblockDomain(): void { $entry = $this->createEntry( 'test entry 1', $this->getMagazineByName('acme'), $this->getUserByUsername('JohnDoe'), 'http://kbin.pub/instances' ); $this->client->loginUser($this->getUserByUsername('JaneDoe')); $crawler = $this->client->request('GET', '/d/kbin.pub'); // Block $this->client->submit($crawler->filter('#sidebar form[name=domain_block] button')->form()); $crawler = $this->client->followRedirect(); // Unblock $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest'); $this->client->submit($crawler->filter('#sidebar form[name=domain_block] button')->form()); $this->assertStringContainsString('{"html":', $this->client->getResponse()->getContent()); $this->assertStringNotContainsString('active', $this->client->getResponse()->getContent()); } } ================================================ FILE: tests/Functional/Controller/Domain/DomainCommentFrontControllerTest.php ================================================ createEntry( 'test entry 1', $this->getMagazineByName('acme'), $this->getUserByUsername('JohnDoe'), 'http://kbin.pub/instances' ); $this->createEntryComment('test comment 1', $entry); $crawler = $this->client->request('GET', '/d/kbin.pub'); $crawler = $this->client->click($crawler->filter('#header')->selectLink('Comments')->link()); $this->assertSelectorTextContains('#header', '/d/kbin.pub'); $this->assertSelectorTextContains('blockquote header', 'JohnDoe'); $this->assertSelectorTextContains('blockquote header', 'to acme in test entry 1'); $this->assertSelectorTextContains('blockquote .content', 'test comment 1'); foreach ($this->getSortOptions() as $sortOption) { $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link()); $this->assertSelectorTextContains('.options__filter', $sortOption); $this->assertSelectorTextContains('h1', 'kbin.pub'); $this->assertSelectorTextContains('h2', ucfirst($sortOption)); } } private function getSortOptions(): array { return ['Hot', 'Newest', 'Active', 'Oldest']; } } ================================================ FILE: tests/Functional/Controller/Domain/DomainFrontControllerTest.php ================================================ createEntry( 'test entry 1', $this->getMagazineByName('acme'), $this->getUserByUsername('JohnDoe'), 'http://kbin.pub/instances' ); $crawler = $this->client->request('GET', '/'); $crawler = $this->client->click($crawler->filter('#content article')->selectLink('More from domain')->link()); $this->assertSelectorTextContains('#header', '/d/kbin.pub'); $this->assertSelectorTextContains('.entry__meta', 'JohnDoe'); $this->assertSelectorTextContains('.entry__meta', 'to acme'); foreach ($this->getSortOptions() as $sortOption) { $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link()); $this->assertSelectorTextContains('.options__filter', $sortOption); $this->assertSelectorTextContains('h1', 'kbin.pub'); $this->assertSelectorTextContains('h2', ucfirst($sortOption)); } } private function getSortOptions(): array { return ['Top', 'Hot', 'Newest', 'Active', 'Commented']; } } ================================================ FILE: tests/Functional/Controller/Domain/DomainSubControllerTest.php ================================================ createEntry( 'test entry 1', $this->getMagazineByName('acme'), $this->getUserByUsername('JohnDoe'), 'http://kbin.pub/instances' ); $this->client->loginUser($this->getUserByUsername('JaneDoe')); $crawler = $this->client->request('GET', '/d/kbin.pub'); // Subscribe $this->client->submit($crawler->filter('#sidebar .domain')->selectButton('Subscribe')->form()); $crawler = $this->client->followRedirect(); $this->assertSelectorExists('#sidebar form[name=domain_subscribe] .active'); $this->assertSelectorTextContains('#sidebar .domain', 'Unsubscribe'); $this->assertSelectorTextContains('#sidebar .domain', '1'); // Unsubscribe $this->client->submit($crawler->filter('#sidebar .domain')->selectButton('Unsubscribe')->form()); $this->client->followRedirect(); $this->assertSelectorNotExists('#sidebar form[name=domain_subscribe] .active'); $this->assertSelectorTextContains('#sidebar .domain', 'Subscribe'); $this->assertSelectorTextContains('#sidebar .domain', '0'); } public function testXmlUserCanSubDomain(): void { $this->createEntry( 'test entry 1', $this->getMagazineByName('acme'), $this->getUserByUsername('JohnDoe'), 'http://kbin.pub/instances' ); $this->client->loginUser($this->getUserByUsername('JaneDoe')); $crawler = $this->client->request('GET', '/d/kbin.pub'); // Subscribe $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest'); $this->client->submit($crawler->filter('#sidebar .domain')->selectButton('Subscribe')->form()); $this->assertStringContainsString('{"html":', $this->client->getResponse()->getContent()); $this->assertStringContainsString('Unsubscribe', $this->client->getResponse()->getContent()); } public function testXmlUserCanUnsubDomain(): void { $this->createEntry( 'test entry 1', $this->getMagazineByName('acme'), $this->getUserByUsername('JohnDoe'), 'http://kbin.pub/instances' ); $this->client->loginUser($this->getUserByUsername('JaneDoe')); $crawler = $this->client->request('GET', '/d/kbin.pub'); // Subscribe $this->client->submit($crawler->filter('#sidebar .domain')->selectButton('Subscribe')->form()); $crawler = $this->client->followRedirect(); // Unsubscribe $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest'); $this->client->submit($crawler->filter('#sidebar .domain')->selectButton('Unsubscribe')->form()); $this->assertStringContainsString('{"html":', $this->client->getResponse()->getContent()); $this->assertStringContainsString('Subscribe', $this->client->getResponse()->getContent()); } } ================================================ FILE: tests/Functional/Controller/Entry/Comment/EntryCommentBoostControllerTest.php ================================================ client->loginUser($this->getUserByUsername('JohnDoe')); $entry = $this->getEntryByTitle( 'test entry 1', 'https://kbin.pub', null, null, $this->getUserByUsername('JaneDoe') ); $this->createEntryComment('test comment 1', $entry, $this->getUserByUsername('JaneDoe')); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/test-entry-1"); $this->client->submit( $crawler->filter('#main .entry-comment')->selectButton('Boost')->form() ); $this->client->followRedirect(); self::assertResponseIsSuccessful(); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/test-entry-1"); $this->assertSelectorTextContains('#main .entry-comment', 'Boost (1)'); $crawler = $this->client->click($crawler->filter('#main .entry-comment')->selectLink('Activity')->link()); $this->client->click($crawler->filter('#main #activity')->selectLink('Boosts (1)')->link()); $this->assertSelectorTextContains('#main .users-columns', 'JohnDoe'); } } ================================================ FILE: tests/Functional/Controller/Entry/Comment/EntryCommentChangeLangControllerTest.php ================================================ client->loginUser($this->getUserByUsername('JohnDoe')); $comment = $this->createEntryComment('test comment 1'); $crawler = $this->client->request('GET', "/m/acme/t/{$comment->entry->getId()}/-/comment/{$comment->getId()}/moderate"); $form = $crawler->filter('.moderate-panel')->selectButton('lang[submit]')->form(); $this->assertSame($form['lang']['lang']->getValue(), 'en'); $form['lang']['lang']->select('fr'); $this->client->submit($form); $this->client->followRedirect(); $this->assertSelectorTextContains('#main .badge-lang', 'French'); } } ================================================ FILE: tests/Functional/Controller/Entry/Comment/EntryCommentCreateControllerTest.php ================================================ kibbyPath = \dirname(__FILE__, 5).'/assets/kibby_emoji.png'; } public function testUserCanCreateEntryComment(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/test-entry-1"); $this->client->submit( $crawler->filter('form[name=entry_comment]')->selectButton('Add comment')->form( [ 'entry_comment[body]' => 'test comment 1', ] ) ); $this->assertResponseRedirects('/m/acme/t/'.$entry->getId().'/test-entry-1'); $this->client->followRedirect(); $this->assertSelectorTextContains('#main blockquote', 'test comment 1'); } public function testUserCannotCreateEntryCommentInLockedEntry(): void { $user = $this->getUserByUsername('JohnDoe'); $this->client->loginUser($user); $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $this->entryManager->toggleLock($entry, $user); $this->client->request('GET', "/m/acme/t/{$entry->getId()}/test-entry-1"); self::assertSelectorTextNotContains('#main', 'Add comment'); } #[Group(name: 'NonThreadSafe')] public function testUserCanCreateEntryCommentWithImage(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/test-entry-1"); $form = $crawler->filter('form[name=entry_comment]')->selectButton('entry_comment[submit]')->form(); $form->get('entry_comment[body]')->setValue('test comment 1'); $form->get('entry_comment[image]')->upload($this->kibbyPath); // Needed since we require this global to be set when validating entries but the client doesn't actually set it $_FILES = $form->getPhpFiles(); $this->client->submit($form); $this->assertResponseRedirects('/m/acme/t/'.$entry->getId().'/test-entry-1'); $crawler = $this->client->followRedirect(); $this->assertSelectorTextContains('#main blockquote', 'test comment 1'); $this->assertSelectorExists('blockquote footer figure img'); $imgSrc = $crawler->filter('blockquote footer figure img')->getNode(0)->attributes->getNamedItem('src')->textContent; $this->assertStringContainsString(self::KIBBY_PNG_URL_RESULT, $imgSrc); $_FILES = []; } #[Group(name: 'NonThreadSafe')] public function testUserCanReplyEntryComment(): void { $comment = $this->createEntryComment( 'test comment 1', $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub'), $this->getUserByUsername('JaneDoe') ); $this->client->loginUser($this->getUserByUsername('JohnDoe')); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/test-entry-1"); $crawler = $this->client->click($crawler->filter('#entry-comment-'.$comment->getId())->selectLink('Reply')->link()); $this->assertSelectorTextContains('#main blockquote', 'test comment 1'); $crawler = $this->client->submit( $crawler->filter('form[name=entry_comment]')->selectButton('Add comment')->form( [ 'entry_comment[body]' => 'test comment 2', ] ) ); $this->assertResponseRedirects('/m/acme/t/'.$entry->getId().'/test-entry-1'); $crawler = $this->client->followRedirect(); $this->assertEquals(2, $crawler->filter('#main blockquote')->count()); } #[Group(name: 'NonThreadSafe')] public function testUserCantCreateInvalidEntryComment(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/test-entry-1"); $this->client->submit( $crawler->filter('form[name=entry_comment]')->selectButton('Add comment')->form( [ 'entry_comment[body]' => '', ] ) ); $this->assertSelectorTextContains( '#content', 'This value should not be blank.' ); } } ================================================ FILE: tests/Functional/Controller/Entry/Comment/EntryCommentDeleteControllerTest.php ================================================ getUserByUsername('user'); $magazine = $this->getMagazineByName('acme'); $entry = $this->getEntryByTitle('comment deletion test', body: 'a comment will be deleted', magazine: $magazine, user: $user); $comment = $this->createEntryComment('Delete me!', $entry, $user); $this->client->loginUser($user); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/comment-deletion-test"); $this->assertSelectorExists('#comments form[action$="delete"]'); $this->client->submit( $crawler->filter('#comments form[action$="delete"]')->selectButton('Delete')->form() ); $this->assertResponseRedirects(); } public function testUserCanSoftDeleteEntryComment() { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByName('acme'); $entry = $this->getEntryByTitle('comment deletion test', body: 'a comment will be deleted', magazine: $magazine, user: $user); $comment = $this->createEntryComment('Delete me!', $entry, $user); $reply = $this->createEntryComment('Are you deleted yet?', $entry, $user, $comment); $this->client->loginUser($user); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/comment-deletion-test"); $this->assertSelectorExists("#entry-comment-{$comment->getId()} form[action$=\"delete\"]"); $this->client->submit( $crawler->filter("#entry-comment-{$comment->getId()} form[action$=\"delete\"]")->selectButton('Delete')->form() ); $this->assertResponseRedirects(); $crawler = $this->client->followRedirect(); $translator = $this->translator; $this->assertSelectorTextContains("#entry-comment-{$comment->getId()} .content", $translator->trans('deleted_by_author')); } } ================================================ FILE: tests/Functional/Controller/Entry/Comment/EntryCommentEditControllerTest.php ================================================ client->loginUser($this->getUserByUsername('JohnDoe')); $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $this->createEntryComment('test comment 1', $entry); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/test-entry-1"); $crawler = $this->client->click($crawler->filter('#main .entry-comment')->selectLink('Edit')->link()); $this->assertSelectorExists('#main .entry-comment'); $this->assertSelectorTextContains('#main .entry-comment', 'test comment 1'); $this->client->submit( $crawler->filter('form[name=entry_comment]')->selectButton('Update comment')->form( [ 'entry_comment[body]' => 'test comment 2 body', ] ) ); $this->client->followRedirect(); $this->assertSelectorTextContains('#main .entry-comment', 'test comment 2 body'); } #[Group(name: 'NonThreadSafe')] public function testAuthorCanEditOwnEntryCommentWithImage(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $this->createEntryComment('test comment 1', $entry, imageDto: $imageDto); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/test-entry-1"); $crawler = $this->client->click($crawler->filter('#main .entry-comment')->selectLink('Edit')->link()); $this->assertSelectorExists('#main .entry-comment'); $this->assertSelectorTextContains('#main .entry-comment', 'test comment 1'); $this->assertSelectorExists('#main .entry-comment img'); $node = $crawler->selectImage('kibby')->getNode(0); $this->assertNotNull($node); $this->assertStringContainsString($imageDto->filePath, $node->attributes->getNamedItem('src')->textContent); $this->client->submit( $crawler->filter('form[name=entry_comment]')->selectButton('Update comment')->form( [ 'entry_comment[body]' => 'test comment 2 body', ] ) ); $crawler = $this->client->followRedirect(); $this->assertSelectorTextContains('#main .entry-comment', 'test comment 2 body'); $this->assertSelectorExists('#main .entry-comment img'); $node = $crawler->selectImage('kibby')->getNode(0); $this->assertNotNull($node); $this->assertStringContainsString($imageDto->filePath, $node->attributes->getNamedItem('src')->textContent); } } ================================================ FILE: tests/Functional/Controller/Entry/Comment/EntryCommentFrontControllerTest.php ================================================ client = $this->prepareEntries(); $this->client->request('GET', '/comments'); $this->assertSelectorTextContains('h1', 'Hot'); $crawler = $this->client->request('GET', '/comments/newest'); $this->assertSelectorTextContains('blockquote header', 'JohnDoe'); $this->assertSelectorTextContains('blockquote header', 'to kbin in test entry 2'); $this->assertSelectorTextContains('blockquote .content', 'test comment 3'); $this->assertcount(3, $crawler->filter('.comment')); foreach ($this->getSortOptions() as $sortOption) { $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link()); $this->assertSelectorTextContains('.options__filter', $sortOption); $this->assertSelectorTextContains('h1', ucfirst($sortOption)); } } public function testMagazinePage(): void { $this->client = $this->prepareEntries(); $this->client->request('GET', '/m/acme/comments'); $this->assertSelectorTextContains('h2', 'Hot'); $crawler = $this->client->request('GET', '/m/acme/comments/newest'); $this->assertSelectorTextContains('blockquote header', 'JohnDoe'); $this->assertSelectorTextNotContains('blockquote header', 'to acme'); $this->assertSelectorTextContains('blockquote header', 'in test entry 1'); $this->assertSelectorTextContains('blockquote .content', 'test comment 2'); $this->assertSelectorTextContains('.head-title', '/m/acme'); $this->assertSelectorTextContains('#sidebar .magazine', 'acme'); $this->assertcount(2, $crawler->filter('.comment')); foreach ($this->getSortOptions() as $sortOption) { $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link()); $this->assertSelectorTextContains('.options__filter', $sortOption); $this->assertSelectorTextContains('h1', 'acme'); $this->assertSelectorTextContains('h2', ucfirst($sortOption)); } } public function testSubPage(): void { $this->client = $this->prepareEntries(); $magazineManager = $this->client->getContainer()->get(MagazineManager::class); $magazineManager->subscribe($this->getMagazineByName('acme'), $this->getUserByUsername('Actor')); $this->client->loginUser($this->getUserByUsername('Actor')); $this->client->request('GET', '/sub/comments'); $this->assertSelectorTextContains('h1', 'Hot'); $crawler = $this->client->request('GET', '/sub/comments/newest'); $this->assertSelectorTextContains('blockquote header', 'JohnDoe'); $this->assertSelectorTextContains('blockquote header', 'to acme in test entry 1'); $this->assertSelectorTextContains('blockquote .content', 'test comment 2'); $this->assertSelectorTextContains('.head-title', '/sub'); $this->assertcount(2, $crawler->filter('.comment')); foreach ($this->getSortOptions() as $sortOption) { $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link()); $this->assertSelectorTextContains('.options__filter', $sortOption); $this->assertSelectorTextContains('h1', ucfirst($sortOption)); } } public function testModPage(): void { $this->client = $this->prepareEntries(); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazineManager = $this->client->getContainer()->get(MagazineManager::class); $moderator = new ModeratorDto($this->getMagazineByName('acme')); $moderator->user = $this->getUserByUsername('Actor'); $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); $this->client->loginUser($this->getUserByUsername('Actor')); $this->client->request('GET', '/mod/comments'); $this->assertSelectorTextContains('h1', 'Hot'); $crawler = $this->client->request('GET', '/mod/comments/newest'); $this->assertSelectorTextContains('blockquote header', 'JohnDoe'); $this->assertSelectorTextContains('blockquote header', 'to acme in test entry 1'); $this->assertSelectorTextContains('blockquote .content', 'test comment 2'); $this->assertSelectorTextContains('.head-title', '/mod'); $this->assertcount(2, $crawler->filter('.comment')); foreach ($this->getSortOptions() as $sortOption) { $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link()); $this->assertSelectorTextContains('.options__filter', $sortOption); $this->assertSelectorTextContains('h1', ucfirst($sortOption)); } } public function testFavPage(): void { $this->client = $this->prepareEntries(); $favouriteManager = $this->favouriteManager; $favouriteManager->toggle( $this->getUserByUsername('Actor'), $this->createEntryComment('test comment 1', $this->getEntryByTitle('test entry 1')) ); $this->client->loginUser($this->getUserByUsername('Actor')); $this->client->request('GET', '/fav/comments'); $this->assertSelectorTextContains('h1', 'Hot'); $crawler = $this->client->request('GET', '/fav/comments/newest'); $this->assertSelectorTextContains('blockquote header', 'JohnDoe'); $this->assertSelectorTextContains('blockquote header', 'to acme in test entry 1'); $this->assertSelectorTextContains('blockquote .content', 'test comment 1'); $this->assertcount(1, $crawler->filter('.comment')); foreach ($this->getSortOptions() as $sortOption) { $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link()); $this->assertSelectorTextContains('.options__filter', $sortOption); $this->assertSelectorTextContains('h1', ucfirst($sortOption)); } } private function prepareEntries(): KernelBrowser { $this->createEntryComment( 'test comment 1', $this->getEntryByTitle('test entry 1', 'https://kbin.pub'), $this->getUserByUsername('JohnDoe') ); $this->createEntryComment( 'test comment 2', $this->getEntryByTitle('test entry 1', 'https://kbin.pub'), $this->getUserByUsername('JohnDoe') ); $this->createEntryComment( 'test comment 3', $this->getEntryByTitle('test entry 2', 'https://kbin.pub', null, $this->getMagazineByName('kbin')), $this->getUserByUsername('JohnDoe') ); return $this->client; } private function getSortOptions(): array { return ['Hot', 'Newest', 'Active', 'Oldest']; } } ================================================ FILE: tests/Functional/Controller/Entry/Comment/EntryCommentModerateControllerTest.php ================================================ client->loginUser($this->getUserByUsername('JohnDoe')); $comment = $this->createEntryComment('test comment 1'); $crawler = $this->client->request('get', "/m/{$comment->magazine->name}/t/{$comment->entry->getId()}"); $this->client->click($crawler->filter('#entry-comment-'.$comment->getId())->selectLink('Moderate')->link()); $this->assertSelectorTextContains('.moderate-panel', 'Ban'); } public function testXmlModCanShowPanel(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); $comment = $this->createEntryComment('test comment 1'); $crawler = $this->client->request('get', "/m/{$comment->magazine->name}/t/{$comment->entry->getId()}"); $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest'); $this->client->click($crawler->filter('#entry-comment-'.$comment->getId())->selectLink('Moderate')->link()); $this->assertStringContainsString('moderate-panel', $this->client->getResponse()->getContent()); } public function testUnauthorizedCanNotShowPanel(): void { $this->client->loginUser($this->getUserByUsername('JaneDoe')); $comment = $this->createEntryComment('test comment 1'); $this->client->request('get', "/m/{$comment->magazine->name}/t/{$comment->entry->getId()}"); $this->assertSelectorTextNotContains('#entry-comment-'.$comment->getId(), 'Moderate'); $this->client->request( 'get', "/m/{$comment->magazine->name}/t/{$comment->entry->getId()}/-/comment/{$comment->getId()}/moderate" ); $this->assertResponseStatusCodeSame(403); } } ================================================ FILE: tests/Functional/Controller/Entry/EntryBoostControllerTest.php ================================================ client->loginUser($this->getUserByUsername('JohnDoe')); $entry = $this->getEntryByTitle( 'test entry 1', 'https://kbin.pub', null, null, $this->getUserByUsername('JaneDoe') ); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/test-entry-1"); $this->client->submit( $crawler->filter('#main .entry')->selectButton('Boost')->form([]) ); $crawler = $this->client->followRedirect(); $this->assertSelectorTextContains('#main .entry', 'Boost (1)'); $this->client->click($crawler->filter('#activity')->selectLink('Boosts (1)')->link()); $this->assertSelectorTextContains('#main .users-columns', 'JohnDoe'); } } ================================================ FILE: tests/Functional/Controller/Entry/EntryChangeAdultControllerTest.php ================================================ client->loginUser($this->getUserByUsername('JohnDoe')); $entry = $this->getEntryByTitle( 'test entry 1', 'https://kbin.pub', ); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/-/moderate"); $this->client->submit( $crawler->filter('.moderate-panel')->selectButton('Mark NSFW')->form([ 'adult' => 'on', ]) ); $this->client->followRedirect(); $this->assertSelectorTextContains('#main .entry .badge', '18+'); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/-/moderate"); $this->client->submit( $crawler->filter('.moderate-panel')->selectButton('Unmark NSFW')->form([ 'adult' => 'off', ]) ); $this->client->followRedirect(); $this->assertSelectorTextNotContains('#main .entry', '18+'); } } ================================================ FILE: tests/Functional/Controller/Entry/EntryChangeLangControllerTest.php ================================================ client->loginUser($this->getUserByUsername('JohnDoe')); $entry = $this->getEntryByTitle( 'test entry 1', 'https://kbin.pub', ); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/-/moderate"); $form = $crawler->filter('.moderate-panel')->selectButton('Change language')->form(); $this->assertSame($form['lang']['lang']->getValue(), 'en'); $form['lang']['lang']->select('fr'); $this->client->submit($form); $this->client->followRedirect(); $this->assertSelectorTextContains('#main .badge-lang', 'French'); } } ================================================ FILE: tests/Functional/Controller/Entry/EntryChangeMagazineControllerTest.php ================================================ getUserByUsername('JohnDoe'); $this->setAdmin($user); $this->client->loginUser($user); $this->getMagazineByName('kbin'); $entry = $this->getEntryByTitle( 'test entry 1', 'https://kbin.pub', ); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/-/moderate"); $this->client->submit( $crawler->filter('form[name=change_magazine]')->selectButton('Change magazine')->form( [ 'change_magazine[new_magazine]' => 'kbin', ] ) ); $this->client->followRedirect(); $this->client->followRedirect(); $this->assertSelectorTextContains('.head-title', 'kbin'); } public function testUnauthorizedUserCantChangeMagazine(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); $this->getMagazineByName('kbin'); $entry = $this->getEntryByTitle( 'test entry 1', 'https://kbin.pub', ); $this->client->request('GET', "/m/acme/t/{$entry->getId()}/-/moderate"); $this->assertSelectorTextNotContains('.moderate-panel', 'Change magazine'); } } ================================================ FILE: tests/Functional/Controller/Entry/EntryCreateControllerTest.php ================================================ kibbyPath = \dirname(__FILE__, 4).'/assets/kibby_emoji.png'; } public function testUserCanCreateEntry() { $this->client->loginUser($this->getUserByUsername('user')); $this->client->request('GET', '/m/acme/new_entry'); $this->assertSelectorExists('form[name=entry]'); } public function testUserCanCreateEntryLinkFromMagazinePage(): void { $this->client->loginUser($this->getUserByUsername('user')); $this->getMagazineByName('acme'); $crawler = $this->client->request('GET', '/m/acme/new_entry'); $this->client->submit( $crawler->filter('form[name=entry]')->selectButton('Add new thread')->form( [ 'entry[url]' => 'https://kbin.pub', 'entry[title]' => 'Test entry 1', 'entry[body]' => 'Test body', ] ) ); $this->assertResponseRedirects('/m/acme/default/newest'); $this->client->followRedirect(); $this->assertSelectorTextContains('article h2', 'Test entry 1'); } public function testUserCanCreateEntryArticleFromMagazinePage() { $this->client->loginUser($this->getUserByUsername('user')); $this->getMagazineByName('acme'); $crawler = $this->client->request('GET', '/m/acme/new_entry'); $this->client->submit( $crawler->filter('form[name=entry]')->selectButton('Add new thread')->form( [ 'entry[title]' => 'Test entry 1', 'entry[body]' => 'Test body', ] ) ); $this->assertResponseRedirects('/m/acme/default/newest'); $this->client->followRedirect(); $this->assertSelectorTextContains('article h2', 'Test entry 1'); } #[Group(name: 'NonThreadSafe')] public function testUserCanCreateEntryPhotoFromMagazinePage() { $this->client->loginUser($this->getUserByUsername('user')); $this->getMagazineByName('acme'); $repository = $this->entryRepository; $crawler = $this->client->request('GET', '/m/acme/new_entry'); $this->assertSelectorExists('form[name=entry]'); $form = $crawler->filter('#main form[name=entry]')->selectButton('Add new thread')->form([ 'entry[title]' => 'Test image 1', 'entry[image]' => $this->kibbyPath, ]); // Needed since we require this global to be set when validating entries but the client doesn't actually set it $_FILES = $form->getPhpFiles(); $this->client->submit($form); $this->assertResponseRedirects('/m/acme/default/newest'); $crawler = $this->client->followRedirect(); $this->assertSelectorTextContains('article h2', 'Test image 1'); $this->assertSelectorExists('figure img'); $imgSrc = $crawler->filter('figure img.thumb-subject')->getNode(0)->attributes->getNamedItem('src')->textContent; $this->assertStringContainsString(self::KIBBY_PNG_URL_RESULT, $imgSrc); $user = $this->getUserByUsername('user'); $entry = $repository->findOneBy(['user' => $user]); $this->assertNotNull($entry); $this->assertNotNull($entry->image); $this->assertStringContainsString(self::KIBBY_PNG_URL_RESULT, $entry->image->filePath); $_FILES = []; } public function testUserCanCreateEntryArticleForAdults() { $this->client->loginUser($this->getUserByUsername('user', hideAdult: false)); $this->getMagazineByName('acme'); $crawler = $this->client->request('GET', '/m/acme/new_entry'); $this->client->submit( $crawler->filter('form[name=entry]')->selectButton('Add new thread')->form( [ 'entry[title]' => 'Test entry 1', 'entry[body]' => 'Test body', 'entry[isAdult]' => '1', ] ) ); $this->assertResponseRedirects('/m/acme/default/newest'); $this->client->followRedirect(); $this->assertSelectorTextContains('article h2', 'Test entry 1'); $this->assertSelectorTextContains('article h2 .danger', '18+'); } public function testPresetValues() { $this->client->loginUser($this->getUserByUsername('user', hideAdult: false)); $this->getMagazineByName('acme'); $crawler = $this->client->request('GET', '/m/acme/new_entry?' .'title=test' .'&url='.urlencode('https://example.com#title') .'&body='.urlencode("**Test**\nbody") .'&imageAlt=alt' .'&isNsfw=1' .'&isOc=1' .'&tags[]=1&tags[]=2' ); $this->assertFormValue('form[name=entry]', 'entry[title]', 'test'); $this->assertFormValue('form[name=entry]', 'entry[url]', 'https://example.com#title'); $this->assertFormValue('form[name=entry]', 'entry[body]', "**Test**\nbody"); $this->assertFormValue('form[name=entry]', 'entry[imageAlt]', 'alt'); $this->assertFormValue('form[name=entry]', 'entry[isAdult]', '1'); $this->assertFormValue('form[name=entry]', 'entry[isOc]', '1'); $this->assertFormValue('form[name=entry]', 'entry[tags]', '1 2'); } public function testPresetImage() { $user = $this->getUserByUsername('user'); $this->client->loginUser($user); $magazine = $this->getMagazineByName('acme'); $imgEntry = $this->createEntry('img', $magazine, $user, imageDto: $this->getKibbyImageDto()); $imgHash = strtok($imgEntry->image->fileName, '.'); // this is necessary so the second entry is guaranteed to be newer than the first sleep(1); $crawler = $this->client->request('GET', '/m/acme/new_entry?' .'title=test' .'&imageHash='.$imgHash ); $this->client->submit($crawler->filter('form[name=entry]')->form()); $crawler = $this->client->followRedirect(); $this->assertSelectorTextContains('article h2', 'test'); $this->assertSelectorExists('figure img'); $imgSrc = $crawler->filter('figure img.thumb-subject')->getNode(0)->attributes->getNamedItem('src')->textContent; $this->assertStringContainsString(self::KIBBY_PNG_URL_RESULT, $imgSrc); } public function testPresetImageNotFound() { $user = $this->getUserByUsername('user'); $this->client->loginUser($user); $magazine = $this->getMagazineByName('acme'); $imgEntry = $this->createEntry('img', $magazine, $user, imageDto: $this->getKibbyImageDto()); $imgHash = strtok($imgEntry->image->fileName, '.'); $imgHash = substr($imgHash, 0, \strlen($imgHash) - 1).'0'; // this is necessary so the second entry is guaranteed to be newer than the first sleep(1); $crawler = $this->client->request('GET', '/m/acme/new_entry?' .'title=test' .'&imageHash='.$imgHash ); $this->assertSelectorTextContains('.alert.alert__danger', 'The image referenced by \'imageHash\' could not be found.'); } } ================================================ FILE: tests/Functional/Controller/Entry/EntryDeleteControllerTest.php ================================================ getUserByUsername('user'); $magazine = $this->getMagazineByName('acme'); $entry = $this->getEntryByTitle('deletion test', body: 'will be deleted', magazine: $magazine, user: $user); $this->client->loginUser($user); $crawler = $this->client->request('GET', '/m/acme'); $this->assertSelectorExists('form[action$="delete"]'); $this->client->submit( $crawler->filter('form[action$="delete"]')->selectButton('Delete')->form() ); $this->assertResponseRedirects(); } public function testUserCanSoftDeleteEntry() { $user = $this->getUserByUsername('user'); $magazine = $this->getMagazineByName('acme'); $entry = $this->getEntryByTitle('deletion test', body: 'will be deleted', magazine: $magazine, user: $user); $comment = $this->createEntryComment('only softly', $entry, $user); $this->client->loginUser($user); $crawler = $this->client->request('GET', '/m/acme'); $this->assertSelectorExists('form[action$="delete"]'); $this->client->submit( $crawler->filter('form[action$="delete"]')->selectButton('Delete')->form() ); $this->assertResponseRedirects(); $this->client->request('GET', "/m/acme/t/{$entry->getId()}/deletion-test"); $translator = $this->translator; $this->assertSelectorTextContains("#entry-{$entry->getId()} header", $translator->trans('deleted_by_author')); } } ================================================ FILE: tests/Functional/Controller/Entry/EntryEditControllerTest.php ================================================ client->loginUser($this->getUserByUsername('JohnDoe')); $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/test-entry-1"); $crawler = $this->client->click($crawler->filter('#main .entry')->selectLink('Edit')->link()); $this->assertSelectorExists('#main .entry_edit'); $this->assertInputValueSame('entry_edit[url]', 'https://kbin.pub'); $this->assertEquals('disabled', $crawler->filter('#entry_edit_magazine')->attr('disabled')); $this->client->submit( $crawler->filter('form[name=entry_edit]')->selectButton('Edit thread')->form( [ 'entry_edit[title]' => 'test entry 2 title', 'entry_edit[body]' => 'test entry 2 body', ] ) ); $this->client->followRedirect(); $this->assertSelectorTextContains('#main .entry header', 'test entry 2 title'); $this->assertSelectorTextContains('#main .entry .entry__body', 'test entry 2 body'); } public function testAuthorCanEditOwnEntryArticle(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); $entry = $this->getEntryByTitle('test entry 1', null, 'entry content test entry 1'); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/test-entry-1"); $crawler = $this->client->click($crawler->filter('#main .entry')->selectLink('Edit')->link()); $this->assertSelectorExists('#main .entry_edit'); $this->assertEquals('disabled', $crawler->filter('#entry_edit_magazine')->attr('disabled')); $this->client->submit( $crawler->filter('form[name=entry_edit]')->selectButton('Edit thread')->form( [ 'entry_edit[title]' => 'test entry 2 title', 'entry_edit[body]' => 'test entry 2 body', ] ) ); $this->client->followRedirect(); $this->assertSelectorTextContains('#main .entry header', 'test entry 2 title'); $this->assertSelectorTextContains('#main .entry .entry__body', 'test entry 2 body'); } #[Group(name: 'NonThreadSafe')] public function testAuthorCanEditOwnEntryImage(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); $imageDto = $this->getKibbyImageDto(); $entry = $this->getEntryByTitle('test entry 1', image: $imageDto); $crawler = $this->client->request('GET', "/m/acme/t/{$entry->getId()}/test-entry-1"); $this->assertResponseIsSuccessful(); $crawler = $this->client->click($crawler->filter('#main .entry')->selectLink('Edit')->link()); $this->assertResponseIsSuccessful(); $this->assertSelectorExists('#main .entry_edit'); $this->assertSelectorExists('#main .entry_edit img'); $node = $crawler->selectImage('kibby')->getNode(0); $this->assertNotNull($node); $this->assertStringContainsString($imageDto->filePath, $node->attributes->getNamedItem('src')->textContent); $this->assertEquals('disabled', $crawler->filter('#entry_edit_magazine')->attr('disabled')); $this->client->submit( $crawler->filter('form[name=entry_edit]')->selectButton('Edit thread')->form( [ 'entry_edit[title]' => 'test entry 2 title', ] ) ); $crawler = $this->client->followRedirect(); $this->assertSelectorTextContains('#main .entry header', 'test entry 2 title'); $this->assertSelectorExists('#main .entry img'); $node = $crawler->selectImage('kibby')->getNode(0); $this->assertNotNull($node); $this->assertStringContainsString($imageDto->filePath, $node->attributes->getNamedItem('src')->textContent); } } ================================================ FILE: tests/Functional/Controller/Entry/EntryFrontControllerTest.php ================================================ client = $this->prepareEntries(); $this->client->request('GET', '/'); $this->assertSelectorTextContains('h1', 'Hot'); $crawler = $this->client->request('GET', '/newest'); $this->assertSelectorTextContains('.entry__meta', 'JohnDoe'); $this->assertSelectorTextContains('.entry__meta', 'to acme'); $this->assertcount(2, $crawler->filter('.entry')); foreach ($this->getSortOptions() as $sortOption) { $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link()); $this->assertSelectorTextContains('.options__filter', $sortOption); $this->assertSelectorTextContains('h1', ucfirst($sortOption)); } } public function testXmlRootPage(): void { $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest'); $this->client->request('GET', '/'); $this->assertStringContainsString('{"html":', $this->client->getResponse()->getContent()); } public function testXmlRootPageIsFrontPage(): void { $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest'); $this->client->request('GET', '/'); $root_content = self::removeTimeElements($this->clearTokens($this->client->getResponse()->getContent())); $this->client->request('GET', '/all'); $frontContent = self::removeTimeElements($this->clearTokens($this->client->getResponse()->getContent())); $this->assertSame($root_content, $frontContent); } public function testFrontPage(): void { $this->client = $this->prepareEntries(); $this->client->request('GET', '/all'); $this->assertSelectorTextContains('h1', 'Hot'); $crawler = $this->client->request('GET', '/all/newest'); $this->assertSelectorTextContains('.entry__meta', 'JohnDoe'); $this->assertSelectorTextContains('.entry__meta', 'to acme'); $this->assertcount(2, $crawler->filter('.entry')); foreach ($this->getSortOptions() as $sortOption) { $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link()); $this->assertSelectorTextContains('.options__filter', $sortOption); $this->assertSelectorTextContains('h1', ucfirst($sortOption)); } } public function testXmlFrontPage(): void { $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest'); $this->client->request('GET', '/all'); $this->assertStringContainsString('{"html":', $this->client->getResponse()->getContent()); } public function testMagazinePage(): void { $this->client = $this->prepareEntries(); $this->client->request('GET', '/m/acme'); $this->assertSelectorTextContains('h2', 'Hot'); $this->client->request('GET', '/m/ACME'); $this->assertSelectorTextContains('h2', 'Hot'); $crawler = $this->client->request('GET', '/m/acme/threads/newest'); $this->assertSelectorTextContains('.entry__meta', 'JohnDoe'); $this->assertSelectorTextNotContains('.entry__meta', 'to acme'); $this->assertSelectorTextContains('.head-title', '/m/acme'); $this->assertSelectorTextContains('#sidebar .magazine', 'acme'); $this->assertSelectorTextContains('#header .active', 'Threads'); $this->assertcount(1, $crawler->filter('.entry')); foreach ($this->getSortOptions() as $sortOption) { $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link()); $this->assertSelectorTextContains('.options__filter', $sortOption); $this->assertSelectorTextContains('h1', 'acme'); $this->assertSelectorTextContains('h2', ucfirst($sortOption)); } } public function testXmlMagazinePage(): void { $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest'); $this->client->request('GET', '/m/acme/newest'); self::assertResponseIsSuccessful(); $this->assertStringContainsString('{"html":', $this->client->getResponse()->getContent()); } public function testSubPage(): void { $this->client = $this->prepareEntries(); $magazineManager = $this->client->getContainer()->get(MagazineManager::class); $magazineManager->subscribe($this->getMagazineByName('acme'), $this->getUserByUsername('Actor')); $this->client->loginUser($this->getUserByUsername('Actor')); $this->client->request('GET', '/sub'); $this->assertSelectorTextContains('h1', 'Hot'); $crawler = $this->client->request('GET', '/sub/threads/newest'); $this->assertSelectorTextContains('.entry__meta', 'JohnDoe'); $this->assertSelectorTextContains('.entry__meta', 'to acme'); $this->assertSelectorTextContains('.head-title', '/sub'); $this->assertSelectorTextContains('#header .active', 'Threads'); $this->assertcount(1, $crawler->filter('.entry')); foreach ($this->getSortOptions() as $sortOption) { $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link()); $this->assertSelectorTextContains('.options__filter', $sortOption); $this->assertSelectorTextContains('h1', ucfirst($sortOption)); } } public function testXmlSubPage(): void { $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $magazineManager = $this->client->getContainer()->get(MagazineManager::class); $magazineManager->subscribe($this->getMagazineByName('acme'), $this->getUserByUsername('Actor')); $this->client->loginUser($this->getUserByUsername('Actor')); $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest'); $this->client->request('GET', '/sub'); $this->assertStringContainsString('{"html":', $this->client->getResponse()->getContent()); } public function testModPage(): void { $this->client = $this->prepareEntries(); $admin = $this->getUserByUsername('admin', isAdmin: true); $magazineManager = $this->client->getContainer()->get(MagazineManager::class); $moderator = new ModeratorDto($this->getMagazineByName('acme')); $moderator->user = $this->getUserByUsername('Actor'); $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); $this->client->loginUser($this->getUserByUsername('Actor')); $this->client->request('GET', '/mod'); $this->assertSelectorTextContains('h1', 'Hot'); $crawler = $this->client->request('GET', '/mod/threads/newest'); $this->assertSelectorTextContains('.entry__meta', 'JohnDoe'); $this->assertSelectorTextContains('.entry__meta', 'to acme'); $this->assertSelectorTextContains('.head-title', '/mod'); $this->assertSelectorTextContains('#header .active', 'Threads'); $this->assertcount(1, $crawler->filter('.entry')); foreach ($this->getSortOptions() as $sortOption) { $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link()); $this->assertSelectorTextContains('.options__filter', $sortOption); $this->assertSelectorTextContains('h1', ucfirst($sortOption)); } } public function testXmlModPage(): void { $admin = $this->getUserByUsername('admin', isAdmin: true); $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $magazineManager = $this->client->getContainer()->get(MagazineManager::class); $moderator = new ModeratorDto($this->getMagazineByName('acme')); $moderator->user = $this->getUserByUsername('Actor'); $moderator->addedBy = $admin; $magazineManager->addModerator($moderator); $this->client->loginUser($this->getUserByUsername('Actor')); $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest'); $this->client->request('GET', '/mod'); $this->assertStringContainsString('{"html":', $this->client->getResponse()->getContent()); } public function testFavPage(): void { $this->client = $this->prepareEntries(); $favouriteManager = $this->favouriteManager; $favouriteManager->toggle( $this->getUserByUsername('Actor'), $this->getEntryByTitle('test entry 1', 'https://kbin.pub') ); $this->client->loginUser($this->getUserByUsername('Actor')); $this->client->request('GET', '/fav'); $this->assertSelectorTextContains('h1', 'Hot'); $crawler = $this->client->request('GET', '/fav/threads/newest'); $this->assertSelectorTextContains('.entry__meta', 'JaneDoe'); $this->assertSelectorTextContains('.entry__meta', 'to kbin'); $this->assertSelectorTextContains('.head-title', '/fav'); $this->assertSelectorTextContains('#header .active', 'Threads'); $this->assertcount(1, $crawler->filter('.entry')); foreach ($this->getSortOptions() as $sortOption) { $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link()); $this->assertSelectorTextContains('.options__filter', $sortOption); $this->assertSelectorTextContains('h1', ucfirst($sortOption)); } } public function testXmlFavPage(): void { $this->getEntryByTitle('test entry 1', 'https://kbin.pub'); $favouriteManager = $this->favouriteManager; $favouriteManager->toggle( $this->getUserByUsername('Actor'), $this->getEntryByTitle('test entry 1', 'https://kbin.pub') ); $this->client->loginUser($this->getUserByUsername('Actor')); $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest'); $this->client->request('GET', '/fav'); $this->assertStringContainsString('{"html":', $this->client->getResponse()->getContent()); } public function testCustomDefaultSort(): void { $older = $this->getEntryByTitle('Older entry'); $older->createdAt = new \DateTimeImmutable('now - 1 day'); $older->updateRanking(); $this->entityManager->flush(); $newer = $this->getEntryByTitle('Newer entry'); $comment = $this->createEntryComment('someone was here', entry: $older); self::assertGreaterThan($older->getRanking(), $newer->getRanking()); $user = $this->getUserByUsername('user'); $user->frontDefaultSort = ESortOptions::Newest->value; $this->entityManager->flush(); $this->client->loginUser($user); $crawler = $this->client->request('GET', '/'); self::assertResponseIsSuccessful(); self::assertSelectorTextContains('.options__filter button', $this->translator->trans(ESortOptions::Newest->value)); $iterator = $crawler->filter('#content div')->children()->getIterator(); /** @var \DOMElement $firstNode */ $firstNode = $iterator->current(); $firstId = $firstNode->attributes->getNamedItem('id')->nodeValue; self::assertEquals("entry-{$newer->getId()}", $firstId); $iterator->next(); $secondNode = $iterator->current(); $secondId = $secondNode->attributes->getNamedItem('id')->nodeValue; self::assertEquals("entry-{$older->getId()}", $secondId); $user->frontDefaultSort = ESortOptions::Commented->value; $this->entityManager->flush(); $crawler = $this->client->request('GET', '/'); self::assertResponseIsSuccessful(); self::assertSelectorTextContains('.options__filter button', $this->translator->trans(ESortOptions::Commented->value)); $iterator = $crawler->filter('#content div')->children()->getIterator(); /** @var \DOMElement $firstNode */ $firstNode = $iterator->current(); $firstId = $firstNode->attributes->getNamedItem('id')->nodeValue; self::assertEquals("entry-{$older->getId()}", $firstId); $iterator->next(); $secondNode = $iterator->current(); $secondId = $secondNode->attributes->getNamedItem('id')->nodeValue; self::assertEquals("entry-{$newer->getId()}", $secondId); } private function prepareEntries(): KernelBrowser { $older = $this->getEntryByTitle( 'test entry 1', 'https://kbin.pub', null, $this->getMagazineByName('kbin', $this->getUserByUsername('JaneDoe')), $this->getUserByUsername('JaneDoe') ); $older->createdAt = new \DateTimeImmutable('now - 1 minute'); $this->getEntryByTitle('test entry 2', 'https://kbin.pub'); return $this->client; } private function getSortOptions(): array { return ['Top', 'Hot', 'Newest', 'Active', 'Commented']; } private function clearTokens(string $responseContent): string { return preg_replace( '#name="token" value=".+"#', '', json_decode($responseContent, true, 512, JSON_THROW_ON_ERROR), )['html']; } private function clearDateTimes(string $responseContent): string { return preg_replace( '/